Compare commits

..

12 Commits

Author SHA1 Message Date
liangshuo-1
f6f242ed57 chore(release): v1.0.20 (#682)
Change-Id: I1fdfa09633bfbe385a191a95b605e1dbcf011768
2026-04-27 20:15:38 +08:00
zhicong666-bytedance
7124b18baa docs(skills): clarify minutes routing semantics (#591) 2026-04-27 20:06:29 +08:00
calendar-assistant
78d92de6af feat: add calendar update shortcut (#678)
Change-Id: Ie2d4bde6cd28bbf4d7946db38c5c9be13edc6ba9
2026-04-27 19:27:20 +08:00
fangshuyu-768
8ec95a4e39 docs(lark-drive): add missing import command examples (#669)
Add example commands for file types declared in the supported-conversions
table but absent from the command examples section: .docx/.doc, .txt,
.html, .xls -> sheet, and .csv -> sheet.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:25:30 +08:00
sang-neo03
fe9dc4ce6a fix(strict-mode): reject explicit --as instead of silently overriding it (#673)
* fix(strict-mode): reject explicit --as instead of silently overriding it

ResolveAs checked strict mode before the --as flag, so `--as bot` under strict=user
  was silently rewritten to user. Reorder so explicit --as is returned as-is and CheckStrictMode rejects the conflict (exit=2). Implicit paths (--as auto / unset) are still forced by
   strict mode.

* fix(strict-mode): fix CI
2026-04-27 15:18:35 +08:00
Schumi Lin
1e2144ee08 docs(readme): add Project (Meegle) to Features table (#660)
Project management for Lark/Feishu is provided by the standalone
meegle-cli (https://github.com/larksuite/meegle-cli), which requires a
separate install. Surface it in the Features table so users can
discover the capability without expecting it to ship inside lark-cli.
2026-04-27 01:37:26 +08:00
zkh-bytedance
20fba1e601 chore(whiteboard): Manual disable edge case for svg compatible (#661) 2026-04-25 21:36:40 +08:00
arnold9672
97f817d088 feat(im): add at-chatter-ids filter to +messages-search (#612)
Add --at-chatter-ids flag to shortcuts/im/im_messages_search.go that
passes filter.at_chatter_ids to the search API, restricting results to
messages that @mention any of the given user open_ids. Messages that
2026-04-25 20:05:14 +08:00
sang-neo03
ddf6f0cb7d feat(pagination): preserve pagination state on truncation and natural… (#659)
* feat(pagination): preserve pagination state on truncation and natural end

* feat(pagination): drop page_token from merged output to reflect aggregate view
2026-04-25 17:54:52 +08:00
shifengjuan-dev
834a899e2b feat(lark-im): add chat.members.bots to skill docs (#616)
- Add chat.members.bots entry under chat.members API resources
- Add chat.members.bots -> im:chat.members:read scope mapping

Change-Id: I57039a9a8649d794bbda84a1e41fae9cc31d570a
2026-04-25 16:23:03 +08:00
liujinkun2025
aa48d70d7a feat(drive): add +search shortcut with flat filter flags (#658)
Expose doc_wiki/search v2 under the drive domain via explicit flags
(--query, --edited-since, --commented-since, --opened-since,
--created-since, --mine, --creator-ids, --doc-types, --folder-tokens,
--space-ids, ...) instead of a nested JSON filter, so natural-language
queries from AI agents map 1:1 to discrete flags.

Time handling:
- my_edit_time and my_comment_time are snapped to the hour (floor/ceil)
  with a stderr notice, since those fields are aggregated at hour
  granularity server-side. create_time passes through as-is.
- open_time has a server-side 3-month cap per request. When
  --opened-since / --opened-until span exceeds 90 days, the CLI narrows
  the request to the most recent 90-day slice and emits a stderr notice
  listing every remaining slice's --opened-* values so the agent can
  re-invoke for older ranges. Spans over 365 days are rejected up front
  to bound runaway slicing.

Flag ergonomics:
- --doc-types accepts mixed case; values are normalized to upper case
  before validation and before being sent to the server.
- --sort default is translated to the server enum DEFAULT_TYPE (every
  other sort value upper-cases 1:1).

Error hints:
- Lark code 99992351 (referenced open_id outside the app's contact
  visibility) is enriched with a +search-specific hint that
  distinguishes API scope from contact visibility and points at
  --creator-ids / --sharer-ids as the likely source.

Skill docs:
- new reference at skills/lark-drive/references/lark-drive-search.md,
  including the open_time slicing protocol and the paginate-within-
  slice-before-switching agent playbook.
- lark-drive/SKILL.md routes resource-discovery to drive +search.
- lark-doc/SKILL.md and lark-doc-search.md mark docs +search as
  deprecated and point users at drive +search.

Change-Id: I36d620045809b448446d4fdbdfa923b05794da19
2026-04-25 16:22:35 +08:00
chanthuang
2e7a11a8e8 feat(mail): support sharing emails to IM chats (#637)
* feat(mail): add +share-to-chat shortcut to share emails as IM cards

Two-step API (create share token → send card) wrapped in a single
shortcut. Supports message-id/thread-id, five receive-id-type variants
(chat_id, open_id, user_id, union_id, email), and dry-run mode.

Change-Id: Ic7b8c01c0d25fef262f35be92555f1fd019bd679
Co-Authored-By: AI

* fix(mail): regenerate SKILL.md from skill-template instead of manual edit

Add missing safety rule 8 (draft link rule) to skill-template/domains/mail.md
so it survives regeneration. SKILL.md is now produced by `make gen-skills`
in the registry repo rather than hand-edited.

Change-Id: I9cf3605deae8b6de2042e40819fedc304967e78e
Co-Authored-By: AI

* fix(mail): add docstrings and use real validation path in tests

- Add Go doc comments to exported symbols for docstring coverage
- Rewrite tests to exercise MailShareToChat.Validate via RuntimeContext
  instead of duplicating validation logic
- Replace hand-rolled containsStr with strings.Contains
- Add httpmock stubs for execute and error path tests

Change-Id: Ic781494f61e9e844224185844bce7b0c48e8e200
Co-Authored-By: AI

* test(mail): add dry-run E2E test for +share-to-chat

Validate request shape (method, URL, mailbox path) under --dry-run
with fake credentials. Covers message-id, thread-id, and custom
mailbox variants.

Change-Id: Iae87bf141cbe4f312d3e9b1fca4ba175052c5c35
Co-Authored-By: AI

* fix(mail): include request body and params in dry-run output

DryRun now mirrors Execute: the share-token POST shows message_id or
thread_id, and the send POST shows receive_id_type and receive_id.
E2E test updated to assert these fields. Also fix strconv.Itoa usage.

Change-Id: I00f8770fd5a12b7354986c5e5077f97cfe5d6653

* style(mail): gofmt dry-run test file

Change-Id: I47dc6a9a47252dcfb7853737f88dfdaef65a0ae7

* test(mail): assert exact API call count in dry-run test

Change-Id: I9f4a1a183b55d03f5248eb4adddfddb08037ca95
2026-04-24 21:11:48 +08:00
49 changed files with 4313 additions and 2671 deletions

View File

@@ -2,6 +2,27 @@
All notable changes to this project will be documented in this file.
## [v1.0.20] - 2026-04-27
### Features
- **drive**: Add `+search` shortcut with flat filter flags (#658)
- **mail**: Support sharing emails to IM chats via `+share-to-chat` (#637)
- **calendar**: Add `+update` shortcut (#678)
- **im**: Add `--at-chatter-ids` filter to `+messages-search` (#612)
- **pagination**: Preserve pagination state on truncation and natural end (#659)
- **lark-im**: Add `chat.members.bots` to skill docs (#616)
### Bug Fixes
- **strict-mode**: Reject explicit `--as` instead of silently overriding it (#673)
- **whiteboard**: Manual disable edge case for svg compatibility (#661)
### Documentation
- **lark-drive**: Add missing import command examples (#669)
- **readme**: Add Project (Meegle) to Features table (#660)
## [v1.0.19] - 2026-04-24
### Features

View File

@@ -39,6 +39,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 🕐 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 and indicators. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
## Installation & Quick Start

View File

@@ -39,6 +39,7 @@
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐和指标 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
## 安装与快速开始

View File

@@ -149,20 +149,6 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
stderr.Reset()
}
func parseDryRunJSON(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
out := stdout.String()
const prefix = "=== Dry Run ===\n"
if !strings.HasPrefix(out, prefix) {
t.Fatalf("expected dry-run prefix, got:\n%s", out)
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(strings.TrimPrefix(out, prefix)), &payload); err != nil {
t.Fatalf("failed to parse dry-run payload: %v\nstdout: %s", err, out)
}
return payload
}
// --- api command ---
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
@@ -402,7 +388,25 @@ func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *
}
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentity(t *testing.T) {
func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
@@ -410,16 +414,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentit
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
payload := parseDryRunJSON(t, stdout)
if got := payload["as"]; got != "bot" {
t.Fatalf("dry-run as = %v, want bot", got)
}
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
}
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
@@ -439,7 +441,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
})
}
func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t *testing.T) {
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
@@ -447,16 +449,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
})
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
payload := parseDryRunJSON(t, stdout)
if got := payload["as"]; got != "bot" {
t.Fatalf("dry-run as = %v, want bot", got)
}
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
}
// --- shortcut command ---

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -208,7 +208,7 @@ func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
ac, errBuf := newTestAPIClient(t, rt)
_, err := ac.PaginateAll(context.Background(), RawApiRequest{
result, err := ac.PaginateAll(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "bot",
@@ -223,6 +223,57 @@ func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
if !strings.Contains(errBuf.String(), "reached page limit (2), stopping. Use --page-all --page-limit 0 to fetch all pages.") {
t.Errorf("expected page limit log, got: %s", errBuf.String())
}
// Truncation must surface in the merged output: has_more stays true so
// callers can detect loss. page_token is intentionally dropped from the
// aggregate view — to fetch more, re-run with a larger --page-limit.
resultMap, _ := result.(map[string]interface{})
data, _ := resultMap["data"].(map[string]interface{})
if hasMore, _ := data["has_more"].(bool); !hasMore {
t.Errorf("expected has_more=true when page limit truncates, got false")
}
if _, exists := data["page_token"]; exists {
t.Errorf("expected page_token to be dropped from merged output, got %v", data["page_token"])
}
}
func TestPaginateAll_NaturalEndClearsPageToken(t *testing.T) {
apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
apiCalls++
hasMore := apiCalls < 2
body := map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
"has_more": hasMore,
},
}
if hasMore {
body["data"].(map[string]interface{})["page_token"] = "next"
}
return jsonResponse(body), nil
})
ac, _ := newTestAPIClient(t, rt)
result, err := ac.PaginateAll(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "bot",
}, PaginationOptions{PageLimit: 10, PageDelay: 0})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
resultMap, _ := result.(map[string]interface{})
data, _ := resultMap["data"].(map[string]interface{})
if hasMore, _ := data["has_more"].(bool); hasMore {
t.Errorf("expected has_more=false at natural end, got true")
}
if _, exists := data["page_token"]; exists {
t.Errorf("expected page_token absent at natural end, got %v", data["page_token"])
}
}
func TestBuildApiReq_QueryParams(t *testing.T) {

View File

@@ -71,7 +71,18 @@ func mergePagedResults(w io.Writer, results []interface{}) interface{} {
mergedData[k] = v
}
mergedData[arrayField] = merged
mergedData["has_more"] = false
// Surface the last page's real has_more so callers can detect truncation
// when --page-limit stops the loop before the API is exhausted. Page tokens
// are intentionally dropped: the merged view is an aggregate, not a resume
// cursor — to fetch more, re-run with a larger --page-limit.
lastHasMore := false
if lastMap, ok := results[len(results)-1].(map[string]interface{}); ok {
if lastData, ok := lastMap["data"].(map[string]interface{}); ok {
lastHasMore, _ = lastData["has_more"].(bool)
}
}
mergedData["has_more"] = lastHasMore
delete(mergedData, "page_token")
delete(mergedData, "next_page_token")

View File

@@ -60,20 +60,22 @@ func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core.Identity) core.Identity {
f.IdentityAutoDetected = false
// Strict mode: force identity regardless of flags or config.
if forced := f.ResolveStrictMode(ctx).ForcedIdentity(); forced != "" {
f.ResolvedIdentity = forced
return forced
}
if cmd != nil && cmd.Flags().Changed("as") {
if flagAs != "auto" {
if flagAs != core.AsAuto {
f.ResolvedIdentity = flagAs
return flagAs
}
// --as auto: fall through to auto-detect
}
mode := f.ResolveStrictMode(ctx)
// Strict mode forces implicit identity choices. Explicit --as user/bot is
// preserved above so CheckStrictMode can reject incompatible requests.
if forced := mode.ForcedIdentity(); forced != "" {
f.ResolvedIdentity = forced
return forced
}
hint := f.resolveIdentityHint(ctx)
if cmd == nil || !cmd.Flags().Changed("as") {
if defaultAs := resolveDefaultAsFromHint(hint); defaultAs != "" && defaultAs != core.AsAuto {

View File

@@ -350,6 +350,42 @@ func TestResolveAs_StrictModeUser_ForceUser(t *testing.T) {
}
}
func TestResolveAs_StrictModeUser_PreservesExplicitBot(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("bot", true)
got := f.ResolveAs(context.Background(), cmd, core.AsBot)
if got != core.AsBot {
t.Errorf("explicit bot should be preserved for strict-mode validation, got %s", got)
}
if err := f.CheckStrictMode(context.Background(), got); err == nil {
t.Fatal("expected strict-mode error for explicit bot in user mode")
}
}
func TestResolveAs_StrictModeBot_PreservesExplicitUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("user", true)
got := f.ResolveAs(context.Background(), cmd, core.AsUser)
if got != core.AsUser {
t.Errorf("explicit user should be preserved for strict-mode validation, got %s", got)
}
if err := f.CheckStrictMode(context.Background(), got); err == nil {
t.Fatal("expected strict-mode error for explicit user in bot mode")
}
}
func TestResolveAs_StrictModeUser_ExplicitAutoForcesUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", true)
got := f.ResolveAs(context.Background(), cmd, core.AsAuto)
if got != core.AsUser {
t.Errorf("--as auto should use strict-mode user identity, got %s", got)
}
}
func TestResolveAs_StrictModeBot_IgnoresDefaultAsUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", DefaultAs: "user", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.19",
"version": "1.0.20",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -607,6 +607,260 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// CalendarUpdate tests
// ---------------------------------------------------------------------------
func TestUpdate_PatchEventOnly(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_update1",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_update1",
"summary": "Updated Meeting",
"start_time": map[string]interface{}{
"timestamp": "1742518800",
},
"end_time": map[string]interface{}{
"timestamp": "1742522400",
},
},
},
},
}
reg.Register(stub)
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_update1",
"--calendar-id", "cal_test123",
"--summary", "Updated Meeting",
"--description", "Updated description",
"--start", "2025-03-21T01:00:00+08:00",
"--end", "2025-03-21T02:00:00+08:00",
"--notify=false",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured patch body: %v", err)
}
if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" {
t.Fatalf("unexpected patch body: %#v", body)
}
if body["need_notification"] != false {
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
}
if !strings.Contains(stdout.String(), "evt_update1") {
t.Fatalf("stdout should contain event id, got: %s", stdout.String())
}
}
func TestUpdate_AddAttendees(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_update2/attendees",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_update2",
"--calendar-id", "cal_test123",
"--add-attendee-ids", "ou_user1,oc_group1,omm_room1",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCalendarCapturedBody(t, stub)
attendees, _ := body["attendees"].([]interface{})
if !calendarBodyHasAttendee(attendees, "user", "user_id", "ou_user1") ||
!calendarBodyHasAttendee(attendees, "chat", "chat_id", "oc_group1") ||
!calendarBodyHasAttendee(attendees, "resource", "room_id", "omm_room1") {
t.Fatalf("unexpected add attendees body: %#v", body)
}
}
func TestUpdate_RemoveAttendees(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_update3/attendees/batch_delete",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_update3",
"--calendar-id", "cal_test123",
"--remove-attendee-ids", "ou_user1,oc_group1,omm_room1",
"--notify=false",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCalendarCapturedBody(t, stub)
deleteIDs, _ := body["delete_ids"].([]interface{})
if body["need_notification"] != false {
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
}
if !calendarBodyHasAttendee(deleteIDs, "user", "user_id", "ou_user1") ||
!calendarBodyHasAttendee(deleteIDs, "chat", "chat_id", "oc_group1") ||
!calendarBodyHasAttendee(deleteIDs, "resource", "room_id", "omm_room1") {
t.Fatalf("unexpected remove attendees body: %#v", body)
}
}
func TestUpdate_CombinedPatchRemoveAdd(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
patchStub := &httpmock.Stub{
Method: "PATCH",
URL: "/events/evt_update4",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_update4", "summary": "Combined"}},
},
}
removeStub := &httpmock.Stub{
Method: "POST",
URL: "/events/evt_update4/attendees/batch_delete",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
}
addStub := &httpmock.Stub{
Method: "POST",
URL: "/events/evt_update4/attendees",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
}
reg.Register(patchStub)
reg.Register(removeStub)
reg.Register(addStub)
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_update4",
"--summary", "Combined",
"--remove-attendee-ids", "ou_old",
"--add-attendee-ids", "ou_new",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(patchStub.CapturedBody) == 0 || len(removeStub.CapturedBody) == 0 || len(addStub.CapturedBody) == 0 {
t.Fatalf("expected patch, remove, and add requests to be captured")
}
}
func TestUpdate_DryRun_MultiStep(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_dry",
"--calendar-id", "cal_test123",
"--summary", "Dry",
"--remove-attendee-ids", "omm_oldroom",
"--add-attendee-ids", "ou_new,omm_newroom",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"PATCH", "batch_delete", "attendees", "omm_oldroom", "omm_newroom"} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run should contain %q, got: %s", want, out)
}
}
}
func TestUpdate_Validation(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{
name: "no fields",
args: []string{"+update", "--event-id", "evt_1", "--as", "bot"},
want: "nothing to update",
},
{
name: "invalid attendee",
args: []string{"+update", "--event-id", "evt_1", "--add-attendee-ids", "bad", "--as", "bot"},
want: "invalid attendee id format",
},
{
name: "duplicate add remove",
args: []string{"+update", "--event-id", "evt_1", "--add-attendee-ids", "ou_same", "--remove-attendee-ids", "ou_same", "--as", "bot"},
want: "appears in both",
},
{
name: "start without end",
args: []string{"+update", "--event-id", "evt_1", "--start", "2025-03-21T00:00:00+08:00", "--as", "bot"},
want: "must be specified together",
},
{
name: "end before start",
args: []string{"+update", "--event-id", "evt_1", "--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T09:00:00+08:00", "--as", "bot"},
want: "end time must be after start time",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, CalendarUpdate, tc.args, f, nil)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected error containing %q, got %v", tc.want, err)
}
})
}
}
func decodeCalendarCapturedBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
}
return body
}
func calendarBodyHasAttendee(items []interface{}, typ, key, value string) bool {
for _, item := range items {
m, _ := item.(map[string]interface{})
if m["type"] == typ && m[key] == value {
return true
}
}
return false
}
// ---------------------------------------------------------------------------
// CalendarAgenda tests
// ---------------------------------------------------------------------------
@@ -627,6 +881,11 @@ func TestCalendarShortcuts_RequireLoginUnlessExplicitBot(t *testing.T) {
shortcut: CalendarCreate,
args: []string{"+create", "--summary", "Test Meeting", "--start", "2025-03-21T00:00:00+08:00", "--end", "2025-03-21T01:00:00+08:00"},
},
{
name: "update",
shortcut: CalendarUpdate,
args: []string{"+update", "--event-id", "evt_1", "--summary", "Updated"},
},
{
name: "freebusy",
shortcut: CalendarFreebusy,
@@ -1710,17 +1969,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
// Shortcuts() registration test
// ---------------------------------------------------------------------------
func TestShortcuts_Returns6(t *testing.T) {
func TestShortcuts_Returns7(t *testing.T) {
shortcuts := Shortcuts()
if len(shortcuts) != 6 {
t.Fatalf("expected 6 shortcuts, got %d", len(shortcuts))
if len(shortcuts) != 7 {
t.Fatalf("expected 7 shortcuts, got %d", len(shortcuts))
}
names := map[string]bool{}
for _, s := range shortcuts {
names[s.Command] = true
}
for _, want := range []string{"+agenda", "+create", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
if !names[want] {
t.Errorf("missing shortcut %s", want)
}

View File

@@ -0,0 +1,384 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var CalendarUpdate = common.Shortcut{
Service: "calendar",
Command: "+update",
Description: "Update a calendar event and incrementally add or remove attendees",
Risk: "write",
Scopes: []string{"calendar:calendar.event:update"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "event-id", Desc: "event ID to update", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "summary", Desc: "event title"},
{Name: "description", Desc: "event description"},
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
{Name: "add-attendee-ids", Desc: "attendee IDs to add, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "remove-attendee-ids", Desc: "attendee IDs to remove, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "notify", Type: "bool", Default: "true", Desc: "send update notification to attendees"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateCalendarUpdate(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return dryRunCalendarUpdate(runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeCalendarUpdate(ctx, runtime)
},
}
func validateCalendarUpdate(runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
for _, flag := range []string{"event-id", "summary", "description", "rrule", "calendar-id", "start", "end", "add-attendee-ids", "remove-attendee-ids"} {
if val := runtime.Str(flag); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
}
}
}
if strings.TrimSpace(runtime.Str("event-id")) == "" {
return common.FlagErrorf("specify --event-id")
}
if _, _, err := buildCalendarUpdateEventData(runtime); err != nil {
return err
}
if err := validateCalendarUpdateAttendees(runtime); err != nil {
return err
}
if !hasCalendarUpdateOperation(runtime) {
return common.FlagErrorf("nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
}
return nil
}
func validateCalendarUpdateAttendees(runtime *common.RuntimeContext) error {
addIDs, err := parseCalendarAttendeeIDs(runtime.Str("add-attendee-ids"))
if err != nil {
return err
}
removeIDs, err := parseCalendarAttendeeIDs(runtime.Str("remove-attendee-ids"))
if err != nil {
return err
}
removeSet := make(map[string]struct{}, len(removeIDs))
for _, id := range removeIDs {
removeSet[id] = struct{}{}
}
for _, id := range addIDs {
if _, ok := removeSet[id]; ok {
return output.ErrValidation("attendee id %q appears in both --add-attendee-ids and --remove-attendee-ids", id)
}
}
return nil
}
func hasCalendarUpdateOperation(runtime *common.RuntimeContext) bool {
if len(runtime.Str("add-attendee-ids")) > 0 || len(runtime.Str("remove-attendee-ids")) > 0 {
return true
}
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
return err == nil && hasEventFields && len(body) > 0
}
func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]interface{}, bool, error) {
body := map[string]interface{}{}
hasFields := false
for _, field := range []string{"summary", "description"} {
if runtime.Cmd.Flags().Changed(field) {
body[field] = runtime.Str(field)
hasFields = true
}
}
if runtime.Cmd.Flags().Changed("rrule") {
rrule := strings.TrimSpace(runtime.Str("rrule"))
if rrule != "" {
body["recurrence"] = rrule
hasFields = true
}
}
startChanged := runtime.Cmd.Flags().Changed("start")
endChanged := runtime.Cmd.Flags().Changed("end")
if startChanged != endChanged {
return nil, false, common.FlagErrorf("--start and --end must be specified together when updating event time")
}
if startChanged {
startTs, err := common.ParseTime(runtime.Str("start"))
if err != nil {
return nil, false, common.FlagErrorf("--start: %v", err)
}
endTs, err := common.ParseTime(runtime.Str("end"), "end")
if err != nil {
return nil, false, common.FlagErrorf("--end: %v", err)
}
s, err := strconv.ParseInt(startTs, 10, 64)
if err != nil {
return nil, false, common.FlagErrorf("invalid start time: %v", err)
}
e, err := strconv.ParseInt(endTs, 10, 64)
if err != nil {
return nil, false, common.FlagErrorf("invalid end time: %v", err)
}
if e <= s {
return nil, false, common.FlagErrorf("end time must be after start time")
}
body["start_time"] = map[string]string{"timestamp": startTs}
body["end_time"] = map[string]string{"timestamp": endTs}
hasFields = true
}
if hasFields {
body["need_notification"] = runtime.Bool("notify")
}
return body, hasFields, nil
}
func parseCalendarAttendeeIDs(attendeesStr string) ([]string, error) {
if strings.TrimSpace(attendeesStr) == "" {
return nil, nil
}
seen := map[string]struct{}{}
var ids []string
for _, raw := range strings.Split(attendeesStr, ",") {
id := strings.TrimSpace(raw)
if id == "" {
continue
}
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func attendeeDeleteIDs(attendeesStr string) ([]map[string]string, error) {
ids, err := parseCalendarAttendeeIDs(attendeesStr)
if err != nil {
return nil, err
}
deleteIDs := make([]map[string]string, 0, len(ids))
for _, id := range ids {
switch {
case strings.HasPrefix(id, "oc_"):
deleteIDs = append(deleteIDs, map[string]string{"type": "chat", "chat_id": id})
case strings.HasPrefix(id, "omm_"):
deleteIDs = append(deleteIDs, map[string]string{"type": "resource", "room_id": id})
case strings.HasPrefix(id, "ou_"):
deleteIDs = append(deleteIDs, map[string]string{"type": "user", "user_id": id})
default:
return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
}
}
return deleteIDs, nil
}
func calendarUpdateIDs(runtime *common.RuntimeContext) (calendarID string, eventID string) {
calendarID = strings.TrimSpace(runtime.Str("calendar-id"))
if calendarID == "" {
calendarID = PrimaryCalendarIDStr
}
eventID = strings.TrimSpace(runtime.Str("event-id"))
return calendarID, eventID
}
func calendarUpdateEventPath(calendarID, eventID string) string {
return fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarID), validate.EncodePathSegment(eventID))
}
func calendarUpdateAttendeesPath(calendarID, eventID string) string {
return calendarUpdateEventPath(calendarID, eventID) + "/attendees"
}
func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
calendarID, eventID := calendarUpdateIDs(runtime)
displayCalendarID := calendarID
if displayCalendarID == "" || displayCalendarID == "primary" {
displayCalendarID = "<primary>"
}
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
d := common.NewDryRunAPI().Set("calendar_id", displayCalendarID).Set("event_id", eventID)
opCount := 0
if hasEventFields {
opCount++
}
if strings.TrimSpace(runtime.Str("remove-attendee-ids")) != "" {
opCount++
}
if strings.TrimSpace(runtime.Str("add-attendee-ids")) != "" {
opCount++
}
if opCount > 1 {
d.Desc("multi-step update: event fields, attendee removal, and attendee addition run in order when requested")
}
steps := 0
if hasEventFields {
steps++
d.PATCH("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
Desc(fmt.Sprintf("[%d] Update event fields", steps)).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
if removeStr := runtime.Str("remove-attendee-ids"); strings.TrimSpace(removeStr) != "" {
deleteIDs, err := attendeeDeleteIDs(removeStr)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
steps++
d.POST("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id/attendees/batch_delete").
Desc(fmt.Sprintf("[%d] Remove attendees", steps)).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(map[string]interface{}{"delete_ids": deleteIDs, "need_notification": runtime.Bool("notify")})
}
if addStr := runtime.Str("add-attendee-ids"); strings.TrimSpace(addStr) != "" {
attendees, err := parseAttendees(addStr, "")
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
steps++
d.POST("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id/attendees").
Desc(fmt.Sprintf("[%d] Add attendees", steps)).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(map[string]interface{}{"attendees": attendees, "need_notification": runtime.Bool("notify")})
}
return d
}
func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) error {
calendarID, eventID := calendarUpdateIDs(runtime)
if eventID == "" {
return output.ErrValidation("specify --event-id")
}
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
if err != nil {
return err
}
completed := []string{}
event := map[string]interface{}{}
if hasEventFields {
data, err := runtime.CallAPI("PATCH", calendarUpdateEventPath(calendarID, eventID), map[string]interface{}{"user_id_type": "open_id"}, body)
err = wrapPredefinedError(err)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to update event %s: %v", eventID, err)
}
if v, _ := data["event"].(map[string]interface{}); v != nil {
event = v
}
completed = append(completed, "event")
}
removedCount := 0
if removeStr := runtime.Str("remove-attendee-ids"); strings.TrimSpace(removeStr) != "" {
deleteIDs, err := attendeeDeleteIDs(removeStr)
if err != nil {
return err
}
_, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID)+"/batch_delete",
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{"delete_ids": deleteIDs, "need_notification": runtime.Bool("notify")})
err = wrapPredefinedError(err)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to remove attendees from event %s after completed steps %v: %v", eventID, completed, err)
}
removedCount = len(deleteIDs)
completed = append(completed, "remove_attendees")
}
addedCount := 0
if addStr := runtime.Str("add-attendee-ids"); strings.TrimSpace(addStr) != "" {
attendees, err := parseAttendees(addStr, "")
if err != nil {
return output.ErrValidation("invalid attendee id: %v", err)
}
_, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID),
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{"attendees": attendees, "need_notification": runtime.Bool("notify")})
err = wrapPredefinedError(err)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees to event %s after completed steps %v: %v", eventID, completed, err)
}
addedCount = len(attendees)
}
result := calendarUpdateResult(eventID, event, addedCount, removedCount)
runtime.OutFormat(result, nil, func(w io.Writer) {
output.PrintTable(w, []map[string]interface{}{result})
fmt.Fprintln(w, "\nEvent updated successfully")
})
return nil
}
func calendarUpdateResult(eventID string, event map[string]interface{}, addedCount, removedCount int) map[string]interface{} {
result := map[string]interface{}{
"event_id": eventID,
"attendees_added_count": addedCount,
"attendees_removed_count": removedCount,
}
if summary, _ := event["summary"].(string); summary != "" {
result["summary"] = summary
}
if description, _ := event["description"].(string); description != "" {
result["description"] = description
}
if start := formatCalendarEventTime(event["start_time"]); start != "" {
result["start"] = start
}
if end := formatCalendarEventTime(event["end_time"]); end != "" {
result["end"] = end
}
return result
}
func formatCalendarEventTime(v interface{}) string {
m, _ := v.(map[string]interface{})
if m == nil {
return ""
}
if tsStr, _ := m["timestamp"].(string); tsStr != "" {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
return time.Unix(ts, 0).Local().Format(time.RFC3339)
}
}
if dt, _ := m["datetime"].(string); dt != "" {
return dt
}
if date, _ := m["date"].(string); date != "" {
return date
}
return ""
}

