Compare commits

..

1 Commits

Author SHA1 Message Date
/
823a55a1ef fix: update sheets filter view condition validation 2026-05-27 17:31:19 +08:00
36 changed files with 221 additions and 2373 deletions

View File

@@ -2,30 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.42] - 2026-05-27
### Features
- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017)
- **im**: Enrich messages with reactions and output `update_time` (#1095)
- **schema**: Output JSON spec envelope for all API commands (#1048)
- **event**: Support `vc` / `note` / `minute` events (#1113)
- **drive**: Add secure label shortcuts (#985)
- **affordance**: Use description and command in affordance example schema (#1126)
### Bug Fixes
- **docs**: Remove unsupported `fetch` text format (#1109)
### Refactor
- **auth**: Drop duplicate top-level user fields in `status` (#1128)
### Documentation
- **doc**: Document block anchor URLs in `lark-doc` skill (#1120)
- **whiteboard**: Improve SVG/Mermaid instructions (#1097)
## [v1.0.41] - 2026-05-26
### Features
@@ -910,7 +886,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39

View File

@@ -61,6 +61,7 @@ func authStatusRun(opts *StatusOptions) error {
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addLegacyUserFields(result, diagnostics.User)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics)
@@ -85,6 +86,29 @@ func effectiveIdentity(d identitydiag.Result) string {
}
}
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
if user.OpenID == "" {
return
}
result["userName"] = user.UserName
result["userOpenId"] = user.OpenID
if user.TokenStatus != "" {
result["tokenStatus"] = user.TokenStatus
}
if user.Scope != "" {
result["scope"] = user.Scope
}
if user.ExpiresAt != "" {
result["expiresAt"] = user.ExpiresAt
}
if user.RefreshExpiresAt != "" {
result["refreshExpiresAt"] = user.RefreshExpiresAt
}
if user.GrantedAt != "" {
result["grantedAt"] = user.GrantedAt
}
}
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
switch result["identity"] {
case identityUser:

View File

@@ -66,19 +66,6 @@ const (
// IM resource ownership mismatch.
LarkErrOwnershipMismatch = 231205
// Mail send: account / mailbox-level failures returned by
// POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send.
// Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code
// because ErrAPI preserves Detail.Code exactly as returned by the server.
// These codes indicate the entire batch will keep failing identically and
// are consumed by shortcuts/mail.isFatalSendErr to abort early.
LarkErrMailboxNotFound = 1234013 // mailbox not found or not active
LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded
LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded
LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded
LarkErrMailQuota = 1236010 // mail quota limit
LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded
)
// legacyHints supplies the per-code actionable hint string for the legacy

View File

@@ -91,32 +91,6 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
}
}
func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
got int
want int
}{
{name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013},
{name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007},
{name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008},
{name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009},
{name: "mail quota", got: LarkErrMailQuota, want: 1236010},
{name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if tt.got != tt.want {
t.Fatalf("code=%d, want %d", tt.got, tt.want)
}
})
}
}
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
// contention error (131009) maps to an actionable retry hint instead of
// a generic "api_error". Surfaces during concurrent wiki +node-create

View File

@@ -575,7 +575,7 @@ func TestParseAffordance_FullPopulated(t *testing.T) {
"do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"},
"prerequisites": []interface{}{"user 身份登录"},
"examples": []interface{}{
map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"},
map[string]interface{}{"title": "获取主日历", "input": map[string]interface{}{}},
},
"related": []interface{}{"calendars.list"},
}
@@ -586,8 +586,7 @@ func TestParseAffordance_FullPopulated(t *testing.T) {
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" ||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
if len(a.Examples) != 1 || a.Examples[0].Title != "获取主日历" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {

View File

@@ -76,11 +76,10 @@ type Affordance struct {
Related []string `json:"related,omitempty"`
}
// AffordanceCase is one example entry: a one-line description plus a
// ready-to-run lark-cli command string.
// AffordanceCase is one example entry.
type AffordanceCase struct {
Description string `json:"description"`
Command string `json:"command"`
Title string `json:"title"`
Input map[string]interface{} `json:"input"`
}
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.

View File

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

View File

@@ -21,7 +21,7 @@ var AppsAccessScopeGet = common.Shortcut{
Command: "+access-scope-get",
Description: "Get Miaoda app access scope configuration",
Risk: "read",
Scopes: []string{"spark:app:read"},
Scopes: []string{"spark:app.access_scope:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -27,7 +27,7 @@ var AppsAccessScopeSet = common.Shortcut{
Command: "+access-scope-set",
Description: "Set Miaoda app access scope (specific / public / tenant)",
Risk: "write",
Scopes: []string{"spark:app:write"},
Scopes: []string{"spark:app.access_scope:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -21,7 +21,7 @@ var AppsHTMLPublish = common.Shortcut{
Command: "+html-publish",
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
Risk: "write",
Scopes: []string{"spark:app:write"},
Scopes: []string{"spark:app:publish"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -155,20 +155,6 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
}
// Preserve API-provided fields (even if this formatter doesn't otherwise use them).
// update_time is only meaningful when the message was actually edited;
// the server echoes update_time == create_time for unedited messages, which
// would otherwise make every output look "updated" to downstream consumers.
if updated {
if v, ok := m["update_time"]; ok && v != nil {
if s, isStr := v.(string); isStr {
if strings.TrimSpace(s) != "" {
msg["update_time"] = common.FormatTime(s)
}
} else {
msg["update_time"] = common.FormatTime(v)
}
}
}
if v, ok := m["chat_id"]; ok {
msg["chat_id"] = v
}

View File

@@ -95,61 +95,6 @@ func TestFormatMessageItem(t *testing.T) {
}
}
func TestFormatMessageItem_UpdateTime_Present(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_edit",
"updated": true,
"create_time": "1710500000",
"update_time": "1710600000",
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"edited"}`},
}
got := FormatMessageItem(raw, nil)
want := common.FormatTime("1710600000")
if got["update_time"] != want {
t.Fatalf("FormatMessageItem() update_time = %#v, want %#v", got["update_time"], want)
}
}
func TestFormatMessageItem_UpdateTime_Absent(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_no_edit",
"updated": false,
"create_time": "1710500000",
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, nil)
if _, ok := got["update_time"]; ok {
t.Fatalf("FormatMessageItem() should not include update_time when absent, got = %#v", got["update_time"])
}
}
// TestFormatMessageItem_UpdateTime_UnchangedMessage: real API behavior — even
// for unedited messages, server returns update_time == create_time. We must
// NOT echo it through, otherwise every message looks "edited" to consumers.
// Gate the output on updated==true.
func TestFormatMessageItem_UpdateTime_UnchangedMessage(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_unchanged",
"updated": false,
"create_time": "1710500000",
"update_time": "1710500000", // server echoes create_time
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, nil)
if v, ok := got["update_time"]; ok {
t.Fatalf("FormatMessageItem() must skip update_time for unedited message, got = %#v", v)
}
}
func TestResolveAppLinkDomain(t *testing.T) {
if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" {
t.Fatalf("resolveAppLinkDomain(feishu) = %q", got)

View File

@@ -1,207 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"fmt"
"net/http"
"github.com/larksuite/cli/shortcuts/common"
)
// reactionsBatchQueryMaxQueries is the server-side hard limit on queries[]
// length for POST /im/v1/messages/reactions/batch_query (see
// larkim/message/members/facade_reaction/service: batchListReactionsMaxMessageIDs).
const reactionsBatchQueryMaxQueries = 20
// EnrichReactions enriches messages with their reactions by calling the
// im.reactions.batch_query API. Messages are modified in place: each message
// that the server returns reactions for gets a "reactions" map attached.
//
// Failure modes (warning to stderr + skip; never aborts main message output):
// - batch_query call fails (network, 5xx, scope insufficient, rate limited):
// each message in the failed batch is marked with "reactions_error": true
// so callers can distinguish "fetch failed" from "no reactions exist".
// - batch_query returns a partial result: only messages the server failed on
// get "reactions_error": true; the successful ones get the reactions block.
//
// The "reactions_error" flag mirrors the "thread_replies_error" pattern in
// thread.go so downstream consumers handle both enrichment failures uniformly.
//
// Output shape (only on messages that the server actually returned data for):
//
// "reactions": {
// "counts": [{"reaction_type": "SMILE", "count": 3}],
// "details": [{"reaction_id": "...", "emoji_type": "SMILE",
// "operator": {...}, "action_time": "..."}]
// }
//
// The server caps queries[] at 20 per call, so messages are split into
// batches of size <= 20 before invoking the API.
func EnrichReactions(runtime *common.RuntimeContext, messages []map[string]interface{}) {
if len(messages) == 0 {
return
}
// Index messages by ID so we can merge reactions back later.
// A single message_id may appear more than once (e.g. mget --message-ids
// om_a,om_a); every occurrence must receive the reactions block, but the
// API should only be queried once per distinct id.
// Walks into msg["thread_replies"] recursively so replies attached by
// ExpandThreadReplies are enriched in the same batched call as their parent.
idIndex := make(map[string][]map[string]interface{}, len(messages))
var ids []string
collectMessageNodes(messages, idIndex, &ids)
if len(ids) == 0 {
return
}
for i := 0; i < len(ids); i += reactionsBatchQueryMaxQueries {
end := i + reactionsBatchQueryMaxQueries
if end > len(ids) {
end = len(ids)
}
fetchReactionsBatch(runtime, ids[i:end], idIndex)
}
}
// collectMessageNodes walks messages (and any nested thread_replies) and
// records each map under its message_id. Distinct ids are appended to *ids in
// first-seen order so the API is queried at most once per id.
func collectMessageNodes(messages []map[string]interface{}, idIndex map[string][]map[string]interface{}, ids *[]string) {
for _, msg := range messages {
if id, _ := msg["message_id"].(string); id != "" {
if _, seen := idIndex[id]; !seen {
*ids = append(*ids, id)
}
idIndex[id] = append(idIndex[id], msg)
}
// thread_replies may arrive as a typed slice (set by ExpandThreadReplies)
// or as []interface{} (e.g. when produced via JSON round-trip).
switch nested := msg["thread_replies"].(type) {
case []map[string]interface{}:
collectMessageNodes(nested, idIndex, ids)
case []interface{}:
typed := make([]map[string]interface{}, 0, len(nested))
for _, raw := range nested {
if m, ok := raw.(map[string]interface{}); ok {
typed = append(typed, m)
}
}
collectMessageNodes(typed, idIndex, ids)
}
}
}
// fetchReactionsBatch invokes batch_query for one batch of <= 20 message IDs
// and merges the results into idIndex. Failures are logged to stderr without
// aborting subsequent batches.
func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIndex map[string][]map[string]interface{}) {
queries := make([]map[string]interface{}, 0, len(batchIDs))
for _, id := range batchIDs {
queries = append(queries, map[string]interface{}{"message_id": id})
}
data, err := runtime.DoAPIJSON(http.MethodPost,
"/open-apis/im/v1/messages/reactions/batch_query",
nil,
map[string]interface{}{"queries": queries},
)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
markReactionsError(batchIDs, idIndex)
return
}
countsByMsg := groupReactionCounts(data["success_msg_reaction_counts"])
detailsByMsg := groupReactionDetails(data["success_msg_reaction_details"])
// Attach the merged reactions block to every message that had any data.
// Each id may map to >1 message map (duplicate input), so iterate the slice.
for _, id := range batchIDs {
msgs := idIndex[id]
if len(msgs) == 0 {
continue
}
counts := countsByMsg[id]
details := detailsByMsg[id]
if len(counts) == 0 && len(details) == 0 {
continue
}
block := make(map[string]interface{}, 2)
if len(counts) > 0 {
block["counts"] = counts
}
if len(details) > 0 {
block["details"] = details
}
for _, msg := range msgs {
msg["reactions"] = block
}
}
// Surface per-message failures from the API response.
if fails, _ := data["fail_msg_reaction_details"].([]interface{}); len(fails) > 0 {
var failedIDs []string
for _, raw := range fails {
item, _ := raw.(map[string]interface{})
if id, _ := item["message_id"].(string); id != "" {
failedIDs = append(failedIDs, id)
}
}
if len(failedIDs) > 0 {
fmt.Fprintf(runtime.IO().ErrOut,
"warning: reactions_partial_failed: %d message(s) failed (%v)\n",
len(failedIDs), failedIDs)
markReactionsError(failedIDs, idIndex)
}
}
}
// markReactionsError flags every message map indexed under the given ids with
// reactions_error=true, so downstream consumers can distinguish "fetch failed"
// from "no reactions exist" by reading stdout alone.
func markReactionsError(ids []string, idIndex map[string][]map[string]interface{}) {
for _, id := range ids {
for _, msg := range idIndex[id] {
msg["reactions_error"] = true
}
}
}
func groupReactionCounts(raw interface{}) map[string][]interface{} {
groups := map[string][]interface{}{}
items, _ := raw.([]interface{})
for _, item := range items {
row, _ := item.(map[string]interface{})
msgID, _ := row["message_id"].(string)
if msgID == "" {
continue
}
entries, _ := row["reaction_count"].([]interface{})
if len(entries) == 0 {
continue
}
groups[msgID] = append(groups[msgID], entries...)
}
return groups
}
func groupReactionDetails(raw interface{}) map[string][]interface{} {
groups := map[string][]interface{}{}
items, _ := raw.([]interface{})
for _, item := range items {
row, _ := item.(map[string]interface{})
msgID, _ := row["message_id"].(string)
if msgID == "" {
continue
}
entries, _ := row["message_reaction_items"].([]interface{})
if len(entries) == 0 {
continue
}
groups[msgID] = append(groups[msgID], entries...)
}
return groups
}

View File

@@ -1,352 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"sort"
"strings"
"testing"
)
// TestEnrichReactions_Success exercises the basic happy path: messages that
// carry reactions get a "reactions" field, messages without reactions stay
// untouched.
func TestEnrichReactions_Success(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/reactions/batch_query") {
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
}
var payload map[string]interface{}
body, _ := io.ReadAll(req.Body)
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
if len(queries) != 2 {
t.Fatalf("queries size = %d, want 2", len(queries))
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_a",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 3},
},
},
},
"success_msg_reaction_details": []interface{}{
map[string]interface{}{
"message_id": "om_a",
"message_reaction_items": []interface{}{
map[string]interface{}{
"reaction_id": "react_1",
"emoji_type": "SMILE",
"operator": map[string]interface{}{"operator_id": "ou_x", "operator_type": "user"},
"action_time": "1710600000",
},
},
},
},
"fail_msg_reaction_details": []interface{}{},
},
}), nil
}))
messages := []map[string]interface{}{
{"message_id": "om_a"},
{"message_id": "om_b"},
}
EnrichReactions(runtime, messages)
reactionsA, ok := messages[0]["reactions"].(map[string]interface{})
if !ok {
t.Fatalf("message om_a missing reactions field: %#v", messages[0])
}
counts, _ := reactionsA["counts"].([]interface{})
if len(counts) != 1 {
t.Fatalf("om_a counts = %d, want 1", len(counts))
}
details, _ := reactionsA["details"].([]interface{})
if len(details) != 1 {
t.Fatalf("om_a details = %d, want 1", len(details))
}
if _, ok := messages[1]["reactions"]; ok {
t.Fatalf("message om_b should not have reactions field (none in response): %#v", messages[1])
}
}
// TestEnrichReactions_BatchSize splits queries into batches of 20 (server-side
// max for batch_query).
func TestEnrichReactions_BatchSize(t *testing.T) {
var observedBatchSizes []int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
observedBatchSizes = append(observedBatchSizes, len(queries))
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
}), nil
}))
messages := make([]map[string]interface{}, 25)
for i := range messages {
messages[i] = map[string]interface{}{"message_id": fmt.Sprintf("om_%02d", i)}
}
EnrichReactions(runtime, messages)
if want := []int{20, 5}; !reflect.DeepEqual(observedBatchSizes, want) {
t.Fatalf("batch sizes = %v, want %v", observedBatchSizes, want)
}
}
// TestEnrichReactions_APIFailure: when the API call fails, messages stay
// without a reactions field but get marked with reactions_error=true so
// downstream consumers can distinguish "fetch failed" from "no reactions".
// Mirrors the thread_replies_error pattern in thread.go.
func TestEnrichReactions_APIFailure(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("simulated network error")
}))
messages := []map[string]interface{}{
{"message_id": "om_a"},
{"message_id": "om_b"},
}
EnrichReactions(runtime, messages)
for _, m := range messages {
if _, ok := m["reactions"]; ok {
t.Fatalf("message %v should have no reactions after API failure", m["message_id"])
}
if v, _ := m["reactions_error"].(bool); !v {
t.Fatalf("message %v should have reactions_error=true after API failure, got = %#v",
m["message_id"], m["reactions_error"])
}
}
}
// TestEnrichReactions_PartialFailure: when batch_query returns a fail entry
// for one ID, that message gets reactions_error=true while the rest stay
// clean (no error flag) and keep their normal reactions block.
func TestEnrichReactions_PartialFailure(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_ok",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
},
},
},
"fail_msg_reaction_details": []interface{}{
map[string]interface{}{"message_id": "om_bad"},
},
},
}), nil
}))
ok := map[string]interface{}{"message_id": "om_ok"}
bad := map[string]interface{}{"message_id": "om_bad"}
EnrichReactions(runtime, []map[string]interface{}{ok, bad})
if _, has := ok["reactions"]; !has {
t.Fatalf("om_ok should have reactions: %#v", ok)
}
if v, _ := ok["reactions_error"].(bool); v {
t.Fatalf("om_ok must not carry reactions_error: %#v", ok)
}
if _, has := bad["reactions"]; has {
t.Fatalf("om_bad should have no reactions block: %#v", bad)
}
if v, _ := bad["reactions_error"].(bool); !v {
t.Fatalf("om_bad should have reactions_error=true, got = %#v", bad["reactions_error"])
}
}
// TestEnrichReactions_EmptyMessages: no messages -> no API call at all.
func TestEnrichReactions_EmptyMessages(t *testing.T) {
called := false
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
called = true
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
}))
EnrichReactions(runtime, nil)
EnrichReactions(runtime, []map[string]interface{}{})
if called {
t.Fatalf("API should not be called when messages list is empty")
}
}
// TestEnrichReactions_SkipsMessagesWithoutID: messages missing message_id
// (defensive) should not crash and not be sent in queries.
func TestEnrichReactions_SkipsMessagesWithoutID(t *testing.T) {
var sentIDs []string
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
for _, q := range queries {
qm, _ := q.(map[string]interface{})
id, _ := qm["message_id"].(string)
sentIDs = append(sentIDs, id)
}
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
}))
messages := []map[string]interface{}{
{"message_id": "om_a"},
{}, // no message_id
{"message_id": ""},
{"message_id": "om_b"},
}
EnrichReactions(runtime, messages)
if want := []string{"om_a", "om_b"}; !reflect.DeepEqual(sentIDs, want) {
t.Fatalf("sent IDs = %v, want %v", sentIDs, want)
}
}
// TestEnrichReactions_WalksThreadReplies: thread_replies nested under a parent
// message must also be enriched, in the same batch_query call as the parent —
// otherwise the parent gets reactions but its replies don't, leaving the output
// inconsistent.
func TestEnrichReactions_WalksThreadReplies(t *testing.T) {
var observedQueriedIDs []string
var observedCallCount int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
observedCallCount++
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
for _, q := range queries {
qm, _ := q.(map[string]interface{})
id, _ := qm["message_id"].(string)
observedQueriedIDs = append(observedQueriedIDs, id)
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_top",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
},
},
map[string]interface{}{
"message_id": "om_reply1",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "THUMBSUP", "count": 2},
},
},
map[string]interface{}{
"message_id": "om_reply2",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "HEART", "count": 3},
},
},
},
},
}), nil
}))
reply1 := map[string]interface{}{"message_id": "om_reply1"}
reply2 := map[string]interface{}{"message_id": "om_reply2"}
top := map[string]interface{}{
"message_id": "om_top",
"thread_replies": []map[string]interface{}{reply1, reply2},
}
messages := []map[string]interface{}{top}
EnrichReactions(runtime, messages)
if observedCallCount != 1 {
t.Fatalf("expected 1 batched API call, got %d", observedCallCount)
}
sort.Strings(observedQueriedIDs)
if want := []string{"om_reply1", "om_reply2", "om_top"}; !reflect.DeepEqual(observedQueriedIDs, want) {
t.Fatalf("queried IDs = %v, want %v (top + thread_replies)", observedQueriedIDs, want)
}
if _, ok := top["reactions"]; !ok {
t.Fatalf("top message missing reactions")
}
if _, ok := reply1["reactions"]; !ok {
t.Fatalf("reply1 missing reactions — thread_replies were not walked")
}
if _, ok := reply2["reactions"]; !ok {
t.Fatalf("reply2 missing reactions — thread_replies were not walked")
}
}
// TestEnrichReactions_DuplicateMessageID: when the caller passes two distinct
// message maps that share the same message_id (e.g. mget --message-ids om_a,om_a),
// both maps must receive the same reactions block, and the API must be queried
// for the id only once.
func TestEnrichReactions_DuplicateMessageID(t *testing.T) {
var observedQueriesPerCall []int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
observedQueriesPerCall = append(observedQueriesPerCall, len(queries))
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_a",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 2},
},
},
},
},
}), nil
}))
first := map[string]interface{}{"message_id": "om_a"}
second := map[string]interface{}{"message_id": "om_a"}
other := map[string]interface{}{"message_id": "om_b"}
messages := []map[string]interface{}{first, other, second}
EnrichReactions(runtime, messages)
if want := []int{2}; !reflect.DeepEqual(observedQueriesPerCall, want) {
t.Fatalf("queries-per-call = %v, want %v (each id once, no dup fetch)", observedQueriesPerCall, want)
}
firstReactions, firstOK := first["reactions"]
secondReactions, secondOK := second["reactions"]
if !firstOK {
t.Fatalf("first om_a entry missing reactions")
}
if !secondOK {
t.Fatalf("second om_a entry missing reactions — dup msg_id was dropped")
}
if !reflect.DeepEqual(firstReactions, secondReactions) {
t.Fatalf("dup entries reactions differ: %#v vs %#v", firstReactions, secondReactions)
}
}

View File

@@ -22,8 +22,8 @@ var ImChatMessageList = common.Shortcut{
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.base:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
@@ -34,7 +34,6 @@ var ImChatMessageList = common.Shortcut{
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
@@ -55,12 +54,7 @@ var ImChatMessageList = common.Shortcut{
dryParams[k] = vs[0]
}
}
d = d.GET("/open-apis/im/v1/messages").Params(dryParams)
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned messages (including thread_replies expanded inline) in batches of up to 20. Pass --no-reactions to skip.")
}
return d
return d.GET("/open-apis/im/v1/messages").Params(dryParams)
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
// Under bot identity, --user-id is not supported; require --chat-id only.
@@ -127,9 +121,6 @@ var ImChatMessageList = common.Shortcut{
convertlib.ResolveSenderNames(runtime, messages, nameCache)
convertlib.AttachSenderNames(messages, nameCache)
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
outData := map[string]interface{}{
"messages": messages,

View File

@@ -22,22 +22,16 @@ var ImMessagesMGet = common.Shortcut{
Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids := common.SplitCSV(runtime.Str("message-ids"))
d := common.NewDryRunAPI().GET(buildMGetURL(ids))
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned messages in batches of up to 20 to attach the reactions block (operator, action_time, counts). Pass --no-reactions to skip.")
}
return d
return common.NewDryRunAPI().GET(buildMGetURL(ids))
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids := common.SplitCSV(runtime.Str("message-ids"))
@@ -75,9 +69,6 @@ var ImMessagesMGet = common.Shortcut{
convertlib.ResolveSenderNames(runtime, messages, nameCache)
convertlib.AttachSenderNames(messages, nameCache)
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
outData := map[string]interface{}{
"messages": messages,

View File

@@ -30,7 +30,7 @@ var ImMessagesSearch = common.Shortcut{
Command: "+messages-search",
Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
Risk: "read",
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
Scopes: []string{"search:message", "contact:user.basic_profile:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
@@ -49,7 +49,6 @@ var ImMessagesSearch = common.Shortcut{
{Name: "page-token", Desc: "page token"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate search results"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
req, err := buildMessagesSearchRequest(runtime)
@@ -69,17 +68,12 @@ var ImMessagesSearch = common.Shortcut{
} else {
d = d.Desc("Step 1: search messages")
}
d = d.
return d.
POST("/open-apis/im/v1/messages/search").
Params(dryParams).
Body(req.body).
Desc("Step 2 (if results): GET /open-apis/im/v1/messages/mget?message_ids=... — batch fetch message details (max 50)").
Desc("Step 3 (if results): POST /open-apis/im/v1/chats/batch_query — fetch chat names for context")
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Step 4 (if results): reaction enrichment in batches of up to 20 messages. Pass --no-reactions to skip.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildMessagesSearchRequest(runtime)
@@ -190,9 +184,6 @@ var ImMessagesSearch = common.Shortcut{
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
convertlib.ResolveSenderNames(runtime, enriched, nameCache)
convertlib.AttachSenderNames(enriched, nameCache)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, enriched)
}
outData := map[string]interface{}{
"messages": enriched,

View File

@@ -24,8 +24,8 @@ var ImThreadsMessagesList = common.Shortcut{
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
@@ -33,7 +33,6 @@ var ImThreadsMessagesList = common.Shortcut{
{Name: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
{Name: "page-token", Desc: "page token"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
threadFlag := runtime.Str("thread")
@@ -66,15 +65,10 @@ var ImThreadsMessagesList = common.Shortcut{
params["page_token"] = pageToken
}
d = d.
return d.
GET("/open-apis/im/v1/messages").
Params(params).
Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr)
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
threadId := runtime.Str("thread")
@@ -130,9 +124,6 @@ var ImThreadsMessagesList = common.Shortcut{
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
convertlib.ResolveSenderNames(runtime, messages, nameCache)
convertlib.AttachSenderNames(messages, nameCache)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
outData := map[string]interface{}{
"thread_id": threadId,

View File

@@ -1,330 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// MaxBatchSendDrafts caps the number of draft IDs accepted in a single
// +draft-send invocation. The limit is purely client-side: it bounds command-
// line length comfortably below ARG_MAX and keeps the failure blast radius of
// a single batch small. It is intentionally local to this shortcut (rather
// than living in limits.go) because no other shortcut shares the semantics.
const MaxBatchSendDrafts = 50
// sentDraft is the per-draft success entry in the +draft-send aggregated
// output. message_id and thread_id come from the server response of
// POST /drafts/:draft_id/send.
type sentDraft struct {
DraftID string `json:"draft_id"`
MessageID string `json:"message_id"`
ThreadID string `json:"thread_id,omitempty"`
}
// failedDraft is the per-draft failure entry. error is the
// human-readable err.Error() string (typically including ClassifyLarkError
// hints); v2 may surface a structured errno field separately once the server-
// side mapping stabilises (see tech-design "待确认事项").
type failedDraft struct {
DraftID string `json:"draft_id"`
Error string `json:"error"`
}
// batchSendOutput is the JSON envelope data shape:
//
// {
// "mailbox_id": "me",
// "total": 3,
// "success_count": 2,
// "failure_count": 1,
// "sent": [{"draft_id":..., "message_id":..., "thread_id":...}, ...],
// "failed":[{"draft_id":..., "error":...}]
// }
//
// failed is marked omitempty so a fully successful batch returns a clean shape
// without an empty array.
type batchSendOutput struct {
MailboxID string `json:"mailbox_id"`
Total int `json:"total"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
Sent []sentDraft `json:"sent"`
Failed []failedDraft `json:"failed,omitempty"`
}
// MailDraftSend is the `+draft-send` shortcut: send N existing drafts
// sequentially via POST /drafts/:draft_id/send, isolating per-draft failures.
// Risk is "high-risk-write"; callers must pass --yes. User identity only —
// drafts are user-owned resources and bot has no coherent semantics here.
//
// Output schema is the batchSendOutput type above. Partial failures (any
// failed[]) return exit 1 with envelope.error.type="partial_failure" so that
// agents can distinguish "all sent" from "some sent" without parsing the
// success_count field.
var MailDraftSend = common.Shortcut{
Service: "mail",
Command: "+draft-send",
Description: "Send one or more existing mail drafts sequentially. Calls " +
"POST /drafts/:draft_id/send for each input ID, isolates per-draft " +
"failures, and aggregates the results. Use after the drafts have " +
"already been created (via the Lark client, +draft-create, or the " +
"drafts.create API).",
Risk: "high-risk-write",
Scopes: []string{"mail:user_mailbox.message:send"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the drafts (default: me)."},
{Name: "draft-id", Type: "string_slice", Required: true,
Desc: "Draft IDs to send; comma-separated or repeat the flag (max 50)."},
{Name: "stop-on-error", Type: "bool",
Desc: "Stop at the first recoverable per-draft failure (default: continue and aggregate). " +
"Fatal errors (auth, permission, network, mailbox-level quota) always abort immediately " +
"regardless of this flag."},
},
Validate: validateDraftSend,
DryRun: dryRunDraftSend,
Execute: executeDraftSend,
}
// executeDraftSend runs the +draft-send command:
//
// 1. Resolve mailbox ID (defaults to "me" via resolveComposeMailboxID).
// 2. Validate the draft-id slice (non-empty, under MaxBatchSendDrafts cap,
// no empty elements).
// 3. Loop over each draft ID, calling POST .../drafts/:id/send directly via
// runtime.CallAPI. Per-draft outcomes:
// - fatal err (isFatalSendErr) → return immediately (bypasses --stop-on-error).
// - recoverable err → append to failed[]; honor --stop-on-error.
// - success + automation_send_disable signal → return immediately with
// ExitAPI/"automation_send_disabled".
// - success → append to sent[].
// 4. Emit batchSendOutput via runtime.Out.
// 5. If any draft failed, return ExitAPI/"partial_failure" so exit code = 1.
func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveComposeMailboxID(rt)
draftIDs, err := normalizedDraftSendIDs(rt)
if err != nil {
return err
}
out := batchSendOutput{MailboxID: mailboxID, Total: len(draftIDs)}
stopOnErr := rt.Bool("stop-on-error")
for i, id := range draftIDs {
idx := i + 1
writeDraftSendProgressf(rt, "[%d/%d] sending draft %s",
idx, len(draftIDs), sanitizeForSingleLine(id))
// Direct CallAPI rather than draftpkg.Send: this shortcut never sends
// a body, so the helper's send_time-aware envelope would add no value.
data, err := rt.CallAPI("POST",
mailboxPath(mailboxID, "drafts", id, "send"), nil, nil)
if err != nil {
if isFatalSendErr(err) {
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
hadProgress := out.hasProgress()
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
if hadProgress {
emitDraftSendOutput(rt, &out)
}
// Account- / mailbox-level failures (auth, permission, network,
// quota) will repeat identically for every remaining draft —
// abort immediately so the caller sees a single clear error
// instead of 100 redundant failed[] entries.
return err
}
writeDraftSendProgressf(rt, "[%d/%d] failed draft %s: %s",
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
if stopOnErr {
break
}
continue
}
if reason := extractAutomationDisabledReason(data); reason != "" {
err := output.Errorf(output.ExitAPI, "automation_send_disabled",
"automation send is disabled for this mailbox: %s", reason)
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
if out.hasProgress() {
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
emitDraftSendOutput(rt, &out)
}
// HTTP success (code: 0) but the backend signaled automation send
// is disabled — every subsequent send will fail the same way, so
// abort the batch with a single descriptive error.
return err
}
s := sentDraft{DraftID: id}
if v, ok := data["message_id"].(string); ok {
s.MessageID = v
}
if v, ok := data["thread_id"].(string); ok {
s.ThreadID = v
}
out.Sent = append(out.Sent, s)
if s.MessageID != "" {
writeDraftSendProgressf(rt, "[%d/%d] sent draft %s message_id=%s",
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(s.MessageID))
} else {
writeDraftSendProgressf(rt, "[%d/%d] sent draft %s",
idx, len(draftIDs), sanitizeForSingleLine(id))
}
}
emitDraftSendOutput(rt, &out)
if out.FailureCount == 0 {
return nil
}
return output.Errorf(output.ExitAPI, "partial_failure",
"%d of %d drafts failed to send", out.FailureCount, out.Total)
}
// dryRunDraftSend builds the --dry-run preview: one POST call per draft ID,
// in input order, with a header description summarising the batch size.
func dryRunDraftSend(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveComposeMailboxID(rt)
draftIDs, _ := normalizedDraftSendIDs(rt)
api := common.NewDryRunAPI().Desc(fmt.Sprintf(
"Send %d existing drafts sequentially", len(draftIDs)))
for _, id := range draftIDs {
api = api.POST(mailboxPath(mailboxID, "drafts", id, "send"))
}
return api
}
func validateDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
_, err := normalizedDraftSendIDs(rt)
return err
}
func normalizedDraftSendIDs(rt *common.RuntimeContext) ([]string, error) {
return normalizeDraftSendIDs(rt.StrSlice("draft-id"))
}
func normalizeDraftSendIDs(draftIDs []string) ([]string, error) {
if len(draftIDs) == 0 {
return nil, output.ErrValidation("--draft-id is required")
}
normalized := make([]string, 0, len(draftIDs))
seen := make(map[string]struct{}, len(draftIDs))
for _, id := range draftIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return nil, output.ErrValidation("--draft-id contains empty value")
}
if _, ok := seen[trimmed]; ok {
return nil, output.ErrValidation("--draft-id contains duplicate value: %s", trimmed)
}
seen[trimmed] = struct{}{}
normalized = append(normalized, trimmed)
}
if len(normalized) > MaxBatchSendDrafts {
return nil, output.ErrValidation(
"too many drafts: %d > %d (split into multiple batches)",
len(normalized), MaxBatchSendDrafts)
}
return normalized, nil
}
func (out *batchSendOutput) hasProgress() bool {
return len(out.Sent) > 0 || len(out.Failed) > 0
}
func emitDraftSendOutput(rt *common.RuntimeContext, out *batchSendOutput) {
out.SuccessCount = len(out.Sent)
out.FailureCount = len(out.Failed)
rt.Out(*out, nil)
}
func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...interface{}) {
if rt == nil || rt.Factory == nil || rt.Factory.IOStreams == nil || rt.Factory.IOStreams.ErrOut == nil {
return
}
fmt.Fprintf(rt.Factory.IOStreams.ErrOut, "mail +draft-send: "+format+"\n", args...)
}
// isFatalSendErr reports whether err is an account- or mailbox-level failure
// that will repeat identically for every subsequent draft. Fatal errors
// bypass --stop-on-error and immediately abort the batch.
//
// Trigger conditions:
//
// - err does not unwrap to an *output.ExitError, or its Detail is missing:
// unknown shapes are treated as fatal so they cannot accidentally
// accumulate into failed[] for every remaining draft.
// - Detail.Type ∈ {"auth", "app_status", "config", "permission",
// "rate_limit", "network"}: token, scope, app-installation problems,
// throttling, and connectivity are account-level.
// - Code == output.ExitNetwork: connectivity loss is account-level.
// - Detail.Code ∈ {LarkErrMailboxNotFound, LarkErrMailSendQuotaUser,
// LarkErrMailSendQuotaUserExt, LarkErrMailSendQuotaTenantExt,
// LarkErrMailQuota, LarkErrTenantStorageLimit}: mailbox / quota
// exhaustion is account-level.
func isFatalSendErr(err error) bool {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return true
}
switch exitErr.Detail.Type {
case "auth", "app_status", "config":
return true
case "permission", "rate_limit", "network":
return true
}
if exitErr.Code == output.ExitNetwork || wrapsExitCode(err, output.ExitNetwork) {
return true
}
switch exitErr.Detail.Code {
case output.LarkErrMailboxNotFound,
output.LarkErrMailSendQuotaUser,
output.LarkErrMailSendQuotaUserExt,
output.LarkErrMailSendQuotaTenantExt,
output.LarkErrMailQuota,
output.LarkErrTenantStorageLimit:
return true
}
return false
}
func wrapsExitCode(err error, code int) bool {
for unwrapped := errors.Unwrap(err); unwrapped != nil; unwrapped = errors.Unwrap(unwrapped) {
if exitErr, ok := unwrapped.(*output.ExitError); ok && exitErr.Code == code {
return true
}
}
return false
}
// extractAutomationDisabledReason returns the human-readable reason when the
// send succeeded at HTTP level (code: 0) but the backend reports that
// automation send is disabled for this mailbox. An empty return value means
// automation send is enabled.
//
// The data["automation_send_disable"] payload is best-effort: a malformed
// shape or missing reason still produces a generic non-empty message so the
// caller can surface the disabled status to the user instead of silently
// continuing.
func extractAutomationDisabledReason(data map[string]interface{}) string {
ad, ok := data["automation_send_disable"]
if !ok {
return ""
}
m, ok := ad.(map[string]interface{})
if !ok {
return "automation send disabled (no reason provided)"
}
if reason, ok := m["reason"].(string); ok && strings.TrimSpace(reason) != "" {
return strings.TrimSpace(reason)
}
return "automation send disabled (no reason provided)"
}

