Compare commits

...

6 Commits

Author SHA1 Message Date
liangshuo-1
cdae999541 chore(release): v1.0.42 (#1137)
Change-Id: Id4478295cf364a01b712b7ddcd4a6cbdc264e28d
2026-05-27 20:52:24 +08:00
raistlin042
36ff632a13 fix(apps): update miaoda scopes after platform consolidation (#1127)
妙搭/spark consolidated the apps domain onto spark:app:read / spark:app:write.
The standalone spark:app:publish and spark:app.access_scope:* scopes are retired.

- +html-publish:      spark:app:publish            -> spark:app:write
- +access-scope-get:  spark:app.access_scope:read  -> spark:app:read
- +access-scope-set:  spark:app.access_scope:write -> spark:app:write

Verified against the official docs for upload_html_code_and_release,
get_app_visibility and update_app_visibility. +create/+update/+list were
already correct (spark:app:write / spark:app:read).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:51:59 +08:00
xukuncx
ab94ee9f54 feat(mail): add +draft-send shortcut for batch draft sending (#1017)
Add `lark-cli mail +draft-send` shortcut that takes one or more existing
draft IDs and sends each via POST /drafts/:draft_id/send sequentially.
Per-draft failures are isolated and aggregated into a structured output;
fatal failures (auth, permission, network, mailbox quota) abort the
entire batch immediately while recoverable failures honor --stop-on-error.

Also extend internal/output with six mail-send-specific errno constants
(LarkErrMailboxNotFound=4013, LarkErrMailSendQuota{User,UserExt,TenantExt},
LarkErrMailQuota, LarkErrTenantStorageLimit) consumed by isFatalSendErr.

Risk is "high-risk-write" so the framework's --yes gate applies; the
shortcut declares only the minimal mail:user_mailbox.message:send scope
to avoid asking users for permissions it does not need.
2026-05-27 18:12:41 +08:00
sammi-bytedance
30327abacb feat(im): enrich messages with reactions + output update_time (#1095)
- Pull messages now auto-call im.reactions.batch_query and attach a
  reactions block (counts + details) to each message. Stops AI from
  misjudging "user already reacted" as "no response yet" and
  re-sending duplicate reactions. Server caps queries[] at 20 per
  call, so messages are split into batches of size <= 20.
- Edited messages additionally surface update_time. The server echoes
  update_time == create_time for unedited messages too, so the field
  is only emitted when updated == true; otherwise every message
  output would look "edited". The value is read via an explicit
  string assertion + TrimSpace so empty strings are filtered properly
  (the previous `v != ""` was a no-op for non-string types).
- All four message-pulling shortcuts (+messages-mget,
  +chat-messages-list, +messages-search, +threads-messages-list) get
  a --no-reactions opt-out flag for callers that want to skip the
  extra round-trip.
- Each shortcut declares im:message.reactions:read on its
  UserScopes/BotScopes (or Scopes for the user-only search command) so
  the auth flow covers the new dependency.
- Each shortcut's --dry-run output now lists the
  reactions/batch_query call (or omits it when --no-reactions is set),
  so callers can audit the full set of API calls before execution.
- Warnings go through runtime.IO().ErrOut (forbidigo lint requires
  IOStreams over os.Stderr in shortcut code).
- Duplicate message_id inputs (e.g. mget --message-ids om_a,om_a)
  attach the reactions block to every entry while still querying the
  API only once per distinct id.
- EnrichReactions walks msg["thread_replies"] recursively, and mget/
  chat-messages-list call it after ExpandThreadReplies, so replies
  receive reactions in the same batched call as their parent message.
- When the batch_query call fails or returns per-message failures,
  the affected messages get reactions_error=true (mirroring the
  thread_replies_error flag from thread.go) so consumers can
  distinguish "fetch failed" from "no reactions exist" by reading
  stdout alone, without depending on the stderr warning channel.
- lark-im skill docs: the default-enrichment contract lives in a
  standalone references/lark-im-message-enrichment.md so the generated
  SKILL.md can't strand it on regeneration. The four read references
  and the raw reactions API reference link to it, and the template
  source skill-template/domains/im.md carries a durable pointer.

Change-Id: Ia9ea74b11945644262bb25c6503fb9b2003c6c98
2026-05-27 18:06:36 +08:00
sang-neo03
70081f62b1 feat: use description and command in affordance example schema (#1126)
Affordance examples previously carried a title plus a structured input
object mirroring the inputSchema. Replace that with a description plus a
command string holding a ready-to-run lark-cli invocation, which is what
an AI agent driving the CLI actually consumes.

No affordance data exists in the registry yet, so this only reshapes the
consuming AffordanceCase type and its tests; the data pipeline
(registry-config.yaml -> gen-registry.py -> meta_data.json) forwards the
new keys verbatim.
2026-05-27 16:08:21 +08:00
AlbertSun
17cbc13fcb refactor(auth): drop duplicate top-level user fields in status (#1128)
* opt: trim duplicate auth status info

* fix: update signals of auth status workflow
2026-05-27 16:07:21 +08:00
33 changed files with 2365 additions and 48 deletions

View File

@@ -2,6 +2,30 @@
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
@@ -886,6 +910,7 @@ 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,7 +61,6 @@ 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)
@@ -86,29 +85,6 @@ 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,6 +66,19 @@ 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,6 +91,32 @@ 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{}{"title": "获取主日历", "input": map[string]interface{}{}},
map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"},
},
"related": []interface{}{"calendars.list"},
}
@@ -586,7 +586,8 @@ 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].Title != "获取主日历" {
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" ||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.41",
"version": "1.0.42",
"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.access_scope:read"},
Scopes: []string{"spark:app: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.access_scope:write"},
Scopes: []string{"spark:app: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:publish"},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -155,6 +155,20 @@ 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,6 +95,61 @@ 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

@@ -0,0 +1,207 @@
// 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

@@ -0,0 +1,352 @@
// 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", "contact:user.base:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg: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"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
@@ -34,6 +34,7 @@ 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()
@@ -54,7 +55,12 @@ var ImChatMessageList = common.Shortcut{
dryParams[k] = vs[0]
}
}
return d.GET("/open-apis/im/v1/messages").Params(dryParams)
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
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
// Under bot identity, --user-id is not supported; require --chat-id only.
@@ -121,6 +127,9 @@ 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,16 +22,22 @@ 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", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base: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"},
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"))
return common.NewDryRunAPI().GET(buildMGetURL(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
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids := common.SplitCSV(runtime.Str("message-ids"))
@@ -69,6 +75,9 @@ 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", "contact:user.basic_profile:readonly"},
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
@@ -49,6 +49,7 @@ 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)
@@ -68,12 +69,17 @@ var ImMessagesSearch = common.Shortcut{
} else {
d = d.Desc("Step 1: search messages")
}
return d.
d = 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)
@@ -184,6 +190,9 @@ 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", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base: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"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
@@ -33,6 +33,7 @@ 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")
@@ -65,10 +66,15 @@ var ImThreadsMessagesList = common.Shortcut{
params["page_token"] = pageToken
}
return d.
d = 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")
@@ -124,6 +130,9 @@ 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

@@ -0,0 +1,330 @@
// 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

@@ -0,0 +1,942 @@
// 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,6 +17,7 @@ func Shortcuts() []common.Shortcut {
MailReplyAll,
MailSend,
MailDraftCreate,
MailDraftSend,
MailDraftEdit,
MailForward,
MailSendReceipt,

View File

@@ -33,6 +33,10 @@ 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,6 +47,10 @@ 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,6 +4,8 @@
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

@@ -0,0 +1,28 @@
# 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,6 +4,8 @@
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,6 +4,8 @@
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,6 +2,8 @@
> **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,6 +4,8 @@
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

@@ -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 the `userOpenId` or `open_id`, 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 `.identities.user.openId` (from `auth status`) or `.data.user.open_id` (from `contact +get-user`), 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,12 +1,13 @@
# Mail CLI E2E Coverage
## Metrics
- Denominator: 62 leaf commands
- Covered: 13
- Coverage: 21.0%
- Denominator: 63 leaf commands
- Covered: 14
- Coverage: 22.2%
## 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.
@@ -16,6 +17,7 @@
| --- | --- | --- | --- | --- | --- |
| ✓ | 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

@@ -0,0 +1,124 @@
// 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

@@ -0,0 +1,166 @@
// 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)
}
})
}