View File

@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
CalendarAgenda,
CalendarCreate,
CalendarUpdate,
CalendarFreebusy,
CalendarRoomFind,
CalendarRsvp,

View File

@@ -0,0 +1,806 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// driveSearchErrUserNotVisible is the Lark service code returned by
// doc_wiki/search when an open_id referenced in --creator-ids / --sharer-ids
// falls outside the app's user-visibility scope (different from the
// search:docs:read API scope).
const driveSearchErrUserNotVisible = 99992351
// open_time has a server-side cap of 3 months per request. Rather than
// reject or silently clamp, we narrow this request to the most recent
// 3-month slice and list the remaining slices in a stderr notice so the
// agent can re-invoke for older ranges.
const (
driveSearchSliceDays = 90 // one slice = server-side 3-month cap
driveSearchMaxOpenedSpanDays = 365 // hard cap: reject --opened-* spans beyond ~1 year
)
var driveSearchSortValues = []string{
"default",
"edit_time",
"edit_time_asc",
"open_time",
"create_time",
}
var driveSearchDocTypeSet = map[string]struct{}{
"DOC": {}, "SHEET": {}, "BITABLE": {}, "MINDNOTE": {}, "FILE": {},
"WIKI": {}, "DOCX": {}, "FOLDER": {}, "CATALOG": {}, "SLIDES": {}, "SHORTCUT": {},
}
// driveSearchHourAggregatedFields lists filter keys the server aggregates at
// hour granularity. We pre-snap start/end and emit a stderr notice so callers
// see what was sent and why.
var driveSearchHourAggregatedFields = map[string]struct{}{
"my_edit_time": {},
"my_comment_time": {},
}
// Server caps list filters at 20 entries each. We reject above-cap input
// locally so users and agents get a named-flag error instead of an opaque
// server-side failure or truncated result.
const (
driveSearchMaxChatIDs = 20
driveSearchMaxSharerIDs = 20
)
// DriveSearch searches docs/wikis via the v2 doc_wiki/search API using flat
// flags instead of a nested JSON filter, which is friendlier for AI agents and
// `--help` readers.
var DriveSearch = common.Shortcut{
Service: "drive",
Command: "+search",
Description: "Search Lark docs, Wiki, and spreadsheet files with flat filters (Search v2: doc_wiki/search)",
Risk: "read",
Scopes: []string{"search:docs:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I created (uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated creator open_ids; mutually exclusive with --mine"},
{Name: "edited-since", Desc: "start of [my edited] time window (e.g. 7d, 1m, 1y, 2026-04-01, RFC3339, unix seconds)"},
{Name: "edited-until", Desc: "end of [my edited] time window"},
{Name: "commented-since", Desc: "start of [my commented] time window"},
{Name: "commented-until", Desc: "end of [my commented] time window"},
{Name: "opened-since", Desc: "start of [my opened] time window"},
{Name: "opened-until", Desc: "end of [my opened] time window"},
{Name: "created-since", Desc: "start of [document created] time window"},
{Name: "created-until", Desc: "end of [document created] time window"},
{Name: "doc-types", Desc: "comma-separated types: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut"},
{Name: "folder-tokens", Desc: "comma-separated folder tokens (doc-only; mutually exclusive with --space-ids)"},
{Name: "space-ids", Desc: "comma-separated wiki space IDs (wiki-only; mutually exclusive with --folder-tokens)"},
{Name: "chat-ids", Desc: "comma-separated chat IDs"},
{Name: "sharer-ids", Desc: "comma-separated sharer open_ids"},
{Name: "only-title", Type: "bool", Desc: "match titles only"},
{Name: "only-comment", Type: "bool", Desc: "search comments only"},
{Name: "sort", Desc: "sort type", Enum: driveSearchSortValues},
{Name: "page-token", Desc: "pagination token from a previous response"},
{Name: "page-size", Default: "15", Desc: "page size (1-20, default 15)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveSearchIDs(readDriveSearchSpec(runtime))
},
Tips: []string{
"Time flags accept relative (e.g. 7d, 1m, 1y), absolute (2026-04-01, RFC3339), or unix seconds.",
"my_edit_time and my_comment_time are hour-aggregated server-side; sub-hour inputs are snapped and a notice is printed to stderr.",
"Use --mine for a quick \"docs I created\" filter. For other people, use --creator-ids ou_xxx,ou_yyy.",
"--folder-tokens limits to doc-only search; --space-ids limits to wiki-only. They cannot be combined.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := readDriveSearchSpec(runtime)
reqBody, notices, err := buildDriveSearchRequest(spec, runtime.UserOpenId(), time.Now())
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
for _, n := range notices {
fmt.Fprintln(runtime.IO().ErrOut, n)
}
return common.NewDryRunAPI().
POST("/open-apis/search/v2/doc_wiki/search").
Body(reqBody)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := readDriveSearchSpec(runtime)
reqBody, notices, err := buildDriveSearchRequest(spec, runtime.UserOpenId(), time.Now())
if err != nil {
return err
}
for _, n := range notices {
fmt.Fprintln(runtime.IO().ErrOut, n)
}
data, err := callDriveSearchAPI(runtime, reqBody)
if err != nil {
return err
}
items, _ := data["res_units"].([]interface{})
normalizedItems := addDriveSearchIsoTimeFields(items)
resultData := map[string]interface{}{
"total": data["total"],
"has_more": data["has_more"],
"page_token": data["page_token"],
"results": normalizedItems,
}
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
renderDriveSearchTable(w, data, normalizedItems)
})
return nil
},
}
// driveSearchSpec is the parsed flag set for a single +search invocation.
type driveSearchSpec struct {
Query string
PageToken string
PageSize string
Mine bool
CreatorIDs []string
EditedSince string
EditedUntil string
CommentedSince string
CommentedUntil string
OpenedSince string
OpenedUntil string
CreatedSince string
CreatedUntil string
DocTypes []string
FolderTokens []string
SpaceIDs []string
ChatIDs []string
SharerIDs []string
OnlyTitle bool
OnlyComment bool
Sort string
}
func readDriveSearchSpec(runtime *common.RuntimeContext) driveSearchSpec {
return driveSearchSpec{
Query: runtime.Str("query"),
PageToken: runtime.Str("page-token"),
PageSize: runtime.Str("page-size"),
Mine: runtime.Bool("mine"),
CreatorIDs: common.SplitCSV(runtime.Str("creator-ids")),
EditedSince: runtime.Str("edited-since"),
EditedUntil: runtime.Str("edited-until"),
CommentedSince: runtime.Str("commented-since"),
CommentedUntil: runtime.Str("commented-until"),
OpenedSince: runtime.Str("opened-since"),
OpenedUntil: runtime.Str("opened-until"),
CreatedSince: runtime.Str("created-since"),
CreatedUntil: runtime.Str("created-until"),
DocTypes: upperAll(common.SplitCSV(runtime.Str("doc-types"))),
FolderTokens: common.SplitCSV(runtime.Str("folder-tokens")),
SpaceIDs: common.SplitCSV(runtime.Str("space-ids")),
ChatIDs: common.SplitCSV(runtime.Str("chat-ids")),
SharerIDs: common.SplitCSV(runtime.Str("sharer-ids")),
OnlyTitle: runtime.Bool("only-title"),
OnlyComment: runtime.Bool("only-comment"),
Sort: strings.TrimSpace(runtime.Str("sort")),
}
}
// buildDriveSearchRequest turns the parsed spec into the API request body and a
// list of stderr notices (e.g. hour-snap adjustments). It does all validation
// that depends on the combination of flag values.
func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.Time) (map[string]interface{}, []string, error) {
if spec.Mine && len(spec.CreatorIDs) > 0 {
return nil, nil, output.ErrValidation("cannot combine --mine and --creator-ids")
}
if len(spec.FolderTokens) > 0 && len(spec.SpaceIDs) > 0 {
return nil, nil, output.ErrValidation("cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
}
if spec.Mine && userOpenID == "" {
return nil, nil, output.ErrValidation("--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config")
}
if err := validateDocTypes(spec.DocTypes); err != nil {
return nil, nil, err
}
pageSize, err := parseDriveSearchPageSize(spec.PageSize)
if err != nil {
return nil, nil, err
}
request := map[string]interface{}{
"query": spec.Query,
"page_size": pageSize,
}
if spec.PageToken != "" {
request["page_token"] = spec.PageToken
}
filter := map[string]interface{}{}
var notices []string
// open_time is capped at 3 months server-side; if the user's window is
// longer, narrow this request and emit a notice with the remaining slices.
if n, err := clampOpenedTimeWindow(&spec, now); err != nil {
return nil, nil, err
} else if n != "" {
notices = append(notices, n)
}
// Creator identity.
switch {
case spec.Mine:
filter["creator_ids"] = []string{userOpenID}
case len(spec.CreatorIDs) > 0:
filter["creator_ids"] = spec.CreatorIDs
}
// Time dimensions — each fills at most one filter key; hour-aggregated ones
// also contribute notices.
timeDims := []struct {
key string
since, til string
}{
{"my_edit_time", spec.EditedSince, spec.EditedUntil},
{"my_comment_time", spec.CommentedSince, spec.CommentedUntil},
{"open_time", spec.OpenedSince, spec.OpenedUntil},
{"create_time", spec.CreatedSince, spec.CreatedUntil},
}
for _, d := range timeDims {
rng, dimNotices, err := buildTimeRangeFilter(d.key, d.since, d.til, now)
if err != nil {
return nil, nil, err
}
if rng != nil {
filter[d.key] = rng
}
notices = append(notices, dimNotices...)
}
// Scalar scope filters.
if len(spec.DocTypes) > 0 {
filter["doc_types"] = spec.DocTypes
}
if len(spec.ChatIDs) > 0 {
filter["chat_ids"] = spec.ChatIDs
}
if len(spec.SharerIDs) > 0 {
filter["sharer_ids"] = spec.SharerIDs
}
if spec.OnlyTitle {
filter["only_title"] = true
}
if spec.OnlyComment {
filter["only_comment"] = true
}
if spec.Sort != "" {
// Server enum uses "DEFAULT_TYPE" for the default sort; every other
// value upper-cases 1:1.
sortType := strings.ToUpper(spec.Sort)
if sortType == "DEFAULT" {
sortType = "DEFAULT_TYPE"
}
filter["sort_type"] = sortType
}
// Wiki-/folder-scoped variants: keep the shared filter, then add the
// scope-specific key only into the correct side.
switch {
case len(spec.FolderTokens) > 0:
docFilter := cloneDriveSearchFilter(filter)
docFilter["folder_tokens"] = spec.FolderTokens
request["doc_filter"] = docFilter
case len(spec.SpaceIDs) > 0:
wikiFilter := cloneDriveSearchFilter(filter)
wikiFilter["space_ids"] = spec.SpaceIDs
request["wiki_filter"] = wikiFilter
default:
request["doc_filter"] = cloneDriveSearchFilter(filter)
request["wiki_filter"] = cloneDriveSearchFilter(filter)
}
return request, notices, nil
}
func parseDriveSearchPageSize(raw string) (int, error) {
if raw == "" {
return 15, nil
}
n, err := strconv.Atoi(raw)
if err != nil {
return 0, output.ErrValidation("--page-size must be a number, got %q", raw)
}
if n <= 0 {
return 15, nil
}
if n > 20 {
n = 20
}
return n, nil
}
// validateDriveSearchIDs checks open_id / chat_id format and enforces the
// 20-entry cap on chat_ids / sharer_ids before we build the API request,
// so misuse surfaces as a named-flag validation error rather than an opaque
// server-side failure or empty result.
func validateDriveSearchIDs(spec driveSearchSpec) error {
for _, id := range spec.CreatorIDs {
if _, err := common.ValidateUserID(id); err != nil {
return output.ErrValidation("--creator-ids %q: %s", id, err)
}
}
if n := len(spec.ChatIDs); n > driveSearchMaxChatIDs {
return output.ErrValidation("--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n)
}
for _, id := range spec.ChatIDs {
if _, err := common.ValidateChatID(id); err != nil {
return output.ErrValidation("--chat-ids %q: %s", id, err)
}
}
if n := len(spec.SharerIDs); n > driveSearchMaxSharerIDs {
return output.ErrValidation("--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n)
}
for _, id := range spec.SharerIDs {
if _, err := common.ValidateUserID(id); err != nil {
return output.ErrValidation("--sharer-ids %q: %s", id, err)
}
}
return nil
}
func validateDocTypes(values []string) error {
for _, v := range values {
// values are already upper-cased by readDriveSearchSpec; compare as-is
// so the filter we emit to the server matches what we validated.
if _, ok := driveSearchDocTypeSet[v]; !ok {
return output.ErrValidation("--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v)
}
}
return nil
}
// upperAll returns a copy of s with every element upper-cased.
func upperAll(s []string) []string {
if len(s) == 0 {
return s
}
out := make([]string, len(s))
for i, v := range s {
out[i] = strings.ToUpper(v)
}
return out
}
// clampOpenedTimeWindow enforces the server-side 3-month cap on open_time by
// narrowing --opened-since / --opened-until to the most recent slice and
// returning a notice that lists every remaining slice, so the agent can
// re-invoke for older ranges. When no clamping is needed, returns ("", nil).
//
// Rules:
// - no --opened-since: skip (no range filter at all)
// - only --opened-since or both set, span ≤ 90 days: skip
// - span in (90, 365] days: clamp current request; spec is mutated in place
// with RFC3339 values so buildTimeRangeFilter parses round-trip
// - span > 365 days: validation error (prevents runaway slice counts)
func clampOpenedTimeWindow(spec *driveSearchSpec, now time.Time) (string, error) {
if spec.OpenedSince == "" {
return "", nil
}
sinceUnix, err := parseTimeValue(spec.OpenedSince, now)
if err != nil {
return "", output.ErrValidation("invalid --opened-since %q: %s", spec.OpenedSince, err)
}
var untilUnix int64
if spec.OpenedUntil != "" {
untilUnix, err = parseTimeValue(spec.OpenedUntil, now)
if err != nil {
return "", output.ErrValidation("invalid --opened-until %q: %s", spec.OpenedUntil, err)
}
} else {
untilUnix = now.Unix()
}
if untilUnix <= sinceUnix {
// Malformed range; let buildTimeRangeFilter / server surface the error.
return "", nil
}
spanSecs := untilUnix - sinceUnix
sliceSecs := int64(driveSearchSliceDays) * 24 * 3600
if spanSecs <= sliceSecs {
return "", nil
}
maxSecs := int64(driveSearchMaxOpenedSpanDays) * 24 * 3600
if spanSecs > maxSecs {
return "", output.ErrValidation(
"--opened-* window spans %d days, exceeds the %d-day (1-year) maximum; narrow the range or run multiple queries",
spanSecs/86400, driveSearchMaxOpenedSpanDays,
)
}
// Build slices newest-to-oldest; last (oldest) slice may be shorter than 90d.
numSlices := int((spanSecs + sliceSecs - 1) / sliceSecs) // ceil
type sliceSpec struct{ start, end int64 }
slices := make([]sliceSpec, numSlices)
cursor := untilUnix
for i := 0; i < numSlices; i++ {
start := cursor - sliceSecs
if start < sinceUnix {
start = sinceUnix
}
slices[i] = sliceSpec{start: start, end: cursor}
cursor = start
}
fmtTime := func(unix int64) string { return time.Unix(unix, 0).Format(time.RFC3339) }
approxMonths := spanSecs / (30 * 24 * 3600)
var b strings.Builder
fmt.Fprintf(&b, "notice: --opened-* window spans %d days (~%d months), exceeds the server-side 3-month (%d-day) limit.\n",
spanSecs/86400, approxMonths, driveSearchSliceDays)
fmt.Fprintf(&b, " this query was narrowed to the most recent slice; %d slices total:\n", numSlices)
// Every slice — including the current one — prints concrete --opened-since
// / --opened-until values so an agent paginating slice 1 can copy them
// verbatim. Reusing the user's original relative time (e.g. "1y") would
// re-resolve against time.Now() on the next call and silently drift the
// window away from any --page-token issued for this call.
for i, s := range slices {
label := fmt.Sprintf("[slice %d/%d]", i+1, numSlices)
if i == 0 {
label = fmt.Sprintf("[slice %d/%d current]", i+1, numSlices)
}
// %-19s pads to "[slice N/M current]" (19 chars at the 5-slice cap).
fmt.Fprintf(&b, " %-19s --opened-since %s --opened-until %s\n",
label, fmtTime(s.start), fmtTime(s.end))
}
fmt.Fprint(&b, " pagination: paginate within a slice via --page-token using that slice's --opened-since / --opened-until values verbatim (NOT the original relative time like '1y' / '8m' — relative times re-resolve against time.Now() and would mismatch the page_token); switch to the next slice's --opened-* flags only after has_more=false, and do not carry --page-token across slices.")
// Rewrite spec so buildTimeRangeFilter emits the clamped window.
spec.OpenedSince = fmtTime(slices[0].start)
spec.OpenedUntil = fmtTime(slices[0].end)
return b.String(), nil
}
// buildTimeRangeFilter parses since/until for one dimension and applies hour
// snapping for server-aggregated fields. Returns nil range when both inputs
// are empty.
func buildTimeRangeFilter(key, since, until string, now time.Time) (map[string]interface{}, []string, error) {
if since == "" && until == "" {
return nil, nil, nil
}
_, hourAggregated := driveSearchHourAggregatedFields[key]
rng := map[string]interface{}{}
var notices []string
if since != "" {
unix, err := parseTimeValue(since, now)
if err != nil {
return nil, nil, output.ErrValidation("invalid --%s-since %q: %s", timeDimCLIName(key), since, err)
}
if hourAggregated && unix%3600 != 0 {
snapped := floorHour(unix)
notices = append(notices, formatHourSnapNotice(key, "start", unix, snapped))
unix = snapped
}
rng["start"] = unix
}
if until != "" {
unix, err := parseTimeValue(until, now)
if err != nil {
return nil, nil, output.ErrValidation("invalid --%s-until %q: %s", timeDimCLIName(key), until, err)
}
if hourAggregated && unix%3600 != 0 {
snapped := ceilHour(unix)
notices = append(notices, formatHourSnapNotice(key, "end", unix, snapped))
unix = snapped
}
rng["end"] = unix
}
return rng, notices, nil
}
// timeDimCLIName maps a filter key back to the CLI flag prefix, for error
// messages that say "--edited-since" rather than "my_edit_time.start".
func timeDimCLIName(key string) string {
switch key {
case "my_edit_time":
return "edited"
case "my_comment_time":
return "commented"
case "open_time":
return "opened"
case "create_time":
return "created"
}
return key
}
func formatHourSnapNotice(key, side string, before, after int64) string {
return fmt.Sprintf("notice: %s has hour-level granularity server-side; %s %s → %s",
key, side,
time.Unix(before, 0).Format("2006-01-02 15:04:05"),
time.Unix(after, 0).Format("2006-01-02 15:04:05"),
)
}
func floorHour(unix int64) int64 {
return unix - (unix % 3600)
}
func ceilHour(unix int64) int64 {
if unix%3600 == 0 {
return unix
}
return floorHour(unix) + 3600
}
var driveSearchRelativeRe = regexp.MustCompile(`^(\d+)([dmy])$`)
// parseTimeValue accepts relative (7d, 1m=30d, 1y=365d), absolute dates in a
// few common layouts, RFC3339, and raw unix seconds.
func parseTimeValue(input string, now time.Time) (int64, error) {
s := strings.TrimSpace(input)
if s == "" {
return 0, fmt.Errorf("empty value")
}
if m := driveSearchRelativeRe.FindStringSubmatch(s); m != nil {
n, _ := strconv.Atoi(m[1])
var days int
switch m[2] {
case "d":
days = n
case "m":
days = n * 30
case "y":
days = n * 365
}
return now.Add(-time.Duration(days) * 24 * time.Hour).Unix(), nil
}
layouts := []string{
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t.Unix(), nil
}
}
// Digit-only string at the end so "20260423" doesn't get misread as unix.
// Real unix seconds for recent times are 10 digits; be conservative and
// require length >= 10 to avoid matching YYYYMMDD. Mirror unixToISO8601's
// ms-vs-s heuristic: 13-digit / >= 1e12 inputs are epoch-millis and get
// normalized to seconds, otherwise a copy-pasted ms timestamp would
// silently parse as a year-57000 unix and then trip the 1-year cap with
// a misleading message.
if len(s) >= 10 {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
if n >= 1e12 {
n /= 1000
}
return n, nil
}
}
return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds")
}
func callDriveSearchAPI(runtime *common.RuntimeContext, reqBody map[string]interface{}) (map[string]interface{}, error) {
data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody)
if err != nil {
return nil, enrichDriveSearchError(err)
}
return data, nil
}
// enrichDriveSearchError adds a +search-specific hint for known opaque Lark
// codes; other errors pass through unchanged.
func enrichDriveSearchError(err error) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return err
}
if exitErr.Detail.Code != driveSearchErrUserNotVisible {
return err
}
detail := *exitErr.Detail
detail.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
return &output.ExitError{
Code: exitErr.Code,
Detail: &detail,
Err: exitErr.Err,
Raw: exitErr.Raw,
}
}
func cloneDriveSearchFilter(src map[string]interface{}) map[string]interface{} {
dst := make(map[string]interface{}, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
// renderDriveSearchTable mirrors the column layout of doc +search so the pretty
// output is consistent for users switching between the two.
func renderDriveSearchTable(w io.Writer, data map[string]interface{}, items []interface{}) {
if len(items) == 0 {
fmt.Fprintln(w, "No matching results found.")
return
}
htmlTagRe := regexp.MustCompile(`</?hb?>`)
var rows []map[string]interface{}
for _, item := range items {
u, _ := item.(map[string]interface{})
if u == nil {
continue
}
var rawTitle string
if s, ok := u["title_highlighted"].(string); ok && s != "" {
rawTitle = s
} else if s, ok := u["title"].(string); ok {
rawTitle = s
}
title := common.TruncateStr(htmlTagRe.ReplaceAllString(rawTitle, ""), 50)
resultMeta, _ := u["result_meta"].(map[string]interface{})
docTypes := ""
if resultMeta != nil {
docTypes = fmt.Sprintf("%v", resultMeta["doc_types"])
}
entityType := fmt.Sprintf("%v", u["entity_type"])
typeStr := docTypes
if typeStr == "" || typeStr == "<nil>" {
typeStr = entityType
}
var url, editTime string
if resultMeta != nil {
if s, ok := resultMeta["url"].(string); ok {
url = s
}
if s, ok := resultMeta["update_time_iso"].(string); ok {
editTime = s
}
}
if len(url) > 80 {
url = url[:80]
}
rows = append(rows, map[string]interface{}{
"type": typeStr,
"title": title,
"edit_time": editTime,
"url": url,
})
}
output.PrintTable(w, rows)
moreHint := ""
hasMore, _ := data["has_more"].(bool)
if hasMore {
moreHint = " (more available, use --format json to get page_token, then --page-token to paginate)"
}
fmt.Fprintf(w, "\n%d result(s)%s\n", len(rows), moreHint)
}
// addDriveSearchIsoTimeFields recursively annotates every `*_time` numeric
// field with a matching `*_time_iso` RFC3339 string, so clients that parse
// JSON output don't have to convert epoch timestamps themselves.
func addDriveSearchIsoTimeFields(value interface{}) []interface{} {
arr, ok := value.([]interface{})
if !ok {
return nil
}
out := make([]interface{}, len(arr))
for i, item := range arr {
out[i] = addDriveSearchIsoTimeFieldsOne(item)
}
return out
}
func addDriveSearchIsoTimeFieldsOne(value interface{}) interface{} {
switch v := value.(type) {
case []interface{}:
result := make([]interface{}, len(v))
for i, item := range v {
result[i] = addDriveSearchIsoTimeFieldsOne(item)
}
return result
case map[string]interface{}:
out := make(map[string]interface{})
for key, item := range v {
if strings.HasSuffix(key, "_time_iso") {
out[key] = item
continue
}
out[key] = addDriveSearchIsoTimeFieldsOne(item)
if strings.HasSuffix(key, "_time") {
// If the input already carries the matching `_iso` sibling,
// the iso-suffix passthrough branch will copy it; don't race
// against it (map iteration order is non-deterministic).
if _, exists := v[key+"_iso"]; exists {
continue
}
if iso := unixToISO8601(item); iso != "" {
out[key+"_iso"] = iso
}
}
}
return out
default:
return value
}
}
func unixToISO8601(v interface{}) string {
if v == nil {
return ""
}
var num float64
switch val := v.(type) {
case float64:
num = val
case json.Number:
parsed, err := val.Float64()
if err != nil {
return ""
}
num = parsed
case string:
parsed, err := strconv.ParseFloat(val, 64)
if err != nil {
return ""
}
num = parsed
case int64:
num = float64(val)
case int:
num = float64(val)
default:
return ""
}
if math.IsInf(num, 0) || math.IsNaN(num) {
return ""
}
secs := int64(num)
if num >= 1e12 {
secs = secs / 1000
}
return time.Unix(secs, 0).Format(time.RFC3339)
}

View File

@@ -0,0 +1,962 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bytes"
"encoding/json"
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/output"
)
// TestClampOpenedTimeWindow covers the 3-month / 1-year boundary logic that
// narrows --opened-since / --opened-until and generates the multi-slice notice.
func TestClampOpenedTimeWindow(t *testing.T) {
t.Parallel()
// Fixed "now" keeps RFC3339 output stable across runs.
now := time.Date(2026, 4, 24, 16, 0, 0, 0, time.UTC)
day := int64(86400)
t.Run("no opened-since: no clamp, no notice", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{OpenedUntil: "2026-04-01"}
notice, err := clampOpenedTimeWindow(&spec, now)
if err != nil || notice != "" {
t.Fatalf("got notice=%q err=%v, want both empty", notice, err)
}
if spec.OpenedSince != "" || spec.OpenedUntil != "2026-04-01" {
t.Fatalf("spec mutated unexpectedly: %+v", spec)
}
})
t.Run("span within 90d: no clamp", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{OpenedSince: "30d"}
notice, err := clampOpenedTimeWindow(&spec, now)
if err != nil || notice != "" {
t.Fatalf("got notice=%q err=%v, want both empty", notice, err)
}
if spec.OpenedSince != "30d" {
t.Fatalf("spec.OpenedSince mutated: %q", spec.OpenedSince)
}
})
t.Run("exactly 90 days: no clamp", func(t *testing.T) {
t.Parallel()
since := now.Unix() - 90*day
spec := driveSearchSpec{
OpenedSince: time.Unix(since, 0).UTC().Format(time.RFC3339),
}
notice, err := clampOpenedTimeWindow(&spec, now)
if err != nil || notice != "" {
t.Fatalf("got notice=%q err=%v, want no clamp at boundary", notice, err)
}
})
t.Run("91 days: 2-slice clamp", func(t *testing.T) {
t.Parallel()
since := now.Unix() - 91*day
spec := driveSearchSpec{
OpenedSince: time.Unix(since, 0).UTC().Format(time.RFC3339),
}
notice, err := clampOpenedTimeWindow(&spec, now)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !strings.Contains(notice, "2 slices total") {
t.Fatalf("expected '2 slices total' in notice, got:\n%s", notice)
}
// Each slice line — including slice 1 — must spell out concrete
// --opened-since / --opened-until values so a paginating agent can
// copy them verbatim instead of re-using the user's original
// relative time (which would drift against time.Now()).
for _, label := range []string{"[slice 1/2 current]", "[slice 2/2]"} {
var line string
for _, l := range strings.Split(notice, "\n") {
if strings.Contains(l, label) {
line = l
break
}
}
if line == "" {
t.Fatalf("missing %s line, got:\n%s", label, notice)
}
if !strings.Contains(line, "--opened-since ") || !strings.Contains(line, "--opened-until ") {
t.Fatalf("%s line must spell out both flag values, got: %q\nfull notice:\n%s", label, line, notice)
}
}
// After clamp the request window is exactly the most recent 90 days.
clampedSince, err := parseTimeValue(spec.OpenedSince, now)
if err != nil {
t.Fatalf("rewritten opened-since not parseable: %v", err)
}
clampedUntil, err := parseTimeValue(spec.OpenedUntil, now)
if err != nil {
t.Fatalf("rewritten opened-until not parseable: %v", err)
}
if clampedUntil-clampedSince != 90*day {
t.Fatalf("clamped span = %d days, want 90", (clampedUntil-clampedSince)/day)
}
if clampedUntil != now.Unix() {
t.Fatalf("clamped until should default to now; got %d, want %d", clampedUntil, now.Unix())
}
})
t.Run("8 months: 3-slice clamp with shorter tail", func(t *testing.T) {
t.Parallel()
since := now.Unix() - 240*day // 8m ≈ 240 days
spec := driveSearchSpec{
OpenedSince: time.Unix(since, 0).UTC().Format(time.RFC3339),
}
notice, err := clampOpenedTimeWindow(&spec, now)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
for _, want := range []string{"3 slices total", "[slice 1/3 current]", "[slice 2/3]", "[slice 3/3]"} {
if !strings.Contains(notice, want) {
t.Fatalf("missing %q in notice:\n%s", want, notice)
}
}
})
t.Run("365 days: 5-slice clamp at upper bound", func(t *testing.T) {
t.Parallel()
since := now.Unix() - 365*day
spec := driveSearchSpec{
OpenedSince: time.Unix(since, 0).UTC().Format(time.RFC3339),
}
notice, err := clampOpenedTimeWindow(&spec, now)
if err != nil {
t.Fatalf("365 days should clamp, got err: %v", err)
}
if !strings.Contains(notice, "5 slices total") {
t.Fatalf("expected '5 slices total' for 365-day span, got:\n%s", notice)
}
})
t.Run("over 365 days: hard-cap error", func(t *testing.T) {
t.Parallel()
since := now.Unix() - 366*day
spec := driveSearchSpec{
OpenedSince: time.Unix(since, 0).UTC().Format(time.RFC3339),
}
_, err := clampOpenedTimeWindow(&spec, now)
if err == nil {
t.Fatal("expected error for 366-day span, got nil")
}
if !strings.Contains(err.Error(), "365-day") {
t.Fatalf("error should mention 365-day cap, got: %v", err)
}
})
t.Run("since > until: no clamp, defer to downstream", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{
OpenedSince: "2026-04-01",
OpenedUntil: "2026-03-01",
}
notice, err := clampOpenedTimeWindow(&spec, now)
if err != nil || notice != "" {
t.Fatalf("got notice=%q err=%v, want both empty for inverted range", notice, err)
}
})
t.Run("invalid opened-since: validation error", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{OpenedSince: "not-a-date"}
_, err := clampOpenedTimeWindow(&spec, now)
if err == nil {
t.Fatal("expected validation error for unparseable since")
}
if !strings.Contains(err.Error(), "--opened-since") {
t.Fatalf("error should name the flag, got: %v", err)
}
})
}
func TestParseDriveSearchPageSize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
want int
wantErr bool
}{
{"empty defaults to 15", "", 15, false},
{"valid in-range", "10", 10, false},
{"zero falls back to 15", "0", 15, false},
{"negative falls back to 15", "-5", 15, false},
{"clamps to 20 when exceeded", "100", 20, false},
{"non-numeric is a hard error", "abc", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parseDriveSearchPageSize(tt.raw)
if (err != nil) != tt.wantErr {
t.Fatalf("err=%v, wantErr=%v", err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Fatalf("got %d, want %d", got, tt.want)
}
})
}
}
func TestValidateDocTypes(t *testing.T) {
t.Parallel()
if err := validateDocTypes(nil); err != nil {
t.Fatalf("nil slice should be valid, got: %v", err)
}
if err := validateDocTypes([]string{"DOC", "SHEET", "BITABLE"}); err != nil {
t.Fatalf("known values should pass, got: %v", err)
}
err := validateDocTypes([]string{"DOC", "PIE"})
if err == nil || !strings.Contains(err.Error(), "PIE") {
t.Fatalf("expected error naming the unknown value, got: %v", err)
}
}
func TestUpperAll(t *testing.T) {
t.Parallel()
if got := upperAll(nil); got != nil {
t.Fatalf("nil input should return nil, got %v", got)
}
got := upperAll([]string{"docx", "Sheet", "BITABLE"})
want := []string{"DOCX", "SHEET", "BITABLE"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestValidateDriveSearchIDs(t *testing.T) {
t.Parallel()
t.Run("all valid", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{
CreatorIDs: []string{"ou_aaa"},
ChatIDs: []string{"oc_xxx"},
SharerIDs: []string{"ou_bbb"},
}
if err := validateDriveSearchIDs(spec); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("bad creator id format", func(t *testing.T) {
t.Parallel()
err := validateDriveSearchIDs(driveSearchSpec{CreatorIDs: []string{"u_bad"}})
if err == nil || !strings.Contains(err.Error(), "--creator-ids") {
t.Fatalf("expected --creator-ids error, got: %v", err)
}
})
t.Run("bad chat id format", func(t *testing.T) {
t.Parallel()
err := validateDriveSearchIDs(driveSearchSpec{ChatIDs: []string{"chat_bad"}})
if err == nil || !strings.Contains(err.Error(), "--chat-ids") {
t.Fatalf("expected --chat-ids error, got: %v", err)
}
})
t.Run("bad sharer id format", func(t *testing.T) {
t.Parallel()
err := validateDriveSearchIDs(driveSearchSpec{SharerIDs: []string{"u_bad"}})
if err == nil || !strings.Contains(err.Error(), "--sharer-ids") {
t.Fatalf("expected --sharer-ids error, got: %v", err)
}
})
t.Run("chat ids exactly at cap is allowed", func(t *testing.T) {
t.Parallel()
ids := make([]string, driveSearchMaxChatIDs)
for i := range ids {
ids[i] = "oc_x"
}
if err := validateDriveSearchIDs(driveSearchSpec{ChatIDs: ids}); err != nil {
t.Fatalf("exactly cap should pass, got: %v", err)
}
})
t.Run("chat ids over cap", func(t *testing.T) {
t.Parallel()
ids := make([]string, driveSearchMaxChatIDs+1)
for i := range ids {
ids[i] = "oc_x"
}
err := validateDriveSearchIDs(driveSearchSpec{ChatIDs: ids})
if err == nil || !strings.Contains(err.Error(), "max") {
t.Fatalf("expected cap error, got: %v", err)
}
})
t.Run("sharer ids exactly at cap is allowed", func(t *testing.T) {
t.Parallel()
ids := make([]string, driveSearchMaxSharerIDs)
for i := range ids {
ids[i] = "ou_x"
}
if err := validateDriveSearchIDs(driveSearchSpec{SharerIDs: ids}); err != nil {
t.Fatalf("exactly cap should pass, got: %v", err)
}
})
t.Run("sharer ids over cap", func(t *testing.T) {
t.Parallel()
ids := make([]string, driveSearchMaxSharerIDs+1)
for i := range ids {
ids[i] = "ou_x"
}
err := validateDriveSearchIDs(driveSearchSpec{SharerIDs: ids})
if err == nil || !strings.Contains(err.Error(), "max") {
t.Fatalf("expected cap error, got: %v", err)
}
})
}
func TestBuildTimeRangeFilter(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 16, 0, 0, 0, time.UTC)
t.Run("both empty: nil range, no notice", func(t *testing.T) {
t.Parallel()
rng, notices, err := buildTimeRangeFilter("open_time", "", "", now)
if err != nil || rng != nil || len(notices) != 0 {
t.Fatalf("got rng=%v notices=%v err=%v", rng, notices, err)
}
})
t.Run("open_time passes through without snap", func(t *testing.T) {
t.Parallel()
rng, notices, err := buildTimeRangeFilter("open_time",
"2026-04-20T10:30:45+08:00", "2026-04-21T11:45:30+08:00", now)
if err != nil {
t.Fatalf("err: %v", err)
}
if len(notices) != 0 {
t.Fatalf("open_time should not snap, got notices: %v", notices)
}
if rng["start"] == nil || rng["end"] == nil {
t.Fatalf("range missing endpoints: %v", rng)
}
})
t.Run("my_edit_time snaps sub-hour values", func(t *testing.T) {
t.Parallel()
rng, notices, err := buildTimeRangeFilter("my_edit_time",
"2026-04-20T10:30:45+08:00", "2026-04-21T11:45:30+08:00", now)
if err != nil {
t.Fatalf("err: %v", err)
}
if len(notices) != 2 {
t.Fatalf("expected 2 snap notices (start + end), got %d: %v", len(notices), notices)
}
startUnix := rng["start"].(int64)
endUnix := rng["end"].(int64)
if startUnix%3600 != 0 || endUnix%3600 != 0 {
t.Fatalf("snapped values should align to hour: start=%d end=%d", startUnix, endUnix)
}
})
t.Run("invalid since surfaces with flag name", func(t *testing.T) {
t.Parallel()
_, _, err := buildTimeRangeFilter("my_edit_time", "garbage", "", now)
if err == nil || !strings.Contains(err.Error(), "--edited-since") {
t.Fatalf("expected --edited-since in error, got: %v", err)
}
})
t.Run("invalid until surfaces with flag name", func(t *testing.T) {
t.Parallel()
_, _, err := buildTimeRangeFilter("open_time", "", "garbage", now)
if err == nil || !strings.Contains(err.Error(), "--opened-until") {
t.Fatalf("expected --opened-until in error, got: %v", err)
}
})
}
func TestFloorAndCeilHour(t *testing.T) {
t.Parallel()
// 16:23:45 = unix 1745195025 (arbitrary)
t.Run("floor truncates", func(t *testing.T) {
t.Parallel()
if got := floorHour(1745195025); got%3600 != 0 || got >= 1745195025 {
t.Fatalf("floor(1745195025)=%d invalid", got)
}
})
t.Run("ceil rounds up", func(t *testing.T) {
t.Parallel()
got := ceilHour(1745195025)
if got%3600 != 0 || got <= 1745195025 {
t.Fatalf("ceil(1745195025)=%d invalid", got)
}
})
t.Run("ceil at exact hour is no-op", func(t *testing.T) {
t.Parallel()
exact := int64(1745193600)
if got := ceilHour(exact); got != exact {
t.Fatalf("ceil at hour boundary should be identity, got %d", got)
}
})
}
func TestParseTimeValue(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 16, 0, 0, 0, time.Local)
tests := []struct {
name string
input string
wantErr bool
}{
{"empty errors", "", true},
{"7d relative", "7d", false},
{"1m relative", "1m", false},
{"1y relative", "1y", false},
{"date-only YYYY-MM-DD", "2026-04-01", false},
{"datetime with space", "2026-04-01 10:00:00", false},
{"datetime with T", "2026-04-01T10:00:00", false},
{"RFC3339 with offset", "2026-04-01T10:00:00+08:00", false},
{"unix seconds", "1745193600", false},
{"too short to be unix, garbage", "12345", true},
{"YYYYMMDD digits not unix", "20260423", true},
{"unparseable text", "not-a-date", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := parseTimeValue(tt.input, now)
if (err != nil) != tt.wantErr {
t.Fatalf("err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
// Sanity: relative units must scale correctly. A regression where "1m"
// silently meant "1 minute" instead of "30 days" would slip past the
// wantErr-only table above; this guards the unit semantics.
t.Run("relative units scale: 7d < 1m < 1y", func(t *testing.T) {
t.Parallel()
got7d, err := parseTimeValue("7d", now)
if err != nil {
t.Fatalf("7d: %v", err)
}
got1m, err := parseTimeValue("1m", now)
if err != nil {
t.Fatalf("1m: %v", err)
}
got1y, err := parseTimeValue("1y", now)
if err != nil {
t.Fatalf("1y: %v", err)
}
// All three are "now minus N days"; larger N means smaller (older) unix.
if !(got1y < got1m && got1m < got7d && got7d < now.Unix()) {
t.Fatalf("expected got1y < got1m < got7d < now; got %d %d %d (now=%d)",
got1y, got1m, got7d, now.Unix())
}
// Spot-check the conversions: "1m" = 30d, "1y" = 365d.
const day = int64(86400)
if now.Unix()-got1m != 30*day {
t.Fatalf("'1m' should resolve to now-30d, got delta %d days", (now.Unix()-got1m)/day)
}
if now.Unix()-got1y != 365*day {
t.Fatalf("'1y' should resolve to now-365d, got delta %d days", (now.Unix()-got1y)/day)
}
})
// Sanity: unix-seconds round-trips exactly (no parsing as date).
t.Run("unix-seconds input round-trips", func(t *testing.T) {
t.Parallel()
got, err := parseTimeValue("1745193600", now)
if err != nil {
t.Fatalf("err: %v", err)
}
if got != 1745193600 {
t.Fatalf("unix round-trip got %d, want 1745193600", got)
}
})
// Regression: a 13-digit epoch-millis timestamp must be normalized to
// seconds. Previously it silently parsed as year-57000 and tripped the
// 1-year cap downstream with a misleading "exceeds 365 days" message.
t.Run("epoch-millis input normalizes to seconds", func(t *testing.T) {
t.Parallel()
got, err := parseTimeValue("1745193600000", now)
if err != nil {
t.Fatalf("err: %v", err)
}
if got != 1745193600 {
t.Fatalf("ms timestamp should normalize to %d seconds, got %d", int64(1745193600), got)
}
})
}
func TestUnixToISO8601(t *testing.T) {
t.Parallel()
const sec int64 = 1745193600 // 2025-04-21 00:00 UTC; only the YYYY-MM-DD prefix is checked below to stay timezone-agnostic
wantPrefix := time.Unix(sec, 0).Format(time.RFC3339)[:10] // YYYY-MM-DD prefix is timezone-stable
tests := []struct {
name string
in interface{}
want string // empty means expect empty result
}{
{"int64", sec, wantPrefix},
{"int", int(sec), wantPrefix},
{"float64", float64(sec), wantPrefix},
{"json.Number", json.Number("1745193600"), wantPrefix},
{"string numeric", "1745193600", wantPrefix},
{"milliseconds get divided", sec * 1000, wantPrefix},
{"nil returns empty", nil, ""},
{"bool ignored", true, ""},
{"unparseable string", "abc", ""},
{"NaN returns empty", math.NaN(), ""},
{"Inf returns empty", math.Inf(1), ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := unixToISO8601(tt.in)
if tt.want == "" {
if got != "" {
t.Fatalf("want empty, got %q", got)
}
return
}
if !strings.HasPrefix(got, tt.want) {
t.Fatalf("got %q, want prefix %q", got, tt.want)
}
})
}
}
func TestAddDriveSearchIsoTimeFields(t *testing.T) {
t.Parallel()
t.Run("non-array input returns nil", func(t *testing.T) {
t.Parallel()
if got := addDriveSearchIsoTimeFields("not-an-array"); got != nil {
t.Fatalf("expected nil, got %v", got)
}
})
t.Run("annotates *_time at top level", func(t *testing.T) {
t.Parallel()
items := []interface{}{
map[string]interface{}{"open_time": int64(1745193600)},
}
row := addDriveSearchIsoTimeFields(items)[0].(map[string]interface{})
if _, ok := row["open_time_iso"].(string); !ok {
t.Fatalf("open_time_iso should have been added, got: %v", row)
}
})
t.Run("recurses into nested map and annotates", func(t *testing.T) {
t.Parallel()
items := []interface{}{
map[string]interface{}{
"result_meta": map[string]interface{}{
"update_time": json.Number("1745193600"),
},
},
}
row := addDriveSearchIsoTimeFields(items)[0].(map[string]interface{})
meta := row["result_meta"].(map[string]interface{})
if _, ok := meta["update_time_iso"].(string); !ok {
t.Fatalf("nested update_time_iso missing, got: %v", meta)
}
})
t.Run("standalone *_time_iso key passes through", func(t *testing.T) {
t.Parallel()
// No sibling *_time key, so the iso-suffix passthrough branch is the
// only one that touches this key — deterministic by construction.
items := []interface{}{
map[string]interface{}{"some_time_iso": "preserved"},
}
row := addDriveSearchIsoTimeFields(items)[0].(map[string]interface{})
if row["some_time_iso"] != "preserved" {
t.Fatalf("existing _time_iso value should pass through, got: %v", row["some_time_iso"])
}
})
// Regression: when both *_time and *_time_iso are present in the same map,
// the pre-existing _iso value must always win, regardless of map iteration
// order. This used to be flaky (a generated iso could overwrite the input
// one depending on which key got visited last).
t.Run("pre-existing *_iso wins over generated when both keys coexist", func(t *testing.T) {
t.Parallel()
const preserved = "PRESERVED-ISO-VALUE"
// Run several times to make a map-iteration-order race surface
// quickly if the guard regresses.
for i := 0; i < 50; i++ {
items := []interface{}{
map[string]interface{}{
"open_time": int64(1745193600),
"open_time_iso": preserved,
},
}
row := addDriveSearchIsoTimeFields(items)[0].(map[string]interface{})
if row["open_time_iso"] != preserved {
t.Fatalf("attempt %d: open_time_iso = %v, want %q (pre-existing must win)",
i, row["open_time_iso"], preserved)
}
}
})
}
func TestEnrichDriveSearchError(t *testing.T) {
t.Parallel()
t.Run("non-ExitError passes through", func(t *testing.T) {
t.Parallel()
orig := errors.New("plain error")
if got := enrichDriveSearchError(orig); got != orig {
t.Fatalf("plain error should pass through unchanged")
}
})
t.Run("ExitError without Detail passes through", func(t *testing.T) {
t.Parallel()
orig := &output.ExitError{Code: 1}
if got := enrichDriveSearchError(orig); got != orig {
t.Fatalf("ExitError without Detail should pass through unchanged")
}
})
t.Run("ExitError with non-matching code passes through", func(t *testing.T) {
t.Parallel()
orig := &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Code: 12345, Message: "other"},
}
if got := enrichDriveSearchError(orig); got != orig {
t.Fatalf("non-matching code should pass through unchanged")
}
})
t.Run("matching code rewrites Hint without mutating original", func(t *testing.T) {
t.Parallel()
orig := &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{
Code: driveSearchErrUserNotVisible,
Message: "[99992351] user not visible",
Hint: "",
},
}
enriched := enrichDriveSearchError(orig)
eErr, ok := enriched.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T", enriched)
}
if eErr == orig {
t.Fatal("should return a new ExitError, not mutate the original")
}
if orig.Detail.Hint != "" {
t.Fatal("original Detail.Hint must remain unchanged")
}
if !strings.Contains(eErr.Detail.Hint, "--creator-ids") {
t.Fatalf("hint should mention --creator-ids, got %q", eErr.Detail.Hint)
}
if eErr.Detail.Message != orig.Detail.Message {
t.Fatalf("Message should be preserved, got %q", eErr.Detail.Message)
}
})
}
func TestCloneDriveSearchFilter(t *testing.T) {
t.Parallel()
src := map[string]interface{}{"a": 1, "b": "x"}
dst := cloneDriveSearchFilter(src)
if !reflect.DeepEqual(src, dst) {
t.Fatalf("clone should equal source")
}
dst["a"] = 99
if src["a"] != 1 {
t.Fatalf("mutating clone should not affect source")
}
}
func TestBuildDriveSearchRequest(t *testing.T) {
t.Parallel()
now := time.Date(2026, 4, 24, 16, 0, 0, 0, time.UTC)
const userOpenID = "ou_self"
t.Run("empty spec emits both filters as empty maps", func(t *testing.T) {
t.Parallel()
req, notices, err := buildDriveSearchRequest(driveSearchSpec{}, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
if len(notices) != 0 {
t.Fatalf("expected no notices, got %v", notices)
}
if _, ok := req["doc_filter"].(map[string]interface{}); !ok {
t.Fatalf("doc_filter missing")
}
if _, ok := req["wiki_filter"].(map[string]interface{}); !ok {
t.Fatalf("wiki_filter missing")
}
if req["page_size"] != 15 {
t.Fatalf("default page_size should be 15, got %v", req["page_size"])
}
})
t.Run("--mine fills creator_ids from userOpenID", func(t *testing.T) {
t.Parallel()
req, _, err := buildDriveSearchRequest(driveSearchSpec{Mine: true}, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
got := req["doc_filter"].(map[string]interface{})["creator_ids"].([]string)
if len(got) != 1 || got[0] != userOpenID {
t.Fatalf("expected [userOpenID], got %v", got)
}
})
t.Run("--mine without userOpenID errors", func(t *testing.T) {
t.Parallel()
_, _, err := buildDriveSearchRequest(driveSearchSpec{Mine: true}, "", now)
if err == nil || !strings.Contains(err.Error(), "--mine") {
t.Fatalf("expected --mine error, got: %v", err)
}
})
t.Run("--mine + --creator-ids mutually exclusive", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{Mine: true, CreatorIDs: []string{"ou_x"}}
_, _, err := buildDriveSearchRequest(spec, userOpenID, now)
if err == nil || !strings.Contains(err.Error(), "--mine") {
t.Fatalf("expected exclusion error, got: %v", err)
}
})
t.Run("--folder-tokens + --space-ids mutually exclusive", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{
FolderTokens: []string{"fld_a"},
SpaceIDs: []string{"sp_b"},
}
_, _, err := buildDriveSearchRequest(spec, userOpenID, now)
if err == nil || !strings.Contains(err.Error(), "--folder-tokens") {
t.Fatalf("expected exclusion error, got: %v", err)
}
})
t.Run("--folder-tokens scopes only doc_filter", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{FolderTokens: []string{"fld_a"}}
req, _, err := buildDriveSearchRequest(spec, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
if _, ok := req["wiki_filter"]; ok {
t.Fatalf("wiki_filter should not be set when --folder-tokens is given")
}
df := req["doc_filter"].(map[string]interface{})
if _, ok := df["folder_tokens"]; !ok {
t.Fatalf("doc_filter must carry folder_tokens")
}
})
t.Run("--space-ids scopes only wiki_filter", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{SpaceIDs: []string{"sp_x"}}
req, _, err := buildDriveSearchRequest(spec, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
if _, ok := req["doc_filter"]; ok {
t.Fatalf("doc_filter should not be set when --space-ids is given")
}
wf := req["wiki_filter"].(map[string]interface{})
if _, ok := wf["space_ids"]; !ok {
t.Fatalf("wiki_filter must carry space_ids")
}
})
t.Run("sort=default maps to DEFAULT_TYPE", func(t *testing.T) {
t.Parallel()
req, _, err := buildDriveSearchRequest(driveSearchSpec{Sort: "default"}, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
if got := req["doc_filter"].(map[string]interface{})["sort_type"]; got != "DEFAULT_TYPE" {
t.Fatalf("sort_type=%v, want DEFAULT_TYPE", got)
}
})
t.Run("sort=edit_time upper-cases 1:1", func(t *testing.T) {
t.Parallel()
req, _, err := buildDriveSearchRequest(driveSearchSpec{Sort: "edit_time"}, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
if got := req["doc_filter"].(map[string]interface{})["sort_type"]; got != "EDIT_TIME" {
t.Fatalf("sort_type=%v, want EDIT_TIME", got)
}
})
t.Run("invalid doc-types surfaces", func(t *testing.T) {
t.Parallel()
_, _, err := buildDriveSearchRequest(driveSearchSpec{DocTypes: []string{"PIE"}}, userOpenID, now)
if err == nil || !strings.Contains(err.Error(), "--doc-types") {
t.Fatalf("expected --doc-types error, got: %v", err)
}
})
t.Run("opened-since 8m triggers clamp notice", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{
OpenedSince: time.Unix(now.Unix()-240*86400, 0).UTC().Format(time.RFC3339),
}
_, notices, err := buildDriveSearchRequest(spec, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
joined := strings.Join(notices, "\n")
if !strings.Contains(joined, "3 slices total") {
t.Fatalf("expected 3-slice clamp notice, got: %s", joined)
}
})
t.Run("scalar filters land in both doc and wiki filters", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{
DocTypes: []string{"DOCX"},
ChatIDs: []string{"oc_a"},
OnlyTitle: true,
OnlyComment: true,
}
req, _, err := buildDriveSearchRequest(spec, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
df := req["doc_filter"].(map[string]interface{})
wf := req["wiki_filter"].(map[string]interface{})
for _, side := range []map[string]interface{}{df, wf} {
if _, ok := side["doc_types"]; !ok {
t.Fatal("doc_types missing")
}
if _, ok := side["chat_ids"]; !ok {
t.Fatal("chat_ids missing")
}
if side["only_title"] != true {
t.Fatal("only_title missing")
}
if side["only_comment"] != true {
t.Fatal("only_comment missing")
}
}
})
}
func TestRenderDriveSearchTable(t *testing.T) {
t.Parallel()
t.Run("empty items prints fallback message", func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
renderDriveSearchTable(&buf, map[string]interface{}{}, nil)
if !strings.Contains(buf.String(), "No matching results found") {
t.Fatalf("expected fallback message, got: %s", buf.String())
}
})
t.Run("strips both <h> and <hb> highlight tags", func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
items := []interface{}{
map[string]interface{}{
"title_highlighted": "<h>hi</h> there <hb>bold</hb>!",
"entity_type": "DOC",
"result_meta": map[string]interface{}{"url": "https://example.com/x"},
},
}
renderDriveSearchTable(&buf, map[string]interface{}{}, items)
out := buf.String()
if strings.Contains(out, "<h>") || strings.Contains(out, "<hb>") || strings.Contains(out, "</h>") || strings.Contains(out, "</hb>") {
t.Fatalf("highlight tags leaked: %s", out)
}
if !strings.Contains(out, "hi there bold!") {
t.Fatalf("plain text should remain after stripping, got: %s", out)
}
})
t.Run("falls back to title when title_highlighted is missing", func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
items := []interface{}{
map[string]interface{}{
"title": "plain title",
"entity_type": "DOC",
"result_meta": map[string]interface{}{
"url": "https://example.com/x",
"update_time_iso": "2026-04-01T00:00:00Z",
"doc_types": "DOC",
},
},
}
renderDriveSearchTable(&buf, map[string]interface{}{}, items)
out := buf.String()
if !strings.Contains(out, "plain title") {
t.Fatalf("expected fallback title, got: %s", out)
}
if strings.Contains(out, "<nil>") {
t.Fatalf("title fallback should not produce <nil>, got: %s", out)
}
})
// Regression: when result_meta is missing url / update_time_iso (or
// result_meta itself is absent), the table must render empty cells, not
// the literal string "<nil>". This used to leak via fmt.Sprintf("%v",
// nil) before the type-assertion guard was added.
t.Run("missing url and update_time_iso render as empty, not <nil>", func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
items := []interface{}{
// minimal item: title only, no result_meta keys at all
map[string]interface{}{
"title_highlighted": "row1",
"entity_type": "DOC",
"result_meta": map[string]interface{}{},
},
// item with no result_meta at all
map[string]interface{}{
"title_highlighted": "row2",
"entity_type": "DOC",
},
}
renderDriveSearchTable(&buf, map[string]interface{}{}, items)
out := buf.String()
if strings.Contains(out, "<nil>") {
t.Fatalf("table must not render <nil> for missing url/edit_time, got:\n%s", out)
}
})
t.Run("appends has_more hint when there are more pages", func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
items := []interface{}{
map[string]interface{}{
"title": "x",
"entity_type": "DOC",
"result_meta": map[string]interface{}{"url": "https://example.com/x"},
},
}
renderDriveSearchTable(&buf, map[string]interface{}{"has_more": true}, items)
if !strings.Contains(buf.String(), "more available") {
t.Fatalf("expected has_more hint, got: %s", buf.String())
}
})
}

