mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce0b68dc0e | ||
|
|
cc16c4d2d7 | ||
|
|
1ee7f22ee5 | ||
|
|
b612dde19e | ||
|
|
4181174352 | ||
|
|
1180baac61 | ||
|
|
db1a3fc0a6 | ||
|
|
7c6abb3834 | ||
|
|
4c63198237 | ||
|
|
c0fbe54ef6 | ||
|
|
4ba39ef392 | ||
|
|
25c72ced6f | ||
|
|
0ed63b02e4 | ||
|
|
5352e6a90a | ||
|
|
16f1a0f320 | ||
|
|
4d625420b0 |
34
CHANGELOG.md
34
CHANGELOG.md
@@ -2,6 +2,38 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.29] - 2026-05-12
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Add agent meeting join, leave, and events shortcuts (#824)
|
||||
- **mail**: Add unknown-flag fuzzy match for `lark-cli mail` commands (#806)
|
||||
- **whiteboard**: Pin `whiteboard-cli` to `v0.2.11` in `lark-whiteboard` skill (#850)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Silence misleading "skills not installed" startup notice (#801)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Refine data analysis SOP wording (#784, #849)
|
||||
- Update README capability descriptions (#793)
|
||||
|
||||
## [v1.0.28] - 2026-05-11
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support UAT for `messages.forward` and add `threads.forward` (#689)
|
||||
- **im**: Add flag shortcuts `+flag-create` / `+flag-list` / `+flag-cancel` for message bookmarks (#770)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Handle duplicate remote sync paths (#803)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Name `--query` / `--member-ids` in `+chat-search` shortcut row (#812)
|
||||
|
||||
## [v1.0.27] - 2026-05-09
|
||||
|
||||
### Features
|
||||
@@ -644,6 +676,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
|
||||
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
|
||||
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27
|
||||
[v1.0.26]: https://github.com/larksuite/cli/releases/tag/v1.0.26
|
||||
[v1.0.25]: https://github.com/larksuite/cli/releases/tag/v1.0.25
|
||||
|
||||
@@ -24,7 +24,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
|
||||
| Category | Capabilities |
|
||||
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
|
||||
| 📅 Calendar | View, create and update events, invite attendees, find meeting rooms, RSVP to invitations, check free/busy & time suggestions |
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
@@ -36,7 +36,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes artifacts and recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||
@@ -136,7 +136,7 @@ lark-cli auth status
|
||||
| Skill | Description |
|
||||
| ------------------------------- |----------------------------------------------------------------------------------------------------------------|
|
||||
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `lark-calendar` | Calendar events (create/update), agenda view, free/busy queries, time suggestions, room finding, RSVP replies |
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
@@ -151,7 +151,7 @@ lark-cli auth status
|
||||
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
|
||||
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
|
||||
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters); upload audio/video to create minutes, download media |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-attendance` | Query personal attendance check-in records |
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
| 类别 | 能力 |
|
||||
| ------------- |--------------------------------------------|
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 📅 日历 | 查看、创建和更新日程,邀请参会人、查找会议室、回复日程邀请、查询忙闲与时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
@@ -36,7 +36,7 @@
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要产物与会议录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
@@ -137,7 +137,7 @@ lark-cli auth status
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- |-------------------------------------------|
|
||||
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-calendar` | 日历日程(创建/更新)、议程查看、忙闲查询、时间建议、会议室查找、回复邀请 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
@@ -152,7 +152,7 @@ lark-cli auth status
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节),上传音视频生成妙记,下载音视频文件 |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-attendance` | 查询个人考勤打卡记录 |
|
||||
|
||||
@@ -504,10 +504,12 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart verifies that when no skills stamp exists,
|
||||
// the composed PendingNotice provider includes a "skills" key with an
|
||||
// empty Current and the cold-start message.
|
||||
func TestSetupNotices_ColdStart(t *testing.T) {
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
|
||||
// produces no skills key in the composed notice. Users who installed
|
||||
// skills via `npx skills add` (no stamp) must not see the misleading
|
||||
// "not installed" notice — only `lark-cli update` users opt into the
|
||||
// drift tracker.
|
||||
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -530,17 +532,10 @@ func TestSetupNotices_ColdStart(t *testing.T) {
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
t.Fatal("GetNotice() = nil, want non-nil for cold start")
|
||||
return // expected — no pending notices at all
|
||||
}
|
||||
skills, ok := notice["skills"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.skills missing, got %+v", notice)
|
||||
}
|
||||
if skills["current"] != "" || skills["target"] != "1.0.21" {
|
||||
t.Errorf("notice.skills = %+v, want {current:\"\", target:\"1.0.21\"}", skills)
|
||||
}
|
||||
if msg, _ := skills["message"].(string); msg != "lark-cli skills not installed, run: lark-cli update" {
|
||||
t.Errorf("notice.skills.message = %q, want cold-start message", msg)
|
||||
if _, ok := notice["skills"]; ok {
|
||||
t.Errorf("notice.skills present in cold-start state, want absent: %+v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
package skillscheck
|
||||
|
||||
// Init runs the synchronous skills version check. Stores a StaleNotice
|
||||
// when the local stamp does not match currentVersion. Safe to call
|
||||
// from cmd/root.go before rootCmd.Execute(); zero network, zero
|
||||
// subprocess — only a local stamp file read.
|
||||
// when the local stamp records a version that does not match
|
||||
// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute();
|
||||
// zero network, zero subprocess — only a local stamp file read.
|
||||
//
|
||||
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
|
||||
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
|
||||
@@ -15,10 +15,12 @@ package skillscheck
|
||||
// - shouldSkip rule met
|
||||
// - ReadStamp returns an I/O error other than ENOENT
|
||||
// - Stamp matches currentVersion (in-sync)
|
||||
// - Stamp is missing (cold start) — only users who ran `lark-cli update`
|
||||
// opt into drift tracking; npx-only installs are intentionally silent.
|
||||
func Init(currentVersion string) {
|
||||
// Clear any stale notice from a prior call so early returns below
|
||||
// (skip rules / read errors / in-sync) leave pending == nil instead
|
||||
// of preserving a stale value from a previous Init invocation.
|
||||
// (skip rules / read errors / cold start / in-sync) leave pending == nil
|
||||
// instead of preserving a stale value from a previous Init invocation.
|
||||
SetPending(nil)
|
||||
if shouldSkip(currentVersion) {
|
||||
return
|
||||
@@ -28,11 +30,19 @@ func Init(currentVersion string) {
|
||||
// Fail closed — don't nag for a transient FS problem.
|
||||
return
|
||||
}
|
||||
if stamp == "" {
|
||||
// Cold start: the stamp is written exclusively by `lark-cli update`
|
||||
// (runSkillsAndStamp). Users who installed skills via
|
||||
// `npx skills add larksuite/cli -g` have no stamp yet — they must
|
||||
// not be nagged with "skills not installed", since the on-disk
|
||||
// skills directory may already be fully populated.
|
||||
return
|
||||
}
|
||||
if stamp == currentVersion {
|
||||
return
|
||||
}
|
||||
SetPending(&StaleNotice{
|
||||
Current: stamp, // "" when never synced
|
||||
Current: stamp, // guaranteed non-empty under the new contract
|
||||
Target: currentVersion,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,17 +29,13 @@ func TestInit_InSync_NoNotice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_ColdStart_NoticeWithEmptyCurrent(t *testing.T) {
|
||||
func TestInit_ColdStart_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
Init("1.0.21")
|
||||
got := GetPending()
|
||||
if got == nil {
|
||||
t.Fatal("GetPending() = nil, want non-nil for cold start")
|
||||
}
|
||||
if got.Current != "" || got.Target != "1.0.21" {
|
||||
t.Errorf("notice = %+v, want {Current:\"\", Target:\"1.0.21\"}", got)
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (cold start is silent)", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,20 +15,20 @@ import (
|
||||
|
||||
// StaleNotice signals that the locally synced skills version does not
|
||||
// match the running binary. Current is the last successfully synced
|
||||
// version (or "" when never synced); Target is the running binary
|
||||
// version. Mirrors internal/update.UpdateInfo's pending-notice pattern.
|
||||
// version (always non-empty — Init no longer emits a notice on cold
|
||||
// start). Target is the running binary version. Mirrors
|
||||
// internal/update.UpdateInfo's pending-notice pattern.
|
||||
type StaleNotice struct {
|
||||
Current string `json:"current"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
// Message returns a single-line, AI-agent-parseable description of the
|
||||
// gap plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
|
||||
// in style ("..., run: lark-cli update" suffix).
|
||||
// drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
|
||||
// in style ("..., run: lark-cli update" suffix). Current is guaranteed
|
||||
// non-empty because Init only emits a StaleNotice for the drift case
|
||||
// (stamp present and != binary version).
|
||||
func (s *StaleNotice) Message() string {
|
||||
if s.Current == "" {
|
||||
return "lark-cli skills not installed, run: lark-cli update"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",
|
||||
s.Current, s.Target,
|
||||
|
||||
@@ -14,11 +14,6 @@ func TestStaleNotice_Message(t *testing.T) {
|
||||
n StaleNotice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"cold_start",
|
||||
StaleNotice{Current: "", Target: "1.0.21"},
|
||||
"lark-cli skills not installed, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
"drift",
|
||||
StaleNotice{Current: "1.0.20", Target: "1.0.21"},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.27",
|
||||
"version": "1.0.29",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -67,6 +67,7 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
if v := runtime.Str("parent-position"); v != "" {
|
||||
body["parent_position"] = v
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
return body
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
if ro := buildReadOption(runtime); ro != nil {
|
||||
body["read_option"] = ro
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
95
shortcuts/doc/docs_fetch_v2_test.go
Normal file
95
shortcuts/doc/docs_fetch_v2_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBuildFetchBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, " DoubaoCLI ")
|
||||
runtime := newFetchBodyTestRuntime(ctx)
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
|
||||
runtime := newCreateBodyTestRuntime(ctx)
|
||||
|
||||
body := buildCreateBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
|
||||
runtime := newUpdateBodyTestRuntime(ctx)
|
||||
|
||||
body := buildUpdateBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if _, ok := body["scene"]; ok {
|
||||
t.Fatalf("did not expect empty scene in fetch body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("scope", "full", "")
|
||||
cmd.Flags().String("start-block-id", "", "")
|
||||
cmd.Flags().String("end-block-id", "", "")
|
||||
cmd.Flags().String("keyword", "", "")
|
||||
cmd.Flags().Int("context-before", 0, "")
|
||||
cmd.Flags().Int("context-after", 0, "")
|
||||
cmd.Flags().Int("max-depth", -1, "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+create"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("content", "<title>hello</title>", "")
|
||||
cmd.Flags().String("parent-token", "", "")
|
||||
cmd.Flags().String("parent-position", "", "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+update"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("command", "append", "")
|
||||
cmd.Flags().Int("revision-id", 0, "")
|
||||
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("block-id", "", "")
|
||||
cmd.Flags().String("src-block-ids", "", "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
@@ -162,5 +162,6 @@ func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
if v := runtime.Str("src-block-ids"); v != "" {
|
||||
body["src_block_ids"] = v
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
@@ -11,6 +12,10 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// docsSceneContextKey lets in-process embedders pass a server-owned docs_ai
|
||||
// scene without exposing it as a user-controlled CLI flag.
|
||||
const docsSceneContextKey = "lark_cli_docs_scene"
|
||||
|
||||
type documentRef struct {
|
||||
Kind string
|
||||
Token string
|
||||
@@ -65,6 +70,20 @@ func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body inter
|
||||
return runtime.DoAPIJSONWithLogID(method, apiPath, nil, body)
|
||||
}
|
||||
|
||||
func docsSceneFromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
scene, _ := ctx.Value(docsSceneContextKey).(string)
|
||||
return strings.TrimSpace(scene)
|
||||
}
|
||||
|
||||
func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{}) {
|
||||
if scene := docsSceneFromContext(runtime.Ctx()); scene != "" {
|
||||
body["scene"] = scene
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveRouteExtra(docID string) (string, error) {
|
||||
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
|
||||
if err != nil {
|
||||
|
||||
865
shortcuts/drive/drive_duplicate_remote_test.go
Normal file
865
shortcuts/drive/drive_duplicate_remote_test.go
Normal file
@@ -0,0 +1,865 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const (
|
||||
duplicateRemoteFileIDFirst = "example-file-token-first"
|
||||
duplicateRemoteFileIDSecond = "example-file-token-second"
|
||||
duplicateRemoteFileIDThird = "example-file-token-third"
|
||||
duplicateRemoteFolderID = "example-folder-token"
|
||||
)
|
||||
|
||||
func TestDriveStatusFailsOnDuplicateRemoteFiles(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
|
||||
if stdout.String() != "" {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullFailsOnDuplicateRemoteFilesBeforeWriting(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
|
||||
if _, statErr := os.Stat(filepath.Join("local", "dup.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("duplicate default failure must not write local dup.txt; stat err=%v", statErr)
|
||||
}
|
||||
if stdout.String() != "" {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullRenameDownloadsDuplicateRemoteFilesWithStableHashSuffix(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst + "/download",
|
||||
Status: 200,
|
||||
Body: []byte("FIRST"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond + "/download",
|
||||
Status: 200,
|
||||
Body: []byte("SECOND"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
renamedRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
|
||||
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
|
||||
mustReadFile(t, filepath.Join("local", renamedRelPath), "SECOND")
|
||||
if strings.Contains(renamedRelPath, duplicateRemoteFileIDSecond) {
|
||||
t.Fatalf("renamed rel_path should not expose raw file token: %s", renamedRelPath)
|
||||
}
|
||||
payload := decodeDrivePullStdout(t, stdout.Bytes())
|
||||
if got := payload.Data.Summary.Downloaded; got != 2 {
|
||||
t.Fatalf("summary.downloaded = %d, want 2", got)
|
||||
}
|
||||
if item := findPullItem(payload.Data.Items, renamedRelPath); item.SourceID == "" || item.FileToken != "" {
|
||||
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
|
||||
}
|
||||
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullRenameStrengthensSuffixWhenShortHashTargetAlreadyExists(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
shortHashRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
|
||||
{"token": duplicateRemoteFileIDThird, "name": shortHashRelPath, "type": "file", "size": 7, "created_time": "3", "modified_time": "3"},
|
||||
})
|
||||
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
|
||||
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
|
||||
registerDownload(reg, duplicateRemoteFileIDThird, "THIRD")
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
occupied := occupiedRemotePaths([]driveRemoteEntry{
|
||||
{RelPath: "dup.txt"},
|
||||
{RelPath: "dup.txt"},
|
||||
{RelPath: shortHashRelPath},
|
||||
})
|
||||
strongerRelPath, err := relPathWithUniqueFileTokenSuffix("dup.txt", duplicateRemoteFileIDSecond, occupied)
|
||||
if err != nil {
|
||||
t.Fatalf("relPathWithUniqueFileTokenSuffix: %v", err)
|
||||
}
|
||||
if strongerRelPath == shortHashRelPath {
|
||||
t.Fatalf("expected stronger unique suffix when %q is already occupied", shortHashRelPath)
|
||||
}
|
||||
mustReadFile(t, filepath.Join("local", shortHashRelPath), "THIRD")
|
||||
mustReadFile(t, filepath.Join("local", strongerRelPath), "SECOND")
|
||||
payload := decodeDrivePullStdout(t, stdout.Bytes())
|
||||
if got := payload.Data.Summary.Downloaded; got != 3 {
|
||||
t.Fatalf("summary.downloaded = %d, want 3", got)
|
||||
}
|
||||
if item := findPullItem(payload.Data.Items, strongerRelPath); item.SourceID == "" || item.FileToken != "" {
|
||||
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
|
||||
}
|
||||
assertPullItemAction(t, stdout.Bytes(), strongerRelPath, "downloaded")
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullRenameAppendsSequenceWhenAllHashSuffixTargetsAreOccupied(t *testing.T) {
|
||||
fileToken := duplicateRemoteFileIDSecond
|
||||
tokenHash := stableTokenHash(fileToken)
|
||||
occupied := map[string]struct{}{
|
||||
"dup.txt": {},
|
||||
relPathWithSuffix("dup.txt", "__lark_"+tokenHash[:12]): {},
|
||||
relPathWithSuffix("dup.txt", "__lark_"+tokenHash[:24]): {},
|
||||
relPathWithSuffix("dup.txt", "__lark_"+tokenHash): {},
|
||||
relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_2"): {},
|
||||
}
|
||||
|
||||
got, err := relPathWithUniqueFileTokenSuffix("dup.txt", fileToken, occupied)
|
||||
if err != nil {
|
||||
t.Fatalf("relPathWithUniqueFileTokenSuffix: %v", err)
|
||||
}
|
||||
want := relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_3")
|
||||
if got != want {
|
||||
t.Fatalf("unique rel_path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelPathWithUniqueFileTokenSuffixReturnsErrorAfterMaxAttempts(t *testing.T) {
|
||||
fileToken := duplicateRemoteFileIDSecond
|
||||
tokenHash := stableTokenHash(fileToken)
|
||||
occupied := map[string]struct{}{
|
||||
"dup.txt": {},
|
||||
}
|
||||
for _, suffix := range []string{
|
||||
"__lark_" + tokenHash[:12],
|
||||
"__lark_" + tokenHash[:24],
|
||||
"__lark_" + tokenHash,
|
||||
} {
|
||||
occupied[relPathWithSuffix("dup.txt", suffix)] = struct{}{}
|
||||
}
|
||||
for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ {
|
||||
occupied[relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_"+strconv.Itoa(attempt))] = struct{}{}
|
||||
}
|
||||
|
||||
_, err := relPathWithUniqueFileTokenSuffix("dup.txt", fileToken, occupied)
|
||||
if err == nil {
|
||||
t.Fatal("expected relPathWithUniqueFileTokenSuffix to fail after exhausting all suffix attempts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullNewestChoosesMostRecentDuplicateRemoteFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
mustReadFile(t, filepath.Join("local", "dup.txt"), "SECOND")
|
||||
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
|
||||
payload := decodeDrivePullStdout(t, stdout.Bytes())
|
||||
if got := payload.Data.Summary.Downloaded; got != 1 {
|
||||
t.Fatalf("summary.downloaded = %d, want 1", got)
|
||||
}
|
||||
if item := findPullItem(payload.Data.Items, "dup.txt"); item.FileToken != duplicateRemoteFileIDSecond {
|
||||
t.Fatalf("stdout should surface the chosen newest file token, got: %#v", item)
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullOldestChoosesOldestDuplicateRemoteFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "oldest",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
|
||||
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
|
||||
payload := decodeDrivePullStdout(t, stdout.Bytes())
|
||||
if got := payload.Data.Summary.Downloaded; got != 1 {
|
||||
t.Fatalf("summary.downloaded = %d, want 1", got)
|
||||
}
|
||||
if item := findPullItem(payload.Data.Items, "dup.txt"); item.FileToken != duplicateRemoteFileIDFirst {
|
||||
t.Fatalf("stdout should surface the chosen oldest file token, got: %#v", item)
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullRenameHandlesNestedDuplicateRemoteFilesEndToEnd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFolderID, "name": "sub", "type": "folder", "created_time": "1", "modified_time": "1"},
|
||||
})
|
||||
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
|
||||
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
renamedRelPath := expectedRenamedRelPath("sub/dup.txt", duplicateRemoteFileIDSecond, 12, 0)
|
||||
mustReadFile(t, filepath.Join("local", "sub", "dup.txt"), "FIRST")
|
||||
mustReadFile(t, filepath.Join("local", filepath.FromSlash(renamedRelPath)), "SECOND")
|
||||
assertPullItemAction(t, stdout.Bytes(), "sub/dup.txt", "downloaded")
|
||||
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushFailsOnDuplicateRemoteFilesBeforeUpload(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "overwrite",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
|
||||
if stdout.String() != "" {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullFailsOnRemoteFileFolderConflictEvenWithRename(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
registerRemoteListing(reg, duplicateRemoteFolderID, nil)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
|
||||
if stdout.String() != "" {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushFailsOnRemoteFileFolderConflictEvenWithNewest(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "dup"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
registerRemoteListing(reg, duplicateRemoteFolderID, nil)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--if-exists", "skip",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
|
||||
if stdout.String() != "" {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushDeleteRemoteDeletesUnchosenDuplicateSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "skip",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushOldestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "dup-oldest-new-token",
|
||||
"version": "v11",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "overwrite",
|
||||
"--on-duplicate-remote", "oldest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
body := decodeDriveMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDFirst {
|
||||
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDFirst)
|
||||
}
|
||||
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDSecond)
|
||||
if deleteStub.CapturedHeaders == nil {
|
||||
t.Fatal("DELETE for the newer duplicate sibling was never issued")
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushNewestResolvesNestedDuplicateRemoteFilesEndToEnd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "sub", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFolderID, "name": "sub", "type": "folder", "created_time": "1", "modified_time": "1"},
|
||||
})
|
||||
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "nested-dup-new-token",
|
||||
"version": "v7",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "overwrite",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
body := decodeDriveMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
|
||||
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
|
||||
}
|
||||
assertPushItemAction(t, stdout.Bytes(), "sub/dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
||||
if deleteStub.CapturedHeaders == nil {
|
||||
t.Fatal("DELETE for nested duplicate sibling was never issued")
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestChooseRemoteFileSortsByParsedTimes(t *testing.T) {
|
||||
files := []driveRemoteEntry{
|
||||
{FileToken: "token_b", CreatedTime: "9", ModifiedTime: "9"},
|
||||
{FileToken: "token_a", CreatedTime: "10", ModifiedTime: "10"},
|
||||
}
|
||||
gotNewest, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
|
||||
if err != nil {
|
||||
t.Fatalf("chooseRemoteFile newest: %v", err)
|
||||
}
|
||||
if gotNewest.FileToken != "token_a" {
|
||||
t.Fatalf("newest token = %q, want token_a", gotNewest.FileToken)
|
||||
}
|
||||
gotOldest, err := chooseRemoteFile(files, driveDuplicateRemoteOldest)
|
||||
if err != nil {
|
||||
t.Fatalf("chooseRemoteFile oldest: %v", err)
|
||||
}
|
||||
if gotOldest.FileToken != "token_b" {
|
||||
t.Fatalf("oldest token = %q, want token_b", gotOldest.FileToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseRemoteFileFallsBackToFileTokenOnTimeParseFailure(t *testing.T) {
|
||||
files := []driveRemoteEntry{
|
||||
{FileToken: "token_a", CreatedTime: "bad", ModifiedTime: "bad"},
|
||||
{FileToken: "token_b", CreatedTime: "10", ModifiedTime: "10"},
|
||||
}
|
||||
got, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
|
||||
if err != nil {
|
||||
t.Fatalf("chooseRemoteFile: %v", err)
|
||||
}
|
||||
if got.FileToken != "token_a" {
|
||||
t.Fatalf("fallback token = %q, want token_a", got.FileToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseRemoteFileRejectsEmptyCandidates(t *testing.T) {
|
||||
_, err := chooseRemoteFile(nil, driveDuplicateRemoteNewest)
|
||||
if err == nil {
|
||||
t.Fatal("expected chooseRemoteFile to reject empty candidates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullRemoteViewsRejectsUnknownStrategy(t *testing.T) {
|
||||
_, _, err := drivePullRemoteViews([]driveRemoteEntry{
|
||||
{RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDFirst},
|
||||
{RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDSecond},
|
||||
}, "mystery")
|
||||
if err == nil {
|
||||
t.Fatal("expected drivePullRemoteViews to reject an unknown duplicate strategy")
|
||||
}
|
||||
}
|
||||
|
||||
func registerDuplicateRemoteFiles(reg *httpmock.Registry) {
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
}
|
||||
|
||||
func registerRemoteListing(reg *httpmock.Registry, folderToken string, files []map[string]interface{}) {
|
||||
items := make([]interface{}, 0, len(files))
|
||||
for _, file := range files {
|
||||
items = append(items, file)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=" + folderToken,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": items,
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func registerDownload(reg *httpmock.Registry, fileToken, body string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/" + fileToken + "/download",
|
||||
Status: 200,
|
||||
Body: []byte(body),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
}
|
||||
|
||||
func assertDuplicateRemotePathError(t *testing.T, err error, relPath string, tokens ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate_remote_path error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "duplicate_remote_path" {
|
||||
t.Fatalf("error detail = %#v, want duplicate_remote_path", exitErr.Detail)
|
||||
}
|
||||
detailMap, ok := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("duplicate detail type = %T, want map[string]interface{}", exitErr.Detail.Detail)
|
||||
}
|
||||
duplicates, ok := detailMap["duplicates_remote"].([]driveDuplicateRemotePath)
|
||||
if !ok {
|
||||
t.Fatalf("duplicate detail duplicates_remote type = %T, want []driveDuplicateRemotePath", detailMap["duplicates_remote"])
|
||||
}
|
||||
if len(duplicates) == 0 {
|
||||
t.Fatal("duplicate detail should include at least one rel_path group")
|
||||
}
|
||||
if _, hasLegacyFilesKey := detailMap["files"]; hasLegacyFilesKey {
|
||||
t.Fatalf("duplicate detail should not expose legacy files key: %#v", detailMap)
|
||||
}
|
||||
var matched bool
|
||||
for _, duplicate := range duplicates {
|
||||
if duplicate.RelPath != relPath {
|
||||
continue
|
||||
}
|
||||
matched = true
|
||||
if len(duplicate.Entries) != len(tokens) {
|
||||
t.Fatalf("duplicate entry count = %d, want %d for rel_path %q", len(duplicate.Entries), len(tokens), relPath)
|
||||
}
|
||||
for i, token := range tokens {
|
||||
if duplicate.Entries[i].FileToken != token {
|
||||
t.Fatalf("duplicate entry %d file_token = %q, want %q", i, duplicate.Entries[i].FileToken, token)
|
||||
}
|
||||
if duplicate.Entries[i].Type == "" {
|
||||
t.Fatalf("duplicate entry %d missing type for rel_path %q", i, relPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("duplicate detail missing rel_path group %q: %#v", relPath, duplicates)
|
||||
}
|
||||
raw, marshalErr := json.Marshal(exitErr.Detail.Detail)
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("marshal detail: %v", marshalErr)
|
||||
}
|
||||
text := string(raw)
|
||||
if !strings.Contains(text, relPath) {
|
||||
t.Fatalf("duplicate detail missing rel_path %q: %s", relPath, text)
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if !strings.Contains(text, token) {
|
||||
t.Fatalf("duplicate detail missing token %q: %s", token, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type drivePullStdoutPayload struct {
|
||||
Data struct {
|
||||
Summary struct {
|
||||
Downloaded int `json:"downloaded"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
} `json:"summary"`
|
||||
Items []struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeDrivePullStdout(t *testing.T, raw []byte) drivePullStdoutPayload {
|
||||
t.Helper()
|
||||
var payload drivePullStdoutPayload
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatalf("decode pull stdout: %v\n%s", err, string(raw))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func findPullItem(items []struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
}, relPath string) struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
} {
|
||||
for _, item := range items {
|
||||
if item.RelPath == relPath {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
}{}
|
||||
}
|
||||
|
||||
func expectedRenamedRelPath(relPath, fileToken string, hashLen, attempt int) string {
|
||||
sum := sha256.Sum256([]byte(fileToken))
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
suffix := "__lark_" + hash[:hashLen]
|
||||
if attempt > 0 {
|
||||
suffix = "__lark_" + hash + "_" + strconv.Itoa(attempt)
|
||||
}
|
||||
dir, base := path.Split(relPath)
|
||||
ext := path.Ext(base)
|
||||
if ext == base {
|
||||
return dir + base + suffix
|
||||
}
|
||||
stem := base[:len(base)-len(ext)]
|
||||
return dir + stem + suffix + ext
|
||||
}
|
||||
|
||||
func assertPullItemAction(t *testing.T, raw []byte, relPath, action string) {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatalf("decode pull stdout: %v\n%s", err, string(raw))
|
||||
}
|
||||
for _, item := range payload.Data.Items {
|
||||
if item.RelPath == relPath && item.Action == action {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing pull item %q/%q in stdout: %s", relPath, action, string(raw))
|
||||
}
|
||||
|
||||
func assertPushItemAction(t *testing.T, raw []byte, relPath, action, fileToken string) {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Action string `json:"action"`
|
||||
FileToken string `json:"file_token"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatalf("decode push stdout: %v\n%s", err, string(raw))
|
||||
}
|
||||
for _, item := range payload.Data.Items {
|
||||
if item.RelPath == relPath && item.Action == action && item.FileToken == fileToken {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing push item %q/%q/%q in stdout: %s", relPath, action, fileToken, string(raw))
|
||||
}
|
||||
@@ -28,10 +28,17 @@ const (
|
||||
type drivePullItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type drivePullTarget struct {
|
||||
DownloadToken string
|
||||
ItemFileToken string
|
||||
ItemSourceID string
|
||||
}
|
||||
|
||||
// DrivePull performs a one-way file-level mirror from a Drive folder onto
|
||||
// a local directory: recursively lists --folder-token, downloads each
|
||||
// type=file entry under --local-dir, and optionally deletes local files
|
||||
@@ -54,12 +61,14 @@ var DrivePull = common.Shortcut{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
|
||||
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteRename, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
|
||||
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
"Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.",
|
||||
"Duplicate remote rel_path conflicts fail by default. Use --on-duplicate-remote=rename to download duplicate files with stable hashed suffixes.",
|
||||
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -102,6 +111,10 @@ var DrivePull = common.Shortcut{
|
||||
if ifExists == "" {
|
||||
ifExists = drivePullIfExistsOverwrite
|
||||
}
|
||||
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
|
||||
if duplicateRemote == "" {
|
||||
duplicateRemote = driveDuplicateRemoteFail
|
||||
}
|
||||
deleteLocal := runtime.Bool("delete-local")
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before we
|
||||
@@ -132,10 +145,13 @@ var DrivePull = common.Shortcut{
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
|
||||
return duplicateRemotePathError(duplicates)
|
||||
}
|
||||
// Two views over the same listing:
|
||||
// - remoteFiles drives the download/skip loop (only type=file
|
||||
// has hashable bytes the local mirror can write back).
|
||||
@@ -143,13 +159,9 @@ var DrivePull = common.Shortcut{
|
||||
// rel_path Drive owns regardless of type, so a local file
|
||||
// shadowed by a remote folder / online doc / shortcut is NOT
|
||||
// treated as orphaned.
|
||||
remoteFiles := make(map[string]string, len(entries))
|
||||
remotePaths := make(map[string]struct{}, len(entries))
|
||||
for rel, entry := range entries {
|
||||
remotePaths[rel] = struct{}{}
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[rel] = entry.FileToken
|
||||
}
|
||||
remoteFiles, remotePaths, err := drivePullRemoteViews(entries, duplicateRemote)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%s", err)
|
||||
}
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
@@ -164,7 +176,10 @@ var DrivePull = common.Shortcut{
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
token := remoteFiles[rel]
|
||||
targetFile := remoteFiles[rel]
|
||||
downloadToken := targetFile.DownloadToken
|
||||
itemFileToken := targetFile.ItemFileToken
|
||||
itemSourceID := targetFile.ItemSourceID
|
||||
target := filepath.Join(rootRelToCwd, rel)
|
||||
|
||||
if info, statErr := runtime.FileIO().Stat(target); statErr == nil {
|
||||
@@ -178,7 +193,8 @@ var DrivePull = common.Shortcut{
|
||||
if info.IsDir() {
|
||||
items = append(items, drivePullItem{
|
||||
RelPath: rel,
|
||||
FileToken: token,
|
||||
FileToken: itemFileToken,
|
||||
SourceID: itemSourceID,
|
||||
Action: "failed",
|
||||
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
|
||||
})
|
||||
@@ -187,19 +203,19 @@ var DrivePull = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if ifExists == drivePullIfExistsSkip {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"})
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "skipped"})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := drivePullDownload(ctx, runtime, token, target); err != nil {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()})
|
||||
if err := drivePullDownload(ctx, runtime, downloadToken, target); err != nil {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()})
|
||||
failed++
|
||||
downloadFailed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"})
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "downloaded"})
|
||||
downloaded++
|
||||
}
|
||||
|
||||
@@ -307,6 +323,66 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file
|
||||
return nil
|
||||
}
|
||||
|
||||
func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]drivePullTarget, map[string]struct{}, error) {
|
||||
remoteFiles := make(map[string]drivePullTarget, len(entries))
|
||||
remotePaths := make(map[string]struct{}, len(entries))
|
||||
fileGroups := make(map[string][]driveRemoteEntry)
|
||||
occupied := occupiedRemotePaths(entries)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Type == driveTypeFile {
|
||||
fileGroups[entry.RelPath] = append(fileGroups[entry.RelPath], entry)
|
||||
continue
|
||||
}
|
||||
remotePaths[entry.RelPath] = struct{}{}
|
||||
}
|
||||
|
||||
relPaths := make([]string, 0, len(fileGroups))
|
||||
for rel := range fileGroups {
|
||||
relPaths = append(relPaths, rel)
|
||||
}
|
||||
sort.Strings(relPaths)
|
||||
|
||||
for _, rel := range relPaths {
|
||||
files := fileGroups[rel]
|
||||
if len(files) == 1 {
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: files[0].FileToken, ItemFileToken: files[0].FileToken}
|
||||
remotePaths[rel] = struct{}{}
|
||||
continue
|
||||
}
|
||||
switch duplicateRemote {
|
||||
case driveDuplicateRemoteRename:
|
||||
candidates := append([]driveRemoteEntry(nil), files...)
|
||||
sortRemoteFiles(candidates, driveDuplicateRemoteOldest)
|
||||
for idx, file := range candidates {
|
||||
targetRel := rel
|
||||
if idx > 0 {
|
||||
var err error
|
||||
targetRel, err = relPathWithUniqueFileTokenSuffix(rel, file.FileToken, occupied)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
remoteFiles[targetRel] = drivePullTarget{
|
||||
DownloadToken: file.FileToken,
|
||||
ItemSourceID: stableTokenIdentifier(file.FileToken),
|
||||
}
|
||||
remotePaths[targetRel] = struct{}{}
|
||||
}
|
||||
case driveDuplicateRemoteNewest, driveDuplicateRemoteOldest:
|
||||
chosen, err := chooseRemoteFile(files, duplicateRemote)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken}
|
||||
remotePaths[rel] = struct{}{}
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
|
||||
}
|
||||
}
|
||||
return remoteFiles, remotePaths, nil
|
||||
}
|
||||
|
||||
// drivePullWalkLocal walks the canonical absolute root and returns the
|
||||
// absolute paths of every regular file underneath it. The caller deletes
|
||||
// some of these paths, so it is critical that they are produced by
|
||||
|
||||
@@ -293,6 +293,49 @@ func TestDrivePullPaginationHandlesPageTokenField(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullRenameSummarizesDuplicateDownloadsAndAvoidsRawTokenInRelPath(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
|
||||
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
renamedRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
|
||||
payload := decodeDrivePullStdout(t, stdout.Bytes())
|
||||
if got := payload.Data.Summary.Downloaded; got != 2 {
|
||||
t.Fatalf("summary.downloaded = %d, want 2", got)
|
||||
}
|
||||
if out := stdout.String(); strings.Contains(out, duplicateRemoteFileIDSecond) {
|
||||
t.Fatalf("stdout should not expose the raw duplicate file token in rename mode, got: %s", out)
|
||||
}
|
||||
if item := findPullItem(payload.Data.Items, renamedRelPath); item.SourceID == "" || item.FileToken != "" {
|
||||
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
|
||||
}
|
||||
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
|
||||
mustReadFile(t, filepath.Join("local", renamedRelPath), "SECOND")
|
||||
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
|
||||
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
// TestDrivePullDeleteLocalRequiresYes verifies the upfront safety guard:
|
||||
// --delete-local without --yes must be rejected before any API call.
|
||||
func TestDrivePullDeleteLocalRequiresYes(t *testing.T) {
|
||||
|
||||
@@ -92,12 +92,14 @@ var DrivePush = common.Shortcut{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}},
|
||||
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
|
||||
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
|
||||
},
|
||||
Tips: []string{
|
||||
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
|
||||
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
|
||||
"Duplicate remote rel_path conflicts fail by default before upload, overwrite, or delete. Use --on-duplicate-remote=newest|oldest only when the conflict is duplicate files and you explicitly want to target one.",
|
||||
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.",
|
||||
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
|
||||
@@ -164,6 +166,10 @@ var DrivePush = common.Shortcut{
|
||||
// rolling-out upload_all `file_token`/`version` protocol field.
|
||||
ifExists = drivePushIfExistsSkip
|
||||
}
|
||||
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
|
||||
if duplicateRemote == "" {
|
||||
duplicateRemote = driveDuplicateRemoteFail
|
||||
}
|
||||
deleteRemote := runtime.Bool("delete-remote")
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before walking.
|
||||
@@ -190,10 +196,13 @@ var DrivePush = common.Shortcut{
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
|
||||
return duplicateRemotePathError(duplicates)
|
||||
}
|
||||
// Two views over the same listing:
|
||||
// - remoteFiles drives upload / overwrite / orphan-delete
|
||||
// decisions (only type=file entries are upload candidates;
|
||||
@@ -203,15 +212,9 @@ var DrivePush = common.Shortcut{
|
||||
// path skip create_folder when an intermediate folder already
|
||||
// exists, and keeps directory recreation idempotent across
|
||||
// reruns.
|
||||
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
|
||||
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
|
||||
for rel, entry := range entries {
|
||||
switch entry.Type {
|
||||
case driveTypeFile:
|
||||
remoteFiles[rel] = entry
|
||||
case driveTypeFolder:
|
||||
remoteFolders[rel] = entry
|
||||
}
|
||||
remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%s", err)
|
||||
}
|
||||
|
||||
var uploaded, skipped, failed, deletedRemote int
|
||||
@@ -333,24 +336,31 @@ var DrivePush = common.Shortcut{
|
||||
}
|
||||
if deleteRemote && !uploadFailed {
|
||||
// Stable iteration order so failures (and tests) are deterministic.
|
||||
remoteRelPaths := make([]string, 0, len(remoteFiles))
|
||||
for p := range remoteFiles {
|
||||
remoteRelPaths := make([]string, 0, len(remoteFileGroups))
|
||||
for p := range remoteFileGroups {
|
||||
remoteRelPaths = append(remoteRelPaths, p)
|
||||
}
|
||||
sort.Strings(remoteRelPaths)
|
||||
|
||||
for _, rel := range remoteRelPaths {
|
||||
keepToken := ""
|
||||
if _, ok := localFiles[rel]; ok {
|
||||
continue
|
||||
if chosen, ok := remoteFiles[rel]; ok {
|
||||
keepToken = chosen.FileToken
|
||||
}
|
||||
}
|
||||
entry := remoteFiles[rel]
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
for _, entry := range remoteFileGroups[rel] {
|
||||
if entry.FileToken == keepToken {
|
||||
continue
|
||||
}
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
|
||||
deletedRemote++
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
|
||||
deletedRemote++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,6 +473,46 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
|
||||
return files, dirs, nil
|
||||
}
|
||||
|
||||
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
|
||||
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
|
||||
fileGroups := make(map[string][]driveRemoteEntry)
|
||||
|
||||
for _, entry := range entries {
|
||||
switch entry.Type {
|
||||
case driveTypeFile:
|
||||
fileGroups[entry.RelPath] = append(fileGroups[entry.RelPath], entry)
|
||||
case driveTypeFolder:
|
||||
remoteFolders[entry.RelPath] = entry
|
||||
}
|
||||
}
|
||||
|
||||
relPaths := make([]string, 0, len(fileGroups))
|
||||
for rel := range fileGroups {
|
||||
relPaths = append(relPaths, rel)
|
||||
}
|
||||
sort.Strings(relPaths)
|
||||
|
||||
for _, rel := range relPaths {
|
||||
files := fileGroups[rel]
|
||||
if len(files) == 1 {
|
||||
remoteFiles[rel] = files[0]
|
||||
continue
|
||||
}
|
||||
switch duplicateRemote {
|
||||
case driveDuplicateRemoteNewest, driveDuplicateRemoteOldest:
|
||||
chosen, err := chooseRemoteFile(files, duplicateRemote)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
remoteFiles[rel] = chosen
|
||||
default:
|
||||
return nil, nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
|
||||
}
|
||||
}
|
||||
return remoteFiles, remoteFolders, fileGroups, nil
|
||||
}
|
||||
|
||||
// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root
|
||||
// folder identified by rootFolderToken) exists on Drive, creating any
|
||||
// missing segments via /open-apis/drive/v1/files/create_folder. Returns the
|
||||
|
||||
@@ -454,6 +454,124 @@ func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "dup-new-token",
|
||||
"version": "v99",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "overwrite",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
body := decodeDriveMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
|
||||
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"uploaded": 1`) {
|
||||
t.Fatalf("expected uploaded=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"deleted_remote": 1`) {
|
||||
t.Fatalf("expected deleted_remote=1, got: %s", out)
|
||||
}
|
||||
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
||||
if deleteStub.CapturedHeaders == nil {
|
||||
t.Fatal("DELETE for the unchosen duplicate sibling was never issued")
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushDeleteRemoteDeletesEntireDuplicateGroupWithoutLocalCounterpart(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
deleteFirst := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
deleteSecond := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(deleteFirst)
|
||||
reg.Register(deleteSecond)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "skip",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"uploaded": 0`) {
|
||||
t.Fatalf("expected uploaded=0, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"deleted_remote": 2`) {
|
||||
t.Fatalf("expected deleted_remote=2, got: %s", out)
|
||||
}
|
||||
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
||||
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDSecond)
|
||||
if deleteFirst.CapturedHeaders == nil || deleteSecond.CapturedHeaders == nil {
|
||||
t.Fatal("expected both duplicate remote DELETE requests to be issued")
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
// TestDrivePushRejectsAbsoluteLocalDir confirms SafeLocalFlagPath surfaces
|
||||
// the proper flag name in the error message.
|
||||
func TestDrivePushRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
|
||||
@@ -118,19 +118,22 @@ var DriveStatus = common.Shortcut{
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duplicates := duplicateRemoteFilePaths(entries); len(duplicates) > 0 {
|
||||
return duplicateRemotePathError(duplicates)
|
||||
}
|
||||
// +status only diffs binary content, so collapse the unified
|
||||
// listing to type=file. Online docs / shortcuts have no
|
||||
// hashable bytes and are intentionally absent from the diff
|
||||
// view (a docx living next to a same-named local file is a
|
||||
// known no-op).
|
||||
remoteFiles := make(map[string]string, len(entries))
|
||||
for rel, entry := range entries {
|
||||
for _, entry := range entries {
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[rel] = entry.FileToken
|
||||
remoteFiles[entry.RelPath] = entry.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,37 @@ func TestDriveStatusPaginatesRemoteListing(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDriveStatusFailsOnRemoteFileFolderConflict(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
|
||||
{"token": "nested-file-token", "name": "child.txt", "type": "file", "size": 1, "created_time": "3", "modified_time": "3"},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDriveStatusRejectsMissingLocalDir(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -5,8 +5,14 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -14,52 +20,63 @@ const (
|
||||
driveListRemotePageSize = 200
|
||||
driveTypeFile = "file"
|
||||
driveTypeFolder = "folder"
|
||||
driveUniqueSuffixMaxSeq = 1024
|
||||
)
|
||||
|
||||
// driveRemoteEntry is one Drive entry returned by listRemoteFolder. It
|
||||
// driveRemoteEntry is one Drive entry returned by listRemoteFolderEntries. It
|
||||
// carries enough metadata for every shortcut that consumes the listing
|
||||
// to build its own per-shortcut view by filtering on Type.
|
||||
type driveRemoteEntry struct {
|
||||
// FileToken is the Drive token for this entry. For type=folder this
|
||||
// is the folder_token; for everything else it is the file_token.
|
||||
FileToken string
|
||||
Name string
|
||||
Size int64
|
||||
// Type is the Drive entry kind verbatim from the API:
|
||||
// "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" |
|
||||
// "mindnote" | "slides" | "shortcut" | …
|
||||
Type string
|
||||
Type string
|
||||
CreatedTime string
|
||||
ModifiedTime string
|
||||
// RelPath is the entry's path relative to the listing root. Encoded
|
||||
// with "/" separators on every platform so it matches the rel_paths
|
||||
// produced by the shortcuts' local walkers.
|
||||
RelPath string
|
||||
}
|
||||
|
||||
// listRemoteFolder recursively lists folderToken under relBase and
|
||||
// returns one entry per Drive item, keyed by rel_path. Subfolders are
|
||||
// descended into and the folder's own entry is also recorded — callers
|
||||
// can reason about "this rel_path is occupied by a folder" without
|
||||
// re-listing.
|
||||
type driveDuplicateRemoteEntry struct {
|
||||
FileToken string `json:"file_token"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
CreatedTime string `json:"created_time,omitempty"`
|
||||
ModifiedTime string `json:"modified_time,omitempty"`
|
||||
}
|
||||
|
||||
type driveDuplicateRemotePath struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Entries []driveDuplicateRemoteEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// listRemoteFolderEntries recursively lists folderToken under relBase and
|
||||
// returns one entry per Drive item. Subfolders are descended into and the
|
||||
// folder's own entry is also recorded, allowing callers to detect multiple
|
||||
// remote files that map to the same rel_path.
|
||||
//
|
||||
// This is the shared backbone for the three sync-disk shortcuts. None
|
||||
// of them need every field at every call site, so each one filters
|
||||
// on Type:
|
||||
// The helper deliberately stores every Drive object kind. Online docs and
|
||||
// shortcuts are skipped by sync shortcuts later, but preserving their rel_path
|
||||
// here prevents destructive mirror modes from treating a local same-named
|
||||
// regular file as an orphan when Drive already owns that path.
|
||||
//
|
||||
// - +status (drive_status.go) keeps Type=="file" and uses FileToken
|
||||
// to drive content-hash diffs against the local tree.
|
||||
// - +pull (drive_pull.go) keeps Type=="file" + FileToken for the
|
||||
// download set, and the full key set (every rel_path) as the
|
||||
// guard for --delete-local.
|
||||
// - +push (drive_push.go) keeps Type=="file" + FileToken for upload /
|
||||
// overwrite / orphan-delete decisions, and Type=="folder" + FileToken
|
||||
// for the create_folder cache.
|
||||
//
|
||||
// Pagination uses common.PaginationMeta, which accepts both
|
||||
// page_token and next_page_token — the Drive list endpoint has
|
||||
// historically returned the latter, but the helper future-proofs
|
||||
// against a backend rename.
|
||||
func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]driveRemoteEntry, error) {
|
||||
out := make(map[string]driveRemoteEntry)
|
||||
// Pagination uses common.PaginationMeta, which accepts both page_token and
|
||||
// next_page_token.
|
||||
func listRemoteFolderEntries(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) ([]driveRemoteEntry, error) {
|
||||
var out []driveRemoteEntry
|
||||
pageToken := ""
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"folder_token": folderToken,
|
||||
"page_size": fmt.Sprint(driveListRemotePageSize),
|
||||
@@ -84,15 +101,24 @@ func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folde
|
||||
continue
|
||||
}
|
||||
rel := joinRelDrive(relBase, fName)
|
||||
out[rel] = driveRemoteEntry{FileToken: fToken, Type: fType, RelPath: rel}
|
||||
out = append(out, driveRemoteEntry{
|
||||
FileToken: fToken,
|
||||
Name: fName,
|
||||
Size: int64(common.GetFloat(f, "size")),
|
||||
Type: fType,
|
||||
CreatedTime: common.GetString(f, "created_time"),
|
||||
ModifiedTime: common.GetString(f, "modified_time"),
|
||||
RelPath: rel,
|
||||
})
|
||||
if fType == driveTypeFolder {
|
||||
sub, err := listRemoteFolder(ctx, runtime, fToken, rel)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub, err := listRemoteFolderEntries(ctx, runtime, fToken, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range sub {
|
||||
out[k] = v
|
||||
}
|
||||
out = append(out, sub...)
|
||||
}
|
||||
}
|
||||
hasMore, nextToken := common.PaginationMeta(result)
|
||||
@@ -104,6 +130,208 @@ func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folde
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemotePath {
|
||||
groups := make(map[string][]driveRemoteEntry)
|
||||
for _, entry := range entries {
|
||||
groups[entry.RelPath] = append(groups[entry.RelPath], entry)
|
||||
}
|
||||
|
||||
relPaths := make([]string, 0, len(groups))
|
||||
for relPath, grouped := range groups {
|
||||
if len(grouped) > 1 {
|
||||
relPaths = append(relPaths, relPath)
|
||||
}
|
||||
}
|
||||
sort.Strings(relPaths)
|
||||
|
||||
duplicates := make([]driveDuplicateRemotePath, 0, len(relPaths))
|
||||
for _, relPath := range relPaths {
|
||||
grouped := append([]driveRemoteEntry(nil), groups[relPath]...)
|
||||
sort.SliceStable(grouped, func(i, j int) bool {
|
||||
if grouped[i].Type != grouped[j].Type {
|
||||
return grouped[i].Type < grouped[j].Type
|
||||
}
|
||||
if cmp, ok := compareDriveTimes(grouped[i].CreatedTime, grouped[j].CreatedTime); ok && cmp != 0 {
|
||||
return cmp < 0
|
||||
}
|
||||
if cmp, ok := compareDriveTimes(grouped[i].ModifiedTime, grouped[j].ModifiedTime); ok && cmp != 0 {
|
||||
return cmp < 0
|
||||
}
|
||||
return grouped[i].FileToken < grouped[j].FileToken
|
||||
})
|
||||
dupEntries := make([]driveDuplicateRemoteEntry, 0, len(grouped))
|
||||
for _, entry := range grouped {
|
||||
dupEntries = append(dupEntries, driveDuplicateRemoteEntry{
|
||||
FileToken: entry.FileToken,
|
||||
Name: entry.Name,
|
||||
Type: entry.Type,
|
||||
Size: entry.Size,
|
||||
CreatedTime: entry.CreatedTime,
|
||||
ModifiedTime: entry.ModifiedTime,
|
||||
})
|
||||
}
|
||||
duplicates = append(duplicates, driveDuplicateRemotePath{RelPath: relPath, Entries: dupEntries})
|
||||
}
|
||||
return duplicates
|
||||
}
|
||||
|
||||
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) *output.ExitError {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "duplicate_remote_path",
|
||||
Message: "multiple Drive entries map to the same rel_path",
|
||||
Detail: map[string]interface{}{
|
||||
"duplicates_remote": duplicates,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
driveDuplicateRemoteFail = "fail"
|
||||
driveDuplicateRemoteRename = "rename"
|
||||
driveDuplicateRemoteNewest = "newest"
|
||||
driveDuplicateRemoteOldest = "oldest"
|
||||
)
|
||||
|
||||
func sortRemoteFiles(files []driveRemoteEntry, strategy string) {
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
a, b := files[i], files[j]
|
||||
switch strategy {
|
||||
case driveDuplicateRemoteNewest:
|
||||
if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 {
|
||||
return cmp > 0
|
||||
} else if !ok {
|
||||
return a.FileToken < b.FileToken
|
||||
}
|
||||
if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 {
|
||||
return cmp > 0
|
||||
} else if !ok {
|
||||
return a.FileToken < b.FileToken
|
||||
}
|
||||
default:
|
||||
if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 {
|
||||
return cmp < 0
|
||||
} else if !ok {
|
||||
return a.FileToken < b.FileToken
|
||||
}
|
||||
if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 {
|
||||
return cmp < 0
|
||||
} else if !ok {
|
||||
return a.FileToken < b.FileToken
|
||||
}
|
||||
}
|
||||
return a.FileToken < b.FileToken
|
||||
})
|
||||
}
|
||||
|
||||
func compareDriveTimes(a, b string) (int, bool) {
|
||||
av, aErr := strconv.ParseInt(a, 10, 64)
|
||||
bv, bErr := strconv.ParseInt(b, 10, 64)
|
||||
if aErr != nil || bErr != nil {
|
||||
return 0, false
|
||||
}
|
||||
switch {
|
||||
case av < bv:
|
||||
return -1, true
|
||||
case av > bv:
|
||||
return 1, true
|
||||
default:
|
||||
return 0, true
|
||||
}
|
||||
}
|
||||
|
||||
func chooseRemoteFile(files []driveRemoteEntry, strategy string) (driveRemoteEntry, error) {
|
||||
if len(files) == 0 {
|
||||
return driveRemoteEntry{}, fmt.Errorf("no Drive entries available for strategy %q", strategy)
|
||||
}
|
||||
candidates := append([]driveRemoteEntry(nil), files...)
|
||||
sortRemoteFiles(candidates, strategy)
|
||||
return candidates[0], nil
|
||||
}
|
||||
|
||||
func isFileOnlyDuplicatePath(duplicate driveDuplicateRemotePath) bool {
|
||||
if len(duplicate.Entries) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, entry := range duplicate.Entries {
|
||||
if entry.Type != driveTypeFile {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func blockingRemotePathConflicts(entries []driveRemoteEntry, duplicateRemote string) []driveDuplicateRemotePath {
|
||||
duplicates := duplicateRemoteFilePaths(entries)
|
||||
if duplicateRemote == driveDuplicateRemoteFail {
|
||||
return duplicates
|
||||
}
|
||||
blocking := make([]driveDuplicateRemotePath, 0, len(duplicates))
|
||||
for _, duplicate := range duplicates {
|
||||
if !isFileOnlyDuplicatePath(duplicate) {
|
||||
blocking = append(blocking, duplicate)
|
||||
}
|
||||
}
|
||||
return blocking
|
||||
}
|
||||
|
||||
func occupiedRemotePaths(entries []driveRemoteEntry) map[string]struct{} {
|
||||
occupied := make(map[string]struct{}, len(entries))
|
||||
for _, entry := range entries {
|
||||
occupied[entry.RelPath] = struct{}{}
|
||||
}
|
||||
return occupied
|
||||
}
|
||||
|
||||
func stableTokenHash(fileToken string) string {
|
||||
sum := sha256.Sum256([]byte(fileToken))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func stableTokenIdentifier(fileToken string) string {
|
||||
hash := stableTokenHash(fileToken)
|
||||
if len(hash) > 12 {
|
||||
hash = hash[:12]
|
||||
}
|
||||
return "hash_" + hash
|
||||
}
|
||||
|
||||
func relPathWithSuffix(relPath, suffix string) string {
|
||||
dir, base := path.Split(relPath)
|
||||
ext := path.Ext(base)
|
||||
if ext == base {
|
||||
return dir + base + suffix
|
||||
}
|
||||
stem := base[:len(base)-len(ext)]
|
||||
return dir + stem + suffix + ext
|
||||
}
|
||||
|
||||
func relPathWithUniqueFileTokenSuffix(relPath, fileToken string, occupied map[string]struct{}) (string, error) {
|
||||
tokenHash := stableTokenHash(fileToken)
|
||||
suffixes := []string{
|
||||
"__lark_" + tokenHash[:12],
|
||||
"__lark_" + tokenHash[:24],
|
||||
"__lark_" + tokenHash,
|
||||
}
|
||||
for _, suffix := range suffixes {
|
||||
candidate := relPathWithSuffix(relPath, suffix)
|
||||
if _, exists := occupied[candidate]; !exists {
|
||||
occupied[candidate] = struct{}{}
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ {
|
||||
candidate := relPathWithSuffix(relPath, "__lark_"+tokenHash+"_"+strconv.Itoa(attempt))
|
||||
if _, exists := occupied[candidate]; !exists {
|
||||
occupied[candidate] = struct{}{}
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq)
|
||||
}
|
||||
|
||||
// joinRelDrive joins a rel_path base with an entry name using "/".
|
||||
// Empty base means the entry sits at the listing root. Mirrors the
|
||||
// behavior the per-shortcut helpers used to ship and keeps rel_paths
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -32,6 +34,18 @@ var mentionFixRe = regexp.MustCompile(`<at\s+(id|open_id|user_id)=("?)([^"\s/>]+
|
||||
var threadIDRe = regexp.MustCompile(`^omt_`)
|
||||
var messageIDRe = regexp.MustCompile(`^om_`)
|
||||
|
||||
func flagMessageID(rt *common.RuntimeContext) (string, error) {
|
||||
id := strings.TrimSpace(rt.Str("message-id"))
|
||||
if id == "" {
|
||||
return "", output.ErrValidation("--message-id is required")
|
||||
}
|
||||
if strings.HasPrefix(id, "omt_") {
|
||||
return "", output.ErrValidation(
|
||||
"invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id)
|
||||
}
|
||||
return validateMessageID(id)
|
||||
}
|
||||
|
||||
func normalizeAtMentions(content string) string {
|
||||
return mentionFixRe.ReplaceAllString(content, `<at user_id="$3">`)
|
||||
}
|
||||
@@ -1432,3 +1446,222 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
|
||||
// FlagType enumerates the kind of bookmark.
|
||||
// Aligned with server-side constants: Unknown=0, Feed=1, Message=2.
|
||||
type FlagType int
|
||||
|
||||
const (
|
||||
FlagTypeUnknown FlagType = 0
|
||||
FlagTypeFeed FlagType = 1
|
||||
FlagTypeMessage FlagType = 2
|
||||
)
|
||||
|
||||
// ItemType enumerates the kind of thing being bookmarked.
|
||||
// Server-side constants (only the types used by IM flags):
|
||||
//
|
||||
// default=0, thread=4, msg_thread=11.
|
||||
//
|
||||
// Note on the two thread-shaped item types:
|
||||
// - ItemTypeThread (4) — thread inside a topic-style chat
|
||||
// - ItemTypeMsgThread (11) — thread inside a regular chat
|
||||
type ItemType int
|
||||
|
||||
const (
|
||||
ItemTypeDefault ItemType = 0
|
||||
ItemTypeThread ItemType = 4 // thread in a topic-style chat
|
||||
ItemTypeMsgThread ItemType = 11 // thread in a regular chat
|
||||
)
|
||||
|
||||
const (
|
||||
flagWriteScope = "im:feed.flag:write"
|
||||
flagReadScope = "im:feed.flag:read"
|
||||
)
|
||||
|
||||
var (
|
||||
flagWriteLookupScopes = append([]string{flagWriteScope}, flagLookupScopes...)
|
||||
flagMessageReadScopes = []string{
|
||||
"im:message.group_msg:get_as_user",
|
||||
"im:message.p2p_msg:get_as_user",
|
||||
}
|
||||
flagLookupScopes = []string{
|
||||
"im:message.group_msg:get_as_user",
|
||||
"im:message.p2p_msg:get_as_user",
|
||||
"im:chat:read",
|
||||
}
|
||||
)
|
||||
|
||||
func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, required []string) error {
|
||||
if len(required) == 0 {
|
||||
return nil
|
||||
}
|
||||
result, err := rt.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(rt.As(), rt.Config.AppID))
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitAuth, "auth",
|
||||
fmt.Sprintf("cannot verify required scope(s): %v", err),
|
||||
flagScopeLoginHint(required))
|
||||
}
|
||||
if result == nil || result.Scopes == "" {
|
||||
fmt.Fprintf(rt.IO().ErrOut,
|
||||
"warning: cannot verify required scope(s) because token scope metadata is unavailable; API may fail if missing: %s\n",
|
||||
strings.Join(required, " "))
|
||||
return nil
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
flagScopeLoginHint(missing))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flagScopeLoginHint(scopes []string) string {
|
||||
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(scopes, " "))
|
||||
}
|
||||
|
||||
// flagItem is one entry in the flags API body. The server expects numeric
|
||||
// enums serialized as strings.
|
||||
type flagItem struct {
|
||||
ItemID string `json:"item_id"`
|
||||
ItemType string `json:"item_type"`
|
||||
FlagType string `json:"flag_type"`
|
||||
}
|
||||
|
||||
// parseItemID inspects an om_ prefix and returns a best-guess
|
||||
// (itemType, flagType) pair. Used when the user omits the explicit enums.
|
||||
// - om_xxx → (default, message)
|
||||
func parseItemID(id string) (ItemType, FlagType, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
switch {
|
||||
case strings.HasPrefix(id, "om_"):
|
||||
return ItemTypeDefault, FlagTypeMessage, nil
|
||||
case id == "":
|
||||
return 0, 0, output.ErrValidation("--message-id cannot be empty")
|
||||
default:
|
||||
return 0, 0, output.ErrValidation(
|
||||
"cannot infer item type from id %q: expected om_ (message) prefix; "+
|
||||
"pass --item-type and --flag-type explicitly if you are using a different id format", id)
|
||||
}
|
||||
}
|
||||
|
||||
// parseItemType converts a user-facing string to the server enum.
|
||||
func parseItemType(s string) (ItemType, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "default":
|
||||
return ItemTypeDefault, nil
|
||||
case "thread":
|
||||
return ItemTypeThread, nil
|
||||
case "msg_thread":
|
||||
return ItemTypeMsgThread, nil
|
||||
}
|
||||
return 0, output.ErrValidation("invalid --item-type %q: expected one of default|thread|msg_thread", s)
|
||||
}
|
||||
|
||||
// parseFlagType converts a user-facing string to the server enum.
|
||||
func parseFlagType(s string) (FlagType, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "message":
|
||||
return FlagTypeMessage, nil
|
||||
case "feed":
|
||||
return FlagTypeFeed, nil
|
||||
}
|
||||
return 0, output.ErrValidation("invalid --flag-type %q: expected one of message|feed", s)
|
||||
}
|
||||
|
||||
// isValidCombo checks if the (ItemType, FlagType) pair is accepted by the server.
|
||||
// Note: (ItemType, FlagType) is shorthand for (item_type, flag_type) — the two
|
||||
// enum fields that determine which layer the flag operates on.
|
||||
//
|
||||
// Valid combinations are:
|
||||
// - (default, message) — regular chat message (message-layer flag)
|
||||
// - (thread, feed) — thread as feed-layer flag (topic-style chat)
|
||||
// - (msg_thread, feed) — message-thread as feed-layer flag (regular chat)
|
||||
func isValidCombo(it ItemType, ft FlagType) bool {
|
||||
return (it == ItemTypeDefault && ft == FlagTypeMessage) ||
|
||||
(it == ItemTypeThread && ft == FlagTypeFeed) ||
|
||||
(it == ItemTypeMsgThread && ft == FlagTypeFeed)
|
||||
}
|
||||
|
||||
// parseItemTypeFromRaw parses a stringified numeric item_type back to ItemType.
|
||||
// Used when re-parsing the serialized enum for combo-validity checks.
|
||||
// Note: Unknown values return ItemTypeDefault (0). This is safe because:
|
||||
// 1. This function only parses values we serialized ourselves via newFlagItem
|
||||
// 2. Unknown server values would fail combo validation or be rejected by the server
|
||||
func parseItemTypeFromRaw(s string) ItemType {
|
||||
switch s {
|
||||
case "0":
|
||||
return ItemTypeDefault
|
||||
case "4":
|
||||
return ItemTypeThread
|
||||
case "11":
|
||||
return ItemTypeMsgThread
|
||||
}
|
||||
return ItemTypeDefault
|
||||
}
|
||||
|
||||
// parseFlagTypeFromRaw parses a stringified numeric flag_type back to FlagType.
|
||||
// Used when re-parsing the serialized enum for combo-validity checks.
|
||||
func parseFlagTypeFromRaw(s string) FlagType {
|
||||
switch s {
|
||||
case "1":
|
||||
return FlagTypeFeed
|
||||
case "2":
|
||||
return FlagTypeMessage
|
||||
}
|
||||
return FlagTypeUnknown
|
||||
}
|
||||
|
||||
// newFlagItem builds a payload entry with numeric-stringified enums.
|
||||
func newFlagItem(itemID string, it ItemType, ft FlagType) flagItem {
|
||||
return flagItem{
|
||||
ItemID: itemID,
|
||||
ItemType: fmt.Sprintf("%d", int(it)),
|
||||
FlagType: fmt.Sprintf("%d", int(ft)),
|
||||
}
|
||||
}
|
||||
|
||||
// getMessageChatID queries the message API to get the chat_id.
|
||||
// Used by flag-create to determine the chat type for feed-layer flags.
|
||||
func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, error) {
|
||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
items, ok := data["items"].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
return "", output.ErrValidation("message not found or unexpected API response format")
|
||||
}
|
||||
|
||||
msg, ok := items[0].(map[string]any)
|
||||
if !ok {
|
||||
return "", output.ErrValidation("unexpected message format in API response")
|
||||
}
|
||||
|
||||
chatID, ok := msg["chat_id"].(string)
|
||||
if !ok {
|
||||
return "", output.ErrValidation("message response missing chat_id field")
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
// resolveThreadFeedItemType determines the correct feed-layer ItemType for a thread
|
||||
// by querying the chat API for chat_mode.
|
||||
// - topic-style chat → ItemTypeThread
|
||||
// - regular chat → ItemTypeMsgThread
|
||||
//
|
||||
// Returns an error if the chat query fails, since guessing the wrong item_type
|
||||
// can cause silent failures in flag operations.
|
||||
func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemType, error) {
|
||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil)
|
||||
if err != nil {
|
||||
return ItemTypeDefault, fmt.Errorf("failed to query chat_mode for chat %s: %w", chatID, err)
|
||||
}
|
||||
|
||||
// DoAPIJSON returns envelope.Data, so chat_mode is at the top level
|
||||
chatMode, _ := data["chat_mode"].(string)
|
||||
if chatMode == "topic" {
|
||||
return ItemTypeThread, nil
|
||||
}
|
||||
return ItemTypeMsgThread, nil
|
||||
}
|
||||
|
||||
@@ -868,6 +868,9 @@ func TestShortcuts(t *testing.T) {
|
||||
"+messages-search",
|
||||
"+messages-send",
|
||||
"+threads-messages-list",
|
||||
"+flag-create",
|
||||
"+flag-cancel",
|
||||
"+flag-list",
|
||||
}
|
||||
if !reflect.DeepEqual(commands, want) {
|
||||
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)
|
||||
|
||||
247
shortcuts/im/im_flag_cancel.go
Normal file
247
shortcuts/im/im_flag_cancel.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ImFlagCancel provides the +flag-cancel shortcut for removing a bookmark.
|
||||
// When no --flag-type is given, it performs double-cancel: removes both message and feed layers.
|
||||
var ImFlagCancel = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+flag-cancel",
|
||||
Description: "Cancel (remove) a bookmark. When no --flag-type is given, " +
|
||||
"performs double-cancel: removes both message and feed layers",
|
||||
Risk: "write",
|
||||
UserScopes: flagWriteLookupScopes,
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "message ID (om_xxx)"},
|
||||
{Name: "item-type", Desc: "item type override: default|thread|msg_thread"},
|
||||
{Name: "flag-type", Desc: "flag type override: message|feed; omit to double-cancel both layers"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := buildCancelItemsForPreview(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
items, _, err := buildCancelItemsForPreview(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/im/v1/flags/cancel").
|
||||
Body(map[string]any{"flag_items": items})
|
||||
if len(items) > 1 {
|
||||
d.Desc("double-cancel: tries both message and feed layers (best-effort); feed-layer skipped if chat_type undeterminable")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
items, err := buildCancelItems(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make separate API calls for each item so they are independent.
|
||||
// If one fails, the other can still succeed.
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
var lastErr error
|
||||
for _, item := range items {
|
||||
itemType := itemTypeString(parseItemTypeFromRaw(item.ItemType))
|
||||
flagType := flagTypeString(parseFlagTypeFromRaw(item.FlagType))
|
||||
result := map[string]any{
|
||||
"item_id": item.ItemID,
|
||||
"item_type": itemType,
|
||||
"flag_type": flagType,
|
||||
}
|
||||
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags/cancel", nil,
|
||||
map[string]any{"flag_items": []flagItem{item}})
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: cancel failed for %s/%s: %v\n",
|
||||
itemType, flagType, err)
|
||||
result["status"] = "failed"
|
||||
result["error"] = err.Error()
|
||||
lastErr = err
|
||||
} else {
|
||||
result["status"] = "ok"
|
||||
result["response"] = data
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]any{"results": results}, nil)
|
||||
return lastErr
|
||||
},
|
||||
}
|
||||
|
||||
// buildCancelItemsForPreview builds cancel items without API calls.
|
||||
// It shows double-cancel when no explicit flags are provided.
|
||||
// DryRun cannot query chat_mode, so feed-layer item_type is represented with
|
||||
// the same auto-detect placeholder used by +flag-create.
|
||||
func buildCancelItemsForPreview(rt *common.RuntimeContext) ([]any, bool, error) {
|
||||
id, err := flagMessageID(rt)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
itOverride := strings.TrimSpace(rt.Str("item-type"))
|
||||
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
|
||||
|
||||
// Explicit override provided → single targeted delete
|
||||
if itOverride != "" || ftOverride != "" {
|
||||
item, err := buildSingleCancelItem(id, itOverride, ftOverride)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return []any{item}, false, nil
|
||||
}
|
||||
|
||||
// No override: show double-cancel (message + feed layers)
|
||||
// Dry-run shows both layers; actual execution is best-effort.
|
||||
return []any{
|
||||
newFlagItem(id, ItemTypeDefault, FlagTypeMessage),
|
||||
map[string]string{
|
||||
"item_id": id,
|
||||
"item_type": "<auto:thread|msg_thread>",
|
||||
"flag_type": fmt.Sprintf("%d", int(FlagTypeFeed)),
|
||||
},
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// buildCancelItems picks the (item_type, flag_type) pairs to cancel.
|
||||
//
|
||||
// Logic:
|
||||
// 1. If --flag-type is explicitly provided, do a single targeted delete.
|
||||
// 2. Otherwise, perform double-cancel: remove both message layer and feed layer.
|
||||
// - Message layer is always included (uses known message_id with ItemTypeDefault)
|
||||
// - Feed layer is best-effort: if chat_type cannot be determined, skip with warning
|
||||
// - Each layer is independent; failure to cancel one doesn't block the other
|
||||
func buildCancelItems(rt *common.RuntimeContext) ([]flagItem, error) {
|
||||
id, err := flagMessageID(rt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itOverride := strings.TrimSpace(rt.Str("item-type"))
|
||||
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
|
||||
|
||||
// Explicit override provided → single targeted delete
|
||||
if itOverride != "" || ftOverride != "" {
|
||||
item, err := buildSingleCancelItem(id, itOverride, ftOverride)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []flagItem{item}, nil
|
||||
}
|
||||
|
||||
// Double-cancel: message layer + feed layer (best effort)
|
||||
// Message layer is always included - we have the message_id and know the combo is valid.
|
||||
items := []flagItem{newFlagItem(id, ItemTypeDefault, FlagTypeMessage)}
|
||||
|
||||
// Feed layer: try to determine chat_type, but don't fail if we can't.
|
||||
// Most messages only have one layer flagged, so this is best-effort cleanup.
|
||||
chatID, err := getMessageChatID(rt, id)
|
||||
if err != nil {
|
||||
// Can't get chat_id, warn and skip feed layer
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: cannot determine feed-layer item_type: %v; skipping feed-layer cancel\n", err)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
feedIT, err := resolveThreadFeedItemType(rt, chatID)
|
||||
if err != nil {
|
||||
// Can't determine chat_type, warn and skip feed layer
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: cannot determine feed-layer item_type: %v; skipping feed-layer cancel\n", err)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Include feed layer
|
||||
items = append(items, newFlagItem(id, feedIT, FlagTypeFeed))
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// buildSingleCancelItem builds a single cancel item when user provides explicit flags.
|
||||
func buildSingleCancelItem(id, itOverride, ftOverride string) (flagItem, error) {
|
||||
var itemType ItemType
|
||||
var flagType FlagType
|
||||
|
||||
if itOverride != "" {
|
||||
it, err := parseItemType(itOverride)
|
||||
if err != nil {
|
||||
return flagItem{}, err
|
||||
}
|
||||
itemType = it
|
||||
}
|
||||
if ftOverride != "" {
|
||||
ft, err := parseFlagType(ftOverride)
|
||||
if err != nil {
|
||||
return flagItem{}, err
|
||||
}
|
||||
flagType = ft
|
||||
}
|
||||
if itOverride == "" || ftOverride == "" {
|
||||
inferIT, inferFT, err := parseItemID(id)
|
||||
if err != nil {
|
||||
return flagItem{}, err
|
||||
}
|
||||
if itOverride == "" {
|
||||
itemType = inferIT
|
||||
}
|
||||
if ftOverride == "" {
|
||||
flagType = inferFT
|
||||
}
|
||||
}
|
||||
if !isValidCombo(itemType, flagType) {
|
||||
// Provide more specific hints for common mistakes
|
||||
if itOverride != "" && ftOverride == "" {
|
||||
if itemType == ItemTypeThread || itemType == ItemTypeMsgThread {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"invalid combination: --item-type=%s requires --flag-type=feed (feed-layer flags are the only valid type for threads)",
|
||||
itOverride)
|
||||
}
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"invalid combination: --item-type=%s with inferred --flag-type=%s; specify --flag-type explicitly to override",
|
||||
itOverride, flagTypeString(flagType))
|
||||
}
|
||||
if itOverride == "" && ftOverride != "" {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"invalid combination: --flag-type=%s with inferred --item-type=%s; specify --item-type explicitly to override",
|
||||
ftOverride, itemTypeString(itemType))
|
||||
}
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"invalid --item-type/--flag-type combination: supported pairs are default+message, thread+feed, and msg_thread+feed")
|
||||
}
|
||||
return newFlagItem(id, itemType, flagType), nil
|
||||
}
|
||||
|
||||
// itemTypeString converts ItemType to a user-facing string.
|
||||
func itemTypeString(it ItemType) string {
|
||||
switch it {
|
||||
case ItemTypeDefault:
|
||||
return "default"
|
||||
case ItemTypeThread:
|
||||
return "thread"
|
||||
case ItemTypeMsgThread:
|
||||
return "msg_thread"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// flagTypeString converts FlagType to a user-facing string.
|
||||
func flagTypeString(ft FlagType) string {
|
||||
switch ft {
|
||||
case FlagTypeFeed:
|
||||
return "feed"
|
||||
case FlagTypeMessage:
|
||||
return "message"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
212
shortcuts/im/im_flag_create.go
Normal file
212
shortcuts/im/im_flag_create.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ImFlagCreate provides the +flag-create shortcut for creating a bookmark on a message.
|
||||
var ImFlagCreate = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+flag-create",
|
||||
Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed to create feed-layer flag (auto-detects chat type)",
|
||||
Risk: "write",
|
||||
UserScopes: flagWriteLookupScopes,
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "message ID (om_xxx)"},
|
||||
{Name: "item-type", Desc: "item type override: default|thread|msg_thread (rarely needed)"},
|
||||
{Name: "flag-type", Desc: "flag type: message (default) or feed"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildCreateItemForPreview(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
item, err := buildCreateItemForPreview(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/im/v1/flags").
|
||||
Body(map[string]any{"flag_items": []any{item}})
|
||||
if m, ok := item.(map[string]string); ok && m["item_type"] == "<auto:thread|msg_thread>" {
|
||||
d.Desc("feed-layer item_type is auto-detected at execution time by reading the message chat and chat_mode")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
item, err := buildCreateItem(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Combo validation already done in Validate, but double-check as a safety net.
|
||||
if !isValidCombo(parseItemTypeFromRaw(item.ItemType), parseFlagTypeFromRaw(item.FlagType)) {
|
||||
return output.ErrValidation(
|
||||
"invalid (item_type=%s, flag_type=%s) combination; the server only accepts "+
|
||||
"(default, message), (thread, feed), or (msg_thread, feed)",
|
||||
item.ItemType, item.FlagType)
|
||||
}
|
||||
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags", nil,
|
||||
map[string]any{"flag_items": []flagItem{item}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildCreateItemForPreview derives a preview payload without making network calls.
|
||||
// Feed-layer execution auto-detects item_type from chat_mode, but dry-run must
|
||||
// not query the message or chat APIs, so it uses an explicit placeholder.
|
||||
func buildCreateItemForPreview(rt *common.RuntimeContext) (any, error) {
|
||||
id, err := flagMessageID(rt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itOverride := strings.TrimSpace(rt.Str("item-type"))
|
||||
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
|
||||
combo, err := parseExplicitFlagCombo(itOverride, ftOverride)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flagType := FlagTypeMessage
|
||||
if combo.FlagTypeSet {
|
||||
flagType = combo.FlagType
|
||||
}
|
||||
if flagType == FlagTypeMessage {
|
||||
return newFlagItem(id, ItemTypeDefault, FlagTypeMessage), nil
|
||||
}
|
||||
|
||||
if combo.ItemTypeSet {
|
||||
return newFlagItem(id, combo.ItemType, FlagTypeFeed), nil
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"item_id": id,
|
||||
"item_type": "<auto:thread|msg_thread>",
|
||||
"flag_type": fmt.Sprintf("%d", int(FlagTypeFeed)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildCreateItem derives a flagItem for the create path.
|
||||
//
|
||||
// Resolution logic:
|
||||
// 1. No --flag-type or --flag-type=message → (default, message)
|
||||
// 2. --flag-type=feed (no --item-type) → query message to get chat_id,
|
||||
// then query chat_mode to determine: topic-style → (thread, feed), regular → (msg_thread, feed)
|
||||
// 3. Both --item-type and --flag-type provided → honor verbatim (for edge cases)
|
||||
func buildCreateItem(rt *common.RuntimeContext) (flagItem, error) {
|
||||
id, err := flagMessageID(rt)
|
||||
if err != nil {
|
||||
return flagItem{}, err
|
||||
}
|
||||
|
||||
itOverride := strings.TrimSpace(rt.Str("item-type"))
|
||||
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
|
||||
combo, err := parseExplicitFlagCombo(itOverride, ftOverride)
|
||||
if err != nil {
|
||||
return flagItem{}, err
|
||||
}
|
||||
|
||||
flagType := FlagTypeMessage
|
||||
if combo.FlagTypeSet {
|
||||
flagType = combo.FlagType
|
||||
}
|
||||
|
||||
// Message-layer flag: always (default, message)
|
||||
if flagType == FlagTypeMessage {
|
||||
return newFlagItem(id, ItemTypeDefault, FlagTypeMessage), nil
|
||||
}
|
||||
|
||||
// Feed-layer flag: need to determine item_type from chat_mode
|
||||
if combo.ItemTypeSet {
|
||||
// User explicitly specified item-type, honor it
|
||||
return newFlagItem(id, combo.ItemType, FlagTypeFeed), nil
|
||||
}
|
||||
|
||||
chatID, err := getMessageChatID(rt, id)
|
||||
if err != nil {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"failed to query message for feed-layer flag: %v; if you know the chat type, specify --item-type explicitly", err)
|
||||
}
|
||||
if chatID == "" {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"message does not belong to a chat; feed-layer flags are only for messages in chats")
|
||||
}
|
||||
|
||||
feedIT, err := resolveThreadFeedItemType(rt, chatID)
|
||||
if err != nil {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"failed to determine chat type: %v; if you know the chat type, specify --item-type explicitly", err)
|
||||
}
|
||||
return newFlagItem(id, feedIT, FlagTypeFeed), nil
|
||||
}
|
||||
|
||||
type explicitFlagCombo struct {
|
||||
ItemType ItemType
|
||||
FlagType FlagType
|
||||
ItemTypeSet bool
|
||||
FlagTypeSet bool
|
||||
}
|
||||
|
||||
func parseExplicitFlagCombo(itOverride, ftOverride string) (explicitFlagCombo, error) {
|
||||
itOverride = strings.TrimSpace(itOverride)
|
||||
ftOverride = strings.TrimSpace(ftOverride)
|
||||
|
||||
var combo explicitFlagCombo
|
||||
if itOverride != "" {
|
||||
it, err := parseItemType(itOverride)
|
||||
if err != nil {
|
||||
return explicitFlagCombo{}, err
|
||||
}
|
||||
combo.ItemType = it
|
||||
combo.ItemTypeSet = true
|
||||
}
|
||||
if ftOverride != "" {
|
||||
ft, err := parseFlagType(ftOverride)
|
||||
if err != nil {
|
||||
return explicitFlagCombo{}, err
|
||||
}
|
||||
combo.FlagType = ft
|
||||
combo.FlagTypeSet = true
|
||||
}
|
||||
|
||||
if combo.ItemTypeSet && !combo.FlagTypeSet {
|
||||
switch combo.ItemType {
|
||||
case ItemTypeThread, ItemTypeMsgThread:
|
||||
return explicitFlagCombo{}, output.ErrValidation(
|
||||
"--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride)
|
||||
case ItemTypeDefault:
|
||||
return explicitFlagCombo{}, output.ErrValidation(
|
||||
"--item-type=default requires --flag-type=message; or omit both to use default behavior")
|
||||
}
|
||||
}
|
||||
|
||||
if combo.ItemTypeSet && combo.FlagTypeSet && !isValidCombo(combo.ItemType, combo.FlagType) {
|
||||
return explicitFlagCombo{}, output.ErrValidation(
|
||||
"invalid --item-type=%s --flag-type=%s combination; supported pairs are default+message, thread+feed, and msg_thread+feed",
|
||||
itOverride, ftOverride)
|
||||
}
|
||||
|
||||
return combo, nil
|
||||
}
|
||||
|
||||
// validateExplicitCombo validates the (item_type, flag_type) combination when
|
||||
// the user explicitly provides flags. It does not make API calls - it only
|
||||
// validates the logic for what the user explicitly specified.
|
||||
func validateExplicitCombo(itOverride, ftOverride string) error {
|
||||
_, err := parseExplicitFlagCombo(itOverride, ftOverride)
|
||||
return err
|
||||
}
|
||||
300
shortcuts/im/im_flag_list.go
Normal file
300
shortcuts/im/im_flag_list.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// ImFlagList provides the +flag-list shortcut for listing bookmarks.
|
||||
// Feed-type thread entries are auto-enriched with message content.
|
||||
var ImFlagList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+flag-list",
|
||||
Description: "List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{flagReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
|
||||
{Name: "enrich-feed-thread", Type: "bool", Default: "true", Desc: "fetch message content for feed-type thread entries (default true; may call messages/mget and require im:message.group_msg:get_as_user/im:message.p2p_msg:get_as_user; use --enrich-feed-thread=false to avoid extra scopes)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateListOptions(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if err := validateListOptions(runtime); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
d := common.NewDryRunAPI().
|
||||
GET("/open-apis/im/v1/flags").
|
||||
Params(map[string]any{
|
||||
"page_size": strconv.Itoa(runtime.Int("page-size")),
|
||||
"page_token": runtime.Str("page-token"),
|
||||
})
|
||||
if runtime.Bool("enrich-feed-thread") {
|
||||
d.Desc("conditional enrichment: if feed/thread flag items are missing message content, execution may also call GET /open-apis/im/v1/messages/mget and requires scopes im:message.group_msg:get_as_user im:message.p2p_msg:get_as_user; pass --enrich-feed-thread=false to skip this extra call and extra scopes")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// When --page-token is explicitly provided, the user wants a specific page —
|
||||
// no auto-pagination regardless of --page-all.
|
||||
if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") {
|
||||
return executeListAllPages(runtime)
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSON("GET", "/open-apis/im/v1/flags", listQuery(runtime), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("enrich-feed-thread") {
|
||||
if err := enrichFeedThreadItems(runtime, data); err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: feed-thread enrichment failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateListOptions(rt *common.RuntimeContext) error {
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return output.ErrValidation("--page-size must be an integer between 1 and 50")
|
||||
}
|
||||
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
|
||||
return output.ErrValidation("--page-limit must be an integer between 1 and 1000")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listQuery builds the query parameters for the flag list API call.
|
||||
// page_token is required by the server even on the first page — pass empty
|
||||
// string when the user hasn't supplied one.
|
||||
func listQuery(rt *common.RuntimeContext) larkcore.QueryParams {
|
||||
return larkcore.QueryParams{
|
||||
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
|
||||
"page_token": []string{rt.Str("page-token")},
|
||||
}
|
||||
}
|
||||
|
||||
// enrichFeedThreadItems attaches message body to feed-shape thread entries
|
||||
// by calling messages/mget. The list API returns only IDs for feed-shape entries,
|
||||
// so this enrichment is needed to provide full message content.
|
||||
//
|
||||
// NOTE: This function modifies data["flag_items"] in place by adding a "message" key
|
||||
// to each feed-thread entry.
|
||||
func enrichFeedThreadItems(rt *common.RuntimeContext, data map[string]any) error {
|
||||
// Only enrich active flags (flag_items), not canceled flags (delete_flag_items).
|
||||
// Canceled message-type flags don't show message content, so thread-type flags don't need it either.
|
||||
items, _ := data["flag_items"].([]any)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Index any messages the server already returned — saves a mget round-trip
|
||||
// (ItemType=default+FlagType=Message responses already carry the message body).
|
||||
byID := make(map[string]map[string]any)
|
||||
if inline, ok := data["messages"].([]any); ok {
|
||||
for _, m := range inline {
|
||||
mm, _ := m.(map[string]any)
|
||||
if mm == nil {
|
||||
continue
|
||||
}
|
||||
if id := asString(mm["message_id"]); id != "" {
|
||||
byID[id] = mm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect feed-thread ids whose message body wasn't inlined — dedup to cut mget calls.
|
||||
need := map[string]bool{}
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
ft := asString(m["flag_type"])
|
||||
itStr := asString(m["item_type"])
|
||||
if ft != strconv.Itoa(int(FlagTypeFeed)) {
|
||||
continue
|
||||
}
|
||||
if itStr != strconv.Itoa(int(ItemTypeThread)) && itStr != strconv.Itoa(int(ItemTypeMsgThread)) {
|
||||
continue
|
||||
}
|
||||
id := asString(m["item_id"])
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, inlined := byID[id]; !inlined {
|
||||
need[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(need) > 0 {
|
||||
if err := checkFlagRequiredScopes(rt.Ctx(), rt, flagMessageReadScopes); err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]string, 0, len(need))
|
||||
for id := range need {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// /messages/mget accepts max 50 IDs per request — batch if needed.
|
||||
const mgetBatchSize = 50
|
||||
for i := 0; i < len(ids); i += mgetBatchSize {
|
||||
end := i + mgetBatchSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
batch := ids[i:end]
|
||||
got, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/mget",
|
||||
larkcore.QueryParams{"message_ids": batch}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fetched, _ := got["items"].([]any)
|
||||
for _, m := range fetched {
|
||||
mm, _ := m.(map[string]any)
|
||||
if mm == nil {
|
||||
continue
|
||||
}
|
||||
if id := asString(mm["message_id"]); id != "" {
|
||||
byID[id] = mm
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(byID) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Attach message payload to the matching list entries.
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
ft := asString(m["flag_type"])
|
||||
itType := asString(m["item_type"])
|
||||
if ft != strconv.Itoa(int(FlagTypeFeed)) {
|
||||
continue
|
||||
}
|
||||
if itType != strconv.Itoa(int(ItemTypeThread)) && itType != strconv.Itoa(int(ItemTypeMsgThread)) {
|
||||
continue
|
||||
}
|
||||
if msg, ok := byID[asString(m["item_id"])]; ok {
|
||||
m["message"] = msg
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// asString converts an arbitrary value to its string representation.
|
||||
// Handles string, float64, int, int64, and json.Number types; returns empty string for other types.
|
||||
func asString(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(x)
|
||||
case int64:
|
||||
return strconv.FormatInt(x, 10)
|
||||
case json.Number:
|
||||
return x.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// executeListAllPages fetches all pages and merges the results into a single response.
|
||||
// The flag list API returns items sorted by update_time ascending, so the last page
|
||||
// contains the newest items.
|
||||
func executeListAllPages(rt *common.RuntimeContext) error {
|
||||
maxPages := rt.Int("page-limit")
|
||||
if maxPages < 1 {
|
||||
maxPages = 20
|
||||
}
|
||||
if maxPages > 1000 {
|
||||
maxPages = 1000
|
||||
}
|
||||
|
||||
// Use make([]any, 0) to ensure empty arrays serialize as [] not null
|
||||
allFlagItems := make([]any, 0)
|
||||
allDeleteFlagItems := make([]any, 0)
|
||||
allMessages := make([]any, 0)
|
||||
var lastHasMore bool
|
||||
var lastPageToken string
|
||||
prevPageToken := "__START__" // Sentinel to detect unchanged token
|
||||
|
||||
for page := 0; page < maxPages; page++ {
|
||||
token := ""
|
||||
if page > 0 {
|
||||
token = lastPageToken
|
||||
}
|
||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/flags",
|
||||
larkcore.QueryParams{
|
||||
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
|
||||
"page_token": []string{token},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if v, ok := data["flag_items"].([]any); ok {
|
||||
allFlagItems = append(allFlagItems, v...)
|
||||
}
|
||||
if v, ok := data["delete_flag_items"].([]any); ok {
|
||||
allDeleteFlagItems = append(allDeleteFlagItems, v...)
|
||||
}
|
||||
if v, ok := data["messages"].([]any); ok {
|
||||
allMessages = append(allMessages, v...)
|
||||
}
|
||||
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
|
||||
// Progress output to stderr
|
||||
fmt.Fprintf(rt.IO().ErrOut, "page %d: %d flags, %d deleted\n",
|
||||
page+1, len(allFlagItems), len(allDeleteFlagItems))
|
||||
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
// Detect server anomaly: same token returned twice means infinite loop
|
||||
if lastPageToken == prevPageToken {
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n")
|
||||
break
|
||||
}
|
||||
prevPageToken = lastPageToken
|
||||
}
|
||||
|
||||
merged := map[string]any{
|
||||
"flag_items": allFlagItems,
|
||||
"delete_flag_items": allDeleteFlagItems,
|
||||
"messages": allMessages,
|
||||
"has_more": lastHasMore,
|
||||
"page_token": lastPageToken,
|
||||
}
|
||||
|
||||
if rt.Bool("enrich-feed-thread") {
|
||||
if err := enrichFeedThreadItems(rt, merged); err != nil {
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: feed-thread enrichment failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
rt.Out(merged, nil)
|
||||
return nil
|
||||
}
|
||||
1812
shortcuts/im/im_flag_test.go
Normal file
1812
shortcuts/im/im_flag_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,5 +18,8 @@ func Shortcuts() []common.Shortcut {
|
||||
ImMessagesSearch,
|
||||
ImMessagesSend,
|
||||
ImThreadsMessagesList,
|
||||
ImFlagCreate,
|
||||
ImFlagCancel,
|
||||
ImFlagList,
|
||||
}
|
||||
}
|
||||
|
||||
288
shortcuts/mail/flag_suggest.go
Normal file
288
shortcuts/mail/flag_suggest.go
Normal file
@@ -0,0 +1,288 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// flagName is a package-private snapshot of a pflag.Flag's identity.
|
||||
type flagName struct {
|
||||
long, short string
|
||||
hidden bool
|
||||
}
|
||||
|
||||
// Candidate is a single suggested flag returned to the user when an
|
||||
// unknown flag is detected. It is serialised into the ErrorEnvelope's
|
||||
// error.detail.candidates[] array.
|
||||
type Candidate struct {
|
||||
// Flag is the long-form spelling of the suggested flag, e.g. "--to".
|
||||
Flag string `json:"flag"`
|
||||
// Shorthand is the single-character shorthand (without the leading
|
||||
// dash) when the suggested flag has one; empty otherwise.
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
// Distance is the Levenshtein edit distance to the unknown token.
|
||||
// Zero indicates a bidirectional prefix hit (Reason == "prefix").
|
||||
Distance int `json:"distance"`
|
||||
// Reason explains how the candidate was matched: "prefix" for
|
||||
// bidirectional prefix hits, "edit_distance" for fuzzy matches.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// maxCandidates caps the number of suggestions returned per error so
|
||||
// the JSON envelope stays compact and the user-visible hint remains
|
||||
// scannable.
|
||||
const maxCandidates = 5
|
||||
|
||||
// InstallOnMail attaches the unknown-flag fuzzy-match hook on the mail
|
||||
// service cobra parent command. It is invoked exactly once from
|
||||
// shortcuts/register.go inside the `service == "mail"` branch.
|
||||
//
|
||||
// Cobra's FlagErrorFunc walks up the parent chain looking for the nearest
|
||||
// non-nil hook, so every mail subcommand inherits this behaviour without
|
||||
// any per-shortcut wiring.
|
||||
func InstallOnMail(svc *cobra.Command) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
svc.SetFlagErrorFunc(flagSuggestErrorFunc)
|
||||
}
|
||||
|
||||
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a
|
||||
// structured *output.ExitError carrying candidate suggestions. Any other
|
||||
// error is passed through unchanged so cobra's existing handling kicks in.
|
||||
func flagSuggestErrorFunc(c *cobra.Command, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
token, isShorthand, ok := parseUnknownToken(err.Error())
|
||||
if !ok {
|
||||
// Non unknown-flag errors (e.g. "required flag(s) ... not set")
|
||||
// pass through to cmd/root.go::handleRootError's fallback path.
|
||||
return err
|
||||
}
|
||||
names := collectFlags(c)
|
||||
var matches []Candidate
|
||||
if isShorthand {
|
||||
matches = suggestShorthand(token, names)
|
||||
} else {
|
||||
matches = suggest(token, names)
|
||||
}
|
||||
// Normalise to a non-nil slice so the JSON envelope always emits
|
||||
// `candidates: []` instead of `null`, keeping the wire shape stable
|
||||
// for downstream parsers regardless of command-state.
|
||||
if matches == nil {
|
||||
matches = []Candidate{}
|
||||
}
|
||||
hint := buildHint(c, matches)
|
||||
detail := map[string]any{
|
||||
"unknown": rawUnknownToken(token, isShorthand),
|
||||
"command_path": c.CommandPath(),
|
||||
"candidates": matches,
|
||||
}
|
||||
// Code is ExitAPI (=1), matching cobra's default unknown-flag exit
|
||||
// code. The structured type discrimination lives in error.type.
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: err.Error(),
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parseUnknownToken extracts the offending flag name from a pflag error
|
||||
// string. Recognised forms:
|
||||
//
|
||||
// - "unknown flag: --tos"
|
||||
// - "unknown flag: --bogus=val"
|
||||
// - "unknown shorthand flag: 'X' in -Xyz"
|
||||
//
|
||||
// Anything else returns (_, _, false) so the caller can pass the error
|
||||
// through unchanged.
|
||||
func parseUnknownToken(errMsg string) (token string, isShorthand bool, ok bool) {
|
||||
const longPrefix = "unknown flag: --"
|
||||
const shortPrefix = "unknown shorthand flag: '"
|
||||
switch {
|
||||
case strings.HasPrefix(errMsg, longPrefix):
|
||||
rest := errMsg[len(longPrefix):]
|
||||
if eq := strings.IndexAny(rest, "= \t"); eq >= 0 {
|
||||
rest = rest[:eq]
|
||||
}
|
||||
return rest, false, rest != ""
|
||||
case strings.HasPrefix(errMsg, shortPrefix):
|
||||
rest := errMsg[len(shortPrefix):]
|
||||
end := strings.IndexByte(rest, '\'')
|
||||
if end <= 0 {
|
||||
return "", false, false
|
||||
}
|
||||
return rest[:end], true, true
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
// rawUnknownToken re-attaches the leading dash(es) to a bare token so the
|
||||
// JSON envelope echoes the user-visible spelling.
|
||||
func rawUnknownToken(token string, isShorthand bool) string {
|
||||
if isShorthand {
|
||||
return "-" + token
|
||||
}
|
||||
return "--" + token
|
||||
}
|
||||
|
||||
// collectFlags snapshots the merged local + persistent + inherited flag
|
||||
// set of cmd. The hidden bit is preserved on each entry; the suggest
|
||||
// helpers apply the actual filter so the slice stays reusable.
|
||||
func collectFlags(cmd *cobra.Command) []flagName {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
var out []flagName
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
out = append(out, flagName{long: f.Name, short: f.Shorthand, hidden: f.Hidden})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// suggest produces top-N long-flag candidates for an unknown token, using
|
||||
// bidirectional prefix matching first and Levenshtein distance for the
|
||||
// remainder. Hidden flags and empty long names are skipped. Results are
|
||||
// stably sorted by (Distance asc, Flag asc) and capped at maxCandidates.
|
||||
func suggest(unknown string, names []flagName) []Candidate {
|
||||
if unknown == "" || len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
threshold := levThreshold(unknown)
|
||||
out := make([]Candidate, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
|
||||
// Priority 1: bidirectional prefix match.
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(n.long, unknown) || strings.HasPrefix(unknown, n.long) {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
seen[n.long] = struct{}{}
|
||||
}
|
||||
}
|
||||
// Priority 2: Levenshtein distance, skipping already-matched names.
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n.long]; ok {
|
||||
continue
|
||||
}
|
||||
if d := levenshtein(unknown, n.long); d <= threshold {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: d, Reason: "edit_distance"})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Distance != out[j].Distance {
|
||||
return out[i].Distance < out[j].Distance
|
||||
}
|
||||
return out[i].Flag < out[j].Flag
|
||||
})
|
||||
if len(out) > maxCandidates {
|
||||
out = out[:maxCandidates]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// suggestShorthand produces candidates for an unknown single-character
|
||||
// shorthand. It first looks for exact f.Shorthand matches; if there are
|
||||
// none, it falls back to long names that begin with the same character.
|
||||
// Levenshtein is deliberately not used here since single-char edit
|
||||
// distance would match almost every flag.
|
||||
func suggestShorthand(c string, names []flagName) []Candidate {
|
||||
if c == "" || len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Candidate, 0)
|
||||
for _, n := range names {
|
||||
if n.hidden {
|
||||
continue
|
||||
}
|
||||
if n.short == c {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(n.long, c) {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Flag < out[j].Flag })
|
||||
if len(out) > maxCandidates {
|
||||
out = out[:maxCandidates]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildHint returns a one-line hint suitable for the ErrorEnvelope.
|
||||
// When at least one candidate exists, the top hit is named; otherwise
|
||||
// the user is directed to --help.
|
||||
func buildHint(c *cobra.Command, matches []Candidate) string {
|
||||
if len(matches) == 0 {
|
||||
return fmt.Sprintf("Run `%s --help` to view available flags", c.CommandPath())
|
||||
}
|
||||
return fmt.Sprintf("Did you mean: %s ?", matches[0].Flag)
|
||||
}
|
||||
|
||||
// levThreshold returns the maximum acceptable Levenshtein distance for a
|
||||
// token of the given length, clamped to [1, 4].
|
||||
func levThreshold(s string) int {
|
||||
t := len(s)/3 + 1
|
||||
if t < 1 {
|
||||
return 1
|
||||
}
|
||||
if t > 4 {
|
||||
return 4
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// levenshtein computes the standard Levenshtein edit distance between
|
||||
// two ASCII strings using a 2-row dynamic-programming table.
|
||||
func levenshtein(a, b string) int {
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
prev := make([]int, lb+1)
|
||||
curr := make([]int, lb+1)
|
||||
for j := 0; j <= lb; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(curr[j-1]+1, prev[j]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[lb]
|
||||
}
|
||||
352
shortcuts/mail/flag_suggest_test.go
Normal file
352
shortcuts/mail/flag_suggest_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// --- suggest (long-flag) ---
|
||||
|
||||
func TestSuggest_Prefix(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "to", short: "t"},
|
||||
{long: "cc"},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggest("tos", names)
|
||||
require.NotEmpty(t, got)
|
||||
// "tos" has --to as a prefix → bidirectional prefix hit, Distance=0.
|
||||
assert.Equal(t, "--to", got[0].Flag)
|
||||
assert.Equal(t, 0, got[0].Distance)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
}
|
||||
|
||||
func TestSuggest_Levenshtein(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "subject"},
|
||||
{long: "body"},
|
||||
{long: "to"},
|
||||
}
|
||||
// Distance 1 from "subject".
|
||||
got := suggest("subjec", names)
|
||||
require.NotEmpty(t, got)
|
||||
// "subjec" is prefix of "subject" → bidirectional prefix.
|
||||
assert.Equal(t, "--subject", got[0].Flag)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
|
||||
// True edit-distance: "subjeect" is not a prefix either way of "subject".
|
||||
got = suggest("subjeect", names)
|
||||
require.NotEmpty(t, got)
|
||||
assert.Equal(t, "--subject", got[0].Flag)
|
||||
assert.Equal(t, "edit_distance", got[0].Reason)
|
||||
assert.GreaterOrEqual(t, got[0].Distance, 1)
|
||||
}
|
||||
|
||||
func TestSuggest_HiddenSkipped(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "internal-debug", hidden: true},
|
||||
{long: "interactive"},
|
||||
}
|
||||
got := suggest("internal", names)
|
||||
for _, c := range got {
|
||||
assert.NotEqual(t, "--internal-debug", c.Flag, "hidden flag must not appear in suggestions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggest_TopNAndStableSort(t *testing.T) {
|
||||
// 6 names all within threshold and at the same distance (1) from the
|
||||
// unknown token so that the lexicographic tiebreak and maxCandidates
|
||||
// cap are both exercised. (Earlier the names were 3-distance from
|
||||
// "zzz" which is above the threshold of 2 — suggest returned empty
|
||||
// and the assertions trivially passed.)
|
||||
names := []flagName{
|
||||
{long: "aaab"},
|
||||
{long: "aaac"},
|
||||
{long: "aaad"},
|
||||
{long: "aaae"},
|
||||
{long: "aaaf"},
|
||||
{long: "aaag"},
|
||||
}
|
||||
got := suggest("aaaa", names)
|
||||
require.Len(t, got, maxCandidates, "must cap at maxCandidates")
|
||||
// All distances equal → lex ordering by Flag asc, top 5 alphabetically.
|
||||
wantFlags := []string{"--aaab", "--aaac", "--aaad", "--aaae", "--aaaf"}
|
||||
gotFlags := []string{got[0].Flag, got[1].Flag, got[2].Flag, got[3].Flag, got[4].Flag}
|
||||
assert.Equal(t, wantFlags, gotFlags, "tiebreak must order by Flag asc")
|
||||
}
|
||||
|
||||
// --- suggestShorthand ---
|
||||
|
||||
func TestSuggestShorthand_Exact(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "to", short: "t"},
|
||||
{long: "cc", short: "c"},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggestShorthand("t", names)
|
||||
require.NotEmpty(t, got)
|
||||
assert.Equal(t, "--to", got[0].Flag)
|
||||
assert.Equal(t, "t", got[0].Shorthand)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_PrefixFallback(t *testing.T) {
|
||||
// No short matches "x"; fall back to long names starting with "x".
|
||||
names := []flagName{
|
||||
{long: "xargs"},
|
||||
{long: "xterm"},
|
||||
{long: "yargs"},
|
||||
}
|
||||
got := suggestShorthand("x", names)
|
||||
require.NotEmpty(t, got)
|
||||
flags := make([]string, 0, len(got))
|
||||
for _, c := range got {
|
||||
flags = append(flags, c.Flag)
|
||||
}
|
||||
assert.Contains(t, flags, "--xargs")
|
||||
assert.Contains(t, flags, "--xterm")
|
||||
assert.NotContains(t, flags, "--yargs")
|
||||
}
|
||||
|
||||
// --- parseUnknownToken ---
|
||||
|
||||
func TestParseUnknownToken_Long(t *testing.T) {
|
||||
tok, isShort, ok := parseUnknownToken("unknown flag: --tos")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "tos", tok)
|
||||
|
||||
tok, isShort, ok = parseUnknownToken("unknown flag: --bogus=val")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "bogus", tok, "must strip =value tail")
|
||||
|
||||
tok, _, ok = parseUnknownToken("unknown flag: --bogus value")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "bogus", tok, "must strip whitespace tail")
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_Shorthand(t *testing.T) {
|
||||
tok, isShort, ok := parseUnknownToken("unknown shorthand flag: 'X' in -X")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, isShort)
|
||||
assert.Equal(t, "X", tok)
|
||||
|
||||
tok, isShort, ok = parseUnknownToken("unknown shorthand flag: 'q' in -qrs")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, isShort)
|
||||
assert.Equal(t, "q", tok)
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_NotMatch(t *testing.T) {
|
||||
cases := []string{
|
||||
`required flag(s) "to" not set`,
|
||||
"some unrelated error",
|
||||
"",
|
||||
"unknown command \"foo\" for \"mail\"",
|
||||
}
|
||||
for _, in := range cases {
|
||||
tok, isShort, ok := parseUnknownToken(in)
|
||||
assert.False(t, ok, "input %q must not match", in)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "", tok)
|
||||
}
|
||||
}
|
||||
|
||||
// --- flagSuggestErrorFunc ---
|
||||
|
||||
// newFakeMailCmd builds a cobra command tree resembling the mail parent
|
||||
// with a handful of flags exercised by the hook tests.
|
||||
func newFakeMailCmd() *cobra.Command {
|
||||
c := &cobra.Command{Use: "mail"}
|
||||
c.Flags().String("to", "", "recipients")
|
||||
c.Flags().String("cc", "", "cc recipients")
|
||||
c.Flags().String("subject", "", "subject")
|
||||
c.Flags().StringP("body", "b", "", "body")
|
||||
return c
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsExitError(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
|
||||
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr), "expected *output.ExitError, got %T", got)
|
||||
require.NotNil(t, exitErr.Detail)
|
||||
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
|
||||
assert.Equal(t, "unknown flag: --tos", exitErr.Detail.Message)
|
||||
assert.Contains(t, exitErr.Detail.Hint, "--to")
|
||||
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
require.True(t, ok, "Detail.Detail should be map[string]any")
|
||||
assert.Equal(t, "--tos", detail["unknown"])
|
||||
assert.Equal(t, cmd.CommandPath(), detail["command_path"])
|
||||
|
||||
cands, ok := detail["candidates"].([]Candidate)
|
||||
require.True(t, ok, "candidates should be []Candidate")
|
||||
require.NotEmpty(t, cands)
|
||||
|
||||
var foundTo bool
|
||||
for _, c := range cands {
|
||||
if c.Flag == "--to" {
|
||||
foundTo = true
|
||||
assert.Equal(t, "prefix", c.Reason)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundTo, "expected --to in candidates")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
in := errors.New(`required flag(s) "to" not set`)
|
||||
got := flagSuggestErrorFunc(cmd, in)
|
||||
// Identity passthrough: same error pointer.
|
||||
assert.Same(t, in, got, "non-unknown-flag errors must be returned unchanged")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_ExitCodeIsOne(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
// Hard contract — both compile-time and runtime guards:
|
||||
assert.Equal(t, output.ExitAPI, exitErr.Code, "unknown_flag must use ExitAPI, not ExitValidation")
|
||||
assert.Equal(t, 1, output.ExitAPI, "ExitAPI constant must remain 1")
|
||||
}
|
||||
|
||||
// --- edge-case coverage ---
|
||||
|
||||
func TestInstallOnMail_NilIsNoop(t *testing.T) {
|
||||
// Must not panic; the nil-guard is the contract.
|
||||
InstallOnMail(nil)
|
||||
}
|
||||
|
||||
func TestInstallOnMail_InstallsHook(t *testing.T) {
|
||||
c := newFakeMailCmd()
|
||||
InstallOnMail(c)
|
||||
require.NotNil(t, c.FlagErrorFunc())
|
||||
got := c.FlagErrorFunc()(c, errors.New("unknown flag: --tos"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr), "installed hook must produce *output.ExitError")
|
||||
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
assert.NoError(t, flagSuggestErrorFunc(cmd, nil))
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_LongUnknown_StripsValueTail(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos=alice@example.com"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
assert.Equal(t, "--tos", detail["unknown"], "value tail must be stripped before echoing")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_ShorthandUnknown(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown shorthand flag: 'b' in -bXY"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
assert.Equal(t, "-b", detail["unknown"])
|
||||
cands, ok := detail["candidates"].([]Candidate)
|
||||
require.True(t, ok)
|
||||
// newFakeMailCmd has --body/-b; exact shorthand hit expected.
|
||||
require.NotEmpty(t, cands)
|
||||
assert.Equal(t, "--body", cands[0].Flag)
|
||||
assert.Equal(t, "b", cands[0].Shorthand)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_CandidatesAlwaysArray(t *testing.T) {
|
||||
// A cobra command with no flags forces collectFlags → empty names →
|
||||
// suggest → nil. The envelope must still expose candidates as a
|
||||
// non-nil []Candidate so the JSON wire shape is "candidates: []"
|
||||
// rather than "candidates: null".
|
||||
bare := &cobra.Command{Use: "mail"}
|
||||
got := flagSuggestErrorFunc(bare, errors.New("unknown flag: --bogus"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
cands, ok := detail["candidates"].([]Candidate)
|
||||
require.True(t, ok, "candidates must be []Candidate even when empty")
|
||||
assert.NotNil(t, cands, "candidates must be non-nil empty slice, not nil")
|
||||
assert.Empty(t, cands)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NoCandidatesUsesHelpHint(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
// Token with no plausible neighbor in {to, cc, subject, body}.
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --zzzzzzz"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
assert.Contains(t, exitErr.Detail.Hint, "--help")
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_EmptyAndMalformed(t *testing.T) {
|
||||
// Long form with empty token after the prefix.
|
||||
_, _, ok := parseUnknownToken("unknown flag: --")
|
||||
assert.False(t, ok, "empty long token must not match")
|
||||
|
||||
// Shorthand with no closing quote.
|
||||
_, _, ok = parseUnknownToken("unknown shorthand flag: 'q")
|
||||
assert.False(t, ok, "shorthand without closing quote must not match")
|
||||
|
||||
// Shorthand with empty char between quotes.
|
||||
_, _, ok = parseUnknownToken("unknown shorthand flag: '' in -")
|
||||
assert.False(t, ok, "empty shorthand token must not match")
|
||||
}
|
||||
|
||||
func TestSuggest_EmptyInputs(t *testing.T) {
|
||||
assert.Nil(t, suggest("", []flagName{{long: "to"}}))
|
||||
assert.Nil(t, suggest("foo", nil))
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_EmptyInputs(t *testing.T) {
|
||||
assert.Nil(t, suggestShorthand("", []flagName{{long: "to", short: "t"}}))
|
||||
assert.Nil(t, suggestShorthand("x", nil))
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_HiddenSkipped(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "secret", short: "s", hidden: true},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggestShorthand("s", names)
|
||||
for _, c := range got {
|
||||
assert.NotEqual(t, "--secret", c.Flag, "hidden shorthand must not be suggested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectFlags_NilSafe(t *testing.T) {
|
||||
assert.Nil(t, collectFlags(nil))
|
||||
}
|
||||
|
||||
func TestLevThreshold_Clamp(t *testing.T) {
|
||||
// len 0 → 0/3+1 = 1
|
||||
assert.Equal(t, 1, levThreshold(""))
|
||||
// len 3 → 2
|
||||
assert.Equal(t, 2, levThreshold("abc"))
|
||||
// Long token caps at 4.
|
||||
assert.Equal(t, 4, levThreshold("aaaaaaaaaaaaaaaaaaaa"))
|
||||
}
|
||||
|
||||
func TestLevenshtein_EmptyAndIdentical(t *testing.T) {
|
||||
assert.Equal(t, 0, levenshtein("", ""))
|
||||
assert.Equal(t, 3, levenshtein("", "abc"))
|
||||
assert.Equal(t, 3, levenshtein("abc", ""))
|
||||
assert.Equal(t, 0, levenshtein("abc", "abc"))
|
||||
assert.Equal(t, 1, levenshtein("abc", "abd"))
|
||||
}
|
||||
@@ -99,5 +99,8 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
for _, shortcut := range shortcuts {
|
||||
shortcut.MountWithContext(ctx, svc, f)
|
||||
}
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package shortcuts
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -305,6 +307,65 @@ func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterShortcutsInstallsMailFlagSuggestHook is the end-to-end
|
||||
// wiring guard for the mail unknown-flag fuzzy-match feature: it ensures
|
||||
// the `if service == "mail" { mail.InstallOnMail(svc) }` branch in
|
||||
// RegisterShortcutsWithContext is actually exercised, so a future refactor
|
||||
// that drops the branch (or breaks the import) will fail this test rather
|
||||
// than silently regressing the structured-error contract.
|
||||
func TestRegisterShortcutsInstallsMailFlagSuggestHook(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
mailCmd, _, err := program.Find([]string{"mail"})
|
||||
if err != nil {
|
||||
t.Fatalf("find mail command: %v", err)
|
||||
}
|
||||
if mailCmd == nil || mailCmd.Name() != "mail" {
|
||||
t.Fatalf("mail command not mounted: %#v", mailCmd)
|
||||
}
|
||||
|
||||
// The FlagErrorFunc lookup walks up to the nearest non-nil hook, so
|
||||
// invoking it on the mail parent (or any of its children) must yield
|
||||
// a structured *output.ExitError with type "unknown_flag".
|
||||
got := mailCmd.FlagErrorFunc()(mailCmd, errors.New("unknown flag: --bogus"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T (%v)", got, got)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Fatalf("expected Detail.Type=unknown_flag, got %#v", exitErr.Detail)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected Code=ExitAPI(%d), got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterShortcutsLeavesNonMailFlagErrorUntouched confirms the
|
||||
// install is scoped: a non-mail service must keep the default cobra
|
||||
// pass-through behaviour, otherwise an accidental fall-through in
|
||||
// register.go would silently change every domain's error envelope.
|
||||
func TestRegisterShortcutsLeavesNonMailFlagErrorUntouched(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
baseCmd, _, err := program.Find([]string{"base"})
|
||||
if err != nil {
|
||||
t.Fatalf("find base command: %v", err)
|
||||
}
|
||||
in := errors.New("unknown flag: --bogus")
|
||||
got := baseCmd.FlagErrorFunc()(baseCmd, in)
|
||||
// Default cobra hook is identity — anything else means the mail hook
|
||||
// leaked across domains.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(got, &exitErr) {
|
||||
t.Fatalf("base service unexpectedly produced *output.ExitError: %#v", exitErr)
|
||||
}
|
||||
if got != in {
|
||||
t.Fatalf("base service should pass through original error pointer, got %T (%v)", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
output := os.Getenv("SHORTCUTS_OUTPUT")
|
||||
if output == "" {
|
||||
|
||||
@@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut {
|
||||
VCSearch,
|
||||
VCNotes,
|
||||
VCRecording,
|
||||
VCMeetingJoin,
|
||||
VCMeetingLeave,
|
||||
VCMeetingEvents,
|
||||
}
|
||||
}
|
||||
|
||||
984
shortcuts/vc/vc_meeting_events.go
Normal file
984
shortcuts/vc/vc_meeting_events.go
Normal file
@@ -0,0 +1,984 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
vcMeetingEventsAPIPath = "/open-apis/vc/v1/bots/events"
|
||||
defaultVCMeetingEventsSize = 20
|
||||
minVCMeetingEventsPageSize = 20
|
||||
maxVCMeetingEventsPageSize = 100
|
||||
maxVCMeetingEventsPages = 200
|
||||
)
|
||||
|
||||
var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
|
||||
// toUnixSeconds converts a supported CLI time input into a Unix seconds string.
|
||||
func toUnixSeconds(input string, hint ...string) (string, error) {
|
||||
ts, err := common.ParseTime(input, hint...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := strconv.ParseInt(ts, 10, 64); err != nil {
|
||||
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
|
||||
}
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// VCMeetingEvents lists bot meeting events for a meeting.
|
||||
var VCMeetingEvents = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-events",
|
||||
Description: "List bot meeting events by meeting ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-id", Required: true, Desc: "meeting ID to query"},
|
||||
{Name: "start", Desc: "time lower bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"},
|
||||
{Name: "end", Desc: "time upper bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"},
|
||||
{Name: "page-token", Desc: "page token for the next page"},
|
||||
{Name: "page-size", Default: "20", Desc: "page size, 20-100 (default 20)"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all available pages"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := meetingEventsPageSize(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseMeetingEventsTimeRange(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
startTime, endTime, err := parseMeetingEventsTimeRange(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params, err := buildMeetingEventsParams(runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
dryRun := common.NewDryRunAPI()
|
||||
if runtime.Bool("page-all") {
|
||||
dryRun = dryRun.Desc("Auto-paginates through all available pages")
|
||||
}
|
||||
dryRun = dryRun.GET(vcMeetingEventsAPIPath)
|
||||
if flat := flattenQueryParams(params); len(flat) > 0 {
|
||||
dryRun.Params(flat)
|
||||
}
|
||||
return dryRun
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
startTime, endTime, err := parseMeetingEventsTimeRange(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, events, hasMore, pageToken, err := fetchMeetingEvents(ctx, runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events = compactMeetingEvents(events)
|
||||
outData := map[string]interface{}{
|
||||
"events": events,
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
|
||||
timeline := buildMeetingEventTimeline(events)
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
|
||||
if len(timeline.entries) == 0 {
|
||||
fmt.Fprintln(w, "No meeting events.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderMeetingEventsPretty(timeline))
|
||||
})
|
||||
if runtime.Format == "pretty" && pageToken != "" {
|
||||
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
|
||||
if hasMore {
|
||||
fmt.Fprintln(runtime.IO().Out, "more available")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
|
||||
if runtime.Bool("page-all") {
|
||||
return maxVCMeetingEventsPageSize, nil
|
||||
}
|
||||
pageSizeStr := strings.TrimSpace(runtime.Str("page-size"))
|
||||
if pageSizeStr == "" {
|
||||
return defaultVCMeetingEventsSize, nil
|
||||
}
|
||||
pageSize, err := strconv.Atoi(pageSizeStr)
|
||||
if err != nil {
|
||||
return 0, common.FlagErrorf("invalid --page-size %q: must be an integer", pageSizeStr)
|
||||
}
|
||||
if pageSize < minVCMeetingEventsPageSize {
|
||||
return minVCMeetingEventsPageSize, nil
|
||||
}
|
||||
if pageSize > maxVCMeetingEventsPageSize {
|
||||
return maxVCMeetingEventsPageSize, nil
|
||||
}
|
||||
return pageSize, nil
|
||||
}
|
||||
|
||||
func meetingEventsPaginationConfig(runtime *common.RuntimeContext) (bool, int) {
|
||||
if !runtime.Bool("page-all") {
|
||||
return false, 0
|
||||
}
|
||||
return true, maxVCMeetingEventsPages
|
||||
}
|
||||
|
||||
func validateMeetingEventsMeetingID(meetingID string) error {
|
||||
meetingID = strings.TrimSpace(meetingID)
|
||||
if meetingID == "" {
|
||||
return common.FlagErrorf("--meeting-id is required")
|
||||
}
|
||||
value, err := strconv.ParseInt(meetingID, 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return common.FlagErrorf("--meeting-id must be a positive integer, got %q", meetingID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseMeetingEventsTimeRange validates --start/--end and returns Unix seconds strings.
|
||||
func parseMeetingEventsTimeRange(runtime *common.RuntimeContext) (string, string, error) {
|
||||
start := strings.TrimSpace(runtime.Str("start"))
|
||||
end := strings.TrimSpace(runtime.Str("end"))
|
||||
if start == "" && end == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
var startTime, endTime string
|
||||
if start != "" {
|
||||
parsed, err := toUnixSeconds(start)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--start: %v", err)
|
||||
}
|
||||
startTime = parsed
|
||||
}
|
||||
if end != "" {
|
||||
parsed, err := toUnixSeconds(end, "end")
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--end: %v", err)
|
||||
}
|
||||
endTime = parsed
|
||||
}
|
||||
if startTime != "" && endTime != "" {
|
||||
startValue, _ := strconv.ParseInt(startTime, 10, 64)
|
||||
endValue, _ := strconv.ParseInt(endTime, 10, 64)
|
||||
if startValue > endValue {
|
||||
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
|
||||
}
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
|
||||
func buildMeetingEventsParams(runtime *common.RuntimeContext, startTime, endTime string) (larkcore.QueryParams, error) {
|
||||
pageSize, err := meetingEventsPageSize(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := make(larkcore.QueryParams)
|
||||
params.Set("meeting_id", strings.TrimSpace(runtime.Str("meeting-id")))
|
||||
params.Set("page_size", strconv.Itoa(pageSize))
|
||||
if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" {
|
||||
params.Set("page_token", pageToken)
|
||||
}
|
||||
if startTime != "" {
|
||||
params.Set("start_time", startTime)
|
||||
}
|
||||
if endTime != "" {
|
||||
params.Set("end_time", endTime)
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func fetchMeetingEvents(ctx context.Context, runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, []interface{}, bool, string, error) {
|
||||
params, err := buildMeetingEventsParams(runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, nil, false, "", err
|
||||
}
|
||||
autoPaginate, pageLimit := meetingEventsPaginationConfig(runtime)
|
||||
if !autoPaginate {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, nil, false, "", err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
events := common.GetSlice(data, "events")
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
pageToken, _ := data["page_token"].(string)
|
||||
return data, events, hasMore, pageToken, nil
|
||||
}
|
||||
|
||||
var (
|
||||
allEvents []interface{}
|
||||
lastData map[string]interface{}
|
||||
lastPageToken string
|
||||
lastHasMore bool
|
||||
)
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, nil, false, "", err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
lastData = data
|
||||
events := common.GetSlice(data, "events")
|
||||
allEvents = append(allEvents, events...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
params.Set("page_token", lastPageToken)
|
||||
}
|
||||
if lastData == nil {
|
||||
lastData = map[string]interface{}{}
|
||||
}
|
||||
lastData["events"] = allEvents
|
||||
lastData["has_more"] = lastHasMore
|
||||
lastData["page_token"] = lastPageToken
|
||||
return lastData, allEvents, lastHasMore, lastPageToken, nil
|
||||
}
|
||||
|
||||
func flattenQueryParams(params larkcore.QueryParams) map[string]interface{} {
|
||||
if len(params) == 0 {
|
||||
return nil
|
||||
}
|
||||
flat := make(map[string]interface{}, len(params))
|
||||
for key, values := range params {
|
||||
switch len(values) {
|
||||
case 0:
|
||||
continue
|
||||
case 1:
|
||||
flat[key] = values[0]
|
||||
default:
|
||||
copied := make([]string, len(values))
|
||||
copy(copied, values)
|
||||
flat[key] = copied
|
||||
}
|
||||
}
|
||||
return flat
|
||||
}
|
||||
|
||||
func compactMeetingEvents(events []interface{}) []interface{} {
|
||||
compacted := make([]interface{}, 0, len(events))
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
if payload := common.GetMap(event, "payload"); payload != nil {
|
||||
event["payload"] = compactMeetingPayload(payload)
|
||||
}
|
||||
compacted = append(compacted, event)
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
func compactMeetingPayload(payload map[string]interface{}) map[string]interface{} {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
compacted := make(map[string]interface{}, len(payload))
|
||||
for key, value := range payload {
|
||||
if items, ok := value.([]interface{}); ok && len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
compacted[key] = value
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
type meetingTimeline struct {
|
||||
topic string
|
||||
startTime time.Time
|
||||
hasStart bool
|
||||
endTime time.Time
|
||||
hasEnd bool
|
||||
entries []meetingTimelineEntry
|
||||
}
|
||||
|
||||
type meetingTimelineEntry struct {
|
||||
when time.Time
|
||||
hasWhen bool
|
||||
sequence int
|
||||
group int
|
||||
subject string
|
||||
description string
|
||||
details []string
|
||||
}
|
||||
|
||||
func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||
timeline := meetingTimeline{}
|
||||
var sequence int
|
||||
var group int
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
payload := common.GetMap(event, "payload")
|
||||
if payload == nil {
|
||||
continue
|
||||
}
|
||||
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
|
||||
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
|
||||
}
|
||||
for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) {
|
||||
timeline.entries = append(timeline.entries, entry)
|
||||
}
|
||||
group++
|
||||
}
|
||||
sort.SliceStable(timeline.entries, func(i, j int) bool {
|
||||
left := timeline.entries[i]
|
||||
right := timeline.entries[j]
|
||||
switch {
|
||||
case left.hasWhen && right.hasWhen:
|
||||
if left.when.Equal(right.when) {
|
||||
return left.sequence < right.sequence
|
||||
}
|
||||
return left.when.Before(right.when)
|
||||
case left.hasWhen:
|
||||
return true
|
||||
case right.hasWhen:
|
||||
return false
|
||||
default:
|
||||
return left.sequence < right.sequence
|
||||
}
|
||||
})
|
||||
return timeline
|
||||
}
|
||||
|
||||
func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) {
|
||||
if timeline == nil || meeting == nil {
|
||||
return
|
||||
}
|
||||
if timeline.topic == "" {
|
||||
timeline.topic = common.GetString(meeting, "topic")
|
||||
}
|
||||
if !timeline.hasStart {
|
||||
if parsed, ok := parseFlexibleTime(common.GetString(meeting, "start_time")); ok {
|
||||
timeline.startTime = parsed
|
||||
timeline.hasStart = true
|
||||
}
|
||||
}
|
||||
if !timeline.hasEnd {
|
||||
if parsed, ok := parseFlexibleTime(common.GetString(meeting, "end_time")); ok {
|
||||
timeline.endTime = parsed
|
||||
timeline.hasEnd = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry {
|
||||
payload := common.GetMap(event, "payload")
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
eventType := meetingEventType(event)
|
||||
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
|
||||
switch eventType {
|
||||
case "participant_joined":
|
||||
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
case "participant_left":
|
||||
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
case "transcript_received":
|
||||
return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
case "chat_received":
|
||||
return chatEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
case "magic_share_started":
|
||||
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
case "magic_share_ended":
|
||||
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
default:
|
||||
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "participant_joined_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "加入了会议", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "join_time"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "participant"))
|
||||
if subject == "" {
|
||||
subject = "未知参会人"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "participant_left_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "离开了会议", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "leave_time"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "participant"))
|
||||
if subject == "" {
|
||||
subject = "未知参会人"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "transcript_received_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "产生了转写", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "start_time_ms"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "speaker"))
|
||||
if subject == "" {
|
||||
subject = "未知发言人"
|
||||
}
|
||||
text := strings.TrimSpace(common.GetString(item, "text"))
|
||||
description := "产生了转写"
|
||||
if text != "" {
|
||||
description = text
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "chat_received_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "发送了消息", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "send_time"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
|
||||
if subject == "" {
|
||||
subject = "未知发送者"
|
||||
}
|
||||
typeLabel := chatMessageTypeLabel(item)
|
||||
description := strings.TrimSpace(common.GetString(item, "content"))
|
||||
if description == "" {
|
||||
description = fmt.Sprintf("[%s] 发送了消息", typeLabel)
|
||||
} else {
|
||||
description = fmt.Sprintf("[%s] %s", typeLabel, description)
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "magic_share_started_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "开始共享内容", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "time"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
|
||||
if subject == "" {
|
||||
subject = "未知用户"
|
||||
}
|
||||
title := strings.TrimSpace(common.GetString(common.GetMap(item, "share_doc"), "title"))
|
||||
url := strings.TrimSpace(common.GetString(common.GetMap(item, "share_doc"), "url"))
|
||||
description := "开始共享内容"
|
||||
if title != "" {
|
||||
description = fmt.Sprintf("开始共享「%s」", title)
|
||||
}
|
||||
var details []string
|
||||
if url != "" {
|
||||
details = append(details, "URL: "+url)
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "magic_share_ended_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "结束共享", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "time"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
|
||||
if subject == "" {
|
||||
subject = "未知用户"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry {
|
||||
entry := meetingTimelineEntry{
|
||||
when: when,
|
||||
hasWhen: hasWhen,
|
||||
sequence: *sequence,
|
||||
group: group,
|
||||
subject: subject,
|
||||
description: description,
|
||||
details: details,
|
||||
}
|
||||
*sequence = *sequence + 1
|
||||
return entry
|
||||
}
|
||||
|
||||
func parseFlexibleTime(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if ts, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
switch {
|
||||
case ts > 1_000_000_000_000:
|
||||
return time.UnixMilli(ts), true
|
||||
case ts > 0:
|
||||
return time.Unix(ts, 0), true
|
||||
}
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func renderMeetingEventsPretty(timeline meetingTimeline) string {
|
||||
var b strings.Builder
|
||||
if timeline.topic != "" {
|
||||
fmt.Fprintf(&b, "会议主题:%s\n", escapePrettyText(timeline.topic))
|
||||
}
|
||||
if timeline.hasStart || timeline.hasEnd {
|
||||
fmt.Fprintf(&b, "会议时间:%s\n", escapePrettyText(formatMeetingWindow(timeline.startTime, timeline.hasStart, timeline.endTime, timeline.hasEnd)))
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, entry := range timeline.entries {
|
||||
fmt.Fprintf(&b, "[%s] ", formatTimelineOffset(entry.when, entry.hasWhen, timeline.startTime, timeline.hasStart))
|
||||
if entry.subject != "" {
|
||||
if entry.description == "" {
|
||||
fmt.Fprintln(&b, escapePrettyText(entry.subject))
|
||||
for _, detail := range entry.details {
|
||||
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if needsColon(entry.description) {
|
||||
fmt.Fprintf(&b, "%s: %s\n", escapePrettyText(entry.subject), escapePrettyText(entry.description))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%s %s\n", escapePrettyText(entry.subject), escapePrettyText(entry.description))
|
||||
}
|
||||
for _, detail := range entry.details {
|
||||
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(&b, escapePrettyText(entry.description))
|
||||
for _, detail := range entry.details {
|
||||
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapePrettyText(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
if unicode.IsControl(r) {
|
||||
fmt.Fprintf(&b, "\\u%04X", r)
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatMeetingWindow(start time.Time, hasStart bool, end time.Time, hasEnd bool) string {
|
||||
switch {
|
||||
case hasStart && hasEnd:
|
||||
if !end.After(start) {
|
||||
return fmt.Sprintf("%s(进行中)", start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
return fmt.Sprintf("%s - %s", start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"), end.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"))
|
||||
case hasStart:
|
||||
return start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05")
|
||||
case hasEnd:
|
||||
return end.In(meetingDisplayLocation).Format("2006-01-02 15:04:05")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func formatTimelineOffset(when time.Time, hasWhen bool, meetingStart time.Time, hasMeetingStart bool) string {
|
||||
if hasWhen && hasMeetingStart {
|
||||
diff := when.Sub(meetingStart)
|
||||
if diff < 0 {
|
||||
diff = 0
|
||||
}
|
||||
totalSeconds := int(diff.Seconds())
|
||||
hours := totalSeconds / 3600
|
||||
minutes := (totalSeconds % 3600) / 60
|
||||
seconds := totalSeconds % 60
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
if hasWhen {
|
||||
return when.In(meetingDisplayLocation).Format("15:04:05")
|
||||
}
|
||||
return "??:??:??"
|
||||
}
|
||||
|
||||
func needsColon(description string) bool {
|
||||
switch description {
|
||||
case "发送了消息", "产生了转写":
|
||||
return false
|
||||
default:
|
||||
return !strings.HasPrefix(description, "加入了") &&
|
||||
!strings.HasPrefix(description, "离开了") &&
|
||||
!strings.HasPrefix(description, "被移出") &&
|
||||
!strings.HasPrefix(description, "会议结束") &&
|
||||
!strings.HasPrefix(description, "开始共享") &&
|
||||
!strings.HasPrefix(description, "结束共享")
|
||||
}
|
||||
}
|
||||
|
||||
func leaveAction(item map[string]interface{}) string {
|
||||
switch int(common.GetFloat(item, "leave_reason")) {
|
||||
case 2:
|
||||
return "因会议结束离开了会议"
|
||||
case 3:
|
||||
return "被移出了会议"
|
||||
default:
|
||||
return "离开了会议"
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventUserWithID(user map[string]interface{}) string {
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
userID := common.GetString(user, "id")
|
||||
userName := common.GetString(user, "user_name")
|
||||
switch {
|
||||
case userName != "" && userID != "":
|
||||
return fmt.Sprintf("%s(%s)", userName, userID)
|
||||
case userName != "":
|
||||
return userName
|
||||
case userID != "":
|
||||
return userID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventType(event map[string]interface{}) string {
|
||||
if eventType := common.GetString(event, "event_type"); eventType != "" {
|
||||
return eventType
|
||||
}
|
||||
return common.GetString(common.GetMap(event, "payload"), "activity_event_type")
|
||||
}
|
||||
|
||||
func meetingEventSummary(event map[string]interface{}) string {
|
||||
payload := common.GetMap(event, "payload")
|
||||
eventType := meetingEventType(event)
|
||||
switch eventType {
|
||||
case "participant_joined":
|
||||
return participantJoinedSummary(payload)
|
||||
case "participant_left":
|
||||
return participantLeftSummary(payload)
|
||||
case "transcript_received":
|
||||
return transcriptReceivedSummary(payload)
|
||||
case "chat_received":
|
||||
return chatReceivedSummary(payload)
|
||||
case "magic_share_started":
|
||||
return magicShareStartedSummary(payload)
|
||||
case "magic_share_ended":
|
||||
return magicShareEndedSummary(payload)
|
||||
default:
|
||||
return fallbackMeetingEventSummary(payload, eventType)
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "participant_joined_items")
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return "participant joined"
|
||||
case 1:
|
||||
user := common.GetMap(firstSliceMap(payload, "participant_joined_items"), "participant")
|
||||
if label := meetingEventUserLabel(user); label != "" {
|
||||
return fmt.Sprintf("participant %s joined", label)
|
||||
}
|
||||
return "participant joined"
|
||||
default:
|
||||
return fmt.Sprintf("%d participants joined", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func participantLeftSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "participant_left_items")
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return "participant left"
|
||||
case 1:
|
||||
user := common.GetMap(firstSliceMap(payload, "participant_left_items"), "participant")
|
||||
if label := meetingEventUserLabel(user); label != "" {
|
||||
return fmt.Sprintf("participant %s left", label)
|
||||
}
|
||||
return "participant left"
|
||||
default:
|
||||
return fmt.Sprintf("%d participants left", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func transcriptReceivedSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "transcript_received_items")
|
||||
if len(items) > 1 {
|
||||
return fmt.Sprintf("%d transcript items", len(items))
|
||||
}
|
||||
item := firstSliceMap(payload, "transcript_received_items")
|
||||
text := common.GetString(item, "text")
|
||||
speaker := meetingEventUserLabel(common.GetMap(item, "speaker"))
|
||||
switch {
|
||||
case speaker != "" && text != "":
|
||||
return fmt.Sprintf("speaker %s: %s", speaker, text)
|
||||
case speaker != "":
|
||||
return fmt.Sprintf("speaker %s transcript received", speaker)
|
||||
case text != "":
|
||||
return fmt.Sprintf("transcript: %s", text)
|
||||
default:
|
||||
return "transcript received"
|
||||
}
|
||||
}
|
||||
|
||||
func chatReceivedSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "chat_received_items")
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return "chat received"
|
||||
case 1:
|
||||
item := firstSliceMap(payload, "chat_received_items")
|
||||
content := common.GetString(item, "content")
|
||||
operator := meetingEventUserDisplayName(common.GetMap(item, "operator"))
|
||||
switch {
|
||||
case operator != "" && content != "":
|
||||
return fmt.Sprintf("%s: %s", operator, content)
|
||||
case operator != "":
|
||||
return fmt.Sprintf("message by %s", operator)
|
||||
case content != "":
|
||||
return fmt.Sprintf("message: %s", content)
|
||||
default:
|
||||
return "chat received"
|
||||
}
|
||||
default:
|
||||
count, operator := summarizeChatOperators(items)
|
||||
switch {
|
||||
case count == 1 && operator != "":
|
||||
return fmt.Sprintf("%d messages by %s", len(items), operator)
|
||||
case count > 1:
|
||||
return fmt.Sprintf("%d messages by %d users", len(items), count)
|
||||
default:
|
||||
return fmt.Sprintf("%d messages", len(items))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func magicShareStartedSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "magic_share_started_items")
|
||||
if len(items) > 1 {
|
||||
return fmt.Sprintf("%d share start events", len(items))
|
||||
}
|
||||
item := firstSliceMap(payload, "magic_share_started_items")
|
||||
shareID := common.GetString(item, "share_id")
|
||||
title := common.GetString(common.GetMap(item, "share_doc"), "title")
|
||||
switch {
|
||||
case shareID != "" && title != "":
|
||||
return fmt.Sprintf("share %s started: %s", shareID, title)
|
||||
case shareID != "":
|
||||
return fmt.Sprintf("share %s started", shareID)
|
||||
case title != "":
|
||||
return fmt.Sprintf("share started: %s", title)
|
||||
default:
|
||||
return "share started"
|
||||
}
|
||||
}
|
||||
|
||||
func magicShareEndedSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "magic_share_ended_items")
|
||||
if len(items) > 1 {
|
||||
return fmt.Sprintf("%d share end events", len(items))
|
||||
}
|
||||
item := firstSliceMap(payload, "magic_share_ended_items")
|
||||
if shareID := common.GetString(item, "share_id"); shareID != "" {
|
||||
return fmt.Sprintf("share %s ended", shareID)
|
||||
}
|
||||
return "share ended"
|
||||
}
|
||||
|
||||
func fallbackMeetingEventSummary(payload map[string]interface{}, eventType string) string {
|
||||
meeting := common.GetMap(payload, "meeting")
|
||||
if topic := common.GetString(meeting, "topic"); topic != "" {
|
||||
if eventType != "" {
|
||||
return fmt.Sprintf("%s: %s", eventType, topic)
|
||||
}
|
||||
return topic
|
||||
}
|
||||
if eventType != "" {
|
||||
return eventType
|
||||
}
|
||||
return "meeting event"
|
||||
}
|
||||
|
||||
func firstSliceMap(payload map[string]interface{}, key string) map[string]interface{} {
|
||||
items := common.GetSlice(payload, key)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
first, _ := items[0].(map[string]interface{})
|
||||
return first
|
||||
}
|
||||
|
||||
func meetingEventUserLabel(user map[string]interface{}) string {
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
userID := common.GetString(user, "id")
|
||||
userName := common.GetString(user, "user_name")
|
||||
switch {
|
||||
case userID != "" && userName != "":
|
||||
return fmt.Sprintf("%s (%s)", userID, userName)
|
||||
case userID != "":
|
||||
return userID
|
||||
case userName != "":
|
||||
return userName
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventUserDisplayName(user map[string]interface{}) string {
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
if userName := common.GetString(user, "user_name"); userName != "" {
|
||||
return userName
|
||||
}
|
||||
return common.GetString(user, "id")
|
||||
}
|
||||
|
||||
func chatMessageTypeLabel(item map[string]interface{}) string {
|
||||
code := int(common.GetFloat(item, "message_type"))
|
||||
switch code {
|
||||
case 1:
|
||||
return "text"
|
||||
case 2:
|
||||
return "system"
|
||||
case 3:
|
||||
return "reaction"
|
||||
case 4:
|
||||
return "encrypted"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeChatOperators(items []interface{}) (int, string) {
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
operator := meetingEventUserDisplayName(common.GetMap(item, "operator"))
|
||||
if operator == "" {
|
||||
continue
|
||||
}
|
||||
seen[operator] = struct{}{}
|
||||
}
|
||||
if len(seen) != 1 {
|
||||
return len(seen), ""
|
||||
}
|
||||
for operator := range seen {
|
||||
return 1, operator
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
931
shortcuts/vc/vc_meeting_events_test.go
Normal file
931
shortcuts/vc/vc_meeting_events_test.go
Normal file
@@ -0,0 +1,931 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
func newMeetingEventsRuntime() *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-id", "", "")
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
cmd.Flags().String("page-size", "", "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
return common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
}
|
||||
|
||||
func mustSetMeetingEventsFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
|
||||
t.Helper()
|
||||
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("Flags().Set(%q, %q) error = %v", name, value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: vcMeetingEventsAPIPath,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"total": len(events),
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
"events": events,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-1",
|
||||
"event_type": "participant_joined",
|
||||
"event_time": "2026-04-17T08:00:00Z",
|
||||
"payload": map[string]interface{}{
|
||||
"activity_event_type": "participant_joined",
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "7628568141510692381",
|
||||
"topic": "项目例会",
|
||||
"meeting_no": "724939760",
|
||||
"start_time": "1776407700",
|
||||
"end_time": "1776411300",
|
||||
},
|
||||
"participant_joined_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"participant": map[string]interface{}{
|
||||
"id": "bot_001",
|
||||
"user_name": "Demo Bot",
|
||||
},
|
||||
"join_time": "2026-04-17T08:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedEventOngoing() map[string]interface{} {
|
||||
event := participantJoinedEvent()
|
||||
payload := common.GetMap(event, "payload")
|
||||
meeting := common.GetMap(payload, "meeting")
|
||||
meeting["start_time"] = "1776410100"
|
||||
meeting["end_time"] = "1776410100"
|
||||
return event
|
||||
}
|
||||
|
||||
func chatReceivedEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-2",
|
||||
"event_type": "chat_received",
|
||||
"event_time": "2026-04-17T08:05:00Z",
|
||||
"payload": map[string]interface{}{
|
||||
"activity_event_type": "chat_received",
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "7628568141510692381",
|
||||
"topic": "项目例会",
|
||||
"meeting_no": "724939760",
|
||||
"start_time": "1776407700",
|
||||
"end_time": "1776411300",
|
||||
},
|
||||
"participant_joined_items": []interface{}{},
|
||||
"participant_left_items": []interface{}{},
|
||||
"transcript_received_items": []interface{}{},
|
||||
"magic_share_started_items": []interface{}{},
|
||||
"magic_share_ended_items": []interface{}{},
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"content": "hello",
|
||||
"message_type": 3,
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func multiChatReceivedEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-3",
|
||||
"event_type": "chat_received",
|
||||
"event_time": "2026-04-17T08:06:00Z",
|
||||
"payload": map[string]interface{}{
|
||||
"activity_event_type": "chat_received",
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "7628568141510692381",
|
||||
"topic": "项目例会",
|
||||
"meeting_no": "724939760",
|
||||
"start_time": "1776407700",
|
||||
"end_time": "1776411300",
|
||||
},
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"content": "第一条\n第二行",
|
||||
"message_type": 3,
|
||||
"send_time": "1776408061000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"content": "第二条",
|
||||
"message_type": 3,
|
||||
"send_time": "1776408062000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func magicShareStartedEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-4",
|
||||
"event_type": "magic_share_started",
|
||||
"event_time": "2026-04-17T08:07:00Z",
|
||||
"payload": map[string]interface{}{
|
||||
"activity_event_type": "magic_share_started",
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "7628568141510692381",
|
||||
"topic": "项目例会",
|
||||
"meeting_no": "724939760",
|
||||
"start_time": "1776407700",
|
||||
"end_time": "1776411300",
|
||||
},
|
||||
"magic_share_started_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"time": "1776408123000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u2",
|
||||
"user_name": "Bob",
|
||||
},
|
||||
"share_doc": map[string]interface{}{
|
||||
"title": "共享文档",
|
||||
"url": "https://example.com/doc",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatReceivedSummary_MultipleItems(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{"content": "hello"},
|
||||
map[string]interface{}{"content": "world"},
|
||||
},
|
||||
}
|
||||
|
||||
got := chatReceivedSummary(payload)
|
||||
if got != "2 messages" {
|
||||
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "2 messages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatReceivedSummary_MultipleItemsSameOperator(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{"content": "hello", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
|
||||
map[string]interface{}{"content": "world", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
|
||||
},
|
||||
}
|
||||
|
||||
got := chatReceivedSummary(payload)
|
||||
if got != "2 messages by Alice" {
|
||||
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "2 messages by Alice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatReceivedSummary_MultipleItemsMultipleOperators(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{"content": "hello", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
|
||||
map[string]interface{}{"content": "world", "operator": map[string]interface{}{"id": "u2", "user_name": "Bob"}},
|
||||
map[string]interface{}{"content": "again", "operator": map[string]interface{}{"id": "u3", "user_name": "Carol"}},
|
||||
},
|
||||
}
|
||||
|
||||
got := chatReceivedSummary(payload)
|
||||
if got != "3 messages by 3 users" {
|
||||
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "3 messages by 3 users")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticipantJoinedSummary_MultipleItems(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"participant_joined_items": []interface{}{
|
||||
map[string]interface{}{"participant": map[string]interface{}{"id": "u1", "user_name": "User 1"}},
|
||||
map[string]interface{}{"participant": map[string]interface{}{"id": "u2", "user_name": "User 2"}},
|
||||
},
|
||||
}
|
||||
|
||||
got := participantJoinedSummary(payload)
|
||||
if got != "2 participants joined" {
|
||||
t.Fatalf("participantJoinedSummary() = %q, want %q", got, "2 participants joined")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_Validation_InvalidMeetingID(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "not-a-number")
|
||||
|
||||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid meeting ID")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "positive integer") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_Validation_InvalidTimeRange(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||||
mustSetMeetingEventsFlag(t, runtime, "start", "200")
|
||||
mustSetMeetingEventsFlag(t, runtime, "end", "100")
|
||||
|
||||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid time range")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "after --end") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_Validation_PageSizeBelowMinDoesNotError(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||||
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
|
||||
|
||||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no validation error for page-size clamp, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_Validation_PageAllIgnoresInvalidPageSize(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||||
mustSetMeetingEventsFlag(t, runtime, "page-all", "true")
|
||||
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
|
||||
|
||||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no validation error when page-all ignores page-size, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_Validation_InvalidPageSizeReturnsFlagError(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||||
mustSetMeetingEventsFlag(t, runtime, "page-size", "foo")
|
||||
|
||||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for non-integer page-size")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid --page-size") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsParams(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||||
mustSetMeetingEventsFlag(t, runtime, "page-size", "40")
|
||||
mustSetMeetingEventsFlag(t, runtime, "page-token", "1710000000000000000")
|
||||
|
||||
params, err := buildMeetingEventsParams(runtime, "1710000000", "1710003600")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMeetingEventsParams() error = %v", err)
|
||||
}
|
||||
if got := params["meeting_id"][0]; got != "7628568141510692381" {
|
||||
t.Fatalf("meeting_id = %q, want %q", got, "7628568141510692381")
|
||||
}
|
||||
if got := params["page_size"][0]; got != "40" {
|
||||
t.Fatalf("page_size = %q, want %q", got, "40")
|
||||
}
|
||||
if got := params["page_token"][0]; got != "1710000000000000000" {
|
||||
t.Fatalf("page_token = %q, want %q", got, "1710000000000000000")
|
||||
}
|
||||
if got := params["start_time"][0]; got != "1710000000" {
|
||||
t.Fatalf("start_time = %q, want %q", got, "1710000000")
|
||||
}
|
||||
if got := params["end_time"][0]; got != "1710003600" {
|
||||
t.Fatalf("end_time = %q, want %q", got, "1710003600")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsParams_PageSizeBelowMinClampsToMin(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||||
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
|
||||
|
||||
params, err := buildMeetingEventsParams(runtime, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMeetingEventsParams() error = %v", err)
|
||||
}
|
||||
if got := params["page_size"][0]; got != "20" {
|
||||
t.Fatalf("page_size = %q, want %q when below min", got, "20")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsParams_PageSizeAboveMaxClampsToMax(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||||
mustSetMeetingEventsFlag(t, runtime, "page-size", "999")
|
||||
|
||||
params, err := buildMeetingEventsParams(runtime, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMeetingEventsParams() error = %v", err)
|
||||
}
|
||||
if got := params["page_size"][0]; got != "100" {
|
||||
t.Fatalf("page_size = %q, want %q when above max", got, "100")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsParams_PageAllUsesMaxPageSize(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||||
mustSetMeetingEventsFlag(t, runtime, "page-all", "true")
|
||||
mustSetMeetingEventsFlag(t, runtime, "page-size", "50")
|
||||
|
||||
params, err := buildMeetingEventsParams(runtime, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMeetingEventsParams() error = %v", err)
|
||||
}
|
||||
if got := params["page_size"][0]; got != "100" {
|
||||
t.Fatalf("page_size = %q, want %q when page-all is set", got, "100")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--page-token", "1710000000000000000",
|
||||
"--page-size", "40",
|
||||
"--start", "1710000000",
|
||||
"--end", "1710003600",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
vcMeetingEventsAPIPath,
|
||||
`"meeting_id": "7628568141510692381"`,
|
||||
`"page_token": "1710000000000000000"`,
|
||||
`"page_size": "40"`,
|
||||
`"start_time": "1710000000"`,
|
||||
`"end_time": "1710003600"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--page-all",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "Auto-paginates through all available pages") {
|
||||
t.Fatalf("dry-run output missing auto-pagination description: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2"))
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--page-all",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 {
|
||||
t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String())
|
||||
}
|
||||
if !strings.Contains(out, `"has_more":false`) {
|
||||
t.Fatalf("expected final has_more=false: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"event_type":"participant_joined"`,
|
||||
`"has_more":true`,
|
||||
`"page_token":"1710000000000000000"`,
|
||||
`"events":[`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := stdout.String()
|
||||
for _, unwanted := range []string{
|
||||
`"participant_joined_items": []`,
|
||||
`"participant_left_items": []`,
|
||||
`"transcript_received_items": []`,
|
||||
`"magic_share_started_items": []`,
|
||||
`"magic_share_ended_items": []`,
|
||||
} {
|
||||
if strings.Contains(out, unwanted) {
|
||||
t.Fatalf("json output should not contain %q: %s", unwanted, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, `"message_type": 3`) {
|
||||
t.Fatalf("json output should keep numeric fields: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"会议主题:项目例会",
|
||||
"会议时间:2026-04-17 15:15:00(进行中)",
|
||||
"Demo Bot(bot_001) 加入了会议",
|
||||
"Alice(u1): [reaction] 第一条\\n第二行",
|
||||
"Alice(u1): [reaction] 第二条",
|
||||
"Bob(u2) 开始共享「共享文档」",
|
||||
"URL: https://example.com/doc",
|
||||
"page_token: 1710000000000000000",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "第二条\n\n[") {
|
||||
t.Fatalf("pretty output should not insert blank lines between event entries: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "第二条\n[") {
|
||||
t.Fatalf("pretty output should keep event entries contiguous: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "page_token: pt_last") {
|
||||
t.Fatalf("pretty output should print page_token even when has_more is false: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "more available") {
|
||||
t.Fatalf("pretty output should not print more-available hint when has_more is false: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub(nil, false, ""))
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
if !strings.Contains(stdout.String(), "No meeting events.") {
|
||||
t.Fatalf("unexpected output: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFlexibleTime(t *testing.T) {
|
||||
t.Run("unix seconds", func(t *testing.T) {
|
||||
got, ok := parseFlexibleTime("1776410100")
|
||||
if !ok {
|
||||
t.Fatal("parseFlexibleTime() ok = false, want true")
|
||||
}
|
||||
if want := time.Unix(1776410100, 0); !got.Equal(want) {
|
||||
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unix millis", func(t *testing.T) {
|
||||
got, ok := parseFlexibleTime("1776408061000")
|
||||
if !ok {
|
||||
t.Fatal("parseFlexibleTime() ok = false, want true")
|
||||
}
|
||||
if want := time.UnixMilli(1776408061000); !got.Equal(want) {
|
||||
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rfc3339", func(t *testing.T) {
|
||||
got, ok := parseFlexibleTime("2026-04-17T08:00:00Z")
|
||||
if !ok {
|
||||
t.Fatal("parseFlexibleTime() ok = false, want true")
|
||||
}
|
||||
if want, _ := time.Parse(time.RFC3339, "2026-04-17T08:00:00Z"); !got.Equal(want) {
|
||||
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
if _, ok := parseFlexibleTime("not-a-time"); ok {
|
||||
t.Fatal("parseFlexibleTime() ok = true, want false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatMeetingWindow(t *testing.T) {
|
||||
start := time.Unix(1776410100, 0)
|
||||
end := time.Unix(1776413700, 0)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start time.Time
|
||||
hasStart bool
|
||||
end time.Time
|
||||
hasEnd bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ongoing",
|
||||
start: start,
|
||||
hasStart: true,
|
||||
end: start,
|
||||
hasEnd: true,
|
||||
want: "2026-04-17 15:15:00(进行中)",
|
||||
},
|
||||
{
|
||||
name: "finished range",
|
||||
start: start,
|
||||
hasStart: true,
|
||||
end: end,
|
||||
hasEnd: true,
|
||||
want: "2026-04-17 15:15:00 - 2026-04-17 16:15:00",
|
||||
},
|
||||
{
|
||||
name: "only start",
|
||||
start: start,
|
||||
hasStart: true,
|
||||
want: "2026-04-17 15:15:00",
|
||||
},
|
||||
{
|
||||
name: "only end",
|
||||
end: end,
|
||||
hasEnd: true,
|
||||
want: "2026-04-17 16:15:00",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := formatMeetingWindow(tt.start, tt.hasStart, tt.end, tt.hasEnd); got != tt.want {
|
||||
t.Fatalf("formatMeetingWindow() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimelineOffset(t *testing.T) {
|
||||
start := time.Unix(1776410100, 0)
|
||||
later := start.Add(90 * time.Second)
|
||||
earlier := start.Add(-5 * time.Minute)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
when time.Time
|
||||
hasWhen bool
|
||||
meetingStart time.Time
|
||||
hasMeetingStart bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "with meeting start",
|
||||
when: later,
|
||||
hasWhen: true,
|
||||
meetingStart: start,
|
||||
hasMeetingStart: true,
|
||||
want: "00:01:30",
|
||||
},
|
||||
{
|
||||
name: "negative diff clamps to zero",
|
||||
when: earlier,
|
||||
hasWhen: true,
|
||||
meetingStart: start,
|
||||
hasMeetingStart: true,
|
||||
want: "00:00:00",
|
||||
},
|
||||
{
|
||||
name: "without meeting start uses wall clock",
|
||||
when: later,
|
||||
hasWhen: true,
|
||||
want: "15:16:30",
|
||||
},
|
||||
{
|
||||
name: "missing when",
|
||||
want: "??:??:??",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := formatTimelineOffset(tt.when, tt.hasWhen, tt.meetingStart, tt.hasMeetingStart); got != tt.want {
|
||||
t.Fatalf("formatTimelineOffset() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenQueryParams(t *testing.T) {
|
||||
params := larkcore.QueryParams{
|
||||
"one": []string{"1"},
|
||||
"many": []string{"2", "3"},
|
||||
"empty": []string{},
|
||||
}
|
||||
|
||||
got := flattenQueryParams(params)
|
||||
want := map[string]interface{}{
|
||||
"one": "1",
|
||||
"many": []string{"2", "3"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("flattenQueryParams() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactMeetingPayload_DropsOnlyEmptySlices(t *testing.T) {
|
||||
got := compactMeetingPayload(map[string]interface{}{
|
||||
"empty_items": []interface{}{},
|
||||
"items": []interface{}{"x"},
|
||||
"zero": 0,
|
||||
"text": "ok",
|
||||
})
|
||||
|
||||
if _, ok := got["empty_items"]; ok {
|
||||
t.Fatalf("compactMeetingPayload() should drop empty_items: %#v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(got["items"], []interface{}{"x"}) {
|
||||
t.Fatalf("compactMeetingPayload() items = %#v, want %#v", got["items"], []interface{}{"x"})
|
||||
}
|
||||
if got["zero"] != 0 || got["text"] != "ok" {
|
||||
t.Fatalf("compactMeetingPayload() preserved fields mismatch: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactMeetingEvents_IgnoresNonMapsAndCompactsPayload(t *testing.T) {
|
||||
got := compactMeetingEvents([]interface{}{
|
||||
"skip-me",
|
||||
map[string]interface{}{
|
||||
"event_type": "chat_received",
|
||||
"payload": map[string]interface{}{
|
||||
"chat_received_items": []interface{}{"x"},
|
||||
"empty_items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(compactMeetingEvents()) = %d, want 1", len(got))
|
||||
}
|
||||
event, _ := got[0].(map[string]interface{})
|
||||
payload := common.GetMap(event, "payload")
|
||||
if _, ok := payload["empty_items"]; ok {
|
||||
t.Fatalf("compactMeetingEvents() should prune empty payload slices: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
var commands []string
|
||||
for _, shortcut := range got {
|
||||
commands = append(commands, shortcut.Command)
|
||||
}
|
||||
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-events"}
|
||||
if !reflect.DeepEqual(commands, want) {
|
||||
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{name: "meeting ended", item: map[string]interface{}{"leave_reason": 2}, want: "因会议结束离开了会议"},
|
||||
{name: "kicked", item: map[string]interface{}{"leave_reason": 3}, want: "被移出了会议"},
|
||||
{name: "default", item: map[string]interface{}{"leave_reason": 1}, want: "离开了会议"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := leaveAction(tt.item); got != tt.want {
|
||||
t.Fatalf("leaveAction() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventUserWithID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{name: "nil", want: ""},
|
||||
{name: "name and id", user: map[string]interface{}{"user_name": "Alice", "id": "u1"}, want: "Alice(u1)"},
|
||||
{name: "name only", user: map[string]interface{}{"user_name": "Alice"}, want: "Alice"},
|
||||
{name: "id only", user: map[string]interface{}{"id": "u1"}, want: "u1"},
|
||||
{name: "empty", user: map[string]interface{}{}, want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := meetingEventUserWithID(tt.user); got != tt.want {
|
||||
t.Fatalf("meetingEventUserWithID() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "participant joined count",
|
||||
event: map[string]interface{}{
|
||||
"event_type": "participant_joined",
|
||||
"payload": map[string]interface{}{
|
||||
"participant_joined_items": []interface{}{
|
||||
map[string]interface{}{},
|
||||
map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: "2 participants joined",
|
||||
},
|
||||
{
|
||||
name: "participant left with label",
|
||||
event: map[string]interface{}{
|
||||
"event_type": "participant_left",
|
||||
"payload": map[string]interface{}{
|
||||
"participant_left_items": []interface{}{
|
||||
map[string]interface{}{"participant": map[string]interface{}{"user_name": "Bob", "id": "u2"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: "participant u2 (Bob) left",
|
||||
},
|
||||
{
|
||||
name: "fallback unknown event",
|
||||
event: map[string]interface{}{
|
||||
"event_type": "mystery_event",
|
||||
"payload": map[string]interface{}{},
|
||||
},
|
||||
want: "mystery_event",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := meetingEventSummary(tt.event); got != tt.want {
|
||||
t.Fatalf("meetingEventSummary() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapePrettyText(t *testing.T) {
|
||||
got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07)))
|
||||
want := `line1\nline2\t\r\u0007`
|
||||
if got != want {
|
||||
t.Fatalf("escapePrettyText() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsColon(t *testing.T) {
|
||||
tests := []struct {
|
||||
description string
|
||||
want bool
|
||||
}{
|
||||
{description: "发送了消息", want: false},
|
||||
{description: "加入了会议", want: false},
|
||||
{description: "离开了会议", want: false},
|
||||
{description: "开始共享「文档」", want: false},
|
||||
{description: "[text] hello", want: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := needsColon(tt.description); got != tt.want {
|
||||
t.Fatalf("needsColon(%q) = %v, want %v", tt.description, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
shortcuts/vc/vc_meeting_join.go
Normal file
94
shortcuts/vc/vc_meeting_join.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var meetingNumberRe = regexp.MustCompile(`^\d{9}$`)
|
||||
|
||||
// validMeetingNumber checks whether s is a valid 9-digit meeting number.
|
||||
func validMeetingNumber(s string) bool {
|
||||
return meetingNumberRe.MatchString(s)
|
||||
}
|
||||
|
||||
// VCMeetingJoin joins a meeting by meeting number via /vc/v1/bots/join.
|
||||
var VCMeetingJoin = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-join",
|
||||
Description: "Join a meeting by meeting number (bot join)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"vc:meeting.bot.join:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-number", Required: true, Desc: "meeting number to join"},
|
||||
{Name: "password", Desc: "meeting password (if required)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
mn := strings.TrimSpace(runtime.Str("meeting-number"))
|
||||
if !validMeetingNumber(mn) {
|
||||
return common.FlagErrorf("--meeting-number must be exactly 9 digits, got %q", mn)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildMeetingJoinBody(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/vc/v1/bots/join").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildMeetingJoinBody(runtime)
|
||||
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/join", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
meeting, _ := data["meeting"].(map[string]interface{})
|
||||
if meeting == nil {
|
||||
fmt.Fprintln(w, "Joined meeting (no meeting info returned).")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Joined meeting successfully.\n")
|
||||
if id := common.GetString(meeting, "id"); id != "" {
|
||||
fmt.Fprintf(w, " Meeting ID: %s\n", id)
|
||||
}
|
||||
if no := common.GetString(meeting, "meeting_no"); no != "" {
|
||||
fmt.Fprintf(w, " Meeting No: %s\n", no)
|
||||
}
|
||||
if topic := common.GetString(meeting, "topic"); topic != "" {
|
||||
fmt.Fprintf(w, " Topic: %s\n", topic)
|
||||
}
|
||||
if startTime := common.GetString(meeting, "start_time"); startTime != "" {
|
||||
fmt.Fprintf(w, " Start Time: %s\n", startTime)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildMeetingJoinBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
meetingNo := strings.TrimSpace(runtime.Str("meeting-number"))
|
||||
body := map[string]interface{}{
|
||||
"join_type": 1,
|
||||
"join_identify": map[string]interface{}{
|
||||
"meeting_no": meetingNo,
|
||||
},
|
||||
}
|
||||
if pw := strings.TrimSpace(runtime.Str("password")); pw != "" {
|
||||
body["password"] = pw
|
||||
}
|
||||
return body
|
||||
}
|
||||
57
shortcuts/vc/vc_meeting_leave.go
Normal file
57
shortcuts/vc/vc_meeting_leave.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// VCMeetingLeave leaves a meeting via /vc/v1/bots/leave.
|
||||
var VCMeetingLeave = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-leave",
|
||||
Description: "Leave a meeting by meeting ID",
|
||||
Risk: "write",
|
||||
Scopes: []string{"vc:meeting.bot.join:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-id", Required: true, Desc: "meeting ID to leave"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("meeting-id")) == "" {
|
||||
return common.FlagErrorf("--meeting-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/vc/v1/bots/leave").
|
||||
Body(map[string]interface{}{
|
||||
"meeting_id": strings.TrimSpace(runtime.Str("meeting-id")),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
meetingID := strings.TrimSpace(runtime.Str("meeting-id"))
|
||||
body := map[string]interface{}{
|
||||
"meeting_id": meetingID,
|
||||
}
|
||||
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/leave", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Left meeting %s successfully.\n", meetingID)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
536
shortcuts/vc/vc_meeting_test.go
Normal file
536
shortcuts/vc/vc_meeting_test.go
Normal file
@@ -0,0 +1,536 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: pure functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidMeetingNumber(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"9 digits", "123456789", true},
|
||||
{"9 digits leading zero", "012345678", true},
|
||||
{"empty", "", false},
|
||||
{"8 digits", "12345678", false},
|
||||
{"10 digits", "1234567890", false},
|
||||
{"with space", "12345 678", false},
|
||||
{"letters mixed", "12345678a", false},
|
||||
{"pure letters", "abcdefghi", false},
|
||||
{"with dash", "123-456-789", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := validMeetingNumber(tt.in); got != tt.want {
|
||||
t.Errorf("validMeetingNumber(%q) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingJoinBody_WithoutPassword(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-number", "", "")
|
||||
cmd.Flags().String("password", "", "")
|
||||
_ = cmd.Flags().Set("meeting-number", "123456789")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
body := buildMeetingJoinBody(runtime)
|
||||
|
||||
if body["join_type"] != 1 {
|
||||
t.Errorf("join_type = %v, want 1", body["join_type"])
|
||||
}
|
||||
ji, ok := body["join_identify"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("join_identify missing or wrong type: %v", body["join_identify"])
|
||||
}
|
||||
if ji["meeting_no"] != "123456789" {
|
||||
t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"])
|
||||
}
|
||||
if _, exists := body["password"]; exists {
|
||||
t.Errorf("password should be omitted when empty, got %v", body["password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingJoinBody_WithPassword(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-number", "", "")
|
||||
cmd.Flags().String("password", "", "")
|
||||
_ = cmd.Flags().Set("meeting-number", "123456789")
|
||||
_ = cmd.Flags().Set("password", "secret")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
body := buildMeetingJoinBody(runtime)
|
||||
|
||||
if body["password"] != "secret" {
|
||||
t.Errorf("password = %v, want secret", body["password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingJoinBody_TrimsWhitespace(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-number", "", "")
|
||||
cmd.Flags().String("password", "", "")
|
||||
_ = cmd.Flags().Set("meeting-number", " 123456789 ")
|
||||
_ = cmd.Flags().Set("password", " pw ")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
body := buildMeetingJoinBody(runtime)
|
||||
|
||||
ji, _ := body["join_identify"].(map[string]interface{})
|
||||
if ji["meeting_no"] != "123456789" {
|
||||
t.Errorf("meeting_no should be trimmed, got %q", ji["meeting_no"])
|
||||
}
|
||||
if body["password"] != "pw" {
|
||||
t.Errorf("password should be trimmed, got %q", body["password"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validate tests: VCMeetingJoin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingJoin_Validate_MissingNumber(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
// cobra MarkFlagRequired should reject missing --meeting-number
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{"+meeting-join", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --meeting-number is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "meeting-number") {
|
||||
t.Errorf("error should mention meeting-number, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Validate_InvalidFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
num string
|
||||
}{
|
||||
{"too short", "12345678"},
|
||||
{"too long", "1234567890"},
|
||||
{"with letters", "12345abcd"},
|
||||
{"empty after trim", " "},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-number", "", "")
|
||||
cmd.Flags().String("password", "", "")
|
||||
_ = cmd.Flags().Set("meeting-number", tt.num)
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := VCMeetingJoin.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %q", tt.num)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "9 digits") {
|
||||
t.Errorf("error should mention '9 digits', got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Validate_Valid(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-number", "", "")
|
||||
cmd.Flags().String("password", "", "")
|
||||
_ = cmd.Flags().Set("meeting-number", "123456789")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
if err := VCMeetingJoin.Validate(context.Background(), runtime); err != nil {
|
||||
t.Errorf("unexpected validation error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DryRun tests: VCMeetingJoin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingJoin_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "123456789", "--password", "pw123",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/vc/v1/bots/join") {
|
||||
t.Errorf("dry-run should include API path, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "123456789") {
|
||||
t.Errorf("dry-run should include meeting number, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "pw123") {
|
||||
t.Errorf("dry-run should include password, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests: VCMeetingJoin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingJoin_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/join",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "69999999",
|
||||
"meeting_no": "123456789",
|
||||
"topic": "Weekly Sync",
|
||||
"start_time": "1700000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "123456789",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// verify captured request body
|
||||
if len(stub.CapturedBody) == 0 {
|
||||
t.Fatal("expected request body to be captured")
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
|
||||
t.Fatalf("failed to parse request body: %v", err)
|
||||
}
|
||||
if req["join_type"].(float64) != 1 {
|
||||
t.Errorf("join_type = %v, want 1", req["join_type"])
|
||||
}
|
||||
ji, _ := req["join_identify"].(map[string]interface{})
|
||||
if ji["meeting_no"] != "123456789" {
|
||||
t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"])
|
||||
}
|
||||
if _, exists := ji["password"]; exists {
|
||||
t.Errorf("password should be omitted when not provided, got %v", ji["password"])
|
||||
}
|
||||
|
||||
// verify response envelope carries meeting info under data.meeting
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse stdout: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meeting, _ := data["meeting"].(map[string]any)
|
||||
if meeting["id"] != "69999999" {
|
||||
t.Errorf("meeting.id = %v, want 69999999 (envelope: %s)", meeting["id"], stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Execute_WithPassword_CapturesBody(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/join",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "987654321", "--password", "s3cret",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
|
||||
t.Fatalf("failed to parse request body: %v", err)
|
||||
}
|
||||
ji, _ := req["join_identify"].(map[string]interface{})
|
||||
if req["password"] != "s3cret" {
|
||||
t.Errorf("password = %v, want s3cret", req["password"])
|
||||
}
|
||||
if ji["meeting_no"] != "987654321" {
|
||||
t.Errorf("meeting_no = %v, want 987654321", ji["meeting_no"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Execute_PrettyOutput(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/join",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "69999999",
|
||||
"meeting_no": "123456789",
|
||||
"topic": "Weekly Sync",
|
||||
"start_time": "1700000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "123456789",
|
||||
"--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"Joined meeting successfully", "69999999", "123456789", "Weekly Sync", "1700000000"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing %q, got: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Execute_PrettyOutput_NoMeetingInfo(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/join",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "123456789",
|
||||
"--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "no meeting info returned") {
|
||||
t.Errorf("pretty output should fall back to 'no meeting info' notice, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingLeave_Execute_PrettyOutput(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/leave",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{
|
||||
"+meeting-leave", "--meeting-id", "69999999",
|
||||
"--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Left meeting 69999999 successfully") {
|
||||
t.Errorf("pretty output should confirm leave, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Execute_APIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/join",
|
||||
Body: map[string]interface{}{"code": 190001, "msg": "invalid meeting number"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "123456789",
|
||||
"--as", "user",
|
||||
}, f, &bytes.Buffer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid meeting number") {
|
||||
t.Errorf("error should surface API message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validate tests: VCMeetingLeave
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingLeave_Validate_MissingID(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{"+meeting-leave", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --meeting-id is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "meeting-id") {
|
||||
t.Errorf("error should mention meeting-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingLeave_Validate_WhitespaceOnly(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-id", "", "")
|
||||
_ = cmd.Flags().Set("meeting-id", " ")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := VCMeetingLeave.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for whitespace-only meeting-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "meeting-id") {
|
||||
t.Errorf("error should mention meeting-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingLeave_Validate_Valid(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-id", "", "")
|
||||
_ = cmd.Flags().Set("meeting-id", "69999999")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
if err := VCMeetingLeave.Validate(context.Background(), runtime); err != nil {
|
||||
t.Errorf("unexpected validation error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DryRun tests: VCMeetingLeave
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingLeave_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{
|
||||
"+meeting-leave", "--meeting-id", "69999999",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/vc/v1/bots/leave") {
|
||||
t.Errorf("dry-run should include API path, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "69999999") {
|
||||
t.Errorf("dry-run should include meeting-id, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests: VCMeetingLeave
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingLeave_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/leave",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{
|
||||
"+meeting-leave", "--meeting-id", "69999999",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// verify captured request body
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
|
||||
t.Fatalf("failed to parse request body: %v", err)
|
||||
}
|
||||
if req["meeting_id"] != "69999999" {
|
||||
t.Errorf("meeting_id = %v, want 69999999", req["meeting_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingLeave_Execute_TrimsMeetingID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/leave",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{
|
||||
"+meeting-leave", "--meeting-id", " 69999999 ",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
|
||||
t.Fatalf("failed to parse request body: %v", err)
|
||||
}
|
||||
if req["meeting_id"] != "69999999" {
|
||||
t.Errorf("meeting_id should be trimmed, got %q", req["meeting_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingLeave_Execute_APIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/leave",
|
||||
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{
|
||||
"+meeting-leave", "--meeting-id", "69999999", "--as", "user",
|
||||
}, f, &bytes.Buffer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no permission") {
|
||||
t.Errorf("error should surface API message, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
- **Chat**: A group chat or P2P conversation, identified by `chat_id` (oc_xxx).
|
||||
- **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx).
|
||||
- **Reaction**: An emoji reaction on a message.
|
||||
- **Flag**: A bookmark on a message or thread.
|
||||
|
||||
## Resource Relationships
|
||||
|
||||
@@ -35,3 +36,14 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
|
||||
### Card Messages (Interactive)
|
||||
|
||||
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
|
||||
|
||||
### Flag Types
|
||||
|
||||
Flags support two layers:
|
||||
|
||||
- **Message-layer flag**: `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark
|
||||
- **Feed-layer flag**: `(ItemTypeThread/ItemTypeMsgThread, FlagTypeFeed)` — thread as feed-layer bookmark
|
||||
|
||||
Item types for feed-layer flags:
|
||||
- **ItemTypeThread** (4) = thread in a topic-style chat
|
||||
- **ItemTypeMsgThread** (11) = thread in a regular chat
|
||||
|
||||
@@ -12,6 +12,7 @@ metadata:
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
|
||||
> **执行前必做:** 执行任何 `base` 命令前,必须先阅读对应命令的 reference 文档,再调用命令。
|
||||
> **查询类任务必做:** 涉及筛选、排序、Top/Bottom N、聚合、多表关联、查询后写入或判断全局结论时,必须先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),再选择 `record / view / data-query` 路径。
|
||||
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;如需先解析 Wiki 链接,可先调用 `lark-cli wiki ...`。
|
||||
> **分流规则:** 如果用户要“把本地文件导入成 Base / 多维表格 / bitable”,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
|
||||
|
||||
@@ -104,7 +105,7 @@ metadata:
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 记录读取统一先读 read SOP guide:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query`;`+record-get` 支持重复 `--record-id` 或 `--json` 读取多条记录 |
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 记录读取统一先读 data analysis SOP:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query`;`+record-get` 支持重复 `--record-id` 或 `--json` 读取多条记录 |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
|
||||
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token` 从 `+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403) |
|
||||
@@ -120,7 +121,7 @@ metadata:
|
||||
|------|------------------|----------------|----------|
|
||||
| `+view-list / +view-get` | 列出视图,或获取视图详情 | [`lark-base-view-list.md`](references/lark-base-view-list.md)、[`lark-base-view-get.md`](references/lark-base-view-get.md) | `+view-list` 只能串行执行;`+view-get` 适合查看已有视图配置 |
|
||||
| `+view-create / +view-delete / +view-rename` | 创建、删除或重命名视图 | [`lark-base-view-create.md`](references/lark-base-view-create.md)、[`lark-base-view-delete.md`](references/lark-base-view-delete.md)、[`lark-base-view-rename.md`](references/lark-base-view-rename.md) | 创建前先确认表和视图类型;删除前先确认目标;用户已明确新名字时可直接重命名 |
|
||||
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
|
||||
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
|
||||
| `+view-get-sort / +view-set-sort` | 读取或配置排序 | [`lark-base-view-get-sort.md`](references/lark-base-view-get-sort.md)、[`lark-base-view-set-sort.md`](references/lark-base-view-set-sort.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-group / +view-set-group` | 读取或配置分组 | [`lark-base-view-get-group.md`](references/lark-base-view-get-group.md)、[`lark-base-view-set-group.md`](references/lark-base-view-set-group.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-visible-fields / +view-set-visible-fields` | 读取或配置视图可见字段 | [`lark-base-view-get-visible-fields.md`](references/lark-base-view-get-visible-fields.md)、[`lark-base-view-set-visible-fields.md`](references/lark-base-view-set-visible-fields.md) | 用于控制视图中的字段顺序与可见性;字段名必须来自真实结构 |
|
||||
@@ -228,14 +229,24 @@ metadata:
|
||||
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
|
||||
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create`、`+table-create` 或 `+record-upsert` |
|
||||
|
||||
### 3.3 表名、字段名与表达式引用
|
||||
### 3.3 查询执行契约
|
||||
|
||||
涉及查询、统计或判断结论时,先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),并遵守以下高优先级规则:
|
||||
|
||||
1. `+record-list` 默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实,不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。
|
||||
2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询服务中执行;不要先拉明细到本地上下文再手工筛选排序。
|
||||
3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。
|
||||
4. 多表查询必须先确认关系字段和连接键;link 单元格里的 `record_id` 是关系键,不是用户可读答案。
|
||||
5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。
|
||||
|
||||
### 3.4 表名、字段名与表达式引用
|
||||
|
||||
1. 表名、字段名必须精确匹配真实返回,来源应是 `+table-list / +table-get / +field-list`。
|
||||
2. 不要凭自然语言猜名称,不要自行改写用户口述中的表名、字段名。
|
||||
3. `formula / lookup / data-query / workflow` 中出现的名称同样必须精确匹配;表达式引用、where 条件、DSL 字段名、workflow 配置都遵守同一规则。
|
||||
4. 跨表场景必须额外读取目标表结构,不能只看当前表。
|
||||
|
||||
### 3.4 Token 与链接
|
||||
### 3.5 Token 与链接
|
||||
|
||||
这是高优先级章节。只要用户输入里出现链接、token,或报错涉及 `baseToken` / `wiki_token` / `obj_token`,都应优先回到这里检查。
|
||||
|
||||
@@ -254,7 +265,7 @@ metadata:
|
||||
| `slides` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
| `mindnote` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
|
||||
### 3.5 身份选择与权限降级策略
|
||||
### 3.6 身份选择与权限降级策略
|
||||
|
||||
多维表格通常属于用户的个人或团队资源。**默认应优先使用 `--as user`(用户身份)执行所有 Base 操作**,始终显式指定身份。
|
||||
|
||||
@@ -282,10 +293,11 @@ lark-cli auth login --domain base
|
||||
|
||||
1. 先判断任务属于哪个模块,选对命令族。
|
||||
2. 如果用户给了链接,先解析 token,不要把 wiki token、完整 URL 或其他对象 ID 误当成 `base_token`。
|
||||
3. 先拿结构,再写命令,避免猜表名、字段名、表达式引用。
|
||||
4. 定位到命令后,先读对应 reference,再执行命令。
|
||||
5. 执行命令,并按返回结果判断下一步。
|
||||
6. 回复时返回关键结果和后续可继续操作的信息,方便 agent 链式执行下一步。
|
||||
3. 如果是查询类任务,先判断问题范围,阅读 data analysis SOP,再决定使用 `record / view / data-query`。
|
||||
4. 先拿结构,再写命令,避免猜表名、字段名、表达式引用。
|
||||
5. 定位到命令后,先读对应 reference,再执行命令。
|
||||
6. 执行命令,并按返回结果判断下一步。
|
||||
7. 回复时返回关键结果和后续可继续操作的信息,方便 agent 链式执行下一步。
|
||||
|
||||
### 4.2 不可违反规则
|
||||
|
||||
@@ -297,11 +309,12 @@ lark-cli auth login --domain base
|
||||
6. 只写可写字段;系统字段、附件字段、`formula`、`lookup` 默认不作为普通记录写入目标。
|
||||
7. 聚合分析与取数分流;统计走 `+data-query`,关键词检索走 `+record-search`,明细走 `+record-list / +record-get`。
|
||||
8. 筛选查询按视图能力执行;先用 `+view-set-filter` 配置筛选,再结合 `+record-list` 读取。
|
||||
9. Base 场景不要改走裸 API,不要切去 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
10. 统一使用 `--base-token`。
|
||||
11. workflow 场景先读 schema,不要凭自然语言猜 `type`。
|
||||
12. dashboard 场景先读 guide;提到图表、看板、block 就先进入 dashboard 模块。
|
||||
13. formula / lookup 场景先读 guide;没读 guide 前不要直接创建或更新。
|
||||
9. 全局查询不得基于默认分页、小 `--limit` 或未证明全量的本地 `jq` 结果下结论。
|
||||
10. Base 场景不要改走裸 API,不要切去 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
11. 统一使用 `--base-token`。
|
||||
12. workflow 场景先读 schema,不要凭自然语言猜 `type`。
|
||||
13. dashboard 场景先读 guide;提到图表、看板、block 就先进入 dashboard 模块。
|
||||
14. formula / lookup 场景先读 guide;没读 guide 前不要直接创建或更新。
|
||||
|
||||
### 4.3 并发、分页与批量限制
|
||||
|
||||
|
||||
88
skills/lark-base/references/lark-base-data-analysis-sop.md
Normal file
88
skills/lark-base/references/lark-base-data-analysis-sop.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Base data analysis SOP
|
||||
|
||||
Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、排序、Top/Bottom N、聚合统计、分组聚合、多表关联、临时分析和查询后写入前的目标定位。
|
||||
|
||||
具体命令参数不要在本文猜;需要时跳到对应 reference:
|
||||
|
||||
- `+data-query`: [lark-base-data-query.md](lark-base-data-query.md)
|
||||
- 视图筛选/排序/投影: [lark-base-view-set-filter.md](lark-base-view-set-filter.md), [lark-base-view-set-sort.md](lark-base-view-set-sort.md), [lark-base-view-set-visible-fields.md](lark-base-view-set-visible-fields.md)
|
||||
- 记录读取: [lark-base-record.md](lark-base-record.md)
|
||||
|
||||
## 0. Hard Rules
|
||||
|
||||
- 全局问题不能用默认 `+record-list --limit N` 片面地回答。
|
||||
- `jq` / shell / 本地代码是在个人电脑或当前运行环境中处理已返回数据,只适合小范围结果;超过 200 行默认不推荐本地统计、排序或求极值,应改用 Base 云端查询服务的 filter/sort/aggregate。
|
||||
- “最高、最低、最新、最早、Top、Bottom、总数、全部、异常、最大、最小、最多、最少、优先级最高”等全局语义,必须在 Base 云端查询服务中完成筛选、排序或聚合。
|
||||
- `+record-search` 用于关键词检索字段的展示文本;可搜多类字段,但匹配的是文本表示(如人员命中 name),不要用它替代金额、状态、日期、空值等结构化条件。
|
||||
- 不要依赖已有视图,除非用户明确指定该视图,或你已读取并验证其 filter/sort/projection 符合当前问题。
|
||||
- 交付输出必须使用用户可读的真实字段值;内部 ID、`record_id`、关联记录 ID、open_id、编码字段只可作为连接键或定位键,不能替代最终输出,除非用户明确要求输出这些键值。
|
||||
- 每次读取必须做最小投影,并包含后续解释、回查或写入需要的业务 key。
|
||||
|
||||
## 1. Intent -> Tool Path
|
||||
|
||||
| 用户意图 | 首选路径 | 关键规则 |
|
||||
| --- | --- | --- |
|
||||
| 看几条、预览、示例 | `+record-list --limit N` | 保持局部语义;不要推广为全局结论 |
|
||||
| 已知 `record_id` | `+record-get` | 直接读取;不要 search/list 反查 |
|
||||
| 明确关键词 | `+record-search` | 按字段展示文本命中;使用 `search_fields` 限定匹配范围、`select_fields` 投影降低返回内容 token 量;不要把文本检索当作结构化关联解析 |
|
||||
| 按条件找明细记录 | 先创建临时视图设置筛选和可见字段,再用 `+record-list --view-id` 读取 | 条件字段来自 `+field-list`;不要先读全表再本地过滤 |
|
||||
| 排序 / TopN 原始记录 | 临时视图 filter/sort/projection -> `+record-list --view-id --limit N` | 最高/最新降序,最低/最早升序 |
|
||||
| 聚合 / 分组 / 分组排序 | `+data-query` | 使用 filters/dimensions/measures/sort/limit |
|
||||
| 聚合后输出实体字段 | `+data-query` 得到业务 key -> record 路径回查明细 | `+data-query` 不返回原始记录或 link 明细;聚合结果中的 key 需要再解析成用户要求字段 |
|
||||
| 多表 / 多跳关联 | 以候选数最小的事实表为驱动表,沿业务 key 或 link `record_id` 逐跳回查 | 读出 link 单元格里的关联 `record_id` 后,到被关联表批量 `+record-get` 展示字段,并在回答用结果中合并展示 |
|
||||
| 查询后写入 / 视图化 | 先用本 SOP 得到可复核的目标记录 id 集合 | 再进入记录写入或视图配置;高价值可复用查询优先沉淀为持久视图 |
|
||||
|
||||
## 2. Execution Patterns
|
||||
|
||||
### 2.1 结构化明细与 TopN
|
||||
|
||||
使用视图路径:
|
||||
|
||||
1. `+field-list` 确认筛选字段、排序字段、展示字段、业务 key。
|
||||
2. `+view-create` 创建 grid 视图。
|
||||
3. 设置 filter/sort/visible fields。
|
||||
4. `+record-list --view-id <view_id> --limit <N>` 读取结果。
|
||||
|
||||
不要从未筛选、未排序的全表输出中手动挑选。一次性查询可用临时视图;如果这个筛选/排序结果对用户后续查看有价值,应保留为持久视图,不要删除,并告知用户视图名称和用途。视图参数细节见 view-set references。
|
||||
|
||||
### 2.2 聚合分析与 TopN
|
||||
|
||||
使用 `+data-query`:
|
||||
|
||||
- 让 Base 云端查询服务完成 filters、dimensions、measures、sort、pagination.limit。
|
||||
- `pagination.limit` 是 Base 云端查询服务中的聚合结果限制,不是本地分页扫描。
|
||||
- 需要输出明细或用户可读字段时,先拿业务 key,再用 record 路径精确回查。
|
||||
- 字段类型、日期 value、DSL shape 以 [lark-base-data-query.md](lark-base-data-query.md) 为准。
|
||||
|
||||
### 2.3 关系查询与回查
|
||||
|
||||
- link 单元格通常是关联表 `record_id` 数组,不是用户可读内容,只是连接键。
|
||||
- 先用 `+field-list` 确认 link 字段的 `link_table`、业务唯一键和展示字段。
|
||||
- 从驱动表拿到候选记录后,用关联 `record_id` 到关联表 `+record-get` 批量读取记录内容。
|
||||
- 多跳关系逐跳建立 `record_id/key -> 用户可读字段` 映射;最终用户可读的信息。
|
||||
|
||||
禁止:
|
||||
|
||||
- 把 link `record_id` 当最终输出。
|
||||
- 用 `+record-search` 搜 link `record_id`。
|
||||
- 基于 ID、自增编号、link 值做语义猜测;禁止依赖字段先验、样本记忆补全交付输出。
|
||||
|
||||
## 3. Range & Pagination Contract
|
||||
|
||||
- `+record-list` 默认页、固定 `--limit`、本地 `jq`、shell 管道、手工浏览输出,都只覆盖已读取范围;超过 200 行不要把本地处理当作推荐路径。
|
||||
- `has_more=true`、存在下一页 offset/page token、或返回行数等于 page size,都表示可能还有未读取数据。
|
||||
- 对全局问题,只有 Base 云端查询服务已经通过 filter/sort/aggregate 收敛目标范围,或 `data-query` 已在云端完成聚合、排序和限制时,才可以用有限返回形成结论。
|
||||
- 必须全量导出时,按 CLI 分页语义串行翻页;不要并发调用 `+record-list`。
|
||||
|
||||
## 4. Final Answer Check
|
||||
|
||||
形成交付输出前必须能确认:
|
||||
|
||||
- 问题范围是局部样例、单点定位、全局明细、聚合分析、多表关联,还是查询后写入。
|
||||
- 筛选、排序、聚合是否发生在 Base 云端查询服务中,而不是本地 `jq` / shell 中。
|
||||
- 如果使用 `jq` / shell,本地输入是否是 200 行以内的小范围结果;超过 200 行是否已改用 Base 云端查询服务查询。
|
||||
- 如果使用 `+record-list`,是否处理了 `has_more`,且投影包含业务 key 和解释字段。
|
||||
- 如果涉及关系查询,是否按 `record_id` 或业务 key 精确回查,交付输出是否来自关联表真实字段。
|
||||
- 交付输出能追溯到表、字段、筛选条件、排序/聚合条件和连接键。
|
||||
|
||||
任一项无法确认时,继续查询或明确说明只能得到局部结论。
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
对多维表格数据进行聚合查询(分组、过滤、排序、聚合计算),基于以下语法的 JSON DSL:
|
||||
|
||||
查询类任务还必须先遵守 [`lark-base-data-analysis-sop.md`](lark-base-data-analysis-sop.md)。`+data-query` 适合让筛选、分组、聚合、排序和 TopN 在 Base 云端查询服务中执行;不要用默认分页的 `+record-list` 或本地 `jq` 替代聚合查询。
|
||||
|
||||
## 限制
|
||||
|
||||
- **权限要求**(按文档类型分流):
|
||||
@@ -51,6 +53,23 @@ lark-cli base +data-query \
|
||||
"measures": [{"field_name": "金额", "aggregation": "sum", "alias": "total"}],
|
||||
"shaper": {"format": "flat"}
|
||||
}'
|
||||
|
||||
# 聚合后如需读取明细,先让 data-query 返回可回查的业务 key
|
||||
lark-cli base +data-query \
|
||||
--base-token MAGObxxxxx \
|
||||
--dsl '{
|
||||
"datasource": {"type": "table", "table": {"tableId": "tblxxxxxxxx"}},
|
||||
"dimensions": [{"field_name": "业务编号", "alias": "biz_key"}],
|
||||
"measures": [{"field_name": "指标值", "aggregation": "max", "alias": "max_value"}],
|
||||
"filters": {
|
||||
"type": 1,
|
||||
"conjunction": "and",
|
||||
"conditions": [{"field_name": "状态", "operator": "is", "value": ["有效"]}]
|
||||
},
|
||||
"sort": [{"field_name": "max_value", "order": "desc"}],
|
||||
"pagination": {"limit": 10},
|
||||
"shaper": {"format": "flat"}
|
||||
}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -397,6 +416,19 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|
||||
- 每个 value 是 CellValue 对象,实际值在 `value` 字段中,如 `{"value": "北京"}` 或 `{"value": 12345.00}`
|
||||
- 失败时结果在 `data.error` 中,包含具体错误码和信息
|
||||
|
||||
## 与记录读取组合
|
||||
|
||||
`+data-query` 不返回原始记录或 link 字段明细。需要输出聚合结果对应的原始记录字段、展示值或关联表字段时,按以下方式组合:
|
||||
|
||||
1. 用 `+data-query` 在 Base 云端查询服务中完成全局筛选、分组、聚合、排序和 TopN,得到业务 key、分组值或候选范围。
|
||||
2. 如果已经拿到候选记录的 `record_id`,用 `+record-get` 读取明细字段。
|
||||
3. 如果拿到的是结构化业务 key(例如编号、状态、日期、金额等),优先创建临时视图做精确过滤后再 `+record-list --view-id` 读取;不要用 `+record-search` 代替结构化条件。
|
||||
4. 只有候选条件本身是文本展示值关键词时,才使用 `+record-search`,并用 `search_fields` 限定范围、`select_fields` 做投影。
|
||||
5. 若候选记录包含 link 字段,提取关联 `record_id` 后到关联表用 `+record-get` 批量读取展示字段。
|
||||
6. 最终回答业务字段,不要把内部 `record_id` 当作用户可读答案。
|
||||
|
||||
不要把 `data-query pagination.limit` 理解为分页扫描;它只限制 Base 云端查询服务返回的聚合结果行数,不支持 offset。需要全量明细导出时回到 data analysis SOP 的 record 分页规则。
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ **必须先查表结构**:DSL 的 `field_name` 必须与表中字段名称精确匹配(区分大小写),不能凭猜测构造。先用 `lark-cli base +field-list --base-token <base_token> --table-id <table_id>` 获取真实字段名
|
||||
@@ -408,10 +440,12 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|
||||
- ⚠️ **数据表标识 `tableId` vs `tableName`**:datasource 中可以用 `tableId`(如 `tblXXX`)或 `tableName`(数据表的用户自定义显示名称),二选一,不要混用
|
||||
- ⚠️ **`pagination.limit` 最大 5000**:超过会报错,且不支持 offset,只支持 limit
|
||||
- ⚠️ **所有 alias 必须全局唯一**:dimensions 和 measures 之间的 alias 也不能重名
|
||||
- ⚠️ **不要用本地分页结果替代 data-query**:凡是全局计数、分组、聚合、排序 TopN,优先让 `+data-query` 在 Base 云端查询服务中执行;默认页 `+record-list` 后本地统计只能得到已读取范围内的结果
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
- [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) — 查询范围、选路、下推、分页、record 明细回查和关系查询 SOP
|
||||
- [lark-base-cell-value.md](lark-base-cell-value.md) — CellValue 格式规范
|
||||
- [lark-base-shortcut-field-properties.md](lark-base-shortcut-field-properties.md) — shortcut 字段类型与 JSON 结构
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# base record read SOP
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和全局参数。
|
||||
|
||||
记录读取由 6 个功能组合完成:选路、字段投影、视图预处理、分页与范围、返回结构解释、link 关联读取。
|
||||
|
||||
## 1. 读取选路
|
||||
|
||||
| 场景 | 使用方式 | 规则 |
|
||||
|------|------|------|
|
||||
| 已知 `record_id` | `+record-get` | 只读单条记录,不要用 search/list 反查。 |
|
||||
| 明确关键词检索 | `+record-search` | 只用于文本关键词检索;金额、状态、日期等结构化条件不要用 search。 |
|
||||
| 普通明细读取 / 导出 / 查看前 N 条 | `+record-list` | 优先加 `--view-id` 时只读该视图可见记录与可见字段;或者加 `--field-id` 手动裁剪字段;不传 `--view-id` 时会读取全表。 |
|
||||
| 明确筛选 / 排序 / Top N / Bottom N 且需要原始记录或 `record_id` | 创建带 filter + sort 的临时视图 + `+record-list --view-id` | 让视图完成 filter/sort projection,LLM 不擅长手工筛选排序,建议用视图完成。 |
|
||||
| 统计 / 聚合结果且不需要 `record_id` | 转到 [`lark-base-data-query.md`](lark-base-data-query.md) | `data-query` 是特殊分析 DSL,不是记录读取工具。 |
|
||||
|
||||
## 2. 字段投影
|
||||
|
||||
- `FieldListFirst`: 不清楚字段结构时先 `+field-list`,确认筛选字段、排序字段、展示字段、关联字段、业务唯一键字段。
|
||||
- `UseRealField`: 字段名和字段 ID 必须来自 `+field-list` 返回,不要凭自然语言猜字段名。
|
||||
- `MinimalProjection`: 每次读取只返回本次任务需要的字段;`+record-list` 用重复 `--field-id`,视图读取用 `+view-set-visible-fields`。
|
||||
- `FieldScopePriority`: 返回字段优先级为显式投影字段(`+record-list --field-id` / `record-search select_fields`) > 视图可见字段 > 全表字段;需要稳定列范围时必须显式投影。
|
||||
- `LongFieldAvoidance`: 默认不要读取 `trace`、`raw`、长文本、附件等高噪声字段,除非任务明确需要。
|
||||
- `BusinessKey`: 后续要定位、更新或解释记录时,投影中必须包含可识别业务字段,例如订单号、日报ID、姓名、编号。
|
||||
|
||||
## 3. 视图预处理
|
||||
|
||||
适用于结构化筛选、排序、最高/最低、倒数、Top/Bottom N、按条件找记录等场景。
|
||||
|
||||
1. `+field-list` 获取字段 ID、字段名和字段类型。
|
||||
2. `+view-create` 创建临时 `grid` 视图,名称带任务语义,例如 `tmp_query_销售额升序`。
|
||||
3. `+view-set-filter` 设置筛选条件;空值是否参与必须按用户语义判断。
|
||||
4. `+view-set-sort` 设置排序条件;最高/最新用降序,最低/最早/倒数用升序。
|
||||
5. `+view-set-visible-fields` 设置投影字段,只保留业务键、排序字段、筛选解释字段、需要展示或二跳的字段。
|
||||
6. `+record-list --view-id <view_id> --limit <N>` 读取结果;不要再从未排序全表输出中手动挑选。
|
||||
|
||||
## 4. 分页与范围
|
||||
|
||||
- `ViewScope`: URL 带 `view_id` 时先判断用户是否要求“该视图下”;全表问题不要误用 URL 视图范围,应该根据需求创建合适的临时视图完成查询任务。
|
||||
- `ViewIdScope`: `+record-list --view-id` 是作用域参数;仅用于用户指定的视图,或本次任务主动创建的临时筛选 / 排序 / 投影视图。
|
||||
- `NeedAllPages`: 用户要求全部、导出、统计、最高/最低且未用视图/limit 限定时,必须检查 `has_more` 并串行翻页。
|
||||
- `LimitWhenScoped`: 用户只要示例、前 N 条、Top/Bottom N,使用 `--limit` 控制结果规模。
|
||||
- `NoConcurrentList`: `+record-list` 禁止并发调用;分页和多表读取必须串行。
|
||||
- `DataQueryScope`: `data-query` 的筛选 DSL 与视图筛选不是同一套语法;不要混用。
|
||||
|
||||
## 5. 返回结构解释
|
||||
|
||||
- `ColumnMapping`: `fields` / `field_id_list` 定义 `data` 每列含义;解释记录前先建立列到字段名的映射。
|
||||
- `RowMapping`: `record_id_list[i]` 与 `data[i]` 是同一行;需要后续定位、更新或关联时,按下标整理成 `record_id + 字段名:值` 的小表。
|
||||
- `BusinessMatch`: 后续引用目标记录时按业务字段匹配,不靠肉眼数行号。
|
||||
- `FieldType`: 按字段类型解释值;数字、货币、日期、人员、formula、lookup、attachment、link 不要当普通文本处理。
|
||||
- `EmptyValue`: 空值参与筛选或排序前必须明确语义;不要默认把空值当 `0`、空字符串或有效状态。
|
||||
- `AnswerCheck`: 最终回答前复核答案记录来自读取结果、筛选排序已应用、字段含义和 record_id 映射无误。
|
||||
|
||||
## 6. link 关联字段读取
|
||||
|
||||
link 字段是关联单元格;读取结果通常是关联表的 `record_id` 数组,不是用户可读名称。
|
||||
|
||||
| 步骤 | 做法 |
|
||||
|------|------|
|
||||
| 识别 link 字段 | 用 `+field-list` 查看字段类型为 `link`,并读取 `link_table` 确认关联目标表。 |
|
||||
| 读取当前表 | 在当前表 `+record-list` / `+record-get` 中保留 link 字段和业务键字段。 |
|
||||
| 解析单元格值 | link 单元格通常形如 `[{"id":"rec..."}]`;提取其中每个 `id` 作为关联表 `record_id`。 |
|
||||
| 读取关联表 | 到 `link_table` 使用 `+record-get --record-id <rec...>` 或裁剪后的 `+record-list` 读取显示字段。 |
|
||||
| 建立映射 | 形成 `关联record_id -> 显示字段值` 映射,再回填当前表结果。 |
|
||||
| 多值处理 | 多个关联值保持原顺序;可去重批量读取,但回答时按原单元格顺序输出。 |
|
||||
|
||||
禁止事项:
|
||||
|
||||
- 不要把 link 单元格里的 `record_id` 当作最终答案。
|
||||
- 不要用 `+record-search` 搜索 link `record_id` 来查关联记录。
|
||||
- 不要凭关联 `record_id` 猜名称、负责人、门店等显示值。
|
||||
- 不要只看当前表字段名推断关联表结构;跨表读取前必须拿关联表字段结构。
|
||||
|
||||
## 7. 命令 help
|
||||
|
||||
- `HelpFirst`: 参数、示例、JSON shape 和取值约束以 `lark-cli base +record-get --help`、`+record-search --help`、`+record-list --help` 为准。
|
||||
- `RecordSearchJson`: 构造 `+record-search --json` 前先看 `+record-search --help`,确认 `keyword/search_fields/select_fields/view_id/offset/limit` 的结构和约束。
|
||||
- `RecordListProjection`: 构造 `+record-list` 前先看 `+record-list --help`,确认 `--field-id`、`--view-id`、`--offset`、`--limit` 的语义。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-view-set-filter.md](lark-base-view-set-filter.md)
|
||||
- [lark-base-view-set-sort.md](lark-base-view-set-sort.md)
|
||||
- [lark-base-view-set-visible-fields.md](lark-base-view-set-visible-fields.md)
|
||||
- [lark-base-data-query.md](lark-base-data-query.md)
|
||||
@@ -8,7 +8,7 @@ record 相关命令索引。
|
||||
|
||||
| 文档 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| [lark-base-record-read-sop.md](lark-base-record-read-sop.md) | `+record-get` / `+record-search` / `+record-list` | 记录读取统一选路、筛选排序投影 SOP |
|
||||
| [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) | `+record-get` / `+record-search` / `+record-list` / `+data-query` / 视图筛选排序 | 数据查询与分析统一选路、筛选排序投影、聚合后回查明细 SOP |
|
||||
| [lark-base-record-upsert.md](lark-base-record-upsert.md) | `+record-upsert` | 创建或更新记录 |
|
||||
| [lark-base-record-batch-create.md](lark-base-record-batch-create.md) | `+record-batch-create` | 按 `fields/rows` 批量创建记录 |
|
||||
| [lark-base-record-batch-update.md](lark-base-record-batch-update.md) | `+record-batch-update` | 批量更新记录 |
|
||||
@@ -19,7 +19,7 @@ record 相关命令索引。
|
||||
|
||||
## 说明
|
||||
|
||||
- 读取记录前优先阅读 [lark-base-record-read-sop.md](lark-base-record-read-sop.md),它合并了 `+record-get` / `+record-search` / `+record-list` 的选路和 SOP。
|
||||
- 读取记录前优先阅读 [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md),它合并了 `record / view / data-query` 的选路、分页、投影、聚合后回查明细和 link 关联读取。
|
||||
- 聚合页只保留目录职责;写入、删除、历史等命令的详细说明请进入对应单命令文档。
|
||||
- 所有 `+xxx-list` 调用都必须串行执行;若要批量跑多个 list 请求,只能串行执行。
|
||||
- `+record-list` 支持重复传参 `--field-id` 做字段筛选。
|
||||
|
||||
@@ -229,8 +229,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | One-way **file-level** mirror of a Drive folder onto a local directory (Drive → local). Supports `--if-exists` (overwrite/skip) and `--delete-local` for orphan cleanup; the destructive `--delete-local` requires `--yes` and only unlinks regular files — empty local directories left behind by remote folder deletes are NOT pruned. Item-level failures exit non-zero (`error.type=partial_failure`) and skip the `--delete-local` pass to avoid half-synced state. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the target is outside cwd. |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | One-way **file-level** mirror of a Drive folder onto a local directory (Drive → local). Duplicate remote `rel_path` conflicts fail by default before writing; for duplicate files only, `--on-duplicate-remote rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` explicitly choose one. Supports `--if-exists` (overwrite/skip) and `--delete-local` for orphan cleanup; the destructive `--delete-local` requires `--yes` and only unlinks regular files — empty local directories left behind by remote folder deletes are NOT pruned. Item-level failures exit non-zero (`error.type=partial_failure`) and skip the `--delete-local` pass to avoid half-synced state. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the target is outside cwd. |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |
|
||||
@@ -238,7 +238,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
|
||||
| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. |
|
||||
| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Duplicate remote `rel_path` conflicts fail by default before upload / overwrite / delete; use `--on-duplicate-remote newest\|oldest` only when the conflict is duplicate files and you explicitly want to target one existing remote file. Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
|
||||
|
||||
|
||||
@@ -15,10 +15,23 @@
|
||||
| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 |
|
||||
| `summary.failed` | 下载或写盘失败的文件数 |
|
||||
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
|
||||
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `action` / 失败时的 `error`) |
|
||||
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error`) |
|
||||
|
||||
`summary.failed > 0` 时命令以 **非零状态码**(`exit=1`,`error.type=partial_failure`)退出,且同一份 `summary + items` 会在 `error.detail` 里返回;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
|
||||
| 策略 | 行为 |
|
||||
|------|------|
|
||||
| `fail` | 默认。返回所有冲突条目的完整信息,不写盘 |
|
||||
| `rename` | 仅适用于 duplicate file。下载全部重复文件;第一个保留原名,后续文件使用稳定 hash 后缀生成唯一文件名;若短后缀目标已被占用,会自动升级到更强后缀 |
|
||||
| `newest` | 只下载 `modified_time` 最新的远端文件 |
|
||||
| `oldest` | 只下载 `created_time` 最早的远端文件 |
|
||||
|
||||
`rename` 命名规则稳定且可追溯:`report.pdf` 的后续重复项会落盘为 `report__lark_<hash>.pdf`,例如 `report__lark_3a2f4c5d6e7f.pdf`。如果这个短 hash 目标名已经被同目录下的其他远端对象占用,CLI 会自动改用更长的稳定 hash,必要时再追加序号后缀,直到目标名唯一。此模式下 `items[]` 不再返回可直接复用的 Drive `file_token`;CLI 会在 `source_id` 中返回稳定 hash 标识符,供日志、比对和人工排查使用。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -29,6 +42,10 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists skip
|
||||
|
||||
# 云端有多个同名二进制文件时,显式下载全部并用稳定 hash 后缀改名
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--on-duplicate-remote rename
|
||||
|
||||
# 文件级镜像:下载新文件 + 删除云端没有的本地文件(不删空目录)
|
||||
# (--delete-local 必须搭配 --yes,否则会被 Validate 直接拒绝)
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
@@ -42,6 +59,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | 源 Drive 文件夹 token |
|
||||
| `--if-exists` | 否 | enum | 本地文件已存在时的策略:`overwrite`(默认)/ `skip` |
|
||||
| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `rename` / `newest` / `oldest` |
|
||||
| `--delete-local` | 否 | bool | 删除本地"云端没有的常规文件"(**不删空目录**,因此是 file-level mirror);**必须配合 `--yes`** |
|
||||
| `--yes` | 否 | bool | 确认 `--delete-local`;不传时该破坏性操作在 Validate 阶段被拒绝 |
|
||||
|
||||
@@ -50,6 +68,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- **只下载 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)会被跳过 —— 它们没有等价的本地二进制可写盘,否则会变成产生噪声的"假"下载。
|
||||
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`,本地缺失的父目录会被自动创建。
|
||||
- 已存在的本地文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自己改名再 pull。
|
||||
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote rename|newest|oldest` 时才会继续。
|
||||
|
||||
## --delete-local 的安全行为
|
||||
|
||||
@@ -58,6 +77,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- `--delete-local`(无 `--yes`)→ Validate 直接报错:`--delete-local requires --yes`,没有任何下载、列表请求或删除发生。
|
||||
- `--delete-local --yes`,**且下载阶段全部成功** → 扫一遍 `--local-dir` 下所有常规文件,把不在云端清单里的逐个 `os.Remove`。**只删常规文件,不删目录**:远端文件夹被删除后,对应本地目录会保留空壳。
|
||||
- `--delete-local --yes`,**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `partial_failure` 非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
|
||||
- 远端同名文件冲突且使用默认 `fail` → 在下载阶段前失败,删除阶段不会运行。
|
||||
- 不传 `--delete-local` → `summary.deleted_local` 永远是 0;命令对本地"多余"文件视而不见。
|
||||
|
||||
第 6 章里把 `+pull --delete-local` 标了 `high-risk-write`,CLI 这边的实现等价于"未传 `--yes` 时拒绝执行",符合该约束的精神。
|
||||
@@ -74,15 +94,15 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
},
|
||||
"items": [
|
||||
{"rel_path": "...", "file_token": "...", "action": "downloaded"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "skipped"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "failed", "error": "..."},
|
||||
{"rel_path": "...", "source_id": "hash_3a2f4c5d6e7f", "action": "downloaded"},
|
||||
{"rel_path": "...", "source_id": "hash_3a2f4c5d6e7f", "action": "failed", "error": "..."},
|
||||
{"rel_path": "...", "action": "deleted_local"},
|
||||
{"rel_path": "...", "action": "delete_failed", "error": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。删除条目(`deleted_local` / `delete_failed`)没有 `file_token`,因为该文件本来就只在本地。
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。删除条目(`deleted_local` / `delete_failed`)没有 `file_token`。`rename` 模式下,duplicate 文件条目会返回 `source_id` 而不是可调用 API 的真实 `file_token`;其余模式仍返回真实 `file_token`。
|
||||
|
||||
## 性能注意
|
||||
|
||||
|
||||
@@ -21,6 +21,18 @@
|
||||
|
||||
> 本地目录(包括空目录)会被镜像到 Drive;新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token,不会重复 `create_folder`,也不会出现在 `items[]` 里。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
|
||||
| 策略 | 行为 |
|
||||
|------|------|
|
||||
| `fail` | 默认。返回所有冲突条目的完整信息,不写远端 |
|
||||
| `newest` | 只把本地文件与 `modified_time` 最新的远端文件对齐 |
|
||||
| `oldest` | 只把本地文件与 `created_time` 最早的远端文件对齐 |
|
||||
|
||||
`+push` 不提供 `rename`:本地一个文件无法表达要覆盖多个远端对象。若用户想保留多个云端副本,应先显式整理云端文件,再重新 push。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -32,6 +44,10 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists overwrite
|
||||
|
||||
# 云端已有多个同名二进制文件时,显式选择一个远端目标再覆盖
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists overwrite --on-duplicate-remote newest
|
||||
|
||||
# 文件级镜像同步:上传 / 覆盖 + 删除本地不存在的远端文件
|
||||
# (--delete-remote 必须搭配 --yes,否则会被 Validate 直接拒绝;
|
||||
# 且 Validate 阶段会动态检查 space:document:delete scope,缺权限会立刻失败,
|
||||
@@ -47,6 +63,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | 目标 Drive 文件夹 token |
|
||||
| `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`(**默认**,安全)/ `overwrite`(依赖灰度后端协议,详见"覆盖语义") |
|
||||
| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `newest` / `oldest` |
|
||||
| `--delete-remote` | 否 | bool | 删除云端本地不存在的文件(文件级镜像;**不会**清理远端只有的目录);**必须配合 `--yes`**,且 Validate 阶段会动态检查 `space:document:delete` scope |
|
||||
| `--yes` | 否 | bool | 确认 `--delete-remote`;不传时该破坏性操作在 Validate 阶段被拒绝 |
|
||||
|
||||
@@ -55,6 +72,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- **只上传 / 覆盖 / 删除 Drive `type=file`**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)即使在同一 rel_path 下出现,也不会被覆盖或删除 —— 它们没有等价的本地二进制。
|
||||
- **本地目录结构整体被镜像**:所有子目录(含**空目录**)会按需在 Drive 上 `create_folder`;同名远端目录复用其 token,不重建。空目录不计入 `summary.uploaded`,但会在 `items[]` 里以 `folder_created` 形式留痕。
|
||||
- 已存在的远端文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自行改名再 push。
|
||||
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote newest|oldest` 时才会选择一个远端文件继续。启用 `--delete-remote` 时,未被选中的 duplicate sibling 也会被删除,最终远端只保留一个被选中的文件副本;只有在 `--if-exists=overwrite` 成功时,才能保证该副本内容与本地对齐。
|
||||
|
||||
## 覆盖语义
|
||||
|
||||
@@ -71,6 +89,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。
|
||||
- `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。
|
||||
- `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**(stderr 上有提示),命令以非零状态退出,远端不会被破坏。
|
||||
- 远端同名冲突且使用默认 `fail`,或冲突里混有 folder / 其他非 `type=file` 对象 → 在上传阶段前失败,删除阶段不会运行。
|
||||
- 不传 `--delete-remote` → `summary.deleted_remote` 永远是 0;命令对远端"多余"文件视而不见。
|
||||
- 在线文档(docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。
|
||||
- **远端只有的空目录、本地已删除的目录**也不会被清理 —— 这是"文件级镜像"的语义边界,命令不会对目录结构做主动收敛。
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
|
||||
只读命令:流式 hash,不下载落盘;但双端都有的文件会从云端拉一份字节流过来在内存里算 hash,大目录 / 大文件会有可观的网络流量。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,`+status` 会在下载/hash 前直接失败,返回 `error.type=duplicate_remote_path`,并在 `error.detail.duplicates_remote[]` 中列出该路径下所有冲突条目的 `file_token`、`type`、名称、大小和时间字段;其中 `created_time`、`modified_time` 缺失时会省略,`size` 在缺失或为 `0` 时都可能被省略。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -38,6 +42,8 @@ lark-cli drive +status \
|
||||
|
||||
## 输出 schema
|
||||
|
||||
成功时:
|
||||
|
||||
```json
|
||||
{
|
||||
"new_local": [{"rel_path": "..."}],
|
||||
@@ -49,10 +55,34 @@ lark-cli drive +status \
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir` 或 `--folder-token` 的根。仅本地存在时没有 `file_token` 字段。
|
||||
|
||||
远端同名文件冲突时:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "duplicate_remote_path",
|
||||
"message": "multiple Drive entries map to the same rel_path",
|
||||
"detail": {
|
||||
"duplicates_remote": [
|
||||
{
|
||||
"rel_path": "dup.txt",
|
||||
"entries": [
|
||||
{"file_token": "<full_file_token>", "type": "file", "name": "dup.txt", "size": 5, "created_time": "1730000000", "modified_time": "1730000000"},
|
||||
{"file_token": "<folder_token>", "type": "folder", "name": "dup.txt", "created_time": "1730000060", "modified_time": "1730000060"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 比较范围
|
||||
|
||||
- **只比对 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)都被跳过 —— 它们没有等价的本地二进制可对齐,否则会在 `new_remote` 里产生大量误报。
|
||||
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`。
|
||||
- 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。
|
||||
- 本地侧只比对常规文件(regular file);符号链接、设备文件等被忽略。
|
||||
|
||||
## 范围限制
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-im
|
||||
version: 1.0.0
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员时使用。"
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、管理标记数据时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -18,6 +18,7 @@ metadata:
|
||||
- **Chat**: A group chat or P2P conversation, identified by `chat_id` (oc_xxx).
|
||||
- **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx).
|
||||
- **Reaction**: An emoji reaction on a message.
|
||||
- **Flag**: A bookmark on a message or thread.
|
||||
|
||||
## Resource Relationships
|
||||
|
||||
@@ -50,6 +51,17 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
|
||||
|
||||
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
|
||||
|
||||
### Flag Types
|
||||
|
||||
Flags support two layers:
|
||||
|
||||
- **Message-layer flag**: `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark
|
||||
- **Feed-layer flag**: `(ItemTypeThread/ItemTypeMsgThread, FlagTypeFeed)` — thread as feed-layer bookmark
|
||||
|
||||
Item types for feed-layer flags:
|
||||
- **ItemTypeThread** (4) = thread in a topic-style chat
|
||||
- **ItemTypeMsgThread** (11) = thread in a regular chat
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
@@ -58,7 +70,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
|----------|------|
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by keyword and/or member open_ids (e.g. look up chat_id by group name); user/bot; supports member/type filters, sorting, and pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by `--query` keyword and/or `--member-ids`; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, and pagination |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
@@ -66,6 +78,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
|
||||
| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message or thread; user-only; defaults to message-layer flag; feed-layer flag requires explicit --item-type + --flag-type |
|
||||
| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, checks if the message is a thread root message; if so, cancels both message and feed layers |
|
||||
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination |
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -86,7 +101,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
|
||||
### chat.members
|
||||
|
||||
- `bots` — 获取群内机器人列表。 Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
- `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
- `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
|
||||
- `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
|
||||
- `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
@@ -94,7 +109,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
### messages
|
||||
|
||||
- `delete` — 撤回消息。Identity: supports `user` and `bot`; for `bot` calls, the bot must be in the chat to revoke group messages; to revoke another user's group message, the bot must be the owner, an admin, or the creator; for user P2P recalls, the target user must be within the bot's availability.
|
||||
- `forward` — 转发消息。Identity: `bot` only (`tenant_access_token`).
|
||||
- `forward` — 转发消息。Identity: supports `user` and `bot`.
|
||||
- `merge_forward` — 合并转发消息。Identity: `bot` only (`tenant_access_token`).
|
||||
- `read_users` — 查询消息已读信息。Identity: `bot` only (`tenant_access_token`); the bot must be in the chat, and can only query read status for messages it sent within the last 7 days.
|
||||
|
||||
@@ -105,6 +120,10 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
- `delete` — 删除消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message, and can only delete reactions added by itself.[Must-read](references/lark-im-reactions.md)
|
||||
- `list` — 获取消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
|
||||
|
||||
### threads
|
||||
|
||||
- `forward` — 转发话题。Identity: supports `user` and `bot`.
|
||||
|
||||
### images
|
||||
|
||||
- `create` — 上传图片。Identity: `bot` only (`tenant_access_token`).
|
||||
@@ -132,6 +151,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
| `messages.forward` | `im:message` |
|
||||
| `messages.merge_forward` | `im:message` |
|
||||
| `messages.read_users` | `im:message:readonly` |
|
||||
| `threads.forward` | `im:message` |
|
||||
| `reactions.batch_query` | `im:message.reactions:read` |
|
||||
| `reactions.create` | `im:message.reactions:write_only` |
|
||||
| `reactions.delete` | `im:message.reactions:write_only` |
|
||||
@@ -140,3 +160,4 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
| `pins.create` | `im:message.pins:write_only` |
|
||||
| `pins.delete` | `im:message.pins:write_only` |
|
||||
| `pins.list` | `im:message.pins:read` |
|
||||
|
||||
|
||||
67
skills/lark-im/references/lark-im-flag-cancel.md
Normal file
67
skills/lark-im/references/lark-im-flag-cancel.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# im +flag-cancel
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
|
||||
|
||||
This skill maps to shortcut: `lark-cli im +flag-cancel`. Underlying API: `POST /open-apis/im/v1/flags/cancel`.
|
||||
|
||||
## Double-Cancel Behavior (Important)
|
||||
|
||||
A message can have flags on both layers simultaneously:
|
||||
- Message layer: `(default, message)`
|
||||
- Feed layer: `(thread, feed)` or `(msg_thread, feed)` depending on chat type
|
||||
|
||||
**When no `--flag-type` is specified, the shortcut performs double-cancel**: removes both message layer and feed layer flags. The server handles cancel requests for non-existent flags idempotently, so this is safe.
|
||||
|
||||
**Feed layer item_type is determined by chat_mode**:
|
||||
- Topic-style chat (`chat_mode=topic`) → `item_type=thread`
|
||||
- Regular chat (`chat_mode=group`) → `item_type=msg_thread`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Double-cancel both layers (recommended default)
|
||||
lark-cli im +flag-cancel --as user --message-id om_xxx
|
||||
|
||||
# Only cancel message layer
|
||||
lark-cli im +flag-cancel --as user --message-id om_xxx --flag-type message
|
||||
|
||||
# Only cancel feed layer (need to specify item-type)
|
||||
lark-cli im +flag-cancel --as user --message-id om_xxx --item-type thread --flag-type feed
|
||||
|
||||
# Preview request
|
||||
lark-cli im +flag-cancel --as user --message-id om_xxx --dry-run
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|------|------|------|
|
||||
| `--message-id <om_xxx>` | Required | Message ID |
|
||||
| `--flag-type <name>` | No | `message` or `feed`; **when omitted, double-cancels both layers** |
|
||||
| `--item-type <name>` | No | `default\|thread\|msg_thread`; required when `--flag-type feed` |
|
||||
| `--as user` | Required | Currently only supports user identity |
|
||||
|
||||
## Idempotency
|
||||
|
||||
The server doesn't return an error for cancel requests when the flag doesn't exist, so repeated `+cancel` calls are idempotent.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Required scopes: `im:feed.flag:write`, `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user`, `im:chat:read`
|
||||
- The message/chat read scopes are used by the default double-cancel path to auto-detect the feed-layer item type.
|
||||
|
||||
## Note
|
||||
|
||||
- **Do not call +flag-list for verification**: If the cancel API returns success, the flag is removed. Calling +flag-list to verify is expensive (requires full pagination) and unnecessary.
|
||||
|
||||
## Finding Message ID Efficiently
|
||||
|
||||
If you have message content but not the message ID:
|
||||
|
||||
1. **Use `+messages-search`** to find the message by content, then extract `message_id` from the result
|
||||
2. **Do NOT use `+flag-list`** to find the message — it requires full pagination and is very inefficient
|
||||
|
||||
```bash
|
||||
# Search by message content to find message_id
|
||||
lark-cli im +messages-search --as user --query "message content here" -q '.data.items[0].message_id'
|
||||
```
|
||||
67
skills/lark-im/references/lark-im-flag-create.md
Normal file
67
skills/lark-im/references/lark-im-flag-create.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# im +flag-create
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
|
||||
|
||||
This skill maps to shortcut: `lark-cli im +flag-create`. Underlying API: `POST /open-apis/im/v1/flags`.
|
||||
|
||||
## Default Behavior
|
||||
|
||||
- **Message-layer flag** (default): `item_type=default, flag_type=message`
|
||||
- **Feed-layer flag**: Use `--flag-type feed` — automatically detects chat type to determine `item_type`:
|
||||
- Topic-style chat (`chat_mode=topic`) → `item_type=thread`
|
||||
- Regular chat (`chat_mode=group`) → `item_type=msg_thread`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Flag a message (default: message-layer)
|
||||
lark-cli im +flag-create --as user --message-id om_xxx
|
||||
|
||||
# Create feed-layer flag (auto-detects chat type)
|
||||
lark-cli im +flag-create --as user --message-id om_xxx --flag-type feed
|
||||
|
||||
# Explicit item-type override (rarely needed)
|
||||
lark-cli im +flag-create --as user --message-id om_xxx --item-type thread --flag-type feed
|
||||
|
||||
# Preview request (dry-run, doesn't send)
|
||||
lark-cli im +flag-create --as user --message-id om_xxx --dry-run
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|------|------|------|
|
||||
| `--message-id <om_xxx>` | Required | Message ID |
|
||||
| `--flag-type <name>` | No | `message` (default) or `feed` |
|
||||
| `--item-type <name>` | No | Override auto-detection: `default\|thread\|msg_thread` (rarely needed) |
|
||||
| `--as user` | Required | Currently only supports user identity |
|
||||
|
||||
## Valid Combinations
|
||||
|
||||
The server only accepts these `(item_type, flag_type)` pairs:
|
||||
|
||||
- `(default, message)` — regular message flag
|
||||
- `(thread, feed)` — feed flag in topic-style chat
|
||||
- `(msg_thread, feed)` — feed flag in regular chat
|
||||
|
||||
## Permissions
|
||||
|
||||
- Required scopes: `im:feed.flag:write`, `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user`, `im:chat:read`
|
||||
- The message/chat read scopes are used when `--flag-type feed` is used without explicit `--item-type` so the CLI can auto-detect chat type.
|
||||
- If missing, CLI will prompt with `lark-cli auth login --scope "..."`
|
||||
|
||||
## Note
|
||||
|
||||
- **Do not call +flag-list for verification**: If the create API returns success, the flag is created. Calling +flag-list to verify is expensive (requires full pagination) and unnecessary.
|
||||
|
||||
## Finding Message ID Efficiently
|
||||
|
||||
If you have message content but not the message ID:
|
||||
|
||||
1. **Use `+messages-search`** to find the message by content, then extract `message_id` from the result
|
||||
2. **Do NOT use `+flag-list`** to find the message — it requires full pagination and is very inefficient
|
||||
|
||||
```bash
|
||||
# Search by message content to find message_id
|
||||
lark-cli im +messages-search --as user --query "message content here" -q '.data.items[0].message_id'
|
||||
```
|
||||
100
skills/lark-im/references/lark-im-flag-list.md
Normal file
100
skills/lark-im/references/lark-im-flag-list.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# im +flag-list
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
|
||||
|
||||
This skill maps to shortcut: `lark-cli im +flag-list`. Underlying API: `GET /open-apis/im/v1/flags`.
|
||||
|
||||
## Sorting Rules (Important)
|
||||
|
||||
The API returns data sorted by `update_time` in **ascending order**, meaning **oldest first, newest last**. When `has_more=true`, you cannot simply take the first page's items as the latest flags — you must paginate through all pages and take the last item on the last page as the newest.
|
||||
|
||||
Recommended: use `--page-all` for auto-pagination to get the complete list, then use `-q '.data.flag_items[-1]'` to get the latest item.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Fetch first page (default page-size=50)
|
||||
lark-cli im +flag-list --as user
|
||||
|
||||
# Manual pagination with custom page size
|
||||
lark-cli im +flag-list --as user --page-size 30 --page-token <page_token>
|
||||
|
||||
# Auto-paginate to get all flags (recommended)
|
||||
lark-cli im +flag-list --as user --page-all
|
||||
|
||||
# Auto-paginate + get the latest flag
|
||||
lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]'
|
||||
|
||||
# Auto-paginate + get only item_id list
|
||||
lark-cli im +flag-list --as user --page-all -q '.data.flag_items[].item_id'
|
||||
|
||||
# Disable auto-enrichment of message content (enabled by default)
|
||||
lark-cli im +flag-list --as user --page-all --enrich-feed-thread=false
|
||||
|
||||
# Limit max pages (default 20, max 1000)
|
||||
lark-cli im +flag-list --as user --page-all --page-limit 10
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|------|------|------|
|
||||
| `--page-size <n>` | 50 | Range 1-50 (server max is 50) |
|
||||
| `--page-token <token>` | empty | Pagination token from previous page; empty string must still be provided |
|
||||
| `--page-all` | false | Auto-paginate to fetch all pages and merge results |
|
||||
| `--page-limit <n>` | 20 | Max pages in `--page-all` mode (max 1000) |
|
||||
| `--enrich-feed-thread` | true | Auto-enrich feed-layer thread entries with message content (calls `im.messages.mget`) |
|
||||
| `--as user` | Required | Currently only supports user identity |
|
||||
|
||||
## Response Structure
|
||||
|
||||
The response has `data` as the main body, with fields described below:
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|------|
|
||||
| `flag_items` | array | List of currently existing (not canceled) flags, sorted by `update_time` ascending |
|
||||
| `delete_flag_items` | array | List of previously canceled flags, sorted by `update_time` ascending |
|
||||
| `messages` | array | Message content inlined by the server for `(default, message)` type flags |
|
||||
| `has_more` | boolean | Whether there's a next page |
|
||||
| `page_token` | string | Pagination token for the next page |
|
||||
|
||||
Note: `(thread, feed)` / `(msg_thread, feed)` entries are automatically enriched via `mget` by the shortcut, and written to the corresponding entry's `message` field.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **delete_flag_items are not enriched**: Message content is only fetched for active flags (`flag_items`), not canceled flags (`delete_flag_items`). If you need message content for a canceled flag, query the message separately using `+messages-mget --message-ids <item_id>`.
|
||||
|
||||
## Response Example (Sanitized)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"delete_flag_items": [
|
||||
{
|
||||
"create_time": "xxx",
|
||||
"flag_type": "xxx",
|
||||
"item_id": "xxx",
|
||||
"item_type": "xxx",
|
||||
"update_time": "xxx"
|
||||
}
|
||||
],
|
||||
"flag_items": [
|
||||
{
|
||||
"create_time": "xxx",
|
||||
"flag_type": "xxx",
|
||||
"item_id": "xxx",
|
||||
"item_type": "xxx",
|
||||
"update_time": "xxx"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"messages": [],
|
||||
"page_token": "xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
- Base scope: `im:feed.flag:read`
|
||||
- Additional scopes only when `--enrich-feed-thread=true` needs to fetch missing message content: `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user`
|
||||
121
skills/lark-vc-agent/SKILL.md
Normal file
121
skills/lark-vc-agent/SKILL.md
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: lark-vc-agent
|
||||
version: 1.0.0
|
||||
description: "飞书视频会议:让机器人代当前用户加入/离开正在进行的会议,并读取会议期间的实时事件(参会人加入与离开、发言、聊天、屏幕共享等)。1. 用户提供 9 位会议号、要求代为入会或离会时使用 +meeting-join / +meeting-leave——会真实产生入会/离会记录。2. 会议进行中用户想知道“谁加入了”“谁离开了”“谁在发言”“有人共享屏幕吗”等会中动态时,机器人入会后用 +meeting-events 读取事件时间线。3. 典型场景:参会机器人、会中助手、代为旁听、代为参会。前提:机器人只能读到它自己参会过且仍在进行中的会议的事件;查询已结束会议的参会名单、纪要或逐字稿请使用 lark-vc 技能。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli vc --help"
|
||||
---
|
||||
|
||||
# vc-agent (v1)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取以下两份 skill 文档:**
|
||||
|
||||
- [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、身份切换、权限处理
|
||||
- [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) — 视频会议的核心概念(Meeting / Note / Minutes 等),本 skill 直接复用,不再重复定义
|
||||
|
||||
## 内测提示
|
||||
|
||||
- 当前功能正在内测中,仅少数用户可用。忽略 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 中的权限申请流程,以下方的指引为准。
|
||||
- 如果 `lark-cli` 提示 `missing required scope(s)` / `permission_violations`,不要申请对应权限,而是提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`。
|
||||
- 如果 `lark-cli` 返回 `error.code=20017` / `ErrNotInGray`,提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`。
|
||||
|
||||
## 定位
|
||||
|
||||
本 skill 与 [`lark-vc`](../lark-vc/SKILL.md) 并列:
|
||||
|
||||
- **`lark-vc`** **负责"会后查询"**:搜索历史会议、参会人快照、纪要/逐字稿/录制
|
||||
- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 机器人离会
|
||||
|
||||
按此分工路由,避免两个 skill 语义混淆。
|
||||
|
||||
| 用户意图示例 | 应路由到 |
|
||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| "帮我入会 123456789"、"代我参会"、"让机器人进会旁听" | **本 skill** `+meeting-join` |
|
||||
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"(**进行中会议**,且**机器人已入会**) | **本 skill** `+meeting-events` |
|
||||
| "退出会议"、"让机器人离开" | **本 skill** `+meeting-leave` |
|
||||
| "昨天那场会有谁参加过"、"搜昨天的会"、"查纪要/逐字稿/录制" | [`lark-vc`](../lark-vc/SKILL.md) |
|
||||
| "帮我参会,结束后把纪要发到群" 等跨阶段场景 | 按序编排:本 skill(入会 → 读事件 → 离会)→ [`lark-vc`](../lark-vc/SKILL.md) / [`lark-minutes`](../lark-minutes/SKILL.md)(拉纪要)→ [`lark-im`](../lark-im/SKILL.md)(发群) |
|
||||
|
||||
## 核心场景
|
||||
|
||||
### 1. 加入正在进行的会议(写操作)
|
||||
|
||||
1. 只有用户明确表达"让 Agent **真实入会**"(参会机器人、会中助手、代为旁听、代参会)时才用 `+meeting-join`。只是查数据不要入会。
|
||||
2. `+meeting-join --meeting-number` 只接受 **9 位纯数字**会议号,不是会议链接整串、也不是 `meeting_id`。
|
||||
3. 返回体中的 `meeting.id` **必须立刻记录**——后续 `+meeting-events` / `+meeting-leave` 都靠它,**不能用 9 位会议号替代**。
|
||||
4. 入会对所有参会人可见,执行前核实 9 位会议号来源,避免误入错会。
|
||||
5. 仅支持 `user` 身份,需提前 `lark-cli auth login`。
|
||||
6. 若入会失败,优先查看 `+meeting-join` reference 的错误排查段落,重点确认会议号、密码、会议状态、等候室 / 审批以及会议是否禁止当前身份加入。
|
||||
|
||||
### 2. 感知会中事件(读操作)
|
||||
|
||||
1. 用户要看"会议里正在发生什么"(参会人加入/离开、聊天、转写、屏幕共享)时,用 `+meeting-events`。
|
||||
2. 输入是 **`meeting_id`**(长数字 ID),不是 9 位会议号。
|
||||
3. Bot 必须**真实参会过**(先 `+meeting-join`),否则事件流通常不可见。具体的状态边界、结束后宽限窗口与错误码(如 `10005 / 20001 / 20002`)请查看 `+meeting-events` reference。
|
||||
4. **不能做会后复盘**,**不能替代参会人快照查询**。如果会议已结束:
|
||||
- 想拿纪要文档或逐字稿文档 token:用 `lark-cli vc +notes --meeting-ids <meeting.id>`
|
||||
- 想拿 AI 产物(summary / todos / chapters)或导出逐字稿文件:先用 `lark-cli vc +recording --meeting-ids <meeting.id>` 拿 `minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`
|
||||
- 想看参会人快照:用 `vc meeting get --with-participants`(见 [`lark-vc`](../lark-vc/SKILL.md))
|
||||
5. **默认必须使用** **`--page-all`**,除非用户明确要求“只查一页”,或确实需要控制返回体大小。
|
||||
6. 输出格式默认优先 `--format pretty`(时间线更易读);只有在需要完整保留原始消息流与结构化字段时,才使用 `--format json`。
|
||||
7. **必须识别分页信号**:只要响应里出现 `has_more=true`、pretty 里的 `more available`,或返回了非空 `page_token`,就不能把当前结果当作完整事件流;默认应继续分页,或明确告诉用户当前只是部分结果。
|
||||
8. 保留响应里的 `page_token`,下次增量拉取直接续,不要从头再拉。
|
||||
9. **只要你是基于** **`+meeting-events`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
|
||||
|
||||
### 3. 离开会议(写操作)
|
||||
|
||||
1. 任务完成、或用户要求结束时,用 `+meeting-leave --meeting-id <从 +meeting-join 拿到的 meeting.id>`。
|
||||
2. `--meeting-id` **必须**是 `+meeting-join` 返回的长数字 `meeting.id`,**不接受 9 位会议号**。
|
||||
3. 离会**立即生效**,机器人从会议的参会人列表中消失,对其他参会人可见;若需要重新入会,再跑一次 `+meeting-join` 即可(非真正"不可逆")。
|
||||
4. 仅支持 `user` 身份。
|
||||
|
||||
### 4. Agent 参会最小闭环示范
|
||||
|
||||
```bash
|
||||
# 1. 入会,捕获 meeting.id
|
||||
JOIN=$(lark-cli vc +meeting-join --meeting-number 123456789 --format json)
|
||||
MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
|
||||
|
||||
# 2. 会中轮询事件
|
||||
# 默认用 --page-all 拉全当前可见事件;下次增量优先复用 page_token
|
||||
# 典型间隔 10-30 秒
|
||||
lark-cli vc +meeting-events --meeting-id "$MID" --page-all --format pretty
|
||||
|
||||
# 3. 任务完成或用户要求结束时离会
|
||||
lark-cli vc +meeting-leave --meeting-id "$MID"
|
||||
|
||||
# 4. 会后可选:取纪要 / 逐字稿(跨到 lark-vc)
|
||||
lark-cli vc +notes --meeting-ids "$MID"
|
||||
```
|
||||
|
||||
## Shortcuts
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
|
||||
|
||||
| Shortcut | 类型 | 说明 |
|
||||
| --------------------------------------------------------------- | -- | -------------------------------------------------------------------------- |
|
||||
| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number |
|
||||
| [`+meeting-events`](references/lark-vc-agent-meeting-events.md) | 读 | List bot meeting events (participant joined/left, transcript, chat, share) |
|
||||
| [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md) | 写 | Leave a meeting by meeting\_id |
|
||||
|
||||
- 使用 `+meeting-join` 前**必须**阅读 [references/lark-vc-agent-meeting-join.md](references/lark-vc-agent-meeting-join.md),了解入参格式与写操作可见性风险。
|
||||
- 使用 `+meeting-events` 前**必须**阅读 [references/lark-vc-agent-meeting-events.md](references/lark-vc-agent-meeting-events.md),了解 `meeting_id` 来源、分页、错误码(10005 / 20001 / 20002)与 "bot 仍在会中" 硬约束。
|
||||
- 使用 `+meeting-leave` 前**必须**阅读 [references/lark-vc-agent-meeting-leave.md](references/lark-vc-agent-meeting-leave.md),了解 `meeting_id` 的来源与写操作可见性。
|
||||
|
||||
## 权限表
|
||||
|
||||
| Shortcut | 所需 scope |
|
||||
| ----------------- | ------------------------------ |
|
||||
| `+meeting-join` | `vc:meeting.bot.join:write` |
|
||||
| `+meeting-events` | `vc:meeting.meetingevent:read` |
|
||||
| `+meeting-leave` | `vc:meeting.bot.join:write` |
|
||||
|
||||
## 延伸
|
||||
|
||||
- 查已结束会议、参会人快照、搜索历史会议 → [`lark-vc`](../lark-vc/SKILL.md)
|
||||
- 会议纪要、逐字稿 → [`lark-vc`](../lark-vc/SKILL.md) 的 `+notes`
|
||||
- 妙记产物(AI 总结 / 转写 / 章节)→ [`lark-minutes`](../lark-minutes/SKILL.md)
|
||||
- 会后把产物发到群 / 私聊 → [`lark-im`](../lark-im/SKILL.md)
|
||||
- 认证、身份切换、scope 管理 → [`lark-shared`](../lark-shared/SKILL.md)
|
||||
247
skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md
Normal file
247
skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md
Normal file
@@ -0,0 +1,247 @@
|
||||
|
||||
# vc +meeting-events
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查询当前 bot 在一场正在进行的视频会议中收到的会中事件列表。该命令是**读操作**。对进行中会议,要求 bot 当前仍在会中;对已结束会议,存在一个**结束后 5 分钟内的宽限窗口**,只要 bot 曾经在这场会里出现过,仍可继续拉取事件。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-events`(调用 `GET /open-apis/vc/v1/bots/events`)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 默认用法:全量拉取当前可见事件
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty
|
||||
|
||||
# 指定时间范围,并拉全该时间窗内当前可见事件
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --page-all --format pretty
|
||||
|
||||
# 基于上一次保存的 page_token 继续查新增事件
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-token <last_page_token> --page-all --format pretty
|
||||
|
||||
# 调试或控制返回体大小时,显式只查一页
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json
|
||||
|
||||
# 预览 API 调用(不实际请求)
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--meeting-id <id>` | 是 | 会议 ID(长数字 ID,不是 9 位会议号) |
|
||||
| `--start <time>` | 否 | 起始时间,支持 ISO 8601 / `YYYY-MM-DD` / Unix 秒 |
|
||||
| `--end <time>` | 否 | 结束时间,支持 ISO 8601 / `YYYY-MM-DD` / Unix 秒 |
|
||||
| `--page-token <token>` | 否 | 从指定分页游标继续拉取下一页 |
|
||||
| `--page-size <n>` | 否 | 单页模式每页大小。CLI 会自动夹紧到 `20-100`;传 `--page-all` 时固定使用 `100` |
|
||||
| `--page-all` | 否 | 自动分页,直到没有更多页面为止(内部有安全上限) |
|
||||
| `--format <fmt>` | 否 | 输出格式:json (CLI 默认) / pretty(本 skill 推荐默认) / table / ndjson / csv |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 输入必须是 meeting_id,不是 9 位会议号
|
||||
|
||||
`--meeting-id` 必须是会议的长数字 ID。它通常来自:
|
||||
- `+meeting-join` 返回体中的 `meeting.id`
|
||||
- `+search` 结果中的 `id`
|
||||
|
||||
**不要**把 9 位会议号(`--meeting-number`)传给这个命令。
|
||||
|
||||
### 2. 仅支持 user 身份
|
||||
|
||||
该命令仅支持 `user` 身份。
|
||||
|
||||
### 3. bot 必须在会中,或在会议结束后的 5 分钟宽限窗口内曾经在会中
|
||||
|
||||
这是查询“bot 在会中观察到的事件”的接口。若 bot 已离会、未入会、或会议已经无法再判断 bot 身份,后端通常会报:
|
||||
- `bot is not in meeting, no permission`
|
||||
|
||||
因此,最稳妥的调用顺序通常是:
|
||||
|
||||
```bash
|
||||
# 先入会
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 记录返回的 meeting.id
|
||||
|
||||
# 再查询事件
|
||||
lark-cli vc +meeting-events --meeting-id <meeting.id>
|
||||
```
|
||||
|
||||
更精确地说,后端当前的判断规则是:
|
||||
|
||||
- **会议进行中**:要求 bot **当前仍在会中**
|
||||
- **会议已结束后的 5 分钟内**:只要 bot **曾经在这场会中出现过**,仍可拉取事件
|
||||
- **会议结束超过 5 分钟**:按会议结束处理,通常不再返回事件流
|
||||
- **bot 从未真实入会过**:即使会议仍在进行或刚结束,也会返回 `10005 bot is not in meeting`
|
||||
|
||||
### 4. 自动分页规则
|
||||
|
||||
- **先分清两层默认值**:
|
||||
- shortcut 本身:不传 `--page-all` 时,只查 1 页。
|
||||
- 本 skill 的默认策略:除非用户明确要求只看一页,或你确实需要控制返回体大小,否则默认**必须主动带 `--page-all`**,把当前可见事件尽量一次拉全。
|
||||
- 传 `--page-all`:开启自动分页,直到没有更多页面为止。
|
||||
- `--page-all` 时,CLI 固定使用最大 `page_size=100`。
|
||||
|
||||
执行准则:
|
||||
|
||||
- **默认命令模板**:`lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty`
|
||||
- 如果你发现自己执行成了不带 `--page-all` 的单页查询,而响应里又出现 `has_more=true` / `more available` / 非空 `page_token`,应立刻意识到这只是部分结果。
|
||||
- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --meeting-id <meeting.id> --page-token <returned_page_token> --page-all --format pretty`
|
||||
- 只有在用户明确要求“就看第一页”“先不要翻页”时,才不要默认带 `--page-all`
|
||||
- 只要你是基于 `+meeting-events` 来回答一场**正在进行中的会议内容**,就不能直接复用上一次查询结果。无论用户是在问“现在是谁在说话”“刚刚发生了什么”“最新事件有哪些”,还是让你“总结一下这个会议讲什么”,都必须先重新执行一次 `+meeting-events`,确认拿到的是最新事件流,再回答用户。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
|
||||
|
||||
### 5. pretty / json 输出差异
|
||||
|
||||
- `--format pretty`:输出会议主题、会议时间和逐条时间线,适合快速理解“发生了什么”,也是本 skill 的默认推荐格式。
|
||||
- `--format json`:保留完整原始 `events[]` 结构——参会人 open_id、聊天原文、share_doc、分页字段都在原始响应里,适合提取字段、联动其他命令或做进一步程序处理。
|
||||
|
||||
**选型原则**:只要目标是告诉用户“发生了什么”,默认就用 `--page-all --format pretty`;只有在需要完整原始消息流和结构化字段时,才改用 `json`。
|
||||
|
||||
> **注意**:pretty 输出中的正文文本会做单行转义,真实换行会显示为 `\n`,避免打乱时间线布局。
|
||||
|
||||
### 6. 内容理解模式:共享文档不能只看标题
|
||||
|
||||
当用户意图是:
|
||||
|
||||
- “总结这个会议”
|
||||
- “这个会议讲了什么”
|
||||
- “有哪些结论 / 待办 / 关键讨论”
|
||||
- “共享文档里在讲什么”
|
||||
|
||||
不要只基于事件时间线直接回答。此时 `+meeting-events` 只是**线索发现器**,不是最终信息源。
|
||||
|
||||
执行准则:
|
||||
|
||||
- 这类问题默认先用 `lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format json` 拉取最新事件流。
|
||||
- 如果事件中出现共享文档线索,例如:
|
||||
- `magic_share_started`
|
||||
- `share_doc.title`
|
||||
- `share_doc.url`
|
||||
- 必须继续读取共享文档内容,再生成总结,不能只根据“开始共享了某文档”这条事件和文档标题来概括会议内容。
|
||||
- 若存在多个共享文档,优先读取**最近一次共享**的文档。
|
||||
- 若文档读取失败,必须明确说明“以下总结仅基于会中事件流,未成功读取共享文档内容”。
|
||||
|
||||
### 7. 关于 `page_token` 的返回与续拉
|
||||
|
||||
- 不管这次是只查 1 页,还是通过 `--page-all` 已经把当前可见事件都拿完,都应把最后拿到的 `page_token` 一并保留下来并返回给用户。
|
||||
- 只要响应里出现 `has_more=true`、pretty 里出现 `more available`,或返回了非空 `page_token`,就必须先判断当前结果是否完整;默认情况下,这意味着你还需要继续分页。
|
||||
- 如果没有使用 `--page-all`,但出现了上述分页信号,默认应继续用返回的 `page_token` 拉下一页,而不是直接结束。只有在用户明确不要继续翻页时,才可以停止并明确说明当前结果不完整。
|
||||
- 下次继续“查新增事件”时,应优先复用上一次保存的 `page_token`,而不是从头全量再拉一次。
|
||||
- 只有在用户明确要求“从头回放全部事件”时,才忽略历史 `page_token`,重新从第一页开始。
|
||||
- 但如果用户要你回答的是**当前这场会正在讲什么**,而不是“上一次之后新增了什么”,也要先做一次新的事件查询,再决定是否需要基于旧 `page_token` 继续补拉。
|
||||
|
||||
## 返回结构
|
||||
|
||||
常见顶层字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `events` | 事件列表 |
|
||||
| `has_more` | 是否还有下一页 |
|
||||
| `page_token` | 下一页游标 |
|
||||
|
||||
事件 `event_type` 常见类型:
|
||||
|
||||
| event_type | 含义 |
|
||||
|-----------|------|
|
||||
| `participant_joined` | 有参会人加入会议 |
|
||||
| `participant_left` | 有参会人离开会议 |
|
||||
| `chat_received` | 收到会中聊天消息 |
|
||||
| `transcript_received` | 收到转写文本 |
|
||||
| `magic_share_started` | 开始共享内容 / 文档 |
|
||||
| `magic_share_ended` | 结束共享 |
|
||||
|
||||
## pretty 输出示例
|
||||
|
||||
```text
|
||||
会议主题:张三的视频会议
|
||||
会议时间:2026-04-17 15:28:52(进行中)
|
||||
|
||||
[00:00:33] 明日之虾BOE(ou_xxx) 加入了会议
|
||||
[00:00:41] 张三(ou_xxx): [text] 6666
|
||||
[00:00:44] 张三(ou_xxx) 开始共享《智能纪要:飞书20251022-140223 2026年3月9日》
|
||||
URL: https://...
|
||||
[00:01:32] 张三(ou_xxx): [reaction] JIAYI
|
||||
```
|
||||
|
||||
## 如何获取输入参数
|
||||
|
||||
| 输入参数 | 获取方式 |
|
||||
|---------|---------|
|
||||
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+search` 结果中的 `id` |
|
||||
| `start` / `end` | 用户给出的时间范围;如未给出则默认取全量可见事件 |
|
||||
| `page-token` | 上一页或上一次查询结果中保存的 `page_token`;建议持久化保存,便于下次继续拉取新增事件 |
|
||||
|
||||
## Agent 组合场景
|
||||
|
||||
### 场景 1:入会后查看会中发生了什么
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入会议,记录返回的 meeting.id
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 第 2 步:查询事件流
|
||||
lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty
|
||||
```
|
||||
|
||||
### 场景 2:过滤某段时间内的事件
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-events \
|
||||
--meeting-id <meeting.id> \
|
||||
--start 2026-04-17T15:00:00+08:00 \
|
||||
--end 2026-04-17T16:00:00+08:00 \
|
||||
--page-all \
|
||||
--format pretty
|
||||
```
|
||||
|
||||
### 场景 3:基于上一次的 `page_token` 继续查新增事件
|
||||
|
||||
```bash
|
||||
# 上一次查询结束后,保留最后返回的 page_token
|
||||
# 这次直接从该游标继续拉新增事件
|
||||
lark-cli vc +meeting-events \
|
||||
--meeting-id <meeting.id> \
|
||||
--page-token <last_page_token> \
|
||||
--page-all \
|
||||
--format pretty
|
||||
```
|
||||
|
||||
适用规则:
|
||||
|
||||
- 当用户说“继续看新事件”“看上次之后新增了什么”时,优先使用上一次保存的 `page_token`。
|
||||
- 如果这次返回里仍有 `has_more=true`、pretty 里出现 `more available`,或又返回了新的 `page_token`,说明新增事件还没拉完,应继续分页,而不是把当前页误当成完整增量结果。
|
||||
- 只有在用户明确要求“从头回放全部事件”时,才忽略已有 `page_token`,重新从第一页开始。
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入长数字 `meeting.id` |
|
||||
| `10005 bot is not in meeting` | bot 从未真实入会该会议;或会议已结束但 bot 从未在会中出现过 | 先 `+meeting-join --meeting-number <9位号>` 真实入会再查;如果会议已经结束且当时 bot 没进过会,本接口也拉不到数据。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`**(不依赖 bot 身份参会) |
|
||||
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。若要拿纪要文档或逐字稿 token,用 `lark-cli vc +notes --meeting-ids <meeting.id>`;若要拿 AI 产物(summary / todos / chapters)或导出逐字稿文件,先用 `lark-cli vc +recording --meeting-ids <meeting.id>` 拿 `minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
|
||||
| `20002 meeting not exist` | `meeting_id` 错误,或会议实例当前已不可获取(常见于把 9 位会议号当 meeting_id 传) | 确认传入的是长数字 `meeting_id`,不是 9 位会议号 |
|
||||
| `HTTP 404` / `HTTP 500` | 服务端当前无法找到或处理该会议实例 | 换一个正在进行且 bot 可见的 meeting_id,或排查后端问题 |
|
||||
|
||||
## 提示
|
||||
|
||||
- 这是**会中事件流**查询,不适合拿来搜历史会议记录;搜历史会议请用 `+search`。
|
||||
- 如果会议已经结束,不要卡在 `+meeting-events`:
|
||||
- 想拿纪要文档或逐字稿 token:用 `lark-cli vc +notes --meeting-ids <meeting.id>`
|
||||
- 想拿 AI 产物(summary / todos / chapters)或导出逐字稿文件:先用 `lark-cli vc +recording --meeting-ids <meeting.id>` 拿 `minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`
|
||||
- 事件列表是否完整,取决于 bot 何时入会、何时离会,以及后端当前可见的会中事件范围。对于已结束会议,通常只在**结束后 5 分钟内**、且 bot **曾经在会中**时还能继续拉到事件。
|
||||
- 查询"谁参加过某会议"请用 `vc meeting get --params '{"meeting_id":"<id>","with_participants":true}'`——这是参会人**快照** API,不依赖 bot 是否参会,对已结束会议也可查;**不要** 用 `+meeting-events` 做参会人查询。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 先真实入会
|
||||
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 完成任务后离会
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
|
||||
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill)
|
||||
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
133
skills/lark-vc-agent/references/lark-vc-agent-meeting-join.md
Normal file
133
skills/lark-vc-agent/references/lark-vc-agent-meeting-join.md
Normal file
@@ -0,0 +1,133 @@
|
||||
|
||||
# vc +meeting-join
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
通过 9 位会议号加入一场正在进行的视频会议(bot join)。这是一次**写操作**,会实际让当前身份加入会议。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-join`(调用 `POST /open-apis/vc/v1/bots/join`)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 仅指定会议号(无密码)
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 指定会议号 + 密码
|
||||
lark-cli vc +meeting-join --meeting-number 123456789 --password 8888
|
||||
|
||||
# 输出格式
|
||||
lark-cli vc +meeting-join --meeting-number 123456789 --format json
|
||||
|
||||
# 预览 API 调用(不实际加入会议)
|
||||
lark-cli vc +meeting-join --meeting-number 123456789 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--meeting-number <no>` | 是 | 会议号,必须为 **9 位纯数字** |
|
||||
| `--password <pw>` | 否 | 会议密码,仅在该会议设置了入会密码时传入 |
|
||||
| `--format <fmt>` | 否 | 输出格式:json (默认) / pretty / table / ndjson / csv |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 仅支持 user 身份
|
||||
|
||||
该命令仅支持 `user` 身份。
|
||||
|
||||
### 2. 会议号格式严格校验
|
||||
|
||||
`--meeting-number` 必须是 9 位纯数字,否则本地校验直接报错:
|
||||
`--meeting-number must be exactly 9 digits`。
|
||||
|
||||
常见错误来源:
|
||||
- 把会议链接整条粘进来(应仅取尾部的 9 位数字)
|
||||
- 把 `meeting_id`(长数字 ID)当成会议号传入(两者不是同一个东西)
|
||||
|
||||
### 3. 会议必须已开始且允许入会
|
||||
|
||||
- 会议必须处于**进行中**状态,bot 无法加入尚未开始或已结束的会议。
|
||||
- 若会议设置了**等候室 / 入会审批**,bot 可能需要主持人放行后才真正入会。
|
||||
- 若返回 `HTTP 403: no permission`(错误码 `121003`),不要只理解成“账号没权限”。这类报错更常见的原因是:会议参数或会控配置当前不满足入会条件,例如会议号填错、密码未传或错误、会议尚未开始、等候室 / 入会审批未放行、会议禁止外部/特定身份加入等。应先确认这些配置项,再重试。
|
||||
|
||||
### 4. 机器人入会后对其他参会人可见
|
||||
|
||||
这是一次真实入会操作,机器人会立即出现在参会人列表中,其他参会人可见,并产生会议日志。误入错会的社交成本高于技术成本——执行前优先确认 9 位会议号的来源(用户输入 / 会议链接末尾),不要臆造。参数格式有疑问时可用 `--dry-run` 预览请求体。
|
||||
|
||||
## 输出结果
|
||||
|
||||
接口返回会议基本信息,字段视具体响应而定,常见字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `meeting.id` | 会议 ID(可后续传给 `+meeting-leave --meeting-id`) |
|
||||
| `meeting.meeting_no` | 会议号(与入参一致) |
|
||||
| `meeting.topic` | 会议主题 |
|
||||
| `meeting.start_time` | 会议开始时间 |
|
||||
|
||||
> **重要**:拿到 `meeting.id` 后务必保留,退出会议(`+meeting-leave`)需要使用它,而不是会议号。
|
||||
|
||||
## 如何获取输入参数
|
||||
|
||||
| 输入参数 | 获取方式 |
|
||||
|---------|---------|
|
||||
| `meeting-number` | 会议号由主持人分享;也可从会议链接尾部解析 9 位数字 |
|
||||
| `password` | 若会议设置了入会密码,由主持人提供 |
|
||||
|
||||
## Agent 组合场景
|
||||
|
||||
### 场景 1:加入会议 → 离开会议(最小闭环)
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入会议,记录返回的 meeting.id
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 第 2 步:完成任务后,使用上一步返回的 meeting.id 离开会议
|
||||
lark-cli vc +meeting-leave --meeting-id <meeting.id>
|
||||
```
|
||||
|
||||
### 场景 2:加入会议 → 会后拉取纪要 / 录制
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入并参会
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 第 2 步:离会
|
||||
lark-cli vc +meeting-leave --meeting-id <meeting.id>
|
||||
|
||||
# 第 3 步:会议结束后,查询录制(拿到 minute_token)
|
||||
lark-cli vc +recording --meeting-ids <meeting.id>
|
||||
|
||||
# 第 4 步:查询会议纪要(总结 / 待办 / 章节 / 逐字稿)
|
||||
lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
```
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| `--meeting-number must be exactly 9 digits` | 会议号不是 9 位纯数字 | 检查是否误传了会议链接或 meeting_id |
|
||||
| 会议密码错误 | `--password` 错误或未提供 | 向主持人确认会议密码 |
|
||||
| 会议不存在 / 已结束 | 会议号错误或会议未进行中 | 确认会议正在进行中 |
|
||||
| `HTTP 403: no permission` / `121003` | 入会前置条件不满足,通常不是单纯 scope 问题 | 依次确认:1)会议允许智能体加入;2)会议号正确;3)如有密码,已正确传入 `--password`;4)会议已开始;5)等候室 / 入会审批已放行;6)会议未禁止当前身份加入(如限制外部、限制 bot、仅特定成员可入会);确认后重试 |
|
||||
| 入会被拒绝 | 等候室 / 入会审批 / 限制外部入会 | 联系主持人放行或调整会议设置 |
|
||||
|
||||
## 提示
|
||||
|
||||
- 仅在 Agent 需要**真实加入**会议(例如参会机器人、会中助手)时使用;只拉取会议数据不需要入会。
|
||||
- 入会会让机器人立即出现在参会列表;若要回退,直接 `+meeting-leave` 即可。参数格式不确定时可选 `--dry-run` 预览,但不是必经步骤。
|
||||
- 执行成功后,立即记录返回的 `meeting.id`,用于后续 `+meeting-leave` / `+meeting-events`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 对应的离会命令
|
||||
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议记录
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
|
||||
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill)
|
||||
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
111
skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md
Normal file
111
skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md
Normal file
@@ -0,0 +1,111 @@
|
||||
|
||||
# vc +meeting-leave
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
通过 `meeting_id` 离开当前身份所在的视频会议(bot leave)。这是一次**写操作**,会实际把当前身份从会议中移出。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-leave`(调用 `POST /open-apis/vc/v1/bots/leave`)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 通过 meeting_id 离会
|
||||
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28
|
||||
|
||||
# 输出格式
|
||||
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --format json
|
||||
|
||||
# 预览 API 调用(不实际离会)
|
||||
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--meeting-id <id>` | 是 | 会议 ID(**不是 9 位会议号**) |
|
||||
| `--format <fmt>` | 否 | 输出格式:json (默认) / pretty / table / ndjson / csv |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 入参是 meeting_id,不是会议号
|
||||
|
||||
`--meeting-id` 必须是会议的长数字 ID,通常由 `+meeting-join` 返回体中的 `meeting.id` 提供,也可从 `+search` 结果中的 `id` 字段获取。**传 9 位会议号会失败**。
|
||||
|
||||
### 2. 仅支持 user 身份
|
||||
|
||||
该命令仅支持 `user` 身份。只能让当前身份自己离会,无法强制移出其他参会人。
|
||||
|
||||
### 3. 当前身份必须在会议中
|
||||
|
||||
必须先通过 `+meeting-join` 或其他方式在该会议中,否则接口会报错。
|
||||
|
||||
### 4. 离会立即生效,对其他参会人可见
|
||||
|
||||
机器人会立刻从参会列表消失;若会议启用了录制/纪要,bot 的参会时段到此截止。确认任务完成再调用;如需要重新入会,再跑 `+meeting-join` 即可(非真正"不可逆")。
|
||||
|
||||
## 输出结果
|
||||
|
||||
接口成功返回时,默认输出:`Left meeting <meeting-id> successfully.`。
|
||||
`--format json` 返回 API 原始响应体。
|
||||
|
||||
## 如何获取输入参数
|
||||
|
||||
| 输入参数 | 获取方式 |
|
||||
|---------|---------|
|
||||
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+search` 结果中的 `id` 字段 |
|
||||
|
||||
## Agent 组合场景
|
||||
|
||||
### 场景 1:加入 → 完成任务 → 离开(最小闭环)
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入会议,记录 meeting.id
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 第 2 步:在会中完成任务(如监听发言、记录信息等)
|
||||
# ...
|
||||
|
||||
# 第 3 步:使用上一步记录的 meeting.id 离会
|
||||
lark-cli vc +meeting-leave --meeting-id <meeting.id>
|
||||
```
|
||||
|
||||
### 场景 2:会后补拉产物
|
||||
|
||||
```bash
|
||||
# 第 1 步:离会后会议仍在进行或已结束
|
||||
lark-cli vc +meeting-leave --meeting-id <meeting.id>
|
||||
|
||||
# 第 2 步:会议结束后查询录制
|
||||
lark-cli vc +recording --meeting-ids <meeting.id>
|
||||
|
||||
# 第 3 步:查询会议纪要
|
||||
lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
```
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入从 `+meeting-join` 得到的 `meeting.id` |
|
||||
| `meeting not found` / `invalid meeting_id` | 误传了 9 位会议号 | 必须使用 `meeting.id`,不是会议号 |
|
||||
| `not in meeting` | 当前身份并不在该会议中 | 确认先 `+meeting-join` 成功 |
|
||||
|
||||
## 提示
|
||||
|
||||
- 离会会让机器人从参会列表消失,对其他参会人可见;若需要重新入会直接再 `+meeting-join`,不是真正的"不可逆"。参数格式不确定时可选 `--dry-run` 预览。
|
||||
- 与 `+meeting-join` 成对使用:能 join 的身份才能 leave。
|
||||
- `meeting_id` 必须来自 `+meeting-join` 的返回值,不要用 9 位会议号。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 对应的入会命令
|
||||
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
|
||||
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill)
|
||||
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-vc
|
||||
version: 1.0.0
|
||||
description: "飞书视频会议:查询会议记录、获取会议纪要产物(总结、待办、章节、逐字稿)。1. 查询已经结束的会议数量或详情时使用本技能(如历史日期| 昨天 | 上周 | 今天已经开过的会议等场景),查询未开始的会议日程使用 lark-calendar 技能。2. 支持通过关键词、时间范围、组织者、参与者、会议室等筛选条件搜索会议记录。3. 获取或整理会议纪要时使用本技能。"
|
||||
description: "飞书视频会议:搜索历史会议、查询会议纪要产物(总结、待办、章节、逐字稿)、查询会议参会人快照。1. 查询已经结束的会议数量或详情时使用本技能(如历史日期|昨天|上周|今天已经开过的会议等场景),查询未开始的会议日程使用 lark-calendar 技能。2. 支持通过关键词、时间范围、组织者、参与者、会议室等筛选条件搜索会议。3. 获取或整理会议纪要、逐字稿、录制产物时使用本技能。4. 查询“谁参加过某会议”“参会人列表”等参会人快照信息用 vc meeting get --with-participants(任意时点可查,含已结束会议)。注意:**Agent 真实入会/离会、感知正在进行中会议的实时事件**请使用 lark-vc-agent 技能,本技能不覆盖写操作和会中事件流。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -14,8 +14,7 @@ metadata:
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting\_id 标识。
|
||||
- **会议记录(Meeting Record)**:视频会议结束后生成的记录,支持通过关键词、时间段、参会人、组织者、会议室等筛选条件搜索会议室。
|
||||
- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting\_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。
|
||||
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结、待办、章节)和逐字稿文档。
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写和会议纪要,通过 minute\_token 标识。
|
||||
- **纪要文档(MainDoc)**:AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`。
|
||||
@@ -67,6 +66,23 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
|
||||
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
|
||||
```
|
||||
|
||||
### 4. 查询参会人快照(读操作)
|
||||
|
||||
用户问"谁参加过这场会议""这个会议有哪些参会人""某某参会了吗"等**参会人快照**类问题时,使用 **`vc meeting get --with-participants`**:这是参会人服务端快照 API,不依赖 bot 身份参会,**已结束会议也可查**:
|
||||
|
||||
```bash
|
||||
lark-cli vc meeting get --params '{"meeting_id":"<meeting_id>","with_participants":true}'
|
||||
```
|
||||
|
||||
选型判断表:
|
||||
|
||||
| 用户意图 | 推荐命令 | 所在 skill |
|
||||
|---------|---------|--------|
|
||||
| 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill |
|
||||
| 已结束会议的发言内容 | `vc +notes` 取 `verbatim_doc_token` 再 `docs +fetch` | 本 skill |
|
||||
| **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `vc +meeting-events` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
|
||||
| **Agent 真实入会 / 离会** | `vc +meeting-join` / `vc +meeting-leave` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
|
||||
|
||||
## 资源关系
|
||||
|
||||
```
|
||||
@@ -109,6 +125,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
|
||||
- 使用 `+notes` 命令时,必须阅读 [references/lark-vc-notes.md](references/lark-vc-notes.md),了解查询参数、产物类型和返回值结构。
|
||||
- 使用 `+recording` 命令时,必须阅读 [references/lark-vc-recording.md](references/lark-vc-recording.md),了解查询参数和返回值结构。
|
||||
|
||||
> **Agent 参会相关命令已独立**:`+meeting-join` / `+meeting-leave` / `+meeting-events` 请使用 [`lark-vc-agent`](../lark-vc-agent/SKILL.md) 技能。
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
@@ -146,3 +164,5 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa
|
||||
| `+recording --calendar-event-ids` | `vc:record:readonly`、`calendar:calendar:read`、`calendar:calendar.event:read` |
|
||||
| `+search` | `vc:meeting.search:read` |
|
||||
| `meeting.get` | `vc:meeting.meetingevent:read` |
|
||||
|
||||
> Agent 参会相关 scope(`vc:meeting.bot.join:write` / `vc:meeting.meetingevent:read`)见 [`lark-vc-agent`](../lark-vc-agent/SKILL.md)。
|
||||
|
||||
@@ -109,7 +109,7 @@ lark-cli vc +notes --minute-tokens <minute_token>
|
||||
|
||||
```bash
|
||||
# 第 1 步:搜索历史会议,拿到 meeting_ids
|
||||
lark-cli vc +search --query "周会" --start yesterday
|
||||
lark-cli vc +search --query "周会" --start 2026-03-10
|
||||
|
||||
# 第 2 步:使用上一步返回的 meeting_ids 查询录制,拿到 minute_tokens
|
||||
lark-cli vc +recording --meeting-ids <ids>
|
||||
|
||||
@@ -88,7 +88,17 @@ lark-cli vc +search --query "周会" --format json
|
||||
|
||||
当返回 `has_more=true` 时,使用响应中的 `page_token` 配合 `--page-token` 获取下一页结果。
|
||||
|
||||
### 5. 日期型 `--end` 包含当天整天
|
||||
### 5. 机器人可同时加入多个会议
|
||||
|
||||
机器人支持同时加入多个正在进行中的会议;加入新会议前,不需要先退出已经在会中的其他会议。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 不要假设 bot 一次只能在一个会议中
|
||||
- 如果用户要求 bot 再加入另一场会,可以直接继续执行对应的入会命令
|
||||
- 只有在用户明确要求结束某一场会中的 bot 参会时,才调用对应的离会命令
|
||||
|
||||
### 6. 日期型 `--end` 包含当天整天
|
||||
|
||||
当 `--end` 传入的是仅日期格式(如 `2026-03-10`)时,CLI 会将它解释为当天 `23:59:59`,而不是当天 `00:00:00`。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ metadata:
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 运行 `lark-cli --version`,确认可用,无需询问用户。
|
||||
> - 运行 `npx -y @larksuite/whiteboard-cli@^0.2.10 -v`,确认可用,无需询问用户。
|
||||
> - 运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 -v`,确认可用,无需询问用户。
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
@@ -124,7 +124,7 @@ diagram.png ← 渲染结果
|
||||
|
||||
```bash
|
||||
# 第一步:dry-run 探测
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i <产物文件> --to openapi --format json \
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
|
||||
| lark-cli whiteboard +update \
|
||||
--whiteboard-token <Token> \
|
||||
--source - --input_format raw \
|
||||
@@ -132,7 +132,7 @@ npx -y @larksuite/whiteboard-cli@^0.2.10 -i <产物文件> --to openapi --format
|
||||
--overwrite --dry-run --as user
|
||||
|
||||
# 第二步:确认后执行
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i <产物文件> --to openapi --format json \
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
|
||||
| lark-cli whiteboard +update \
|
||||
--whiteboard-token <Token> \
|
||||
--source - --input_format raw \
|
||||
|
||||
@@ -4,20 +4,24 @@
|
||||
|
||||
## 概述
|
||||
|
||||
画板 DSL 支持 `type: 'image'` 节点,但图片不能直接使用 URL,必须先上传到飞书获取 **media token**,然后在 DSL 中引用。
|
||||
画板 DSL 支持 `type: 'image'` 节点,但图片不能直接使用 URL 或其他域的 token,**必须先上传到目标画板获取 `whiteboard` 域 media token**,然后在 DSL 中引用。
|
||||
|
||||
**关键约束**:
|
||||
- 图片 token 必须通过 `docs +media-upload --parent-type whiteboard` 上传获取
|
||||
- 图片必须上传到**目标画板**(`--parent-node` 设为目标画板 token),跨画板的 token 不可用
|
||||
- `drive +upload` 获取的 Drive file token **不能**用于画板图片节点
|
||||
**核心规则**:不管图片从哪来(本地文件、URL、文档中的 `docx_image` token、其他域的 Drive token),都必须通过 `docs +media-upload --parent-type whiteboard --parent-node <目标画板token>` 上传,拿到画板专属的 media token 后才能在 DSL 中使用。直接使用非 `whiteboard` 域的 token 会导致画板 API 报 500(错误码 2891001)或图片在文档中消失。
|
||||
|
||||
## Step 0:图片准备流程
|
||||
|
||||
### 1. 下载图片
|
||||
### 1. 获取图片到本地
|
||||
|
||||
用 `curl` 下载图片到本地。**必须使用能根据关键词返回相关图片的图片源**。
|
||||
根据图片来源选择对应方式:
|
||||
|
||||
**推荐图片源**:
|
||||
| 图片来源 | 获取方式 |
|
||||
|---------|---------|
|
||||
| 本地文件 | 直接使用 |
|
||||
| 网络 URL | `curl -L -o photo.jpg "<URL>"` |
|
||||
| 文档中的图片 token | `lark-cli docs +media-download --token <token> --output ./photo.png` |
|
||||
| 其他域的 Drive token | `lark-cli docs +media-download --token <token> --output ./photo.png` |
|
||||
|
||||
**图片源选择(需要搜索图片时)**:
|
||||
|
||||
| 图片源类型 | 说明 |
|
||||
|-------|------|
|
||||
@@ -29,11 +33,6 @@
|
||||
- **关键词搜索**:支持按关键词搜索并返回相关图片,确保图片内容与主题匹配
|
||||
- **内容丰富**:图库图片种类多、数量大,能覆盖常见主题(宠物、美食、景点、产品等)
|
||||
|
||||
```bash
|
||||
curl -L -o photo1.jpg "<图片URL>"
|
||||
curl -L -o photo2.jpg "<图片URL>"
|
||||
```
|
||||
|
||||
**严禁使用随机占位图服务**:某些图库仅提供随机占位图,URL 中的关键词参数不会影响返回的图片内容,下载的图片与主题完全无关。
|
||||
|
||||
### 2. 校验图片
|
||||
@@ -74,7 +73,8 @@ lark-cli docs +media-upload --file ./photo3.jpg --parent-type whiteboard --paren
|
||||
|
||||
| 错误现象 | 原因 | 解决 |
|
||||
|---------|------|------|
|
||||
| 画板 API 返回 500(2891001) | 使用了 Drive file token 而非 media token | 改用 `docs +media-upload --parent-type whiteboard` |
|
||||
| 画板 API 返回 500(2891001) | 使用了非 `whiteboard` 域 token(如 `docx_image`、Drive file token) | 下载图片后用 `docs +media-upload --parent-type whiteboard` 重新上传 |
|
||||
| 画板 API 返回 500 | 图片上传到了其他画板 | 重新上传到目标画板 |
|
||||
| 画板在文档中图片消失 | 图片 token 的资源域与画板不匹配 | 确保图片通过 `--parent-type whiteboard --parent-node <画板token>` 上传 |
|
||||
| 图片裂开/无法显示 | token 无效或已过期 | 重新上传获取新 token |
|
||||
| 图片内容与主题无关 | 使用了随机占位图服务 | 改用免费版权图库服务 |
|
||||
|
||||
@@ -74,7 +74,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
|
||||
|
||||
```bash
|
||||
# 使用 whiteboard-cli 生成 OpenAPI 格式并通过管道传递
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i <产物文件> --to openapi --format json \
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
|
||||
| lark-cli whiteboard +update \
|
||||
--whiteboard-token <画板Token> \
|
||||
--source - --input_format raw \
|
||||
@@ -88,7 +88,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
|
||||
|
||||
```bash
|
||||
# 生成 OpenAPI 格式到文件
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i <DSL 文件> --to openapi --format json -o ./temp.json
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <DSL 文件> --to openapi --format json -o ./temp.json
|
||||
|
||||
# 从文件读取并更新
|
||||
lark-cli whiteboard +update \
|
||||
|
||||
@@ -336,7 +336,7 @@ DSL 的语法是严格白名单,不能写原生 CSS 属性(不支持 `alignS
|
||||
先出骨架图导出坐标,再基于坐标补充连线和注解:
|
||||
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i skeleton.json -o step1.png -l coords.json
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i skeleton.json -o step1.png -l coords.json
|
||||
```
|
||||
|
||||
`coords.json` 包含每个带 id 节点的精确坐标(absX, absY, width, height)。
|
||||
|
||||
@@ -272,14 +272,14 @@ SVG 通过 `image/svg+xml` Blob 加载到画布,**不在 HTML DOM 中**,因
|
||||
x?: number; y?: number;
|
||||
width?: WBSizeValue; // 默认 48
|
||||
height?: WBSizeValue; // 默认 48,保持正方形
|
||||
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.10 --icons 输出中选取
|
||||
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.11 --icons 输出中选取
|
||||
color?: string; // 可选颜色覆盖,hex 格式如 '#FF6600'
|
||||
}
|
||||
```
|
||||
|
||||
**获取可用图标**:规划好内容和布局后,运行以下命令查看所有可用图标名,从中选取:
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 --icons
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 --icons
|
||||
```
|
||||
|
||||
用法:
|
||||
|
||||
@@ -13,7 +13,7 @@ Step 1: 路由 & 读取知识
|
||||
Step 2: 生成完整 DSL(含颜色)
|
||||
- 按 content.md 规划信息量和分组
|
||||
- 按 layout.md 选择布局模式和间距
|
||||
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.10 --icons` 查看可用图标
|
||||
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 --icons` 查看可用图标
|
||||
- 按 style.md 上色(用户没指定时用默认经典色板)
|
||||
- 按 schema.md 语法输出完整 JSON
|
||||
- 连线参考 connectors.md,排版参考 typography.md
|
||||
@@ -25,12 +25,12 @@ Step 2: 生成完整 DSL(含颜色)
|
||||
|
||||
Step 3: 渲染 & 审查 → 交付
|
||||
- 渲染前自查(见下方检查清单)
|
||||
- 渲染 PNG(仅用于预览验证,不是最终产物):npx -y @larksuite/whiteboard-cli@^0.2.10 -i diagram.json -o diagram.png
|
||||
- 渲染 PNG(仅用于预览验证,不是最终产物):npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json -o diagram.png
|
||||
- 检查:信息完整?布局合理?配色协调?文字无截断?连线无交叉?
|
||||
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
|
||||
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
|
||||
- 写入画板:用 whiteboard-cli 将 diagram.json 转换为 OpenAPI 格式并 pipe 给 +update:
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i diagram.json --to openapi --format json \
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json --to openapi --format json \
|
||||
| lark-cli whiteboard +update --whiteboard-token <board_token> \
|
||||
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
|
||||
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)
|
||||
|
||||
@@ -16,10 +16,10 @@ Step 3: 渲染验证 & 写入画板 & 交付
|
||||
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
|
||||
2. 保存为 diagram.mmd
|
||||
3. 渲染(仅用于预览验证,PNG 不是最终产物):
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i diagram.mmd -o diagram.png
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd -o diagram.png
|
||||
4. 审查 PNG,有问题修改后重新渲染(最多 2 轮)
|
||||
5. 写入画板:用 whiteboard-cli 将 diagram.mmd 转换为 OpenAPI 格式并 pipe 给 +update:
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i diagram.mmd --to openapi --format json \
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd --to openapi --format json \
|
||||
| lark-cli whiteboard +update --whiteboard-token <board_token> \
|
||||
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
|
||||
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
```
|
||||
建目录 ./diagrams/YYYY-MM-DDTHHMMSS/ (例:./diagrams/2026-04-15T143022/)
|
||||
写文件 <dir>/diagram.svg
|
||||
渲染 npx -y @larksuite/whiteboard-cli@^0.2.10 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
|
||||
检查 npx -y @larksuite/whiteboard-cli@^0.2.10 -i <dir>/diagram.svg -f svg --check
|
||||
导出 npx -y @larksuite/whiteboard-cli@^0.2.10 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
|
||||
渲染 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
|
||||
检查 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --check
|
||||
导出 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
|
||||
```
|
||||
|
||||
`npx -y @larksuite/whiteboard-cli@^0.2.10 --check` 检测 `text-overflow` 和 `node-overlap`, 并结合视觉效果(查看 PNG)进行调整
|
||||
`npx -y @larksuite/whiteboard-cli@^0.2.11 --check` 检测 `text-overflow` 和 `node-overlap`, 并结合视觉效果(查看 PNG)进行调整
|
||||
|
||||
## 画板怎么处理 SVG
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.10` 渲染
|
||||
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
|
||||
- **绝对定位手写**:简单柱状图(≤ 5 个柱)可手写坐标
|
||||
|
||||
## Layout 规则
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.10` 渲染
|
||||
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.10` 渲染
|
||||
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.10` 渲染
|
||||
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.10` 渲染
|
||||
- **脚本生成坐标**(推荐):Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
|
||||
- 不适合手动心算坐标
|
||||
|
||||
## Layout 规则
|
||||
|
||||
@@ -100,7 +100,6 @@ func TestCalendar_CreateEvent(t *testing.T) {
|
||||
"calendar_id": calendarID,
|
||||
"event_id": eventID,
|
||||
},
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
@@ -52,7 +52,6 @@ func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
"summary": calendarSummary,
|
||||
"description": calendarDescription,
|
||||
},
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -116,7 +115,6 @@ func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
Data: map[string]any{
|
||||
"summary": updatedCalendarSummary,
|
||||
},
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
## Metrics
|
||||
- Denominator: 29 leaf commands
|
||||
- Covered: 2
|
||||
- Coverage: 6.9%
|
||||
- Covered: 7
|
||||
- Coverage: 24.1%
|
||||
|
||||
## 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`.
|
||||
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
|
||||
- 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 `duplicate_remote_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.
|
||||
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
- Blocked area: live upload, live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
|
||||
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`, but there is still no live upload workflow coverage.
|
||||
- Blocked area: live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
|
||||
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`; live duplicate/status workflows also use real `+upload` to seed remote fixtures.
|
||||
|
||||
## Command Table
|
||||
|
||||
@@ -26,9 +29,11 @@
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflow seeds via `+upload` and asserts all four buckets |
|
||||
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
|
||||
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |
|
||||
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure |
|
||||
| ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet |
|
||||
| ✕ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget (dry-run only) | `--wiki-token`; `parent_type=wiki`; `parent_node` | no live upload workflow yet |
|
||||
| ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders | dry-run covers wiki-target shape; live workflows assert returned file tokens and consume the uploaded fixtures |
|
||||
| ✕ | drive file.comment.replys create | api | | none | no reply workflow yet |
|
||||
| ✕ | drive file.comment.replys delete | api | | none | no reply workflow yet |
|
||||
| ✕ | drive file.comment.replys list | api | | none | no reply workflow yet |
|
||||
|
||||
208
tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go
Normal file
208
tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
uploadNamedFile := func(t *testing.T, workDir, folderToken, stageName, remoteName, content string) string {
|
||||
t.Helper()
|
||||
stagePath := filepath.Join(workDir, stageName)
|
||||
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write stage file %s: %v", stageName, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Remove(stagePath) })
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", stageName,
|
||||
"--folder-token", folderToken,
|
||||
"--name", remoteName,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
|
||||
return fileToken
|
||||
}
|
||||
|
||||
t.Run("status and pull handle duplicate remote files", func(t *testing.T) {
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-pull-"+suffix, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
|
||||
firstToken := uploadNamedFile(t, workDir, folderToken, "_dup_first.txt", "dup.txt", "first")
|
||||
secondToken := uploadNamedFile(t, workDir, folderToken, "_dup_second.txt", "dup.txt", "second")
|
||||
|
||||
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if statusResult.ExitCode == 0 {
|
||||
t.Fatalf("+status should fail on duplicate remote rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
|
||||
}
|
||||
if !strings.Contains(statusResult.Stderr, `"type": "duplicate_remote_path"`) {
|
||||
t.Fatalf("+status stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
|
||||
}
|
||||
|
||||
pullFailResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if pullFailResult.ExitCode == 0 {
|
||||
t.Fatalf("+pull should fail on duplicate remote rel_path by default\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
|
||||
}
|
||||
if !strings.Contains(pullFailResult.Stderr, `"type": "duplicate_remote_path"`) {
|
||||
t.Fatalf("+pull stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(workDir, "local", "dup.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("default duplicate failure must not write dup.txt; stat err=%v", statErr)
|
||||
}
|
||||
|
||||
pullRenameResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--on-duplicate-remote", "rename",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
pullRenameResult.AssertExitCode(t, 0)
|
||||
pullRenameResult.AssertStdoutStatus(t, true)
|
||||
|
||||
items := gjson.Get(pullRenameResult.Stdout, "data.items")
|
||||
if items.Array() == nil || len(items.Array()) != 2 {
|
||||
t.Fatalf("+pull rename should produce two items, stdout:\n%s", pullRenameResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(pullRenameResult.Stdout, "data.summary.downloaded").Int(); got != 2 {
|
||||
t.Fatalf("+pull rename downloaded=%d, want 2\nstdout:\n%s", got, pullRenameResult.Stdout)
|
||||
}
|
||||
relPaths := []string{
|
||||
gjson.Get(pullRenameResult.Stdout, "data.items.0.rel_path").String(),
|
||||
gjson.Get(pullRenameResult.Stdout, "data.items.1.rel_path").String(),
|
||||
}
|
||||
var renamedRel string
|
||||
for _, rel := range relPaths {
|
||||
if rel != "dup.txt" {
|
||||
renamedRel = rel
|
||||
}
|
||||
}
|
||||
if renamedRel == "" || !strings.HasPrefix(renamedRel, "dup__lark_") || !strings.HasSuffix(renamedRel, ".txt") {
|
||||
t.Fatalf("renamed rel_path = %q, want dup__lark_<hash>.txt\nstdout:\n%s", renamedRel, pullRenameResult.Stdout)
|
||||
}
|
||||
if !strings.Contains(pullRenameResult.Stdout, `"source_id":"hash_`) &&
|
||||
!strings.Contains(pullRenameResult.Stdout, `"source_id": "hash_`) {
|
||||
t.Fatalf("+pull rename stdout should contain source_id for duplicate items\nstdout:\n%s", pullRenameResult.Stdout)
|
||||
}
|
||||
if strings.Contains(pullRenameResult.Stdout, firstToken) || strings.Contains(pullRenameResult.Stdout, secondToken) {
|
||||
t.Fatalf("+pull rename stdout should not expose raw duplicate file tokens\nstdout:\n%s", pullRenameResult.Stdout)
|
||||
}
|
||||
require.FileExists(t, filepath.Join(workDir, "local", "dup.txt"))
|
||||
require.FileExists(t, filepath.Join(workDir, "local", filepath.FromSlash(renamedRel)))
|
||||
})
|
||||
|
||||
t.Run("push resolves duplicate remote files and converges status", func(t *testing.T) {
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-push-"+suffix, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workDir, "local", "dup.txt"), []byte("local-overwrite"), 0o644); err != nil {
|
||||
t.Fatalf("write local dup.txt: %v", err)
|
||||
}
|
||||
|
||||
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_first.txt", "dup.txt", "remote-first")
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_second.txt", "dup.txt", "remote-second")
|
||||
|
||||
pushResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--if-exists", "overwrite",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
pushResult.AssertExitCode(t, 0)
|
||||
pushResult.AssertStdoutStatus(t, true)
|
||||
if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 {
|
||||
t.Fatalf("+push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(pushResult.Stdout, "data.summary.deleted_remote").Int(); got != 1 {
|
||||
t.Fatalf("+push deleted_remote=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
|
||||
}
|
||||
|
||||
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
statusResult.AssertExitCode(t, 0)
|
||||
statusResult.AssertStdoutStatus(t, true)
|
||||
if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 {
|
||||
t.Fatalf("+status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "dup.txt" {
|
||||
t.Fatalf("+status unchanged rel_path=%q, want dup.txt\nstdout:\n%s", got, statusResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 ||
|
||||
gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 ||
|
||||
gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 {
|
||||
t.Fatalf("+status should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -171,3 +171,44 @@ func TestDrive_PullDryRunRejectsMissingFolderToken(t *testing.T) {
|
||||
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
for _, strategy := range []string{"rename", "newest", "oldest"} {
|
||||
t.Run(strategy, func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--on-duplicate-remote", strategy,
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,3 +241,44 @@ func TestDrive_PushDryRunRejectsMissingFolderToken(t *testing.T) {
|
||||
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
for _, strategy := range []string{"newest", "oldest"} {
|
||||
t.Run(strategy, func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--on-duplicate-remote", strategy,
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# IM CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 29 leaf commands
|
||||
- Covered: 9
|
||||
- Coverage: 31.0%
|
||||
- Denominator: 30 leaf commands
|
||||
- Covered: 11
|
||||
- Coverage: 36.7%
|
||||
|
||||
## Summary
|
||||
- TestIM_ChatUpdateWorkflow: proves `im +chat-create`, `im +chat-update`, and `im chats get`; key `t.Run(...)` proof points are `update chat name as bot`, `update chat description as bot`, and `get updated chat as bot`.
|
||||
@@ -12,6 +12,7 @@
|
||||
- TestIM_ChatMessageWorkflowAsUser: proves the user chat message flow through `create chat as user`, `send message as user`, and `list chat messages as user` with the created message ID and content asserted from read-after-write output.
|
||||
- TestIM_MessageGetWorkflowAsUser: proves user message readback through `batch get message as user` after creating a fresh chat and sending a unique message.
|
||||
- TestIM_MessageReplyWorkflowAsBot: proves threaded reply flow through `reply to message in thread as bot` and `list thread replies as bot`, reading back the reply from `im +threads-messages-list`.
|
||||
- TestIM_MessageForwardWorkflowAsUser: proves UAT-backed API forwarding through `im messages forward` and `im threads forward` using a fresh message/thread fixture; skips the forward assertions when the current test app/UAT lacks IM forward permission.
|
||||
- Blocked area: `im +chat-search` did not reliably return freshly created private chats in UAT, and `im +messages-search` did not reliably index freshly sent messages in time for a deterministic read-after-write assertion, so both remain uncovered.
|
||||
|
||||
## Command Table
|
||||
@@ -37,9 +38,10 @@
|
||||
| ✕ | im chats update | api | | none | only covered indirectly through `+chat-update` |
|
||||
| ✕ | im images create | api | | none | no image upload workflow yet |
|
||||
| ✕ | im messages delete | api | | none | no recall workflow yet |
|
||||
| ✕ | im messages forward | api | | none | no forward workflow yet |
|
||||
| ✓ | im messages forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward message with api command as user | `message_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh message back into the test chat using UAT |
|
||||
| ✕ | im messages merge_forward | api | | none | no merge-forward workflow yet |
|
||||
| ✕ | im messages read_users | api | | none | no read-user workflow yet |
|
||||
| ✓ | im threads forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward thread with api command as user | `thread_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh thread back into the test chat using UAT |
|
||||
| ✕ | im pins create | api | | none | pin workflows not covered |
|
||||
| ✕ | im pins delete | api | | none | pin workflows not covered |
|
||||
| ✕ | im pins list | api | | none | pin workflows not covered |
|
||||
|
||||
304
tests/cli_e2e/im/flag_workflow_test.go
Normal file
304
tests/cli_e2e/im/flag_workflow_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestIM_FlagWorkflowAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
parentT := t
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
chatName := "im-flag-" + suffix
|
||||
messageText := "flag-test-msg-" + suffix
|
||||
var chatID string
|
||||
var messageID string
|
||||
|
||||
t.Run("create chat as user", func(t *testing.T) {
|
||||
chatID = createChatAs(t, parentT, ctx, chatName, "user")
|
||||
})
|
||||
|
||||
t.Run("send message as user", func(t *testing.T) {
|
||||
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
|
||||
})
|
||||
|
||||
t.Run("create flag as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-create",
|
||||
"--message-id", messageID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("list flags as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-list",
|
||||
"--page-size", "10",
|
||||
"--page-all",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
return true
|
||||
}
|
||||
// Check if our message is in the list
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
if item.Get("item_id").String() == messageID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Verify our flagged message is in the list
|
||||
var found bool
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
if item.Get("item_id").String() == messageID {
|
||||
found = true
|
||||
// Verify it's a message-type flag (flag_type=2)
|
||||
require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)")
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected message %s in flag list", messageID)
|
||||
})
|
||||
|
||||
t.Run("cancel flag as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-cancel",
|
||||
"--message-id", messageID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("verify flag removed", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-list",
|
||||
"--page-size", "10",
|
||||
"--page-all",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
return true
|
||||
}
|
||||
// Check if our message is still in the list
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
if item.Get("item_id").String() == messageID {
|
||||
return true // Still there, retry
|
||||
}
|
||||
}
|
||||
return false // Not found, success
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Verify our message is NOT in the list
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
require.NotEqual(t, messageID, item.Get("item_id").String(), "message should not be in flag list after cancel")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIM_FlagCreateWithExplicitTypeAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
parentT := t
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
chatName := "im-flag-explicit-" + suffix
|
||||
messageText := "flag-explicit-msg-" + suffix
|
||||
var chatID string
|
||||
var messageID string
|
||||
|
||||
t.Run("create chat as user", func(t *testing.T) {
|
||||
chatID = createChatAs(t, parentT, ctx, chatName, "user")
|
||||
})
|
||||
|
||||
t.Run("send message as user", func(t *testing.T) {
|
||||
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
|
||||
})
|
||||
|
||||
t.Run("create flag with explicit types as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-create",
|
||||
"--message-id", messageID,
|
||||
"--item-type", "default",
|
||||
"--flag-type", "message",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("list flags to verify explicit types as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-list",
|
||||
"--page-size", "10",
|
||||
"--page-all",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
return true
|
||||
}
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
if item.Get("item_id").String() == messageID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Verify explicit types were applied
|
||||
var found bool
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
if item.Get("item_id").String() == messageID {
|
||||
found = true
|
||||
require.Equal(t, "0", item.Get("item_type").String(), "expected item_type=0 (default)")
|
||||
require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)")
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected message %s in flag list", messageID)
|
||||
})
|
||||
|
||||
t.Run("cancel flag with explicit types as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-cancel",
|
||||
"--message-id", messageID,
|
||||
"--flag-type", "message",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIM_FlagListPaginationAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
t.Run("list flags with page-all as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-list",
|
||||
"--page-size", "5",
|
||||
"--page-all",
|
||||
"--page-limit", "3",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIM_FlagDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "fake_user_token")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("create flag dry-run", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-create",
|
||||
"--message-id", "om_test_dry_run",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Stdout, "POST")
|
||||
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags")
|
||||
require.Contains(t, result.Stdout, "flag_items")
|
||||
require.Contains(t, result.Stdout, "om_test_dry_run")
|
||||
})
|
||||
|
||||
t.Run("cancel flag dry-run with om", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-cancel",
|
||||
"--message-id", "om_test_dry_run",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Stdout, "POST")
|
||||
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags/cancel")
|
||||
require.Contains(t, result.Stdout, "flag_items")
|
||||
require.Contains(t, result.Stdout, "om_test_dry_run")
|
||||
})
|
||||
|
||||
t.Run("list flag dry-run", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-list",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Stdout, "GET")
|
||||
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags")
|
||||
require.Contains(t, result.Stdout, "page_size")
|
||||
})
|
||||
}
|
||||
184
tests/cli_e2e/im/message_forward_workflow_test.go
Normal file
184
tests/cli_e2e/im/message_forward_workflow_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestIM_MessageForwardWorkflowAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
messageText := "im-forward-msg-" + suffix
|
||||
replyText := "im-forward-reply-" + suffix
|
||||
|
||||
selfOpenID := getSelfOpenID(t, ctx)
|
||||
chatID, messageID := sendDirectMessageToUser(t, ctx, selfOpenID, messageText, "bot")
|
||||
|
||||
t.Run("forward message with api command as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "messages", "forward"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{
|
||||
"message_id": messageID,
|
||||
"receive_id_type": "chat_id",
|
||||
"uuid": "msg-forward-" + suffix,
|
||||
},
|
||||
Data: map[string]any{
|
||||
"receive_id": chatID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipIfMissingIMForwardPermission(t, result)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
|
||||
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
|
||||
require.NotEqual(t, messageID, forwardedID, "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
var threadID string
|
||||
t.Run("create thread fixture as bot", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+messages-reply",
|
||||
"--message-id", messageID,
|
||||
"--text", replyText,
|
||||
"--reply-in-thread",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
threadID = findThreadIDForMessage(t, ctx, chatID, messageID, "bot")
|
||||
})
|
||||
|
||||
t.Run("forward thread with api command as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "threads", "forward"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{
|
||||
"thread_id": threadID,
|
||||
"receive_id_type": "chat_id",
|
||||
"uuid": "thread-forward-" + suffix,
|
||||
},
|
||||
Data: map[string]any{
|
||||
"receive_id": chatID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipIfMissingIMForwardPermission(t, result)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
|
||||
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, "merge_forward", gjson.Get(result.Stdout, "data.msg_type").String(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
}
|
||||
|
||||
func findThreadIDForMessage(t *testing.T, ctx context.Context, chatID string, messageID string, defaultAs string) string {
|
||||
t.Helper()
|
||||
|
||||
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+chat-messages-list",
|
||||
"--chat-id", chatID,
|
||||
"--start", time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
|
||||
"--end", time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
return true
|
||||
}
|
||||
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
|
||||
if item.Get("message_id").String() == messageID && item.Get("thread_id").String() != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
listResult.AssertExitCode(t, 0)
|
||||
listResult.AssertStdoutStatus(t, true)
|
||||
|
||||
for _, item := range gjson.Get(listResult.Stdout, "data.messages").Array() {
|
||||
if item.Get("message_id").String() == messageID {
|
||||
threadID := item.Get("thread_id").String()
|
||||
require.NotEmpty(t, threadID, "expected thread_id for message %s in stdout:\n%s", messageID, listResult.Stdout)
|
||||
return threadID
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("expected message %s in stdout:\n%s", messageID, listResult.Stdout)
|
||||
return ""
|
||||
}
|
||||
|
||||
func getSelfOpenID(t *testing.T, ctx context.Context) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"contact", "+get-user"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
openID := gjson.Get(result.Stdout, "data.user.open_id").String()
|
||||
require.NotEmpty(t, openID, "stdout:\n%s", result.Stdout)
|
||||
return openID
|
||||
}
|
||||
|
||||
func sendDirectMessageToUser(t *testing.T, ctx context.Context, userOpenID string, text string, defaultAs string) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+messages-send",
|
||||
"--user-id", userOpenID,
|
||||
"--text", text,
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
chatID := gjson.Get(result.Stdout, "data.chat_id").String()
|
||||
messageID := gjson.Get(result.Stdout, "data.message_id").String()
|
||||
require.NotEmpty(t, chatID, "stdout:\n%s", result.Stdout)
|
||||
require.NotEmpty(t, messageID, "stdout:\n%s", result.Stdout)
|
||||
return chatID, messageID
|
||||
}
|
||||
|
||||
func skipIfMissingIMForwardPermission(t *testing.T, result *clie2e.Result) {
|
||||
t.Helper()
|
||||
if result == nil || result.ExitCode == 0 {
|
||||
return
|
||||
}
|
||||
stderrLower := strings.ToLower(result.Stderr)
|
||||
if strings.Contains(stderrLower, "permission denied") ||
|
||||
strings.Contains(stderrLower, "230027") ||
|
||||
strings.Contains(stderrLower, "missing_scope") {
|
||||
t.Skipf("skip UAT forward workflow due to missing IM forward permissions: %s", result.Stderr)
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,6 @@ func TestSheets_SpreadsheetsResource(t *testing.T) {
|
||||
"title": "lark-cli-e2e-sheets-resource-" + suffix,
|
||||
"folder_token": folderToken,
|
||||
},
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
@@ -94,7 +94,6 @@ func TestSheets_FilterWorkflow(t *testing.T) {
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
Data: filterData,
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
Reference in New Issue
Block a user