View File

@@ -1,942 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// TestMailDraftSend_Metadata pins the public surface of the +draft-send
// shortcut: command name, risk level, scopes, auth type, and the three
// declared flags. Changing any of these is a public-contract change and must
// be intentional.
func TestMailDraftSend_Metadata(t *testing.T) {
if MailDraftSend.Service != "mail" {
t.Errorf("Service = %q, want %q", MailDraftSend.Service, "mail")
}
if MailDraftSend.Command != "+draft-send" {
t.Errorf("Command = %q, want %q", MailDraftSend.Command, "+draft-send")
}
if MailDraftSend.Risk != "high-risk-write" {
t.Errorf("Risk = %q, want %q", MailDraftSend.Risk, "high-risk-write")
}
if !MailDraftSend.HasFormat {
t.Error("HasFormat must be true so --format is auto-injected")
}
if len(MailDraftSend.AuthTypes) != 1 || MailDraftSend.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailDraftSend.AuthTypes)
}
// Minimum-permission rule: only :send. Adding :modify or :readonly here is
// an explicit scope-policy regression.
if len(MailDraftSend.Scopes) != 1 || MailDraftSend.Scopes[0] != "mail:user_mailbox.message:send" {
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:send]", MailDraftSend.Scopes)
}
flagByName := map[string]common.Flag{}
for _, fl := range MailDraftSend.Flags {
flagByName[fl.Name] = fl
}
mailbox, ok := flagByName["mailbox"]
if !ok {
t.Fatal("missing --mailbox flag")
}
if mailbox.Required {
t.Error("--mailbox must NOT be Required (defaults to me via resolveComposeMailboxID)")
}
if mailbox.Default != "" {
t.Errorf("--mailbox Default should be empty (let resolveComposeMailboxID supply 'me'); got %q", mailbox.Default)
}
draftID, ok := flagByName["draft-id"]
if !ok {
t.Fatal("missing --draft-id flag")
}
if !draftID.Required {
t.Error("--draft-id must be Required so cobra rejects missing-flag invocations")
}
if draftID.Type != "string_slice" {
t.Errorf("--draft-id Type = %q, want %q", draftID.Type, "string_slice")
}
stopOnErr, ok := flagByName["stop-on-error"]
if !ok {
t.Fatal("missing --stop-on-error flag")
}
if stopOnErr.Required {
t.Error("--stop-on-error must be optional")
}
if stopOnErr.Type != "bool" {
t.Errorf("--stop-on-error Type = %q, want %q", stopOnErr.Type, "bool")
}
}
// stubDraftSend registers a stub for POST .../drafts/<draftID>/send with the
// supplied response body. Used to assemble multi-draft test scenarios.
func stubDraftSend(reg *httpmock.Registry, draftID string, body map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts/" + draftID + "/send",
Body: body,
}
reg.Register(stub)
return stub
}
// TestMailDraftSend_AllSuccess verifies the happy path: every draft sends
// successfully, sent[] is fully populated, failed[] is omitted from the JSON,
// and exit code = 0 (err == nil).
func TestMailDraftSend_AllSuccess(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_1",
"thread_id": "thread_1",
},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_2",
"thread_id": "thread_2",
},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2",
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("expected nil err on full success, got %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["total"].(float64) != 2 {
t.Errorf("total = %v, want 2", data["total"])
}
if data["success_count"].(float64) != 2 {
t.Errorf("success_count = %v, want 2", data["success_count"])
}
if data["failure_count"].(float64) != 0 {
t.Errorf("failure_count = %v, want 0", data["failure_count"])
}
sent, ok := data["sent"].([]interface{})
if !ok || len(sent) != 2 {
t.Fatalf("sent[] missing or wrong size: %#v", data["sent"])
}
if _, exists := data["failed"]; exists {
t.Errorf("failed[] should be omitted on full success; got %#v", data["failed"])
}
first := sent[0].(map[string]interface{})
if first["draft_id"] != "d1" || first["message_id"] != "msg_1" || first["thread_id"] != "thread_1" {
t.Errorf("first sent entry shape unexpected: %#v", first)
}
}
// TestMailDraftSend_ProgressWritesToStderr verifies long sends do not look
// hung: per-draft progress is emitted on stderr while stdout remains the
// final machine-readable JSON ledger.
func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_1",
},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 230001,
"msg": "draft not found",
})
stubDraftSend(reg, "d3", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_3",
},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2,d3",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected partial_failure error, got nil")
}
progress := stderr.String()
for _, want := range []string{
"mail +draft-send: [1/3] sending draft d1",
"mail +draft-send: [1/3] sent draft d1 message_id=msg_1",
"mail +draft-send: [2/3] sending draft d2",
"mail +draft-send: [2/3] failed draft d2:",
"mail +draft-send: [3/3] sending draft d3",
"mail +draft-send: [3/3] sent draft d3 message_id=msg_3",
} {
if !strings.Contains(progress, want) {
t.Errorf("stderr missing %q; got %s", want, progress)
}
}
if strings.Contains(stdout.String(), "mail +draft-send:") {
t.Errorf("stdout must not contain progress lines; got %s", stdout.String())
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["success_count"].(float64) != 2 || data["failure_count"].(float64) != 1 {
t.Errorf("unexpected aggregate counts: %#v", data)
}
}
// TestMailDraftSend_PartialFailure verifies that one recoverable per-draft
// failure does not abort the batch; the remaining drafts are attempted; both
// arrays are populated; and the call returns ExitAPI/"partial_failure".
func TestMailDraftSend_PartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
// Non-fatal code (not in the {auth, app_status, config, permission,
// network, 1234013, 1236007, 1236008, 1236009, 1236010, 1236013}
// set) → recoverable.
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 230001,
"msg": "draft not found or already sent",
})
stubDraftSend(reg, "d3", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_3"},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2,d3",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected partial_failure error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "partial_failure" {
t.Errorf("Detail.Type = %v, want partial_failure", exitErr.Detail)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
if data["success_count"].(float64) != 2 {
t.Errorf("success_count = %v, want 2", data["success_count"])
}
if data["failure_count"].(float64) != 1 {
t.Errorf("failure_count = %v, want 1", data["failure_count"])
}
failed, ok := data["failed"].([]interface{})
if !ok || len(failed) != 1 {
t.Fatalf("failed[] missing or wrong size: %#v", data["failed"])
}
failedEntry := failed[0].(map[string]interface{})
if failedEntry["draft_id"] != "d2" {
t.Errorf("failed entry draft_id = %v, want d2", failedEntry["draft_id"])
}
if !strings.Contains(strings.ToLower(failedEntry["error"].(string)), "draft not found") {
t.Errorf("failed entry error should contain server msg, got %q", failedEntry["error"])
}
}
// TestMailDraftSend_StopOnError verifies --stop-on-error short-circuits at the
// first recoverable failure. d3 is intentionally NOT stubbed: if the loop
// kept going, the httpmock RoundTripper would return "no stub for POST
// /user_mailboxes/me/drafts/d3/send" and Execute would surface it.
func TestMailDraftSend_StopOnError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 230001,
"msg": "draft not found",
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2,d3",
"--yes",
"--stop-on-error",
}, f, stdout)
if err == nil {
t.Fatal("expected partial_failure error, got nil")
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["success_count"].(float64) != 1 {
t.Errorf("success_count = %v, want 1", data["success_count"])
}
if data["failure_count"].(float64) != 1 {
t.Errorf("failure_count = %v, want 1", data["failure_count"])
}
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
}
// TestMailDraftSend_FatalAborts verifies that a fatal errno (mailbox not
// found) aborts the batch immediately and does NOT populate failed[]; the
// later drafts are not attempted (d2 is intentionally not stubbed — any
// attempt would be observable as a runner failure from the httpmock layer).
func TestMailDraftSend_FatalAborts(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": output.LarkErrMailboxNotFound,
"msg": "mailbox not found",
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected fatal abort error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailboxNotFound {
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailboxNotFound, exitErr.Detail)
}
// No JSON envelope on stdout because Execute returned early before rt.Out.
if stdout.Len() != 0 {
t.Errorf("expected no JSON output on fatal abort, got %s", stdout.String())
}
}
// TestMailDraftSend_FatalAfterSuccessEmitsLedger verifies that a fatal error
// after earlier side effects still emits the aggregate stdout ledger before
// returning the fatal stderr error. This lets callers avoid blindly retrying a
// draft that was already sent.
func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": output.LarkErrMailSendQuotaUser,
"msg": "user daily send count exceeded",
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2,d3",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected fatal abort error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailSendQuotaUser {
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailSendQuotaUser, exitErr.Detail)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
if data["success_count"].(float64) != 1 {
t.Errorf("success_count = %v, want 1", data["success_count"])
}
if data["failure_count"].(float64) != 1 {
t.Errorf("failure_count = %v, want 1", data["failure_count"])
}
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
t.Errorf("sent[0].draft_id = %q, want d1", got)
}
if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" {
t.Errorf("failed[0].draft_id = %q, want d2", got)
}
}
// TestMailDraftSend_AutomationDisabled verifies that an HTTP-success response
// carrying the automation_send_disable signal aborts the batch with
// ExitAPI/"automation_send_disabled" and does NOT continue to subsequent
// drafts (d2 intentionally has no stub — any attempt would surface as an
// httpmock "no stub" failure).
func TestMailDraftSend_AutomationDisabled(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_1",
"automation_send_disable": map[string]interface{}{
"reason": "policy: outbound automation disabled",
},
},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected automation_send_disabled error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
}
if !strings.Contains(exitErr.Error(), "outbound automation disabled") {
t.Errorf("error message should propagate reason, got %q", exitErr.Error())
}
}
// TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger verifies that an
// automation-send policy stop after earlier successful sends still writes the
// batch ledger to stdout before returning the structured fatal error.
func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_2",
"automation_send_disable": map[string]interface{}{
"reason": "policy: outbound automation disabled",
},
},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2,d3",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected automation_send_disabled error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
if data["success_count"].(float64) != 1 {
t.Errorf("success_count = %v, want 1", data["success_count"])
}
if data["failure_count"].(float64) != 1 {
t.Errorf("failure_count = %v, want 1", data["failure_count"])
}
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
t.Errorf("sent[0].draft_id = %q, want d1", got)
}
if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" {
t.Errorf("failed[0].draft_id = %q, want d2", got)
}
if got := gjsonLikeString(t, data, "failed", 0, "error"); !strings.Contains(got, "outbound automation disabled") {
t.Errorf("failed[0].error should contain reason, got %q", got)
}
}
// TestMailDraftSend_ValidateErrors verifies that input-shape problems are
// caught in the pre-call layers (cobra Required + Validate). No network call
// is registered; the test should fail loudly if any HTTP call is attempted
// (httpmock returns "no stub" in that case).
func TestMailDraftSend_ValidateErrors(t *testing.T) {
cases := []struct {
name string
args []string
wantSub string
wantCobra bool // true → cobra-level MarkFlagRequired error path
}{
{
name: "missing draft-id",
args: []string{"+draft-send", "--yes"},
wantSub: `required flag(s) "draft-id" not set`,
wantCobra: true,
},
{
// cobra's StringSlice treats a bare "" as an unset flag, so pass a
// whitespace-only element instead to drive the Validate-callback
// empty-element branch.
name: "whitespace-only value",
args: []string{"+draft-send", "--draft-id", " ", "--yes"},
wantSub: "--draft-id contains empty value",
},
{
name: "exceeds cap",
args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--yes"},
wantSub: "too many drafts",
},
{
name: "duplicate value",
args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--yes"},
wantSub: "--draft-id contains duplicate value: d1",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailDraftSend, c.args, f, stdout)
if err == nil {
t.Fatalf("expected validation error, got nil")
}
if !strings.Contains(err.Error(), c.wantSub) {
t.Errorf("err = %v, want substring %q", err, c.wantSub)
}
})
}
}
func TestMailDraftSend_DryRunValidateErrors(t *testing.T) {
cases := []struct {
name string
args []string
wantSub string
}{
{
name: "whitespace-only value",
args: []string{"+draft-send", "--draft-id", " ", "--dry-run"},
wantSub: "--draft-id contains empty value",
},
{
name: "exceeds cap",
args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--dry-run"},
wantSub: "too many drafts",
},
{
name: "duplicate value",
args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--dry-run"},
wantSub: "--draft-id contains duplicate value: d1",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailDraftSend, c.args, f, stdout)
if err == nil {
t.Fatalf("expected validation error, got nil")
}
if !strings.Contains(err.Error(), c.wantSub) {
t.Errorf("err = %v, want substring %q", err, c.wantSub)
}
if stdout.Len() != 0 {
t.Errorf("expected no dry-run output on validation error, got %s", stdout.String())
}
})
}
}
// manyDraftIDs returns a CSV string with n synthesised IDs. Used to drive the
// >MaxBatchSendDrafts validation branch without bloating the test file with a
// hand-written list.
func manyDraftIDs(n int) string {
parts := make([]string, n)
for i := range parts {
parts[i] = "d" + strings.Repeat("x", 1) + intToString(i)
}
return strings.Join(parts, ",")
}
// intToString avoids the strconv import noise for a tiny test helper.
func intToString(i int) string {
if i == 0 {
return "0"
}
var buf [20]byte
pos := len(buf)
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
return string(buf[pos:])
}
// TestMailDraftSend_MissingYes verifies the framework's high-risk-write
// confirmation gate triggers ExitConfirmationRequired (10) when --yes is
// omitted, before Execute is called.
func TestMailDraftSend_MissingYes(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1",
}, f, stdout)
if err == nil {
t.Fatal("expected ExitConfirmationRequired, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitConfirmationRequired {
t.Errorf("Code = %d, want ExitConfirmationRequired=%d", exitErr.Code, output.ExitConfirmationRequired)
}
}
// TestMailDraftSend_DryRun verifies --dry-run prints N POST calls in input
// order and does NOT touch the network.
func TestMailDraftSend_DryRun(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", " d1 , d2 ",
"--draft-id", " d3 ",
"--yes",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
s := stdout.String()
for _, want := range []string{
`/user_mailboxes/me/drafts/d1/send`,
`/user_mailboxes/me/drafts/d2/send`,
`/user_mailboxes/me/drafts/d3/send`,
`"method"`,
`"POST"`,
} {
if !strings.Contains(s, want) {
t.Errorf("dry-run output missing %q; got %s", want, s)
}
}
}
// TestMailDraftSend_NormalizesDraftIDs verifies request paths and output use
// trimmed draft IDs rather than preserving CLI whitespace.
func TestMailDraftSend_NormalizesDraftIDs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_2"},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", " d1 , d2 ",
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
t.Errorf("sent[0].draft_id = %q, want d1", got)
}
if got := gjsonLikeString(t, data, "sent", 1, "draft_id"); got != "d2" {
t.Errorf("sent[1].draft_id = %q, want d2", got)
}
}
// TestMailDraftSend_DryRunDirectInvocation drives dryRunDraftSend through a
// hand-built RuntimeContext so the dry-run plan can be inspected without the
// full Mount pipeline. Useful for catching path-encoding regressions in
// mailboxPath().
func TestMailDraftSend_DryRunDirectInvocation(t *testing.T) {
rt := runtimeForMailDraftSendTest(t, map[string]string{
"mailbox": "alice@example.com",
}, []string{"d1", "d2"})
api := dryRunDraftSend(context.Background(), rt)
raw, err := json.Marshal(api)
if err != nil {
t.Fatalf("marshal dry-run failed: %v", err)
}
s := string(raw)
for _, want := range []string{
`/user_mailboxes/alice@example.com/drafts/d1/send`,
`/user_mailboxes/alice@example.com/drafts/d2/send`,
`"method":"POST"`,
} {
if !strings.Contains(s, want) {
t.Errorf("dry-run JSON missing %q; got %s", want, s)
}
}
}
// runtimeForMailDraftSendTest builds a minimal RuntimeContext with the +draft-
// send flag set so the DryRun callback can be exercised directly. Mirrors
// runtimeForMailDeclineReceiptDryRun.
func runtimeForMailDraftSendTest(t *testing.T, strFlags map[string]string, draftIDs []string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("mailbox", "", "")
cmd.Flags().StringSlice("draft-id", nil, "")
cmd.Flags().Bool("stop-on-error", false, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("parse flags failed: %v", err)
}
for k, v := range strFlags {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("set flag --%s failed: %v", k, err)
}
}
for _, id := range draftIDs {
if err := cmd.Flags().Set("draft-id", id); err != nil {
t.Fatalf("set draft-id failed: %v", err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
// TestMailDraftSend_MailboxFallback verifies that omitting --mailbox falls
// through to "me" via resolveComposeMailboxID, and the output reflects it.
func TestMailDraftSend_MailboxFallback(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1",
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["mailbox_id"] != "me" {
t.Errorf("mailbox_id = %v, want me (default)", data["mailbox_id"])
}
}
// TestMailDraftSend_RepeatedFlagAndCSV verifies that string_slice supports
// both the repeated-flag form (--draft-id d1 --draft-id d2) and the
// comma-separated form (--draft-id d1,d2) — and mixing both in one invocation.
func TestMailDraftSend_RepeatedFlagAndCSV(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_2"},
})
stubDraftSend(reg, "d3", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_3"},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2",
"--draft-id", "d3",
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["success_count"].(float64) != 3 {
t.Errorf("success_count = %v, want 3", data["success_count"])
}
}
// TestIsFatalSendErr is a focused unit test for the classifier. Covers every
// branch documented in the doc comment so future tweaks immediately surface
// mis-categorisation.
func TestIsFatalSendErr(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{
name: "nil-like / unknown shape → fatal",
err: errors.New("raw network panic surfaced unwrapped"),
want: true,
},
{
name: "ExitError without Detail → fatal",
err: &output.ExitError{Code: output.ExitInternal},
want: true,
},
{
name: "auth → fatal",
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "auth", Message: "token expired"},
},
want: true,
},
{
name: "app_status → fatal",
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "app_status", Message: "app disabled"},
},
want: true,
},
{
name: "config → fatal",
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "config", Message: "bad app_id"},
},
want: true,
},
{
name: "permission → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "permission", Message: "denied"},
},
want: true,
},
{
name: "rate_limit → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "rate_limit", Code: output.LarkErrRateLimit},
},
want: true,
},
{
name: "ExitNetwork → fatal",
err: &output.ExitError{
Code: output.ExitNetwork,
Detail: &output.ErrDetail{Type: "network", Message: "DNS timeout"},
},
want: true,
},
{
name: "wrapped ExitNetwork → fatal",
err: output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", output.ErrNetwork("DNS timeout")),
want: true,
},
{
name: "LarkErrMailboxNotFound → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailboxNotFound},
},
want: true,
},
{
name: "LarkErrMailSendQuotaUser → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailSendQuotaUser},
},
want: true,
},
{
name: "LarkErrTenantStorageLimit → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrTenantStorageLimit},
},
want: true,
},
{
name: "generic api_error → recoverable",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: 230001},
},
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := isFatalSendErr(c.err)
if got != c.want {
t.Errorf("isFatalSendErr(%s) = %v, want %v", c.name, got, c.want)
}
})
}
}
// TestExtractAutomationDisabledReason verifies all branches of the helper:
// missing key → "", malformed map → generic message, empty/whitespace reason
// → generic message, non-empty reason → trimmed value.
func TestExtractAutomationDisabledReason(t *testing.T) {
cases := []struct {
name string
in map[string]interface{}
want string
}{
{"missing key", map[string]interface{}{"message_id": "x"}, ""},
{"non-map value", map[string]interface{}{
"automation_send_disable": "not a map",
}, "automation send disabled (no reason provided)"},
{"map but no reason", map[string]interface{}{
"automation_send_disable": map[string]interface{}{},
}, "automation send disabled (no reason provided)"},
{"reason empty", map[string]interface{}{
"automation_send_disable": map[string]interface{}{"reason": " "},
}, "automation send disabled (no reason provided)"},
{"reason populated", map[string]interface{}{
"automation_send_disable": map[string]interface{}{"reason": " policy block "},
}, "policy block"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := extractAutomationDisabledReason(c.in)
if got != c.want {
t.Errorf("extractAutomationDisabledReason() = %q, want %q", got, c.want)
}
})
}
}
func gjsonLikeString(t *testing.T, data map[string]interface{}, arrayKey string, index int, field string) string {
t.Helper()
items, ok := data[arrayKey].([]interface{})
if !ok {
t.Fatalf("%s missing or wrong type: %#v", arrayKey, data[arrayKey])
}
if index >= len(items) {
t.Fatalf("%s[%d] missing; len=%d", arrayKey, index, len(items))
}
item, ok := items[index].(map[string]interface{})
if !ok {
t.Fatalf("%s[%d] wrong type: %#v", arrayKey, index, items[index])
}
value, ok := item[field].(string)
if !ok {
t.Fatalf("%s[%d].%s missing or wrong type: %#v", arrayKey, index, field, item[field])
}
return value
}