View File

@@ -20,5 +20,6 @@ func Shortcuts() []common.Shortcut {
DriveDelete,
DriveTaskResult,
DriveApplyPermission,
DriveSearch,
}
}

View File

@@ -23,6 +23,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+delete",
"+task_result",
"+apply-permission",
"+search",
}
if len(got) != len(want) {

View File

@@ -401,6 +401,35 @@ func TestBuildMessagesSearchRequest(t *testing.T) {
t.Fatalf("buildMessagesSearchRequest() error = %v", err)
}
})
t.Run("at-chatter-ids accepts user ids", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "standup",
"at-chatter-ids": "ou_a, ou_b",
}, nil)
got, err := buildMessagesSearchRequest(runtime)
if err != nil {
t.Fatalf("buildMessagesSearchRequest() error = %v", err)
}
filter, _ := got.body["filter"].(map[string]interface{})
ids, _ := filter["at_chatter_ids"].([]string)
want := []string{"ou_a", "ou_b"}
if !reflect.DeepEqual(ids, want) {
t.Fatalf("at_chatter_ids = %#v, want %#v", ids, want)
}
})
t.Run("at-chatter-ids rejects bad id", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"at-chatter-ids": "ou_a,not_a_user",
}, nil)
_, err := buildMessagesSearchRequest(runtime)
if err == nil || !strings.Contains(err.Error(), "invalid user ID format") {
t.Fatalf("buildMessagesSearchRequest() error = %v", err)
}
})
}
func TestBuildSearchChatBodyAdditionalBranches(t *testing.T) {

View File

@@ -42,6 +42,7 @@ var ImMessagesSearch = common.Shortcut{
{Name: "sender-type", Desc: "sender type", Enum: []string{"user", "bot"}},
{Name: "exclude-sender-type", Desc: "exclude sender type", Enum: []string{"user", "bot"}},
{Name: "is-at-me", Type: "bool", Desc: "only messages that @me"},
{Name: "at-chatter-ids", Desc: "filter by @mentioned user open_ids, comma-separated (also matches messages that @all)"},
{Name: "start", Desc: "start time(ISO 8601) with local timezone offset (e.g. 2026-03-24T00:00:00+08:00)"},
{Name: "end", Desc: "end time(ISO 8601) with local timezone offset (e.g. 2026-03-25T23:59:59+08:00)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-50)"},
@@ -245,6 +246,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
chatTypeFlag := runtime.Str("chat-type")
senderTypeFlag := runtime.Str("sender-type")
excludeSenderTypeFlag := runtime.Str("exclude-sender-type")
atChatterIdsFlag := runtime.Str("at-chatter-ids")
startFlag := runtime.Str("start")
endFlag := runtime.Str("end")
pageToken := runtime.Str("page-token")
@@ -324,6 +326,15 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
if runtime.Bool("is-at-me") {
filter["is_at_me"] = true
}
if atChatterIdsFlag != "" {
ids := common.SplitCSV(atChatterIdsFlag)
for _, id := range ids {
if _, err := common.ValidateUserID(id); err != nil {
return nil, err
}
}
filter["at_chatter_ids"] = ids
}
body := map[string]interface{}{"query": query}
if len(filter) > 0 {

View File

@@ -24,7 +24,7 @@ func newMessagesSearchRuntime(t *testing.T, stringFlags map[string]string, boolF
runtime := newBotShortcutRuntime(t, rt)
cmd := &cobra.Command{Use: "test"}
stringFlagNames := []string{"query", "page-token"}
stringFlagNames := []string{"query", "page-token", "at-chatter-ids"}
for _, name := range stringFlagNames {
cmd.Flags().String(name, "", "")
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// validReceiveIDTypes enumerates accepted --receive-id-type values.
var validReceiveIDTypes = map[string]bool{
"chat_id": true,
"open_id": true,
"user_id": true,
"union_id": true,
"email": true,
}
// MailShareToChat shares an email or thread as a card to a Lark IM chat.
var MailShareToChat = common.Shortcut{
Service: "mail",
Command: "+share-to-chat",
Description: "Share an email or thread as a card to a Lark IM chat.",
Risk: "write",
Scopes: []string{
"mail:user_mailbox.message:readonly",
"im:message",
"im:message.send_as_user",
},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-id", Desc: "Message ID to share (mutually exclusive with --thread-id)"},
{Name: "thread-id", Desc: "Thread ID to share (mutually exclusive with --message-id)"},
{Name: "receive-id", Desc: "Receiver ID. Type determined by --receive-id-type.", Required: true},
{Name: "receive-id-type", Default: "chat_id", Desc: "Receiver ID type: chat_id (default), open_id, user_id, union_id, email"},
{Name: "mailbox", Default: "me", Desc: "Mailbox email address (default: me)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(runtime)
msgID := runtime.Str("message-id")
threadID := runtime.Str("thread-id")
receiveID := runtime.Str("receive-id")
receiveIDType := runtime.Str("receive-id-type")
var createBody map[string]interface{}
if threadID != "" {
createBody = map[string]interface{}{"thread_id": threadID}
} else {
createBody = map[string]interface{}{"message_id": msgID}
}
return common.NewDryRunAPI().
Desc("Share email card: create share token → send card to IM chat").
POST(mailboxPath(mailboxID, "messages", "share_token")).
Body(createBody).
POST(mailboxPath(mailboxID, "share_tokens", "<card_id>", "send")).
Params(map[string]interface{}{"receive_id_type": receiveIDType}).
Body(map[string]interface{}{"receive_id": receiveID})
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
msgID := runtime.Str("message-id")
threadID := runtime.Str("thread-id")
if msgID == "" && threadID == "" {
return output.ErrValidation("either --message-id or --thread-id is required")
}
if msgID != "" && threadID != "" {
return output.ErrValidation("--message-id and --thread-id are mutually exclusive")
}
idType := runtime.Str("receive-id-type")
if !validReceiveIDTypes[idType] {
return output.ErrValidation("--receive-id-type must be one of: chat_id, open_id, user_id, union_id, email")
}
return nil
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
msgID := runtime.Str("message-id")
threadID := runtime.Str("thread-id")
receiveID := runtime.Str("receive-id")
receiveIDType := runtime.Str("receive-id-type")
mailboxID := resolveMailboxID(runtime)
var createBody map[string]interface{}
if threadID != "" {
createBody = map[string]interface{}{"thread_id": threadID}
} else {
createBody = map[string]interface{}{"message_id": msgID}
}
createResp, err := runtime.CallAPI("POST",
mailboxPath(mailboxID, "messages", "share_token"),
nil, createBody)
if err != nil {
return fmt.Errorf("create share token: %w", err)
}
cardID, _ := createResp["card_id"].(string)
if cardID == "" {
return fmt.Errorf("create share token: response missing card_id")
}
sendResp, err := runtime.CallAPI("POST",
mailboxPath(mailboxID, "share_tokens", cardID, "send"),
map[string]interface{}{"receive_id_type": receiveIDType},
map[string]interface{}{"receive_id": receiveID})
if err != nil {
return fmt.Errorf("share token created (card_id=%s) but send failed: %w", cardID, err)
}
runtime.Out(map[string]interface{}{
"card_id": cardID,
"im_message_id": sendResp["message_id"],
}, nil)
return nil
},
}

View File

@@ -0,0 +1,190 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestShareToChatValidationErrors(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing both message-id and thread-id",
args: []string{"+share-to-chat", "--receive-id", "oc_xxx"},
wantErr: "either --message-id or --thread-id is required",
},
{
name: "both message-id and thread-id",
args: []string{"+share-to-chat", "--message-id", "m1", "--thread-id", "t1", "--receive-id", "oc_xxx"},
wantErr: "--message-id and --thread-id are mutually exclusive",
},
{
name: "invalid receive-id-type",
args: []string{"+share-to-chat", "--message-id", "m1", "--receive-id", "oc_xxx", "--receive-id-type", "invalid"},
wantErr: "--receive-id-type must be one of",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailShareToChat, tt.args, f, stdout)
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
})
}
}
func TestShareToChatExecuteWithMessageID(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/share_token",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"card_id": "card_001",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/share_tokens/card_001/send",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "om_001",
},
},
})
err := runMountedMailShortcut(t, MailShareToChat, []string{
"+share-to-chat", "--message-id", "m1", "--receive-id", "oc_xxx",
}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := stdout.String()
if !strings.Contains(out, "card_001") {
t.Errorf("expected output to contain card_id, got %s", out)
}
if !strings.Contains(out, "om_001") {
t.Errorf("expected output to contain im_message_id, got %s", out)
}
}
func TestShareToChatExecuteWithThreadID(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/share_token",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"card_id": "card_002",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/share_tokens/card_002/send",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "om_002",
},
},
})
err := runMountedMailShortcut(t, MailShareToChat, []string{
"+share-to-chat", "--thread-id", "t1", "--receive-id", "user@example.com", "--receive-id-type", "email",
}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := stdout.String()
if !strings.Contains(out, "card_002") {
t.Errorf("expected output to contain card_id, got %s", out)
}
}
func TestShareToChatStep1Failure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/share_token",
Body: map[string]interface{}{
"code": 4034,
"msg": "message not found",
},
})
err := runMountedMailShortcut(t, MailShareToChat, []string{
"+share-to-chat", "--message-id", "bad_id", "--receive-id", "oc_xxx",
}, f, stdout)
if err == nil {
t.Fatal("expected error for step 1 failure, got nil")
}
if !strings.Contains(err.Error(), "create share token") {
t.Errorf("expected error to mention 'create share token', got %q", err.Error())
}
}
func TestShareToChatStep2Failure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/share_token",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"card_id": "card_003",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/share_tokens/card_003/send",
Body: map[string]interface{}{
"code": 4046,
"msg": "user not in chat",
},
})
err := runMountedMailShortcut(t, MailShareToChat, []string{
"+share-to-chat", "--message-id", "m1", "--receive-id", "oc_not_in",
}, f, stdout)
if err == nil {
t.Fatal("expected error for step 2 failure, got nil")
}
if !strings.Contains(err.Error(), "card_003") {
t.Errorf("expected error to contain card_id, got %q", err.Error())
}
if !strings.Contains(err.Error(), "send failed") {
t.Errorf("expected error to mention 'send failed', got %q", err.Error())
}
}
func TestValidReceiveIDTypes(t *testing.T) {
expected := []string{"chat_id", "open_id", "user_id", "union_id", "email"}
for _, typ := range expected {
if !validReceiveIDTypes[typ] {
t.Errorf("expected %q to be a valid receive ID type", typ)
}
}
if validReceiveIDTypes["invalid"] {
t.Error("expected 'invalid' to not be a valid receive ID type")
}
}

View File

@@ -22,5 +22,6 @@ func Shortcuts() []common.Shortcut {
MailSendReceipt,
MailDeclineReceipt,
MailSignature,
MailShareToChat,
}
}

View File

@@ -203,6 +203,38 @@ lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
**注意:** 撤回是异步操作,`recall` 返回成功仅表示请求已受理,实际结果需通过 `get_recall_detail` 查询。若响应中无 `recall_available` 字段,说明该邮件或应用不支持撤回,不要主动提及撤回。
### 分享邮件到 IM
将邮件以卡片形式分享到飞书群聊或个人会话。
**依赖 Scope** `mail:user_mailbox.message:readonly`、`im:message`、`im:message.send_as_user`
1. 分享单封邮件到群聊(默认 `--receive-id-type chat_id`
```bash
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id oc_xxx
```
2. 分享整个会话到群聊:
```bash
lark-cli mail +share-to-chat --thread-id <会话ID> --receive-id oc_xxx
```
3. 通过邮箱分享给个人:
```bash
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id user@example.com --receive-id-type email
```
4. 如果不知道群聊 ID先搜索
```bash
lark-cli im +chat-search --query "群名关键词"
```
从结果中获取 `chat_id`,然后执行分享。
**注意:**
- 分享需要用户在目标会话中有发消息权限
- 需要同时授权 mail 和 im 两个域的 scope
- 分享的卡片包含邮件摘要信息,收件人可点击查看
### 正文格式:优先使用 HTML
撰写邮件正文时,**默认使用 HTML 格式**body 内容会被自动检测)。仅当用户明确要求纯文本时,才使用 `--plain-text` 标志强制纯文本模式。