View File

@@ -17,7 +17,6 @@ func Shortcuts() []common.Shortcut {
MailReplyAll,
MailSend,
MailDraftCreate,
MailDraftSend,
MailDraftEdit,
MailForward,
MailSendReceipt,

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -264,7 +265,7 @@ var SheetCreateFilterViewCondition = common.Shortcut{
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color", Required: true},
{Name: "filter-type", Desc: "filter type: multiValue, number, text, color", Required: true},
{Name: "compare-type", Desc: "comparison operator (e.g. less, beginsWith, between)"},
{Name: "expected", Desc: "filter values JSON array (e.g. [\"6\"])", Required: true},
},
@@ -272,7 +273,7 @@ var SheetCreateFilterViewCondition = common.Shortcut{
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
return validateExpectedFlag(runtime.Str("expected"))
return validateFilterViewConditionFlags(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
@@ -306,7 +307,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color"},
{Name: "filter-type", Desc: "filter type: multiValue, number, text, color"},
{Name: "compare-type", Desc: "comparison operator"},
{Name: "expected", Desc: "filter values JSON array"},
},
@@ -319,10 +320,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
!hasNonEmptyStringFlag(runtime, "expected") {
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
}
if s := runtime.Str("expected"); s != "" {
return validateExpectedFlag(s)
}
return nil
return validateFilterViewConditionFlags(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
@@ -469,6 +467,55 @@ func validateExpectedFlag(s string) error {
return nil
}
func validateFilterViewConditionFlags(runtime *common.RuntimeContext) error {
filterType := strings.TrimSpace(runtime.Str("filter-type"))
if filterType != "" {
switch filterType {
case "multiValue", "number", "text", "color":
case "hiddenValue":
return output.ErrValidation("--filter-type hiddenValue is no longer supported by Lark Sheets filter view conditions; use --filter-type multiValue with --expected values to show, and omit --compare-type")
default:
return output.ErrValidation("--filter-type must be one of multiValue, number, text, color; got %q", filterType)
}
}
expected := runtime.Str("expected")
if filterType == "multiValue" {
if strings.TrimSpace(runtime.Str("compare-type")) != "" {
return output.ErrValidation("--compare-type must be omitted when --filter-type multiValue")
}
values, err := parseExpectedStringArray(expected)
if err != nil {
return err
}
if len(values) == 0 {
return output.ErrValidation("--expected must contain at least one value when --filter-type multiValue")
}
for i, value := range values {
if utf8.RuneCountInString(value) > 50000 {
return output.ErrValidation("--expected[%d] must be 50000 characters or fewer when --filter-type multiValue", i)
}
}
return nil
}
if expected != "" {
return validateExpectedFlag(expected)
}
return nil
}
func parseExpectedStringArray(s string) ([]string, error) {
if strings.TrimSpace(s) == "" {
return nil, output.ErrValidation("--expected is required when --filter-type multiValue")
}
var values []string
if err := json.Unmarshal([]byte(s), &values); err != nil {
return nil, output.ErrValidation("--expected must be a JSON string array when --filter-type multiValue (e.g. [\"A\",\"B\"]), got: %s", s)
}
return values, nil
}
func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} {
body := map[string]interface{}{}
if includeConditionID {

View File

@@ -334,6 +334,104 @@ func TestCreateFilterViewConditionExecuteSuccess(t *testing.T) {
}
}
func TestCreateFilterViewConditionMultiValueExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "C", "filter_type": "multiValue"},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "C", "--filter-type", "multiValue",
"--expected", `["A","B"]`, "--as", "user",
}, 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("parse body: %v", err)
}
if body["filter_type"] != "multiValue" {
t.Fatalf("unexpected filter_type: %v", body["filter_type"])
}
if _, ok := body["compare_type"]; ok {
t.Fatalf("multiValue body must omit compare_type: %v", body)
}
expected, ok := body["expected"].([]interface{})
if !ok || len(expected) != 2 || expected[0] != "A" || expected[1] != "B" {
t.Fatalf("unexpected expected values: %#v", body["expected"])
}
}
func TestCreateFilterViewConditionRejectsHiddenValue(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "C", "--filter-type", "hiddenValue",
"--expected", `["A"]`, "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected hiddenValue validation error, got nil")
}
if !strings.Contains(err.Error(), "--filter-type hiddenValue is no longer supported") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestCreateFilterViewConditionRejectsInvalidMultiValueFlags(t *testing.T) {
cases := []struct {
name string
args []string
wantText string
}{
{
name: "compare type",
args: []string{"--compare-type", "less", "--expected", `["A"]`},
wantText: "--compare-type must be omitted",
},
{
name: "empty expected",
args: []string{"--expected", `[]`},
wantText: "at least one value",
},
{
name: "non-string expected",
args: []string{"--expected", `[1]`},
wantText: "JSON string array",
},
{
name: "too long expected",
args: []string{"--expected", `["` + strings.Repeat("x", 50001) + `"]`},
wantText: "50000 characters",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
args := []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "C", "--filter-type", "multiValue",
}
args = append(args, tc.args...)
args = append(args, "--as", "user")
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, args, f, stdout)
if err == nil {
t.Fatalf("expected validation error for %s, got nil", tc.name)
}
if !strings.Contains(err.Error(), tc.wantText) {
t.Fatalf("unexpected error message: %v", err)
}
})
}
}
// ── UpdateFilterViewCondition ────────────────────────────────────────────────
func TestUpdateFilterViewConditionDryRun(t *testing.T) {
@@ -384,6 +482,21 @@ func TestUpdateFilterViewConditionRejectsNoFields(t *testing.T) {
}
}
func TestUpdateFilterViewConditionRejectsHiddenValue(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "C",
"--filter-type", "hiddenValue", "--expected", `["A"]`, "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected hiddenValue validation error, got nil")
}
if !strings.Contains(err.Error(), "--filter-type hiddenValue is no longer supported") {
t.Fatalf("unexpected error message: %v", err)
}
}
// ── ListFilterViewConditions ─────────────────────────────────────────────────
func TestListFilterViewConditionsDryRun(t *testing.T) {

View File

@@ -33,10 +33,6 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
**Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access.
### Default message enrichment (reactions / update_time)
The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Card Messages (Interactive)
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.

View File

@@ -47,10 +47,6 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
**Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access.
### Default message enrichment (reactions / update_time)
The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Card Messages (Interactive)
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.

View File

@@ -4,8 +4,6 @@
Fetch the message list for a conversation. Supports both group chats and direct messages.
By default the response carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Thread replies expanded via auto-`thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
This skill maps to the shortcut: `lark-cli im +chat-messages-list` (internally calls `GET /open-apis/im/v1/messages`, and automatically resolves the p2p chat_id when needed).
## Commands

View File

@@ -1,28 +0,0 @@
# im default message enrichment (reactions / update_time)
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
This is the single source of truth for the automatic message-enrichment contract shared by the four message-pulling shortcuts — [`+messages-mget`](lark-im-messages-mget.md), [`+chat-messages-list`](lark-im-chat-messages-list.md), [`+messages-search`](lark-im-messages-search.md), [`+threads-messages-list`](lark-im-threads-messages-list.md). They automatically attach `reactions` and `update_time` to each returned message, so callers do **not** need to invoke the raw [`im.reactions.batch_query`](lark-im-reactions.md) API separately.
- **`reactions`** — populated from one batched `im.reactions.batch_query` call as `{counts, details}`. The field is only attached when the server actually returns data; messages with no reactions omit it. Replies inside `thread_replies` are enriched in the **same batched call** as their parent, so outer and inner messages follow identical semantics.
- **`update_time`** — emitted only when `updated == true` (message was actually edited). The server echoes `update_time == create_time` for unedited messages too, but the CLI gates that output away so consumers don't misread every message as "edited".
- **Opt-out** — each shortcut accepts `--no-reactions` to skip the extra round-trip when the caller only needs message bodies.
## Scope requirement
The default enrichment requires `im:message.reactions:read`, already declared in each shortcut's `UserScopes` / `BotScopes` (or `Scopes` for the user-only search command), so the framework's pre-flight check surfaces a `missing_scope` error before the request is sent. Bots that were registered before this scope was added need an incremental authorization in the Feishu developer console; users can run:
```bash
lark-cli auth login --scope "im:message.reactions:read"
```
## Data contract — missing field ≠ fetch failure
| Situation | Output |
|---|---|
| Message has no reactions | `reactions` field is omitted (not `{}`, not an empty list) |
| Message was never edited | `update_time` field is omitted |
| Whole batch failed | Messages in that batch carry no `reactions`; one line on stderr: `warning: reactions_batch_query_failed: ...` |
| Some message IDs failed | Failed IDs go to stderr: `warning: reactions_partial_failed: N message(s) failed (...)` |
When deciding "has the user already reacted?", branch on the **presence of the `reactions` field plus its `counts` contents**, not on whether a value is `null` — the field's absence means "no data attached" (which usually means "no reactions exist"), not "fetch failed".

View File

@@ -4,8 +4,6 @@
Fetch message details in batch. Given a list of message IDs, this returns the full content for multiple messages in one call and automatically resolves sender names.
By default the response also carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Replies inside `thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
> **Supports both `--as user` (default) and `--as bot`.**
This skill maps to the shortcut: `lark-cli im +messages-mget` (internally calls `GET /open-apis/im/v1/messages/mget`).

View File

@@ -4,8 +4,6 @@
Search Feishu messages across conversations. This shortcut automatically performs a multi-step workflow: search for message IDs, batch fetch message details, then enrich the results with chat context.
By default each result message also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. With `--page-all`, every page is enriched; pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
> **User identity only** (`--as user`). Bot identity is not supported.
This skill maps to the shortcut: `lark-cli im +messages-search` (internally calls `POST /open-apis/im/v1/messages/search` + batched `GET /open-apis/im/v1/messages/mget`, then batch-fetches chat context).

View File

@@ -2,8 +2,6 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Heads-up — don't reach for `batch_query` by default.** The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) already call `im.reactions.batch_query` automatically and attach the result as a `reactions` block on each message (replies inside `thread_replies` included). Use those shortcuts for any "read reactions of messages I'm already pulling" task. Reach for the raw `batch_query` API only when you have a standalone `message_id` outside that pull flow. See the main [message enrichment](lark-im-message-enrichment.md) for the contract.
This reference is the shared annotation target for the IM reaction APIs:
- `im.reactions.create`

View File

@@ -4,8 +4,6 @@
Fetch the reply message list inside a thread. When `im +chat-messages-list` returns messages that include a `thread_id` field, use this command to inspect all replies in that thread.
By default each reply also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
This skill maps to the shortcut: `lark-cli im +threads-messages-list` (internally calls `GET /open-apis/im/v1/messages` with `container_id_type=thread` to fetch thread messages).
## Commands

View File

@@ -123,6 +123,11 @@ lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx"
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
--condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]'
# 多值筛选:只展示 Grade 为 A 或 B 的行multiValue 不传 compare-type
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
--condition-id "C" --filter-type "multiValue" --expected '["A","B"]'
```
参数:
@@ -134,7 +139,7 @@ lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx"
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
| `--condition-id` | 是 | 列字母,如 `E` |
| `--filter-type` | 是 | `hiddenValue` / `number` / `text` / `color` |
| `--filter-type` | 是 | `multiValue` / `number` / `text` / `color` |
| `--compare-type` | 否 | 比较运算符 |
| `--expected` | 是 | 筛选值 JSON 数组 |

View File

@@ -44,7 +44,7 @@ lark-cli task +create --summary "Test Task" --dry-run
## Workflow
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting `.identities.user.openId` (from `auth status`) or `.data.user.open_id` (from `contact +get-user`), and then passing it to the `--assignee` parameter.
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter.
2. Execute `lark-cli task +create --summary "..." ...`
3. Report the result: task ID and summary.

View File

@@ -1,13 +1,12 @@
# Mail CLI E2E Coverage
## Metrics
- Denominator: 63 leaf commands
- Covered: 14
- Coverage: 22.2%
- Denominator: 62 leaf commands
- Covered: 13
- Coverage: 21.0%
## Summary
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
- TestMail_DraftSendWorkflowAsUser: proves a self-contained user draft-send workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail +draft-send`, and `mail +triage`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create self-addressed draft as user`, `send draft with shortcut as user`, and `find self-received message for cleanup`.
- TestMail_SendWorkflowAsUser: proves a self-contained self-mail workflow across `mail +send`, `mail +triage`, `mail +message`, `mail +messages`, `mail +thread`, `mail +reply`, and `mail +forward`; key `t.Run(...)` proof points are `send mail to self with shortcut as user`, `find self sent mail in triage as user`, `get sent message as user`, `get received message as user`, `get both self sent messages as user`, `get self send thread as user`, `reply to received message with shortcut as user`, `inspect reply draft as user`, `forward received message with shortcut as user`, and `inspect forward draft as user`.
- Blocked area: `mail +reply-all` is still uncovered because the self-send workflow produces only self-recipient traffic and reply-alls recipient expansion becomes degenerate after self-address exclusion; `+signature`, `+watch`, event commands, and many raw message/thread mutation APIs still need dedicated tenant-aware workflows.
@@ -17,7 +16,6 @@
| --- | --- | --- | --- | --- | --- |
| ✓ | mail +draft-create | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/create draft with shortcut as user | `--subject`; `--body`; `--plain-text` | creates a new self-owned draft without relying on external recipients |
| ✓ | mail +draft-edit | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect created draft as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/update draft subject with shortcut as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect updated draft as user | `--draft-id`; `--mailbox me`; `--inspect`; `--set-subject` | shortcut proves readback projection and subject update |
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |

View File

@@ -1,124 +0,0 @@
// 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/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestMail_DraftSendDryRun(t *testing.T) {
setMailDraftSendDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-send",
"--mailbox", "alias@example.com",
"--draft-id", " draft_001, draft_002 ",
"--draft-id", " draft_003 ",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
wantURLs := []string{
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_001/send",
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_002/send",
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_003/send",
}
assert.Equal(t, int64(len(wantURLs)), gjson.Get(result.Stdout, "api.#").Int(), "stdout:\n%s", result.Stdout)
for i, wantURL := range wantURLs {
idx := strconv.Itoa(i)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api."+idx+".method").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, wantURL, gjson.Get(result.Stdout, "api."+idx+".url").String(), "stdout:\n%s", result.Stdout)
assert.False(t, gjson.Get(result.Stdout, "api."+idx+".body").Exists(), "stdout:\n%s", result.Stdout)
}
}
func TestMail_DraftSendDryRunValidation(t *testing.T) {
setMailDraftSendDryRunEnv(t)
tests := []struct {
name string
args []string
wantMsg string
}{
{
name: "reject whitespace draft id",
args: []string{
"mail", "+draft-send",
"--draft-id", " ",
"--dry-run",
},
wantMsg: "--draft-id contains empty value",
},
{
name: "reject too many draft ids",
args: []string{
"mail", "+draft-send",
"--draft-id", manyDraftIDsForE2E(51),
"--dry-run",
},
wantMsg: "too many drafts",
},
{
name: "reject duplicate draft id",
args: []string{
"mail", "+draft-send",
"--draft-id", "draft_001,draft_002,draft_001",
"--dry-run",
},
wantMsg: "--draft-id contains duplicate value: draft_001",
},
}
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, 2)
output := result.Stdout + result.Stderr
assert.Contains(t, output, tt.wantMsg, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
})
}
}
func setMailDraftSendDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "mail_draft_send_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "mail_draft_send_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
func manyDraftIDsForE2E(n int) string {
ids := make([]byte, 0, n*4)
for i := 0; i < n; i++ {
if i > 0 {
ids = append(ids, ',')
}
ids = append(ids, 'd')
ids = strconv.AppendInt(ids, int64(i), 10)
}
return string(ids)
}

View File

@@ -1,166 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
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 TestMail_DraftSendWorkflowAsUser(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
const mailboxID = "me"
suffix := clie2e.GenerateSuffix()
subject := "lark-cli-e2e-mail-draft-send-" + suffix
body := "draft-send workflow body " + suffix
var primaryEmail string
var draftID string
var draftSent bool
var sentMessageID string
var inboxMessageID string
parentT.Cleanup(func() {
if draftID != "" && !draftSent {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"mail", "user_mailbox.drafts", "delete"},
DefaultAs: "user",
Params: map[string]any{
"user_mailbox_id": mailboxID,
"draft_id": draftID,
},
Yes: true,
})
clie2e.ReportCleanupFailure(parentT, "delete draft "+draftID, result, err)
}
var messageIDs []string
if sentMessageID != "" {
messageIDs = append(messageIDs, sentMessageID)
}
if inboxMessageID != "" && inboxMessageID != sentMessageID {
messageIDs = append(messageIDs, inboxMessageID)
}
if len(messageIDs) == 0 {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"mail", "user_mailbox.messages", "batch_trash"},
DefaultAs: "user",
Params: map[string]any{"user_mailbox_id": mailboxID},
Data: map[string]any{"message_ids": messageIDs},
})
clie2e.ReportCleanupFailure(parentT, "trash draft-send messages", result, err)
})
t.Run("get mailbox profile as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"mail", "user_mailboxes", "profile"},
DefaultAs: "user",
Params: map[string]any{"user_mailbox_id": mailboxID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
primaryEmail = gjson.Get(result.Stdout, "data.primary_email_address").String()
require.NotEmpty(t, primaryEmail, "stdout:\n%s", result.Stdout)
})
t.Run("create self-addressed draft as user", func(t *testing.T) {
require.NotEmpty(t, primaryEmail, "mailbox profile should be loaded before draft create")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-create",
"--to", primaryEmail,
"--subject", subject,
"--body", body,
"--plain-text",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
draftID = gjson.Get(result.Stdout, "data.draft_id").String()
require.NotEmpty(t, draftID, "stdout:\n%s", result.Stdout)
})
t.Run("send draft with shortcut as user", func(t *testing.T) {
require.NotEmpty(t, draftID, "draft should be created before +draft-send")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-send",
"--mailbox", mailboxID,
"--draft-id", draftID,
},
DefaultAs: "user",
Yes: true,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.total").Int(), "stdout:\n%s", result.Stdout)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int(), "stdout:\n%s", result.Stdout)
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "data.failure_count").Int(), "stdout:\n%s", result.Stdout)
assert.Equal(t, draftID, gjson.Get(result.Stdout, "data.sent.0.draft_id").String(), "stdout:\n%s", result.Stdout)
sentMessageID = gjson.Get(result.Stdout, "data.sent.0.message_id").String()
require.NotEmpty(t, sentMessageID, "stdout:\n%s", result.Stdout)
draftSent = true
})
t.Run("find self-received message for cleanup", func(t *testing.T) {
require.NotEmpty(t, sentMessageID, "draft should be sent before triage lookup")
for attempt := 0; attempt < 12; attempt++ {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+triage",
"--mailbox", mailboxID,
"--query", subject,
"--max", "10",
"--format", "data",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
for _, item := range gjson.Get(result.Stdout, "messages").Array() {
if item.Get("subject").String() != subject {
continue
}
messageID := item.Get("message_id").String()
if messageID != "" && messageID != sentMessageID {
inboxMessageID = messageID
return
}
}
time.Sleep(2 * time.Second)
}
})
}