View File

@@ -1,7 +1,7 @@
---
name: lark-calendar
version: 1.0.0
description: "飞书日历calendar提供日历与日程会议的全面管理能力。核心场景包括查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段、查询/搜索与预定会议室。注意:涉及【预约日程/会议】或【查询/预定会议室】时,必须先读取 references/lark-calendar-schedule-meeting.md 工作流!高频操作请优先使用 Shortcuts+agenda快速概览今日/近期行程)、+create创建日程并按需邀请参会人及预定会议室、+freebusy查询用户主日历的忙闲信息和rsvp的状态、+rsvp回复日程邀请"
description: "飞书日历calendar提供日历与日程会议的全面管理能力。核心场景包括查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段、查询/搜索与预定会议室。注意:涉及【预约日程/会议】或【查询/预定会议室】时,必须先读取 references/lark-calendar-schedule-meeting.md 工作流!高频操作请优先使用 Shortcuts+agenda快速概览今日/近期行程)、+create创建日程并按需邀请参会人及预定会议室、+update更新既有日程字段或独立增删参会人/会议室)、+freebusy查询用户主日历的忙闲信息和rsvp的状态、+rsvp回复日程邀请"
metadata:
requires:
bins: ["lark-cli"]
@@ -17,8 +17,14 @@ metadata:
**CRITICAL — 会议与日程的意图路由:**
- **查询过去时间的会议**:如果用户明确查询过去时间的会议(如“昨天的会议”、“上周的会议”),**优先使用 [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) 搜索会议记录**。因为会议数据不仅包含从日程发起的视频会议,还包含即时会议,仅查询日程数据会导致结果不全。
- **查询日历/日程或未来时间的会议**:如果用户明确表达的是“日历”、“日程”,或者涉及**未来时间**的安排则属于本技能lark-calendar的业务域请继续使用本技能处理。
**CRITICAL — 任务类型分流:处理“预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间”时,必须先判断用户是在“新建日程”还是“编辑已有日程”。**
- **编辑已有日程的强信号**:用户明确提到某个已存在的日程锚点(如标题、时间段、`这个日程``这场会`)并表达修改动作(如“添加”“移除”“改到”“换会议室”“调整时间”)。这类请求默认走**编辑已有日程**,绝不能直接按新建处理。
- **编辑已有日程的前置步骤**一旦判定为编辑MUST 先定位目标日程或具体实例的 `event_id`再继续后续流程。若是重复性日程MUST 先定位到对应实例的 `event_id`
- **新建日程**:只有当用户表达的是“新约一个会/创建一个日程/安排一次会议”等新增意图,且没有指向某个既有日程的修改动作时,才进入新建流程。
**CRITICAL — 验证与同步延迟在涉及删除日程delete修改日程patch之后如果需要进行二次查询验证操作结果MUST 等待至少 2 秒后再进行查询,以防止因数据同步延迟导致查不到最新数据。注意:不要向用户提及你等待了这 2 秒钟的事情。**
**CRITICAL — 验证与同步延迟在涉及删除日程delete修改日程patch或者涉及添加移除参与人/会议室之后如果需要进行二次查询验证操作结果MUST 等待至少 2 秒后再进行查询,以防止因数据同步延迟导致查不到最新数据。注意:不要向用户提及你等待了这 2 秒钟的事情。**
**CRITICAL — 重复性日程的实例操作:目前已经完全具备对重复性日程的某个具体实例进行操作的能力(例如:编辑某个实例、删除某个实例、为某个实例添加/删除参与人、为某个实例添加/移除会议室)。只要在对应的操作中传递对应实例的 `event_id` 即可。因此MUST 先定位到对应的那次实例的 `event_id`(可通过 `events search_event` 搜索日程,或 `+agenda` 查看对应时间范围的日程等相关查询获取),绝对禁止直接使用原重复性日程的 `event_id` 进行操作。**
**时间与日期推断规范:**
为确保准确性,在涉及时间推断时,请严格遵循以下规则:
@@ -28,14 +34,14 @@ metadata:
## 核心场景
### 1. 预约日程/会议、查询/搜索可用会议室
### 1. 预约日程/会议、编辑已有日程、查询/搜索可用会议室
**BLOCKING REQUIREMENT (阻塞性要求): 只要用户的意图包含“预约日程/会议”或“查询/搜索可用会议室”,你必须立即停止其他思考,优先使用 Read 工具完整读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)!未读取该文件前,绝对禁止执行任何日程创建或会议室查询操作。**
**CRITICAL: 必须严格按照上述文档中定义的工作流Workflow执行后续操作。处理该场景时默认做“智能助理”不要做“表单填写机”。能补全的默认值先补全只有在时间冲突、结果无法唯一确定、时间语义存在歧义时才主动追问。**
**CRITICAL: 执行顺序必须固定为:先补默认值,再判断时间是否明确,再进入“明确时间”或“模糊时间/无时间信息”分支。不要跳步。**
**CRITICAL: 明确时间且需要会议室时,先 `+room-find`,再 `+freebusy`;模糊时间或无时间信息时,先 `+suggestion`,如需会议室再批量 `+room-find`。**
**CRITICAL: 执行顺序必须固定为:先判断任务类型(新建/编辑);若为编辑先定位目标日程 `event_id`;再补默认值或继承已定位日程的已知信息;再判断时间是否明确;最后进入“明确时间”或“模糊时间/无时间信息”分支。不要跳步。**
**CRITICAL: 明确时间且需要会议室时,先基于最终确定的时间块执行 `+room-find`,再按需执行 `+freebusy`;模糊时间或无时间信息时,先 `+suggestion`,如需会议室再批量 `+room-find`。如果是编辑已有日程且不改时间,只新增会议室,则必须基于已定位日程的原始时间执行 `+room-find`,且最终落地时默认保留已存在的会议室;只有用户明确表达“更换会议室”或“移除会议室”时,才删除原会议室。**
**CRITICAL: 当用户说“查会议室”“找会议室”“搜可用会议室”或“推荐常用会议室”时,默认是查会议室可用性,不是查会议室资源名录,更严禁拉取历史日程做统计分析。完整规则以 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) 为准。**
**BLOCKING REQUIREMENT: 即使用户的核心诉求是“查会议室”,只要【没有提供明确的起止时间】,绝对禁止直接调用 `+room-find`!必须先进入【无时间/模糊时间】分支,调用 `+suggestion` 拿到候选时间块后,再将时间块传给 `+room-find`。**
**BLOCKING REQUIREMENT: 只要面临时间方案或会议室方案的选择(如模糊时间、无时间或需要会议室),在调用 `+create` 创建日程之前,必须先向用户展示候选方案并等待用户明确确认。绝对禁止擅自替用户做决定。**
**BLOCKING REQUIREMENT: 只要面临时间方案或会议室方案的选择(如模糊时间、无时间或需要会议室),在最终执行创建新日程或更新既有日程之前,必须先向用户展示候选方案并等待用户明确确认。绝对禁止擅自替用户做决定。**
## 核心概念
@@ -68,6 +74,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli calendar +<verb> [flags]`
|----------|------|
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人ISO 8601 时间) |
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和rsvp的状态 |
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(**无明确时间时禁止直接调用,需先走 +suggestion** |
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
@@ -79,6 +86,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli calendar +<verb> [flags]`
- **凡是用户意图是“预定/查询/搜索可用会议室”时,都必须进入 `references/lark-calendar-schedule-meeting.md` 工作流处理。**
- `+room-find` 的时间输入必须是**确定时间块**,不能是时间区间搜索。
- **强制约束:如果用户仅要求“查询会议室”但未提供明确时间,必须先调用 `+suggestion` 获取可用时间块,然后再将时间块交给 `+room-find` 批量查询。严禁直接猜测时间并盲目调用 `+room-find`。**
- **编辑已有日程时,如果用户表达的是“添加会议室/再加一个会议室”,默认语义是增量添加,必须保留已有会议室;只有在用户明确表达“更换会议室”“把原会议室换掉”“移除会议室”时,才执行旧会议室删除。**
## API Resources

View File

@@ -1,28 +1,35 @@
# 预约日程/会议、查询/搜索可用会议室的工作流
# 预约/改约日程会议、查询/搜索可用会议室的工作流
## CRITICAL 执行摘要(先按这个骨架执行,再看下方细则)
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。** 不要把“预约/查会议室”默认等同于“新建”。
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。** 用户一旦给出了既有日程锚点(标题、时间段、`这个日程``这场会`)并表达修改动作(加人、删人、改时间、换会议室等),默认走编辑流。
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,避免把用户带入表单式问答。
- **先补默认值,再判断时间是否明确。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围。
- **新建流先补默认值,编辑流先继承已定位日程信息。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围;编辑流则优先复用已定位日程的标题、时间、已有参与人和会议室信息作为基线
- **只有三类场景才主动追问用户**:存在时间冲突、搜索结果无法唯一确定、时间语义本身有歧义。
- **编辑流的时间基准必须明确。** 如果编辑时不改时间,则后续会议室搜索必须基于已定位日程的原始起止时间;如果既改时间又加会议室,必须先确定最终时间,再基于该时间搜索会议室。
- **编辑流中“新增会议室”默认是增量语义。** 如果用户说的是“加会议室/再加一个会议室”,最终 `+update` 只做 `add`,默认保留已有会议室;只有在用户明确说“更换会议室/移除会议室”时,才执行旧会议室删除。
- **明确时间**:若需要会议室,先 `+room-find`;再 `+freebusy` 判断参会人忙闲;有冲突时先说明冲突,再让用户决定继续当前时间还是改走 `+suggestion`
- **模糊时间或无时间信息**:先 `+suggestion` 产出候选时间块;若需要会议室,再把这些时间块批量交给 `+room-find`,将“候选时间 + 对应可用会议室”一次性展示给用户选择。
- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接调用 `+create` 创建日程。**
- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入 `+create`
- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接执行创建新日程或更新既有日程。**
- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入最终落地操作:创建新日程,或更新既有日程
- **当用户说“查会议室”“找会议室”“搜可用会议室”时,默认意图是查会议室可用性,不是检索会议室资源名录。**
- **必须按顺序执行。** 不要跳过“补默认值”“判断时间明确性”这两个前置步骤。
- **必须按顺序执行。** 不要跳过“任务类型判定”“目标日程定位(编辑流)”“补默认值/继承基线信息”“判断时间明确性”这前置步骤。
> **💡 核心原则:做智能助理,充分利用默认值规则(如默认标题、时长、参与人等)自动补全信息。极力避免像“表单填写机”一样频繁打断并反问用户,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。**
## 严禁行为
- **严禁在未读取对应子命令文档(如 `lark-calendar-room-find.md``lark-calendar-suggestion.md`)的情况下直接调用命令!** 必须先阅读文档掌握最新参数要求与规范。
- **严禁在尚未判断“新建”还是“编辑”之前,就直接进入创建日程或查会议室动作。**
- **严禁把“给明天上午的‘产品发布会’加人/加群/加会议室”这类带有既有日程锚点 + 修改动词的请求,当成新建日程。** 这类请求必须先定位目标日程。
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一的 `event_id` 前,不得调用 `+update`、也不得基于猜测时间去查会议室。
- **严禁在用户仅要求“查会议室”但未提供明确时间时,直接调用 `+room-find`** 必须先默认一个合理时间范围,调用 `+suggestion` 拿到候选时间块,再将时间块传给 `+room-find`
- **不要在用户完全没给时间时,直接反问“你想约什么时候”。** 先补一个合理时间范围,再进入 `+suggestion`
- **不要在“需要会议室 + 时间模糊”的场景下,先让用户只选时间。** 应先批量查出每个候选时间对应的可用会议室,再让用户一次性完成选择。
- **不要在用户已经选中 `+suggestion` 候选时间后,再重复调用 `+freebusy`。**
- **不要在用户未明确说出城市时,仅凭园区/办公室名自动补城市。**
- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自调用 `+create` 创建日程。**
- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自创建新日程或更新既有日程。**
## 适用场景
@@ -33,11 +40,14 @@
- “帮我推荐一个我以前常用的会议室”
- “查询明天下午可用的会议室”
- “明天下午3点约个日程/日历”
- “把明天上午的日程‘产品发布会’加上 小明
- “给下周一的周会换个会议室”
- “把这个日程改到明天下午,并加上学清 F201”
## 核心概念
- **会议室是日程的一种参与人attendee / resource不能脱离日程单独预定。**
- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**预约日程**操作
- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**日程落地**操作:创建新日程,或更新既有日程
## CRITICAL 约束
@@ -45,9 +55,39 @@
- **当用户说“查会议室”“找会议室”“搜可用会议室”等,默认意图是查询会议室可用性,而不是检索会议室资源名录。**
- **必须严格按照下方【工作流】的步骤顺序完成任务。特别是单独查会议室时,若无明确时间,强制先走“模糊时间/无时间信息”分支调用 `+suggestion`。**
## 任务类型判定
| 类型 | 典型语言信号 | 第一动作 |
|------|--------------|----------|
| 新建日程 | “约个会”“安排一个会议”“新建日程”“帮我订个会议室开会” | 补默认值,再进入时间判断 |
| 编辑已有日程 | “给某个日程加人/删人/加群/加会议室”“把某个日程改到…”“给这场会换个会议室” | 先定位目标日程 `event_id`,再进入后续流程 |
进一步规则:
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程``这场会`、某次实例)和**修改动词**(添加、移除、调整、改到、换、延后、提前),默认判定为**编辑已有日程**。
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`,不能直接拿原重复日程的 `event_id` 做更新。
## 工作流
### 1. 智能推断默认值
### 1. 编辑已有日程:先定位目标日程
一旦判定为编辑流,必须先定位目标日程;没有 `event_id` 就不能继续后续修改动作。
定位规则:
- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda``events search_event` 或实例视图缩小范围。
- 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。
- 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`
编辑流分支规则:
- **仅增删普通参会人/群组,不改时间,也不涉及会议室**:定位完成后可直接进入最终 `+update`
- **新增会议室,但不改时间**:必须基于已定位日程的当前 `start/end` 作为时间块执行 `+room-find`,不能因为用户没重复说时间就退回“无时间信息”。
- **既改时间,又新增会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;最终只增量添加新会议室,不自动删除已有会议室。
- **既改时间,又更换会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;只有在用户明确表达“更换”时,最终才执行“移除旧会议室 + 添加新会议室”。
- **只改时间,不涉及会议室**:沿用下方时间工作流,但最终落地必须是 `+update`,不是 `+create`
### 2. 新建日程:智能推断默认值
以下信息智能推断,减少频繁询问用户:
- **标题**:根据上下文自动生成,例如“沟通对齐”“需求讨论”;如无法推断,默认为“会议”
@@ -57,16 +97,24 @@
当搜索特定参与人(人、群)出现多个结果无法唯一确定时,必须询问用户进行选择确认,并将该偏好记录为长期记忆,以便后续自动识别。
### 2. 判断时间是否明确
### 3. 判断时间是否明确
这一步判断的是**最终要落地的目标时间**,不是只看用户原句里有没有重复说时间。
时间基准规则:
- **新建流**:使用用户给出的时间,或默认补全出的时间范围作为时间基准。
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是时间基准。后续如需查会议室,直接使用这个明确时间块。
- **编辑流且改时间**:用户想改到的新时间才是时间基准;若表达模糊,则进入 `+suggestion`
分两类处理:
- **明确时间**如“明天下午3点”
- **模糊时间**:如“明天下午”“下周找个时间”
### 3. 明确时间
### 4. 明确时间
明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。
明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。这里的“明确时间”既可以来自用户直接表达,也可以来自已定位日程的原始时间。
详见 [`+room-find`](./lark-calendar-room-find.md) 与 [`+freebusy`](./lark-calendar-freebusy.md)。
```bash
@@ -89,16 +137,18 @@ lark-cli calendar +freebusy --start "<start>" --end "<end>"
- **参会人过多或包含群组时的处理**
- 如果参与人过多(例如超过 5 人),为避免高耗时,仅需查询**当前用户(自己)**及少数核心人员的忙闲状态即可。
- 如果参与人中包含**群组**,无需展开群组成员查询其忙闲状态。
- **如果没有冲突**:直接让用户选择会议室(如需),然后调用 `calendar +create` 创建日程
- **编辑已有日程且不改时间,只新增会议室时**:这里的 `--slot` 必须来自已定位日程的当前 `start/end`
- **编辑已有日程且既改时间又加会议室时**:这里的 `--slot` 必须来自候选新时间,而不是旧时间;如果用户是“新增会议室”,后续落地只做添加,不删除旧会议室。
- **如果没有冲突**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
- **如果有冲突**:必须先说明冲突情况,询问用户继续选择这个时间还是换个时间
- **如果说换个时间**:放弃当前时间,转入【模糊时间】流程,调用 `+suggestion` 推荐多个可用时间块
- **如果继续选择这个时间**:直接让用户选择会议室(如需),然后调用 `calendar +create` 创建日程
- **如果继续选择这个时间**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
- 位置信息要优先拆到结构化字段:用户明确说了城市才提取 `--city``--building` 不要再重复携带城市前缀。
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。像 `2L``2F` 这类更像楼层或区域定位的短词,优先视为 `--floor`,不要默认当作 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"``--floor "F2"`
- 会议室名要做轻量归一化:`木星会议室` -> `--room-name "木星"``会议室 02` / `02会议室` -> `--room-name "02"`
-`F3-05` / `F5-07` / `3楼-08` 这类复合表达,若能稳定识别楼层与会议室号,应优先提取为 `--floor + --room-name`,不要把整段直接退化成 `--room-name`
### 4. 模糊时间或无时间信息
### 5. 模糊时间或无时间信息
先调用:
详见 [`+suggestion`](./lark-calendar-suggestion.md);若需要会议室,再结合 [`+room-find`](./lark-calendar-room-find.md)。
@@ -115,11 +165,12 @@ lark-cli calendar +suggestion \
规则:
- 若用户完全没有提供时间信息,应先默认一个合理区间后再调用 `+suggestion`
- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后创建日程
- 编辑流中,若用户表达的是“改到明天下午”“下周找个时间再约”这类模糊新时间,则基于用户期望的新时间范围调用 `+suggestion`;不要继续沿用旧时间
- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入最终落地操作:创建新日程,或更新既有日程。
- **需要会议室**:获取多个候选时间块后,**不要急于让用户选时间**。先将这些时间块一次性交给 `calendar +room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化分行展示,让用户一次性完成选择。(**注意:即使用户最初只说“查会议室”,且未带时间,也必须强制走到这一步,先 suggestion 再 room-find**)。
- 用户一旦选择了 `+suggestion` 返回的时间块,**无需再次调用 `+freebusy`**
### 5. 模糊语义消解与长期记忆构建
### 6. 模糊语义消解与长期记忆构建
针对用户专属的时间表达习惯或存在歧义的时间场景,严禁主观臆断。典型例子包括:
@@ -132,7 +183,7 @@ lark-cli calendar +suggestion \
- 应主动澄清真实意图,而不是自行猜测
- 当用户给出澄清后,应将这类个性化定义沉淀为长期偏好,推动后续直接理解类似表达
### 6. 重复性日程
### 7. 重复性日程
若当前会议为重复性日程,调用 `+room-find` 时需携带 `--event-rrule`
@@ -140,15 +191,16 @@ lark-cli calendar +suggestion \
- `reserve_until_time`
若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则创建**。应:
若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则落地日程**。应:
- 向用户明确说明该会议室最长可约至何时。
- 若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。
### 7. 创建日程
### 8. 落地日程变更
用户确认后调用:
详见 [`+create`](./lark-calendar-create.md)。
如果是新建会议,详见 [`+create`](./lark-calendar-create.md)。
如果是更新既有日程,详见 [`+update`](./lark-calendar-update.md)。必须先定位目标 `event_id`,再按用户意图用 `+update` 独立执行字段更新、添加参会人/会议室、移除参会人/会议室,或组合这些动作。若用户意图是“新增会议室”,默认仅追加 `room_id`,不移除已有会议室。
```bash
lark-cli calendar +create \
@@ -156,10 +208,29 @@ lark-cli calendar +create \
--start "<start>" \
--end "<end>" \
--attendee-ids "ou_xxx,oc_xxx,omm_xxx"
lark-cli calendar +update \
--event-id "<event_id>" \
--start "<start>" \
--end "<end>" \
--add-attendee-ids "omm_new_room"
# 仅当用户明确要求“更换会议室”时,才同时移除旧会议室并添加新会议室
lark-cli calendar +update \
--event-id "<event_id>" \
--remove-attendee-ids "omm_old_room" \
--add-attendee-ids "omm_new_room"
```
规则:
- 需要会议室时,将选中的 `room_id` 写入 `--attendee-ids`
- 新建日程时,可使用 `+create`
- 更新既有日程时,优先使用 `+update`。改时间/标题/描述、添加参会人/会议室、移除参会人/会议室可以分别独立执行;
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新按标题猜测一次目标日程。
- 编辑流中如果只是新增群组或普通参会人,不涉及时间和会议室,可直接 `+update --add-attendee-ids ...`
- 编辑流中如果是“新增会议室但不改时间”,必须先基于目标日程原始时间查到可用会议室,再 `+update --add-attendee-ids "<room_id>"`;默认保留已有会议室。
- 编辑流中如果是“既改时间又新增会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间与新增会议室;默认保留已有会议室。
- 编辑流中如果是“既改时间又更换会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间、移除旧会议室并添加新会议室。
- 需要会议室时,将选中的 `room_id` 写入最终落地请求的参与人列表
- 展示会议室候选时,必须保留 CLI/API 返回的完整 `room_name` 原值;允许附加“推断说明”,但禁止用摘要名、楼层及会议室号、容量/视频标签重组后的名称替换原值
## 用户展示建议

View File

@@ -0,0 +1,105 @@
# calendar +update
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
更新既有日程字段,或独立增量添加/移除参会人和会议室。
`+update` 支持三类互相独立的动作:更新日程字段、添加参会人/会议室、移除参会人/会议室。它们可以单独执行,也可以在同一次命令中组合执行。
需要的 scopes: ["calendar:calendar.event:update"]
## 推荐命令
```bash
# 更新标题、描述、时间
lark-cli calendar +update \
--event-id "<EVENT_ID>" \
--summary "产品评审" \
--description "评审需求范围、排期与风险" \
--start "2026-03-12T14:00+08:00" \
--end "2026-03-12T15:00+08:00"
# 增量添加参会人和会议室
lark-cli calendar +update \
--event-id "<EVENT_ID>" \
--add-attendee-ids "ou_aaa,ou_bbb,omm_room"
# 移除参会人和会议室
lark-cli calendar +update \
--event-id "<EVENT_ID>" \
--remove-attendee-ids "ou_aaa,omm_room"
# 同时更新日程信息、移除旧会议室、添加新会议室
lark-cli calendar +update \
--event-id "<EVENT_ID>" \
--summary "产品评审" \
--start "2026-03-12T15:00+08:00" \
--end "2026-03-12T16:00+08:00" \
--remove-attendee-ids "omm_old_room" \
--add-attendee-ids "omm_new_room"
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程要先定位到目标实例的 `event_id`,不要直接使用原重复日程 ID |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用 `primary` |
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
| `--start <time>` | 否 | 新开始时间ISO 8601`2026-03-12T14:00+08:00`)。更新日程时间时必须同时传 `--end` |
| `--end <time>` | 否 | 新结束时间ISO 8601。更新日程时间时必须同时传 `--start` |
| `--rrule <rrule>` | 否 | 新重复规则RFC5545。**不要使用 COUNT如需限制次数推算后转为 UNTIL** |
| `--add-attendee-ids <id_list>` | 否 | 增量添加参会人/会议室,逗号分隔。支持用户 `ou_`、群组 `oc_`、会议室 `omm_` |
| `--remove-attendee-ids <id_list>` | 否 | 增量移除参会人/会议室,逗号分隔。支持用户 `ou_`、群组 `oc_`、会议室 `omm_` |
| `--notify` | 否 | 是否发送更新通知,默认 `true`。可用 `--notify=false` 静默更新 |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
至少需要提供一个动作:`--summary``--description``--start/--end``--rrule``--add-attendee-ids``--remove-attendee-ids`
## 使用规则
- `--add-attendee-ids` 是**增量添加**,不是替换最终参与人列表。不要用它表达“只保留这些人”。
-`--summary``--description`CLI 以“是否显式传入该 flag”判断是否更新而不是以“值是否为空”判断如果显式传入空字符串会把对应字段清空。
- 只想增删参会人或会议室时,不需要同时传 `--summary``--start``--end` 等日程字段。
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids`
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`
- 会议室是 resource attendee必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
- 更新重复性日程的某一次实例时,必须先通过 `+agenda``events search_event` 或实例视图定位该实例的 `event_id`
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
## 高级用法(完整 API 命令)
`+update` 只覆盖标题、描述、时间、重复规则,以及参会人/会议室的增量添加或移除。
如需更新 `location`(地理位置,不含会议室位置)、`visibility`(日程公开范围)、自定义 `reminders`(提醒设置)、自定义 `attendee_ability`(参与人权限)、自定义 `free_busy_status`(日程忙闲状态)、`color`(颜色)、附件、视频会议信息、全天日程,或在新增参会人时配置可选参加状态 等高级参数,请改用完整的 API 命令。建议先通过 `lark-cli schema calendar.events.patch``lark-cli schema calendar.event.attendees.create``lark-cli schema calendar.event.attendees.batch_delete` 查看完整参数定义。
> 完整 API 命令的时间参数是 **Unix 秒字符串**(非 ISO 8601
## 预约/改约会议室场景
如果用户要“改会议时间”“换会议室”“给现有日程加会议室”,必须先阅读 [`lark-calendar-schedule-meeting.md`](lark-calendar-schedule-meeting.md) 并按其中工作流处理:
- 明确时间且需要会议室:先 `+room-find`,再按需 `+freebusy`,用户确认后再 `+update`
- 模糊时间或无时间:先 `+suggestion`,如需会议室再批量 `+room-find`,用户确认后再 `+update`
- 面临时间方案或会议室方案选择时,必须先展示候选方案并等待用户确认。
## 参会人类型
| 前缀 | 类型 | 说明 |
|------|------|------|
| `ou_` | user | 飞书用户 open_id |
| `oc_` | chat | 飞书群组 |
| `omm_` | resource | 会议室 |
> [!CAUTION]
> 这是**写入操作**。执行前必须确认用户意图,特别是移除参会人/会议室或移动会议时间。
## 参考
- [lark-calendar](../SKILL.md) -- 日历全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
- [lark-calendar-schedule-meeting](lark-calendar-schedule-meeting.md) -- 预约/改约会议与会议室工作流
- [lark-calendar-room-find](lark-calendar-room-find.md) -- 查找可用会议室
- [lark-calendar-freebusy](lark-calendar-freebusy.md) -- 查询忙闲

View File

@@ -37,8 +37,8 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户说"找一个表格""按名称搜电子表格""找报表""最近打开的表格" → `lark-cli docs +search` 做资源发现
- `docs +search` 不只搜文档/Wiki结果里会直接返回 `SHEET` 等云空间对象
- 用户说"找一个表格""按名称搜电子表格""找报表""最近打开的表格""最近我编辑过的 xxx"直接`lark-cli drive +search`(参考 [`lark-drive`](../lark-drive/references/lark-drive-search.md))。**老的 `docs +search` 已进入维护期、后续会下线,不要再新增依赖。**
- `drive +search` 结果里会直接返回 `SHEET` / `Base` / `FOLDER` 等云空间对象,是资源发现的统一入口
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
- 文档内容中出现嵌入的 `<sheet>``<bitable>``<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
@@ -51,7 +51,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
**补充:** `docs +search` 也承担"先定位云空间对象,再切回对应业务 skill 操作"的资源发现入口角色;当用户口头说"表格/报表"时,也优先从这里开始
**补充:** 云空间资源发现统一走 [`drive +search`](../lark-drive/references/lark-drive-search.md);当用户口头说"表格/报表/最近我编辑过的 xxx"时,也优先从 `drive +search` 开始。老的 `docs +search` 只在沿用 `--filter` JSON 的存量脚本里保留,后续会下线
## Shortcuts推荐优先使用
@@ -59,10 +59,10 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-doc-search.md) | Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search) |
| [`+search`](references/lark-doc-search.md) | ⚠️ **Deprecated — use [`drive +search`](../lark-drive/references/lark-drive-search.md)**. Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search). Kept for back-compat; new flows should use the drive-scoped command with flat flags. |
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback) |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |

View File

@@ -1,6 +1,10 @@
# docs +search云空间搜索文档 / Wiki / 电子表格)
> ⚠️ **此命令进入维护期,后续会下线。新用法请使用 [`drive +search`](../../lark-drive/references/lark-drive-search.md)。**
>
> `drive +search` 把所有过滤条件扁平化为独立 flag`--edited-since` / `--mine` / `--doc-types` 等),面向自然语言场景设计,同时新增了 `my_edit_time`(我编辑过)、`my_comment_time`(我评论过)等维度。除非要沿用老脚本里的 `--filter` JSON否则**都应该切到 `drive +search`**。
>
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`,以**用户身份**统一搜索云空间对象。

View File

@@ -16,6 +16,7 @@ metadata:
## 快速决策
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"、"最近一周我打开过的 xxx"、"某人创建的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。老的 `docs +search` 进入维护期、后续会下线,不要新增对它的依赖。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
@@ -221,6 +222,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-drive-search.md) | Search Lark docs, Wiki, and spreadsheet files with flat filter flags (preferred over `docs +search`). Natural-language-friendly: `--edited-since`, `--mine`, `--doc-types`, etc. |
| [`+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 |

View File

@@ -12,12 +12,28 @@
## 命令
```bash
# 导入 Word 为新版文档 (docx)
lark-cli drive +import --file ./report.docx --type docx
lark-cli drive +import --file ./legacy.doc --type docx
# 导入 Markdown 为新版文档 (docx)
lark-cli drive +import --file ./README.md --type docx
# 导入纯文本为新版文档 (docx)
lark-cli drive +import --file ./notes.txt --type docx
# 导入 HTML 为新版文档 (docx)
lark-cli drive +import --file ./page.html --type docx
# 导入 Excel 为电子表格 (sheet)
lark-cli drive +import --file ./data.xlsx --type sheet
# 导入 Excel 97-2003 (.xls) 为电子表格 (sheet)
lark-cli drive +import --file ./legacy.xls --type sheet
# 导入 CSV 为电子表格 (sheet)
lark-cli drive +import --file ./data.csv --type sheet
# 导入 Excel 为多维表格 / Base (bitable)
lark-cli drive +import --file ./crm.xlsx --type bitable --name "客户台账"

View File

@@ -0,0 +1,239 @@
# drive +search云空间搜索扁平 flag面向自然语言场景
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`,以**用户身份**统一搜索云空间对象。
和老的 `docs +search` 相比:
- 把常用过滤条件全部**扁平化为独立 flag**`--edited-since``--mine``--doc-types``--folder-tokens` 等),不再要求用户或 AI 手写嵌套 `--filter` JSON
- 额外暴露了 4 个"我"维度:`my_edit_time`(我编辑过)、`my_comment_time`(我评论过)、`open_time`(我打开过)、`create_time`(文档创建时间)——直接对应用户自然语言里的"最近我编辑过的"、"我评论过的"等表达
- 自动处理 `my_edit_time` / `my_comment_time` 的小时级聚合(服务端存储粒度):亚小时输入会向整点 snap并在 stderr 打出提示
- `--mine` 一键从当前登录用户的 open_id 填 `creator_ids`,不必再先去查 contact
> **资源发现入口统一**`drive +search` 同样返回 `SHEET` / `Base` / `FOLDER` 等全部云空间对象,不只是文档 / Wiki。用户说"找一个表格"、"找报表"、"最近打开的表格"时,也从这里开始;定位后再切到对应业务 skill如 `lark-sheets`)做对象内部操作。
## 命令
> **关键约束:搜索关键词必须通过 `--query` 传递。**
> 正确:`lark-cli drive +search --query "方案"`
> 错误:`lark-cli drive +search 方案`
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
### 自然语言 → 命令映射速查
| 用户说 | 命令 |
|---|---|
| 最近一个月我编辑过的文档 | `lark-cli drive +search --query "" --edited-since 1m` |
| 最近一个月我编辑过 且 我评论过的 | `lark-cli drive +search --query "" --edited-since 1m --commented-since 1m` |
| 最近一周我打开过的表格 | `lark-cli drive +search --query "" --opened-since 7d --doc-types sheet` |
| 我创建的所有文档 | `lark-cli drive +search --query "" --mine` |
| 我 30-60 天前创建的文档(粗略"上个月",按 30 天滑窗算) | `lark-cli drive +search --query "" --mine --created-since 2m --created-until 1m` |
| 我 2026 年 3 月创建的文档(精确日历月) | `lark-cli drive +search --query "" --mine --created-since 2026-03-01 --created-until 2026-04-01` |
| 关键词"预算",最近一周我打开过,按编辑时间降序 | `lark-cli drive +search --query 预算 --opened-since 7d --sort edit_time` |
| 某个 wiki space 下、我 30-60 天前创建的 | `lark-cli drive +search --query "" --mine --space-ids space_xxx --created-since 2m --created-until 1m` |
| 张三创建的文档 | `lark-cli drive +search --query "" --creator-ids ou_zhangsan` |
| 我最近 3 个月评论过的 docx | `lark-cli drive +search --query "" --commented-since 3m --doc-types docx` |
### 更多示例
```bash
# 纯关键词搜索
lark-cli drive +search --query "季度总结"
# 使用服务端 query 高级语法(和 docs +search 一致)
lark-cli drive +search --query 'intitle:方案'
lark-cli drive +search --query '"季度 总结"'
lark-cli drive +search --query '方案 OR 草稿'
lark-cli drive +search --query '方案 -草稿'
# 只搜某个文件夹下的文档
lark-cli drive +search --query 方案 --folder-tokens fld_123456
# 只搜某个知识空间下的 Wiki
lark-cli drive +search --query 研发规范 --space-ids space_1234567890fedcba
# 指定群内分享过的文档
lark-cli drive +search --query 方案 --chat-ids oc_1234567890abcdef
# 只搜标题 / 只搜评论
lark-cli drive +search --query 周报 --only-title
lark-cli drive +search --query 延期原因 --only-comment
# 人类可读格式
lark-cli drive +search --query OKR --format pretty
# 翻页(--format json 先拿 page_token
lark-cli drive +search --query 方案 --format json
lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
```
## 参数
### 核心
| 参数 | 必填 | 说明 |
|---|---|---|
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:``""``OR``-`)。空字符串或省略表示纯 filter 浏览 |
| `--page-size <n>` | 否 | 每页数量,默认 15最大 20。超过 20 自动 clamp非正数≤0回落 15**非数字值直接返回 validation 错误** |
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
| `--format` | 否 | `json`(默认)/ `pretty` |
### 身份creator 维度)
| 参数 | 映射 | 说明 |
|---|---|---|
| `--mine` | `creator_ids = [当前用户 open_id]` | bool。一键"我创建的";从当前登录用户身份(`runtime.UserOpenId()`)解析 open_id取不到直接报错提示运行 `lark-cli auth login` |
| `--creator-ids ou_x,ou_y` | `creator_ids = [...]` | 显式 open_id 列表,逗号分隔;**与 `--mine` 互斥** |
### 时间维度(每个维度一对 since/until
| 参数 | 映射 API 字段 | 是否小时 snap |
|---|---|---|
| `--edited-since` / `--edited-until` | `my_edit_time.start` / `.end` | ✅ start 向下取整end 向上取整 |
| `--commented-since` / `--commented-until` | `my_comment_time.start` / `.end` | ✅ 同上 |
| `--opened-since` / `--opened-until` | `open_time.start` / `.end` | ❌ 原样透传 |
| `--created-since` / `--created-until` | `create_time.start` / `.end` | ❌ 原样透传(文档创建时间,非"我"语义)|
### 作用域
| 参数 | 映射 | 说明 |
|---|---|---|
| `--doc-types docx,sheet` | `doc_types` | 逗号分隔。允许值:`doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut` |
| `--folder-tokens fld_a,fld_b` | `folder_tokens`(仅 doc_filter | 存在时只发 `doc_filter`**与 `--space-ids` 互斥** |
| `--space-ids sp_x` | `space_ids`(仅 wiki_filter | 存在时只发 `wiki_filter`**与 `--folder-tokens` 互斥** |
| `--chat-ids oc_x` | `chat_ids` | 逗号分隔 |
| `--sharer-ids ou_x` | `sharer_ids` | 逗号分隔open_id |
### 其他
| 参数 | 映射 | 说明 |
|---|---|---|
| `--only-title` | `only_title: true` | bool |
| `--only-comment` | `only_comment: true` | bool |
| `--sort <value>` | `sort_type`(转大写枚举) | 允许值:`default, edit_time, edit_time_asc, open_time, create_time` |
> `--sort`CLI 只暴露服务端**正式支持**的 5 个值。服务端 enum 里 `CREATE_TIME_ASC` 协议标注"暂不支持"`ENTITY_CREATE_TIME_ASC` / `ENTITY_CREATE_TIME_DESC` 已废弃CLI 直接不放出来,传了会被 cobra enum 校验拒掉。
## 时间值格式
所有 `--*-since` / `--*-until` 共用:
| 输入 | 含义 |
|---|---|
| `7d` / `30d` | N 天前的当前时刻 |
| `1m` | 30 天前(固定 30 天,**不是**日历月)|
| `3m` / `6m` | 90 / 180 天前 |
| `1y` | 365 天前 |
| `2026-04-01` | 本地时区 00:00:00 |
| `2026-04-01 10:00:00` / `2026-04-01T10:00:00` | 本地时区具体时刻 |
| `2026-04-01T10:00:00+08:00` | RFC3339 带时区 |
| `1743523200`(≥ 10 位纯数字)| Unix 秒直接透传 |
> `m` 绑定 month30 天),不支持 minute——因为 `my_edit_time` / `my_comment_time` 在服务端是小时聚合,分钟粒度没意义。
## 小时聚合my_edit_time / my_comment_time
服务端对这两个字段按整点聚合,亚小时输入会被 CLI 向整点对齐:
```text
start: floor 到整点 16:23:45 → 16:00:00
end: ceil 到整点 16:23:45 → 17:00:00
```
发生对齐时stderr 会打印一条 notice例如
```text
notice: my_edit_time has hour-level granularity server-side;
start 2026-04-22 16:23:00 → 2026-04-22 16:00:00
end 2026-04-22 16:28:00 → 2026-04-22 17:00:00
```
stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
## 输出
- `--format json`(默认):`{ total, has_more, page_token, results: [...] }`;所有 `*_time` 字段递归补 `*_time_iso`
- `--format pretty`4 列 table —— `type | title | edit_time | url`
- `title_highlighted` / `summary_highlighted` 可能包含 `<h>` / `<hb>` 高亮标签,客户端对比前需先剥离
> **注意**:返回体里的 `total` 字段不够准确(官方确认,仅供参考)。需要精确统计的场景,按实际 `results` 做去重和累加,不要把 `total` 当结果数承诺。
## 决策规则
- **和 `docs +search` 的选择**:优先使用 `drive +search`(本指令),不要再用 `docs +search``docs +search` 进入维护期、后续会下线。
- **身份快捷方式**:只要用户说"我创建的",直接 `--mine` 即可,不需要先查 contact 拿 open_id。
- **时间维度选择**
- "我编辑的"、"我修改的" → `--edited-since` / `--edited-until`
- "我评论的"、"我回复过的" → `--commented-since` / `--commented-until`
- "我看过的"、"我打开过的"、"最近看过的" → `--opened-since` / `--opened-until`
- "创建于"、"新建的"(文档整体维度,与"我"无关)→ `--created-since` / `--created-until`
- **作用域选择**
- "某个文件夹下" → `--folder-tokens`doc-only
- "某个 wiki 空间下" → `--space-ids`wiki-only
- 两者不能同时使用,混用会报错
- **身份 flag 互斥**`--mine``--creator-ids` 不要同时传,会直接报错。"我和张三创建的" 用 `--creator-ids ou_me,ou_zhangsan`(需要先拿到自己 open_id但这种场景少见
- **实体补全**
- 用户说"某个群里",先用 `lark-im``chat_id`
- 用户说"某人创建/分享的"(非自己),先用 `lark-contact` 查 open_id再填 `--creator-ids` / `--sharer-ids`
- **查询语义下推**`--query` 支持的服务端高级语法(`intitle:``""``OR``-`)优先使用,不要先模糊搜再在客户端二次过滤。
- **时间表达**
- 模糊相对时间("最近半年"、"过去 30 天"、"最近一周")→ `--*-since 6m` / `--*-since 30d` / `--*-since 7d`,不展开成 ISO 时间
- **日历表达**"上个月"、"上周"、"本月"、"前年"、"今年 3 月"等明确日历单位)→ **必须算出绝对 `YYYY-MM-DD` 边界**(如"上个月" = 上一个日历月的 1 号 → 当月 1 号),**不要近似成 `1m`/`2m`**CLI 里 `m` 是固定 30 天、`y` 固定 365 天,跟日历差 0-3 天,月末月初尤其容易偏出去
- 绝对日期 → 直接 `YYYY-MM-DD` 或 RFC3339
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。只有用户明确要"全部 / 全量 / 继续翻"才继续。单轮翻页上限 5 页。
- **原始返回**:用户要求"原始数据"、"接口返回"时用 `--format json`,不做客户端精确过滤或摘要重写。
## 权限
| 操作 | 所需 scope |
|---|---|
| 搜索云空间对象(文档 / Wiki / 表格等资源发现) | `search:docs:read` |
## 常见错误
| code | 含义 | 处理 |
|---|---|---|
| `99992351` | `--creator-ids` / `--sharer-ids` 里有 open_id 超出**应用的通讯录可见范围**,服务端拒绝识别 | 让管理员在开发者后台把这些用户加进应用的"通讯录可见性"授权里;或把超出范围的 open_id 从参数里去掉。这和 `search:docs:read` scope 不是一回事 —— 是"应用能看见哪些人"而不是"应用能调用哪个接口" |
## 时间范围自动裁剪(`--opened-*` 专有)
服务端对 `open_time` 过滤**每次请求最多支持 3 个月**90 天)窗口。其他三个时间维度(`--edited-*` / `--commented-*` / `--created-*`**不受影响**。
CLI 在发请求前会检查 `--opened-since` 到有效 `--opened-until`(没传则取 `now`)的跨度:
| 跨度 | 行为 |
|---|---|
| ≤ 90 天 | 原样透传 |
| 91 ~ 365 天 | **自动裁剪**到"最近一个 90 天 slice"stderr 打一条 notice 列出所有剩余 slice 的 `--opened-since` / `--opened-until` 参数值 |
| > 365 天 | 直接报 validation 错,要求缩小范围或自行拆分多次查询 |
Notice 示例(用户原本要求"过去 8 个月",会被拆成 3 个 slice
```text
notice: --opened-* window spans 240 days (~8 months), exceeds the server-side 3-month (90-day) limit.
this query was narrowed to the most recent slice; 3 slices total:
[slice 1/3 current] --opened-since 2026-01-24T21:54:02+08:00 --opened-until 2026-04-24T21:54:02+08:00
[slice 2/3] --opened-since 2025-10-26T21:54:02+08:00 --opened-until 2026-01-24T21:54:02+08:00
[slice 3/3] --opened-since 2025-08-27T21:54:02+08:00 --opened-until 2025-10-26T21:54:02+08:00
pagination: paginate within a slice via --page-token using that slice's --opened-since / --opened-until values verbatim (NOT the original relative time like '1y' / '8m' — relative times re-resolve against time.Now() and would mismatch the page_token); switch to the next slice's --opened-* flags only after has_more=false, and do not carry --page-token across slices.
```
### Agent 看到 notice 时的处理
**标准流程(分页 × slice 的先后顺序):**
1. **跑 slice 1**(本次请求已自动裁剪到这个窗口),把结果呈现给用户
2. **先在当前 slice 内翻页**:返回的 `has_more = true` 且用户想看更多时,把 `--opened-since` / `--opened-until` 改成 notice 里 `[slice 1/N current]` 行给出的**具体时间值****不要继续用原始的 `--opened-since 1y` 这种相对值**——CLI 每次调用都按 `time.Now()` 重算窗口,相对值 + `--page-token` 一起跑会让 page_token 绑到一个漂移的窗口上、结果静默失真),加 `--page-token` 继续翻,直到 `has_more = false`
3. **再切换到下一个 slice**:当前 slice 翻完后,如果用户还要"更老的",用 notice 里列的 slice 2 的 `--opened-since` / `--opened-until` 值,**其他 flag`--query``--doc-types``--page-size``--sort`……)保持原样,`--page-token` 不带**,重新发请求
4. **依次递推**slice 2 翻完后切 slice 3以此类推
5. 用户只对最近一段感兴趣时,跳过第 3 步及以后 —— 避免无意义的 API 调用
> `--page-token` 只在单 slice 上下文内有效;切 slice 时不要把上一个 slice 的 `page_token` 带过去。
### 注意事项
- `--sort` 在**单 slice 内部**是正确的。跨 slice 的全局 sort例如"过去一年我打开过的,按 edit_time desc 排")不被 CLI 保证,需要 agent 自行拉完多个 slice 后在客户端 re-sort 再呈现
- 裁剪只改 request 发出去的 `open_time` 范围,`--query` / 其他 filter 不动
- 最后一个最老的slice 常常不足 90 天,这是正常的截断

View File

@@ -86,6 +86,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.
- `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.
@@ -123,6 +124,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `chats.link` | `im:chat:read` |
| `chats.list` | `im:chat:read` |
| `chats.update` | `im:chat:update` |
| `chat.members.bots` | `im:chat.members:read` |
| `chat.members.create` | `im:chat.members:write_only` |
| `chat.members.delete` | `im:chat.members:write_only` |
| `chat.members.get` | `im:chat.members:read` |

View File

@@ -35,6 +35,9 @@ lark-cli im +messages-search --query "reminder" --exclude-sender-type bot
# Only messages that @me
lark-cli im +messages-search --query "announcement" --is-at-me
# Only messages that @mention specific users (results also include messages that @all)
lark-cli im +messages-search --query "release" --at-chatter-ids ou_xxx,ou_yyy
# Combined filters + time range
lark-cli im +messages-search --query "meeting" --sender ou_xxx --chat-type group --start "2026-03-13T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00"
@@ -71,6 +74,7 @@ lark-cli im +messages-search --query "test" --dry-run
| `--sender-type <type>` | No | Sender type: `user` / `bot` |
| `--exclude-sender-type <type>` | No | Exclude messages from `user` or `bot` senders |
| `--is-at-me` | No | Only return messages that mention `@me` |
| `--at-chatter-ids <ids>` | No | Filter by @mentioned user open_ids, comma-separated (`ou_xxx,ou_yyy`). Matched results also include messages that `@all` |
| `--start <time>` | No | Start time with local timezone offset required (e.g. `2026-03-24T00:00:00+08:00`) |
| `--end <time>` | No | End time with local timezone offset required (e.g. `2026-03-25T23:59:59+08:00`) |
| `--page-size <n>` | No | Page size (default 20, range 1-50) |

View File

@@ -217,6 +217,38 @@ lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
**注意:** 撤回是异步操作,`recall` 返回成功仅表示请求已受理,实际结果需通过 `get_recall_detail` 查询。若响应中无 `recall_available` 字段,说明该邮件或应用不支持撤回,不要主动提及撤回。
### 分享邮件到 IM
将邮件以卡片形式分享到飞书群聊或个人会话。
**依赖 Scope** `mail:user_mailbox.message:readonly`、`im:message`、`im:message.send_as_user`
1. 分享单封邮件到群聊(默认 `--receive-id-type chat_id`
```bash
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id oc_xxx
```
2. 分享整个会话到群聊:
```bash
lark-cli mail +share-to-chat --thread-id <会话ID> --receive-id oc_xxx
```
3. 通过邮箱分享给个人:
```bash
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id user@example.com --receive-id-type email
```
4. 如果不知道群聊 ID先搜索
```bash
lark-cli im +chat-search --query "群名关键词"
```
从结果中获取 `chat_id`,然后执行分享。
**注意:**
- 分享需要用户在目标会话中有发消息权限
- 需要同时授权 mail 和 im 两个域的 scope
- 分享的卡片包含邮件摘要信息,收件人可点击查看
### 正文格式:优先使用 HTML
撰写邮件正文时,**默认使用 HTML 格式**body 内容会被自动检测)。仅当用户明确要求纯文本时,才使用 `--plain-text` 标志强制纯文本模式。
@@ -339,6 +371,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`
| [`+send-receipt`](references/lark-mail-send-receipt.md) | Send a read-receipt reply for an incoming message that requested one (i.e. carries the READ_RECEIPT_REQUEST label). Body is auto-generated (subject / recipient / send time / read time) to match the Lark client's receipt format — callers cannot customize it, matching the industry norm that read-receipt bodies are system-generated templates, not free-form replies. Intended for agent use after the user confirms. |
| [`+decline-receipt`](references/lark-mail-decline-receipt.md) | Dismiss the read-receipt request banner on an incoming mail by clearing its READ_RECEIPT_REQUEST label, without sending a receipt. Use when the user wants to silence the prompt but refuse to confirm they have read it. Idempotent — safe to re-run. |
| [`+signature`](references/lark-mail-signature.md) | List or view email signatures with default usage info. |
| [`+share-to-chat`](references/lark-mail-share-to-chat.md) | Share an email or thread as a card to a Lark IM chat. |
## API Resources

View File

@@ -0,0 +1,87 @@
# mail +share-to-chat
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
将邮件以卡片形式分享到飞书 IM 会话(群聊或个人对话)。内部两步完成:创建分享凭证 → 发送卡片到 IM。
**依赖 Scope** `mail:user_mailbox.message:readonly``im:message``im:message.send_as_user`
## 命令
```bash
# 分享单封邮件到群聊(默认 receive-id-type=chat_id
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id oc_xxx
# 分享整个会话到群聊
lark-cli mail +share-to-chat --thread-id <会话ID> --receive-id oc_xxx
# 通过邮箱分享给个人
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id user@example.com --receive-id-type email
# Dry Run
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id oc_xxx --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--message-id <id>` | 否(二选一) | 要分享的邮件 ID`--thread-id` 互斥 |
| `--thread-id <id>` | 否(二选一) | 要分享的邮件会话 ID`--message-id` 互斥 |
| `--receive-id <id>` | 是 | 目标接收者 ID类型由 `--receive-id-type` 决定 |
| `--receive-id-type <type>` | 否 | 接收者 ID 类型(默认 `chat_id`)。可选:`chat_id` / `open_id` / `user_id` / `union_id` / `email` |
| `--mailbox <email>` | 否 | 邮箱地址(默认 `me` |
| `--dry-run` | 否 | 仅打印请求,不执行 |
## 返回值
```json
{
"ok": true,
"data": {
"card_id": "550e8400-e29b-41d4-a716-446655440000",
"im_message_id": "om_dc13264520392913993dd051dba21dcf"
}
}
```
## 典型场景
### 场景 1用户说"帮我把这封邮件分享到项目群"
```bash
# Step 1: 搜索群聊获取 chat_id
lark-cli im +chat-search --query "项目群"
# → 获取 chat_id: oc_xxx
# Step 2: 分享邮件
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id oc_xxx
```
### 场景 2分享整个邮件会话
```bash
lark-cli mail +share-to-chat --thread-id <会话ID> --receive-id oc_xxx
```
### 场景 3通过邮箱分享给个人
```bash
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id alice@example.com --receive-id-type email
```
## 常见错误
| 症状 | 原因 | 解决 |
|------|------|------|
| `either --message-id or --thread-id is required` | 两个参数都未传 | 传入其中一个 |
| `--message-id and --thread-id are mutually exclusive` | 两个参数同时传 | 只传一个 |
| 403 `user not in chat` | 用户不在目标会话中 | 确认用户是群成员 |
| 404 `message not found` | 邮件 ID 无效 | 确认邮件 ID 正确 |
| 403 `permission not granted` | 缺少 `im:message``im:message.send_as_user` scope | 重新授权:`lark-cli auth login --scope "im:message,im:message.send_as_user"` |
## 相关命令
- `lark-cli im +chat-search` — 搜索群聊获取 chat_id
- `lark-cli mail +message` — 查看邮件内容
- `lark-cli mail +thread` — 查看邮件会话

View File

@@ -25,13 +25,15 @@ metadata:
2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户。
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。
4. 如果是会议的妙记,应优先使用 [vc +search](../lark-vc/references/lark-vc-search.md) 先定位会议,再按需通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
5. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准。
### 2. 查看妙记基础信息
1. 当用户只需要确认某条妙记的标题、封面、时长、所有者、URL 等基础信息时,使用 `minutes minutes get`
2. 如果用户给的是妙记 URL应先从 URL 末尾提取 `minute_token`,再调用 `minutes minutes get`
3. 用户意图不明确时,默认先给基础信息,帮助确认是否命中目标妙记
3. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC 链路拿到 `minute_token`,再调用 `minutes minutes get`
4. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。
> 使用 `lark-cli schema minutes.minutes.get` 可查看完整返回值结构。核心字段包含:`title`(标题)、`cover`(封面 URL、`duration`(时长,毫秒)、`owner_id`(所有者 ID、`url`(妙记链接)。
@@ -72,7 +74,8 @@ Minutes (妙记) ← minute_token 标识
> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search`
> - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill
> - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
> - 用户说"我的妙记 / 我拥有的妙记 / 我参与的妙记"时,可将相关过滤条件映射为 `me``me` 表示当前用户
> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要的是逐字稿、总结、待办、章节,再走 `vc +notes --minute-tokens`
> - “我的妙记”“参与的妙记”等自然语言映射细则,以 [minutes +search](references/lark-minutes-search.md) 为准
> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果
> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限
> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get`

View File

@@ -109,7 +109,7 @@ API 限流 5 次/秒,批量下载时需注意控制频率。
|------|---------|
| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx``obcnxxxxxxxxxxxxxxxxxxxx` |
| 妙记元信息查询 | `lark-cli minutes minutes get --params '{"minute_token": "obcn..."}'` |
| 会议纪要查询 | `lark-cli vc +notes --meeting-ids <id>` 返回结果中关联的妙记 token |
| 会议录制查询 | `lark-cli vc +recording --meeting-ids <id>` `lark-cli vc +recording --calendar-event-ids <event_id>` |
## 常见错误与排查

View File

@@ -41,12 +41,17 @@ lark-cli minutes +search --participant-ids "ou_x,ou_y"
# 按所有者过滤open_id逗号分隔
lark-cli minutes +search --owner-ids "ou_owner,ou_owner_2"
# 查询我参与的妙记
# 严格只查我作为参与的妙记(不含我拥有)
lark-cli minutes +search --participant-ids "me"
# 查询我拥有的妙记
lark-cli minutes +search --owner-ids "me"
# 广义查询我参与的妙记(自然语言默认:我拥有 我参与)
lark-cli minutes +search --owner-ids "me" --start 2026-03-10 --end 2026-03-10
lark-cli minutes +search --participant-ids "me" --start 2026-03-10 --end 2026-03-10
# 然后按 token 去重合并两次结果
# 多条件组合查询
lark-cli minutes +search --owner-ids "ou_owner" --participant-ids "ou_x" --start "2026-03-10T00:00+08:00"
@@ -86,11 +91,22 @@ lark-cli minutes +search --query "预算复盘" --format json
`--owner-ids``--participant-ids` 中可使用 `me`,表示当前登录用户。该值会在本地解析为当前用户的 `open_id`,无需手动先查询自己的用户 ID。
若当前环境尚未完成用户登录,或 CLI 无法解析出当前用户的 `open_id`,则应先执行 `lark-cli auth login`,再重新执行搜索。
### 4. 支持分页
### 4. 自然语言中的“参与的妙记”默认按并集理解
当用户说"我参与的妙记""我参加过的妙记""参与过的妙记"时,默认理解为"我涉及的全部妙记"
- 我拥有的妙记:`--owner-ids me`
- 我作为参与者的妙记:`--participant-ids me`
不要只跑一次 `--participant-ids me` 就直接下结论,也不要把 `--owner-ids me``--participant-ids me` 同时塞进一次查询里赌接口语义。应分别查询后,按 `token` 做并集去重。
只有在用户明确说"仅我参与但不是我拥有""别人拥有但我参与""只看参与者身份"时,才只使用 `--participant-ids`
### 5. 支持分页
当返回 `has_more=true` 时,使用响应中的 `page_token` 配合 `--page-token` 获取下一页结果。
### 5. 日期型 `--end` 包含当天整天
### 6. 日期型 `--end` 包含当天整天
`--end` 传入的是仅日期格式(如 `2026-03-10`CLI 会将它解释为当天 `23:59:59`,而不是当天 `00:00:00`
CLI 会先按输入的本地日历日语义解析,再标准化为 RFC3339 时间戳发给 API在 dry-run 或排查请求体时,看到的 `Z` 结尾时间表示同一个绝对时间点的 UTC 表示,不改变“按当天整天查询”的语义。
@@ -102,11 +118,19 @@ CLI 会先按输入的本地日历日语义解析,再标准化为 RFC3339 时
如果用户说“昨天的妙记”“今天的妙记”“某一天内的妙记”,应把 `--start``--end` 都设置为同一天,而不是把 `--end` 设成下一天。
### 6. 会议的妙记先定位会议
### 7. 会议的妙记先定位会议
如果用户明确要找某场会议的妙记,或同时提到“会议 / 开会 / 会”和“妙记”,应优先使用 `vc +search` 先定位会议,再按需通过 `vc +recording` 获取 `minute_token`,不要直接按妙记时间范围或关键词搜索。
只有在无法通过会议搜索定位目标会议,或用户明确要求按妙记维度检索时,才回退到 `minutes +search`
如果用户要的是"某场会议的妙记信息""某个日程对应的妙记详情""minute\_token""妙记链接""标题""时长""owner",正确链路是:
1. `vc +search``calendar +agenda` 先定位会议 / 日程
2. `vc +recording` 获取 `minute_token`
3. `minutes minutes get` 查询妙记基础信息
不要为了查"妙记信息"直接走 `vc +notes --meeting-ids``vc +notes` 只适用于逐字稿、总结、待办、章节等纪要内容。
<br />
## 时间格式
@@ -141,7 +165,7 @@ lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PA
## 搜索结果中的下一步
搜索结果中的 `token` 可直接作为 `minute_token` 用于继续查询妙记产物:
通常先用搜索结果中的 `token` 获取妙记基础信息,确认描述、链接等元数据是否命中目标;需要进一步查看内容时,再继续查询关联的纪要产物。
通常先用搜索结果中的 `token` 获取妙记基础信息,确认描述、链接等元数据是否命中目标;只有需要进一步查看逐字稿、总结、待办、章节时,再继续查询关联的纪要产物。
如果你已经确定目标妙记,优先直接复用搜索结果中的 `token`,避免重复搜索。
@@ -166,8 +190,9 @@ lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2
## 提示
- 当用户说“我的妙记”时,优先理解为 `--owner-ids me`
- 当用户说“我参与的妙记”时,优先理解为 `--participant-ids me`
- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;只有无法定位目标会议时,再回退到妙记搜索
- 当用户说“我参与的妙记”“我参加过的妙记”时,默认理解为 `--owner-ids me` `--participant-ids me` 两次查询后的并集
- 当用户明确说“仅我参与但不是我拥有”时,优先理解为 `--participant-ids me`
- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;如果要的是妙记信息,走 `vc +recording``minutes minutes get`,只有要纪要内容时才走 `vc +notes --minute-tokens`
- 必须使用 `--format json` 输出,你更加擅长解析 JSON 数据。
- 排查参数与请求结构时优先使用 `--dry-run`
- 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的妙记,需要拆分为多次时间范围为一个月查询。
@@ -178,3 +203,4 @@ lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
- [lark-vc](../../lark-vc/SKILL.md) -- 视频会议全部命令

View File

@@ -87,7 +87,9 @@ Meeting (视频会议)
> **优先级**:当用户搜索历史会议时,应优先使用 `vc +search` 而非 `calendar events search`。calendar 的搜索面向日程vc 的搜索面向已结束的会议记录,支持按参会人、组织者、会议室等维度过滤。
>
> **路由规则**:如果用户在问“开过的会”“今天开了哪些会”“最近参加过什么会”“已结束的会议”“历史会议记录”,优先使用 `vc +search`。只有在查询未来日程、待开的会、agenda 时才优先使用 [lark-calendar](../lark-calendar/SKILL.md)。
>
>
> **妙记边界**`+notes` 负责纪要内容、逐字稿和 AI 产物;妙记基础信息请优先看 [`+recording`](references/lark-vc-recording.md) 与 [lark-minutes](../lark-minutes/SKILL.md)。
>
> **特殊情况**: 当用户查询“今天有哪些会议”时,通过 `vc +search` 查询今天开过的会议记录,同时使用 lark-calendar 技能查询今天还未开始的会议,统一整理后展示给用户。
## Shortcuts推荐优先使用

View File

@@ -5,6 +5,8 @@
通过 meeting_id 或 calendar_event_id 查询对应的 minute_token。这是 VC 域和 Minutes 域之间的桥梁命令。只读操作。
> **边界提醒:** 如果用户明确要的是"妙记信息""妙记详情""妙记链接""minute_token""标题""时长""owner"这类妙记元信息,先用本命令拿到 `minute_token`,再调用 `minutes minutes get`。不要直接切到 `vc +notes``vc +notes` 只用于纪要内容和逐字稿。
本 skill 对应 shortcut`lark-cli vc +recording`
## 命令
@@ -83,7 +85,17 @@ lark-cli vc +recording --meeting-ids xxx
lark-cli minutes +download --minute-token <minute_token>
```
### 场景 2知道 meeting_id获取完整纪要(含 AI 产物)
### 场景 2知道 meeting_id查询妙记基础信息
```bash
# 第 1 步:通过 meeting_id 查询录制,拿到 minute_token
lark-cli vc +recording --meeting-ids xxx
# 第 2 步:使用上一步返回的 minute_token 查询妙记基础信息
lark-cli minutes minutes get --params '{"minute_token":"<minute_token>"}'
```
### 场景 3知道 meeting_id想获取完整纪要含 AI 产物)
```bash
# 第 1 步:通过 meeting_id 查询录制,拿到 minute_token
@@ -93,7 +105,7 @@ lark-cli vc +recording --meeting-ids xxx
lark-cli vc +notes --minute-tokens <minute_token>
```
### 场景 3:先搜索会议,再获取录制并下载
### 场景 4:先搜索会议,再获取录制并下载
```bash
# 第 1 步:搜索历史会议,拿到 meeting_ids
@@ -106,7 +118,7 @@ lark-cli vc +recording --meeting-ids <ids>
lark-cli minutes +download --minute-token <token>
```
### 场景 4:从日历事件获取录制
### 场景 5:从日历事件获取录制
```bash
# 第 1 步:通过日历 event_id 查询录制,拿到 minute_token
@@ -131,7 +143,7 @@ lark-cli minutes +download --minute-token <minute_token>
- 默认使用 `--format json` 输出Agent 更擅长解析 JSON 数据。
- 排查参数与请求结构时优先使用 `--dry-run`
- `minute_token` 从录制 URL 尾段解析(`https://meetings.feishu.cn/minutes/{minute_token}`)。
- 拿到 `minute_token`可直接传给 `minutes +download` `vc +notes --minute-tokens`
- 拿到 `minute_token`,如果要妙记基础信息,优先传给 `minutes minutes get`;如果要下载媒体文件,传给 `minutes +download`;如果要逐字稿、总结、待办、章节,再传给 `vc +notes --minute-tokens`
## 参考

View File

@@ -130,11 +130,16 @@ lark-cli vc +search --query "周会" --page-size 15 --page-token "<PAGE_TOKEN>"
## 搜索结果中的下一步
搜索结果中的 `meeting_id` 可直接用于继续查询会议纪要:
搜索结果中的 `meeting_id` 可直接用于继续查询会议纪要或妙记
```bash
# 根据 meeting_id 获取会议纪要
# 如果要会议纪要 / 逐字稿 / AI 总结 / 待办 / 章节
lark-cli vc +notes --meeting-ids <MEETING_ID>
# 如果要会议对应的妙记信息 / minute_token / 妙记链接
lark-cli vc +recording --meeting-ids <MEETING_ID>
# 然后再用返回的 minute_token 调用:
lark-cli minutes minutes get --params '{"minute_token":"<MINUTE_TOKEN>"}'
```
## 常见错误与排查
@@ -151,9 +156,11 @@ lark-cli vc +notes --meeting-ids <MEETING_ID>
- 排查参数与请求结构时优先使用 `--dry-run`
- 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的会议,需要拆分为多次时间范围为一个月查询。
- 不要使用 `yesterday``today` 这类相对时间字面量;请先转换成明确日期,例如 `2026-03-10`
- 用户如果明确问的是“妙记信息”而不是“纪要内容”,不要默认走 `vc +notes`;应先用 `vc +recording`
## 参考
- [lark-vc](../SKILL.md) -- 视频会议全部命令
- [lark-vc-recording](lark-vc-recording.md) -- 查询会议对应的 minute_token
- [lark-vc-notes](lark-vc-notes.md) -- 获取会议纪要
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -24,7 +24,7 @@
**不要以直接生成 json 语法的方式创作 raw 格式的飞书 OpenAPI 原生画板节点参数**
思维导图,时序图,类图,饼图,流程图等图推荐使用 Mermaid/PlantUML 语法绘制。
思维导图,时序图,类图,饼图,流程图等图推荐使用 Mermaid/PlantUML 语法绘制。
而当需要绘制架构图,组织架构图,泳道图,对比图,鱼骨图,柱状图,折线图,树状图,漏斗图,金字塔图,循环/飞轮图,里程碑或其他较为复杂的图表时,推荐参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板) 使用 whiteboard-cli 工具创作。

View File

@@ -39,7 +39,8 @@
## 画板怎么处理 SVG
画板的 svg-parser 把可识别元素转成可编辑节点, 其余降级为内嵌图片(渲染没问题, 虽然不可编辑, 但是可以正常显示), **不需要所有元素都可编辑, 一定要兼顾可编辑和美观漂亮**
画板的 svg-parser 把可识别元素转成可编辑节点, 其余降级为内嵌图片(渲染没问题, 虽然不可编辑, 但是可以正常显示);但 `<radialGradient>` / `<filter>` / `<clipPath>` 等装饰特性画板完全不支持,会导致渲染问题(见下方⚠️)
**不需要所有元素都可编辑, 但必须避免使用不支持的装饰特性, 且要兼顾可编辑和美观漂亮**
**可识别的元素**
@@ -49,5 +50,5 @@
- 分组:`<g>` / `<a>` / `<use>` 引用 `<symbol>`
- 变换:`translate` / `rotate` / `scale` 正常;`skewX` / `skewY` / `matrix(...)` 降级
**装饰特性**
- `<radialGradient>` / `<filter>` / `<pattern>` / `<clipPath>` / `<mask>` → 画板通过图片路径保留视觉 (光晕/阴影/纹理/遮罩等效果都在, 元素不可再编辑但不丢视觉)
**⚠️ [!IMPORTANT] 不支持的装饰特性**
- `<radialGradient>` / `<filter>` / `<pattern>` / `<clipPath>` / `<mask>` → 画板都不支持,**请避免使用,否则会导致画板渲染问题**

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestCalendar_UpdateDryRun(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")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"calendar", "+update",
"--calendar-id", "cal_dry",
"--event-id", "evt_dry",
"--summary", "updated dry-run",
"--start", "2026-04-25T10:00:00+08:00",
"--end", "2026-04-25T11:00:00+08:00",
"--remove-attendee-ids", "ou_old,omm_oldroom",
"--add-attendee-ids", "ou_new,oc_group,omm_newroom",
"--notify=false",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "PATCH", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "updated dry-run", gjson.Get(out, "api.0.body.summary").String(), "stdout:\n%s", out)
require.False(t, gjson.Get(out, "api.0.body.need_notification").Bool(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry/attendees/batch_delete", gjson.Get(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "ou_old", gjson.Get(out, `api.1.body.delete_ids.#(type=="user").user_id`).String(), "stdout:\n%s", out)
require.Equal(t, "omm_oldroom", gjson.Get(out, `api.1.body.delete_ids.#(type=="resource").room_id`).String(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry/attendees", gjson.Get(out, "api.2.url").String(), "stdout:\n%s", out)
require.Equal(t, "ou_new", gjson.Get(out, `api.2.body.attendees.#(type=="user").user_id`).String(), "stdout:\n%s", out)
require.Equal(t, "oc_group", gjson.Get(out, `api.2.body.attendees.#(type=="chat").chat_id`).String(), "stdout:\n%s", out)
require.Equal(t, "omm_newroom", gjson.Get(out, `api.2.body.attendees.#(type=="resource").room_id`).String(), "stdout:\n%s", out)
}

View File

@@ -0,0 +1,134 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestCalendar_UpdateEventWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
suffix := clie2e.GenerateSuffix()
calendarID := getPrimaryCalendarID(t, ctx)
userOpenID := getCurrentUserOpenIDForCalendar(t, ctx)
startAt := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Minute)
endAt := startAt.Add(30 * time.Minute)
updatedStartAt := startAt.Add(30 * time.Minute)
updatedEndAt := updatedStartAt.Add(45 * time.Minute)
createdSummary := "lark-cli-e2e-update-before-" + suffix
updatedSummary := "lark-cli-e2e-update-after-" + suffix
updatedDescription := "updated by calendar update workflow"
var eventID string
var deletedEvent bool
t.Run("create event as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "+create",
"--summary", createdSummary,
"--start", startAt.Format(time.RFC3339),
"--end", endAt.Format(time.RFC3339),
"--calendar-id", calendarID,
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
eventID = gjson.Get(result.Stdout, "data.event_id").String()
require.NotEmpty(t, eventID, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
if eventID == "" || deletedEvent {
return
}
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"calendar", "events", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
clie2e.ReportCleanupFailure(parentT, "delete event "+eventID, deleteResult, deleteErr)
})
})
t.Run("update event and add attendee as bot", func(t *testing.T) {
require.NotEmpty(t, eventID)
require.NotEmpty(t, userOpenID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "+update",
"--event-id", eventID,
"--calendar-id", calendarID,
"--summary", updatedSummary,
"--description", updatedDescription,
"--start", updatedStartAt.Format(time.RFC3339),
"--end", updatedEndAt.Format(time.RFC3339),
"--add-attendee-ids", userOpenID,
"--notify=false",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, eventID, gjson.Get(result.Stdout, "data.event_id").String())
})
t.Run("verify updated event as bot", func(t *testing.T) {
require.NotEmpty(t, eventID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "events", "get"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, updatedSummary, gjson.Get(result.Stdout, "data.event.summary").String())
assert.Equal(t, updatedDescription, gjson.Get(result.Stdout, "data.event.description").String())
assert.Equal(t, unixSecondsRFC3339(updatedStartAt), gjson.Get(result.Stdout, "data.event.start_time.timestamp").String())
assert.Equal(t, unixSecondsRFC3339(updatedEndAt), gjson.Get(result.Stdout, "data.event.end_time.timestamp").String())
})
t.Run("delete event as bot", func(t *testing.T) {
require.NotEmpty(t, eventID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "events", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
deletedEvent = true
})
}

View File

@@ -0,0 +1,338 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDriveSearchDryRun_RequestShape locks in the dry-run request body so
// agents that key off of stdout (URL, doc_filter / wiki_filter, scalar
// filters) don't silently regress. Run end-to-end so cobra flag parsing,
// readDriveSearchSpec, and the dry-run renderer all execute against the
// real binary.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any network call.
func TestDriveSearchDryRun_RequestShape(t *testing.T) {
setDriveSearchE2EEnv(t)
tests := []struct {
name string
args []string
// JSONPath assertions over the dry-run body.
wantURL string
wantQuery string
wantDocFilter bool
wantWikiFilter bool
wantDocFilterFields map[string]string // gjson path under api.0.body.doc_filter -> string value (or "" to require existence only)
wantWikiFilterFields map[string]string
}{
{
name: "basic --query emits both filters",
args: []string{
"drive", "+search",
"--query", "season report",
"--page-size", "5",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "season report",
wantDocFilter: true,
wantWikiFilter: true,
},
{
name: "--folder-tokens scopes to doc_filter only",
args: []string{
"drive", "+search",
"--query", "x",
"--folder-tokens", "fld_aaa,fld_bbb",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantDocFilter: true,
wantDocFilterFields: map[string]string{
"folder_tokens.0": "fld_aaa",
"folder_tokens.1": "fld_bbb",
},
},
{
name: "--space-ids scopes to wiki_filter only",
args: []string{
"drive", "+search",
"--query", "x",
"--space-ids", "sp_xxx",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantWikiFilter: true,
wantWikiFilterFields: map[string]string{
"space_ids.0": "sp_xxx",
},
},
{
name: "--sort default maps to DEFAULT_TYPE in body",
args: []string{
"drive", "+search",
"--query", "x",
"--sort", "default",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantDocFilter: true,
wantWikiFilter: true,
wantDocFilterFields: map[string]string{
"sort_type": "DEFAULT_TYPE",
},
},
{
name: "mixed-case --doc-types is normalized to upper case in body",
args: []string{
"drive", "+search",
"--query", "x",
"--doc-types", "docx,Sheet,BITABLE",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantDocFilter: true,
wantWikiFilter: true,
wantDocFilterFields: map[string]string{
"doc_types.0": "DOCX",
"doc_types.1": "SHEET",
"doc_types.2": "BITABLE",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url=%q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "api.0.body.query").String(); got != tt.wantQuery {
t.Fatalf("body.query=%q, want %q\nstdout:\n%s", got, tt.wantQuery, out)
}
if tt.wantDocFilter && !gjson.Get(out, "api.0.body.doc_filter").Exists() {
t.Fatalf("doc_filter missing\nstdout:\n%s", out)
}
if !tt.wantDocFilter && gjson.Get(out, "api.0.body.doc_filter").Exists() {
t.Fatalf("doc_filter should be omitted\nstdout:\n%s", out)
}
if tt.wantWikiFilter && !gjson.Get(out, "api.0.body.wiki_filter").Exists() {
t.Fatalf("wiki_filter missing\nstdout:\n%s", out)
}
if !tt.wantWikiFilter && gjson.Get(out, "api.0.body.wiki_filter").Exists() {
t.Fatalf("wiki_filter should be omitted\nstdout:\n%s", out)
}
for path, want := range tt.wantDocFilterFields {
if got := gjson.Get(out, "api.0.body.doc_filter."+path).String(); got != want {
t.Fatalf("doc_filter.%s=%q, want %q\nstdout:\n%s", path, got, want, out)
}
}
for path, want := range tt.wantWikiFilterFields {
if got := gjson.Get(out, "api.0.body.wiki_filter."+path).String(); got != want {
t.Fatalf("wiki_filter.%s=%q, want %q\nstdout:\n%s", path, got, want, out)
}
}
})
}
}
// TestDriveSearchDryRun_OpenedClamping locks in the agent-facing slice
// notice for --opened-* spans over 90 days: the request body must carry
// the most recent 90-day window, and stderr must list slice N's flag
// values verbatim so the agent can re-invoke for older ranges.
func TestDriveSearchDryRun_OpenedClamping(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--opened-since", "8m",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// Notice goes to stderr alongside other dimension notices.
for _, want := range []string{
"--opened-* window spans",
"3 slices total",
"[slice 1/3 current]",
"[slice 2/3]",
"[slice 3/3]",
"--page-token",
} {
if !strings.Contains(result.Stderr, want) {
t.Fatalf("notice missing %q\nstderr:\n%s", want, result.Stderr)
}
}
// Slice 1 specifically must spell out concrete --opened-* flag values
// (not just the timestamps in arrow form): an agent paginating slice 1
// has to copy these verbatim, otherwise reusing the original relative
// time '8m' would drift the window against time.Now() and mismatch the
// page_token.
for _, label := range []string{"[slice 1/3 current]", "[slice 2/3]", "[slice 3/3]"} {
var line string
for _, l := range strings.Split(result.Stderr, "\n") {
if strings.Contains(l, label) {
line = l
break
}
}
if !strings.Contains(line, "--opened-since ") || !strings.Contains(line, "--opened-until ") {
t.Fatalf("%s line must spell out both flags, got %q\nfull stderr:\n%s", label, line, result.Stderr)
}
}
// And the request body's open_time must reflect the clamped window
// (start and end both present, span = 90 days exactly).
body := result.Stdout
start := gjson.Get(body, "api.0.body.doc_filter.open_time.start").Int()
end := gjson.Get(body, "api.0.body.doc_filter.open_time.end").Int()
if start == 0 || end == 0 {
t.Fatalf("doc_filter.open_time.start/end missing\nstdout:\n%s", body)
}
if delta := end - start; delta != 90*86400 {
t.Fatalf("clamped span = %d seconds, want %d (90 days)\nstdout:\n%s", delta, 90*86400, body)
}
}
// TestDriveSearchDryRun_RejectsOpenedOver1Year locks in the hard cap: a
// --opened-* span beyond 365 days fails validation up front and never
// reaches the API. Important because the alternative (silent slicing into
// many windows) would produce a rate-limit / runaway request loop.
//
// Dry-run captures spec-level validation errors into the JSON envelope's
// `error` field (api list comes back empty); the process still exits 0
// because the dry-run itself succeeded — it just told you what would have
// failed at execution time.
func TestDriveSearchDryRun_RejectsOpenedOver1Year(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--opened-since", "2y",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
if api := gjson.Get(result.Stdout, "api"); api.IsArray() && len(api.Array()) > 0 {
t.Fatalf("dry-run api list must be empty when validation fails\nstdout:\n%s", result.Stdout)
}
errMsg := gjson.Get(result.Stdout, "error").String()
if !strings.Contains(errMsg, "365-day") {
t.Fatalf("expected 365-day cap message in dry-run error, got %q\nstdout:\n%s", errMsg, result.Stdout)
}
}
// TestDriveSearchDryRun_RejectsInvalidSort locks in the cobra Enum guard.
// CLI intentionally exposes only 5 sort values (default, edit_time,
// edit_time_asc, open_time, create_time); the deprecated /
// not-supported server enum values must be rejected before reaching the
// request layer.
func TestDriveSearchDryRun_RejectsInvalidSort(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--sort", "create_time_asc",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("invalid sort must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
// Pin to the flag name (with dashes) rather than the bare word "sort",
// which would also match "transport" / "sortable" / etc.
if !strings.Contains(combined, "--sort") {
t.Fatalf("expected --sort error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDriveSearchDryRun_RejectsBadDocType verifies the doc-types validator
// is wired at the dry-run path: an unknown enum value surfaces as a
// validation error inside the dry-run JSON envelope rather than reaching
// the server. The process still exits 0 (see RejectsOpenedOver1Year).
func TestDriveSearchDryRun_RejectsBadDocType(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--doc-types", "docx,pie",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
if api := gjson.Get(result.Stdout, "api"); api.IsArray() && len(api.Array()) > 0 {
t.Fatalf("dry-run api list must be empty when validation fails\nstdout:\n%s", result.Stdout)
}
errMsg := gjson.Get(result.Stdout, "error").String()
if !strings.Contains(errMsg, "--doc-types") {
t.Fatalf("expected --doc-types error in dry-run, got %q\nstdout:\n%s", errMsg, result.Stdout)
}
}
func setDriveSearchE2EEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "drive_search_e2e_app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_search_e2e_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"strconv"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestMail_ShareToChatDryRun validates the request shape emitted by
// +share-to-chat under --dry-run: the full CLI binary is invoked end-to-end
// so flag parsing, validation, and the dry-run renderer all execute.
// Fake credentials are sufficient because --dry-run short-circuits before
// any network call.
func TestMail_ShareToChatDryRun(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")
tests := []struct {
name string
args []string
wantURLs []string
wantCreateBody map[string]string
wantSendBody map[string]string
wantSendParams map[string]string
}{
{
name: "message-id with default chat_id",
args: []string{
"mail", "+share-to-chat",
"--message-id", "msg_001",
"--receive-id", "oc_xxx",
"--dry-run",
},
wantURLs: []string{
"/open-apis/mail/v1/user_mailboxes/me/messages/share_token",
"/open-apis/mail/v1/user_mailboxes/me/share_tokens/%3Ccard_id%3E/send",
},
wantCreateBody: map[string]string{"message_id": "msg_001"},
wantSendBody: map[string]string{"receive_id": "oc_xxx"},
wantSendParams: map[string]string{"receive_id_type": "chat_id"},
},
{
name: "thread-id with email type",
args: []string{
"mail", "+share-to-chat",
"--thread-id", "thread_001",
"--receive-id", "user@example.com",
"--receive-id-type", "email",
"--dry-run",
},
wantURLs: []string{
"/open-apis/mail/v1/user_mailboxes/me/messages/share_token",
"/open-apis/mail/v1/user_mailboxes/me/share_tokens/%3Ccard_id%3E/send",
},
wantCreateBody: map[string]string{"thread_id": "thread_001"},
wantSendBody: map[string]string{"receive_id": "user@example.com"},
wantSendParams: map[string]string{"receive_id_type": "email"},
},
{
name: "custom mailbox",
args: []string{
"mail", "+share-to-chat",
"--message-id", "msg_002",
"--receive-id", "oc_xxx",
"--mailbox", "alias@example.com",
"--dry-run",
},
wantURLs: []string{
"/open-apis/mail/v1/user_mailboxes/alias@example.com/messages/share_token",
"/open-apis/mail/v1/user_mailboxes/alias@example.com/share_tokens/%3Ccard_id%3E/send",
},
wantCreateBody: map[string]string{"message_id": "msg_002"},
wantSendBody: map[string]string{"receive_id": "oc_xxx"},
wantSendParams: map[string]string{"receive_id_type": "chat_id"},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
gotCount := int(gjson.Get(out, "api.#").Int())
if gotCount != len(tt.wantURLs) {
t.Fatalf("expected %d API calls, got %d\nstdout:\n%s", len(tt.wantURLs), gotCount, out)
}
for i, wantURL := range tt.wantURLs {
idx := strconv.Itoa(i)
gotMethod := gjson.Get(out, "api."+idx+".method").String()
gotURL := gjson.Get(out, "api."+idx+".url").String()
if gotMethod != "POST" {
t.Fatalf("api[%d].method = %q, want POST\nstdout:\n%s", i, gotMethod, out)
}
if gotURL != wantURL {
t.Fatalf("api[%d].url = %q, want %q\nstdout:\n%s", i, gotURL, wantURL, out)
}
}
for k, v := range tt.wantCreateBody {
got := gjson.Get(out, "api.0.body."+k).String()
if got != v {
t.Fatalf("api[0].body.%s = %q, want %q\nstdout:\n%s", k, got, v, out)
}
}
for k, v := range tt.wantSendBody {
got := gjson.Get(out, "api.1.body."+k).String()
if got != v {
t.Fatalf("api[1].body.%s = %q, want %q\nstdout:\n%s", k, got, v, out)
}
}
for k, v := range tt.wantSendParams {
got := gjson.Get(out, "api.1.params."+k).String()
if got != v {
t.Fatalf("api[1].params.%s = %q, want %q\nstdout:\n%s", k, got, v, out)
}
}
})
}
}