mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
16 Commits
codex/base
...
feat/mail-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81b778de69 | ||
|
|
f3558878cb | ||
|
|
f8fb39bb1f | ||
|
|
e274a749e9 | ||
|
|
a871d1a064 | ||
|
|
fa66195826 | ||
|
|
afd4555892 | ||
|
|
d826704b6f | ||
|
|
d70c87be7d | ||
|
|
e24252a5fb | ||
|
|
d8dcd5394f | ||
|
|
d8997ce19e | ||
|
|
47a97cc249 | ||
|
|
cc56cc5665 | ||
|
|
c02fa92667 | ||
|
|
49f60d9239 |
@@ -212,6 +212,10 @@ func printMessageOutputSchema(runtime *common.RuntimeContext) {
|
||||
// Used by --print-output-schema to let callers discover field names without reading skill docs.
|
||||
func printWatchOutputSchema(runtime *common.RuntimeContext) {
|
||||
schema := map[string]interface{}{
|
||||
"_format_note": "Paths below are the bare per-event structure (as emitted by --format data). " +
|
||||
"With the DEFAULT --format json, each event line is wrapped as " +
|
||||
`{"ok":true,"identity":"user|bot","data":<structure below>} — prefix every path with .data ` +
|
||||
"(e.g. .data.message.message_id; for --msg-format event, .data holds {header, event}).",
|
||||
"minimal": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"message_id": "<message_id>",
|
||||
|
||||
@@ -32,24 +32,6 @@ func TestMailWatchHelpListsJSONShorthand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 行为验证:--json 走 JSON 输出路径,不输出 table read hint
|
||||
func TestMailTriageJSONShorthandDoesNotEmitReadHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage --json returned error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("--json must follow the JSON path, got table hint\nstderr=%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"messages"`) {
|
||||
t.Fatalf("--json stdout missing JSON payload\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 等价性验证:--json 与 --format json 的 dry-run 输出一致
|
||||
func TestMailTriageJSONShorthandDryRunEquivalence(t *testing.T) {
|
||||
f1, stdout1, _, _ := mailShortcutTestFactory(t)
|
||||
@@ -106,7 +88,36 @@ func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
|
||||
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
|
||||
t.Fatalf("message = %q, want enum validation message", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "table, json, data") {
|
||||
if !strings.Contains(problem.Message, "json, pretty, table, ndjson, csv") {
|
||||
t.Fatalf("message = %q, want allowed values list", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:`data` 曾是合法取值,envelope 化后从 Enum 移除,应被硬拒(而非静默降级)
|
||||
func TestMailTriageRejectsRemovedDataFormat(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "data", "--max", "1", "--dry-run"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for removed --format data")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T, want typed errs problem carrier", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--format" {
|
||||
t.Fatalf("param = %q, want --format", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, `invalid value "data" for --format`) {
|
||||
t.Fatalf("message = %q, want data rejection", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--max", "1",
|
||||
"+triage", "--format", "table", "--max", "1",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage returned error: %v", err)
|
||||
@@ -113,23 +113,6 @@ func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageJSONDoesNotEmitReadHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--format", "json", "--max", "1",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage returned error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
if strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("json output must not emit table read hint\nstderr=%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailMessagesExecuteChunksTwentyOneIDsIntoTwoBatchGetCalls(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -47,6 +48,17 @@ const (
|
||||
triageAPIRetries = 2 // retry count in addition to the first attempt
|
||||
)
|
||||
|
||||
// triageOutput is the structured output for +triage: the message list plus
|
||||
// pagination live inside data (im/calendar convention), meta is nil. Passing a
|
||||
// struct (not a map) lets output.toGeneric JSON-round-trip it so ExtractItems
|
||||
// can find the messages array for table/csv/ndjson rendering.
|
||||
type triageOutput struct {
|
||||
Messages []map[string]interface{} `json:"messages"`
|
||||
Total int `json:"total"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
var MailTriage = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+triage",
|
||||
@@ -55,7 +67,7 @@ var MailTriage = common.Shortcut{
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "format", Default: "json", Enum: []string{"json", "pretty", "table", "ndjson", "csv"}, Desc: "output format: json (default) | pretty | table | ndjson | csv"},
|
||||
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
|
||||
{Name: "page-size", Type: "int", Desc: "alias for --max"},
|
||||
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
|
||||
@@ -138,7 +150,6 @@ var MailTriage = common.Shortcut{
|
||||
}
|
||||
mailbox := resolveMailboxID(runtime)
|
||||
hintIdentityFirst(runtime, mailbox)
|
||||
outFormat := runtime.Str("format")
|
||||
query := runtime.Str("query")
|
||||
if query != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--query", query); err != nil {
|
||||
@@ -277,26 +288,26 @@ var MailTriage = common.Shortcut{
|
||||
msg["mailbox_id"] = mailbox
|
||||
}
|
||||
|
||||
switch outFormat {
|
||||
case "json", "data":
|
||||
outData := map[string]interface{}{
|
||||
"messages": messages,
|
||||
"mailbox_id": mailbox,
|
||||
"count": len(messages),
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
output.PrintJson(runtime.IO().Out, outData)
|
||||
default: // "table"
|
||||
if notice != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
|
||||
}
|
||||
// notice 一律走 stderr(不再随 json 带内返回)
|
||||
if notice != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
|
||||
}
|
||||
|
||||
// 标准信封输出:data = {messages, total, has_more, page_token}(与 calendar
|
||||
// +search-event / im 等 list 命令一致,分页放 data;每条 message 已含 mailbox_id);
|
||||
// meta 为 nil;--format pretty 走精排表格,table/csv/ndjson 由 ExtractItems 从
|
||||
// data 对象提取 messages 渲染。用 struct(而非 map):output.toGeneric 对 struct
|
||||
// 会做 JSON round-trip,把嵌套 messages 归一化为 []interface{},ExtractItems 能正确
|
||||
// 探测到数组字段(顶层 map 不 round-trip,会导致 table/csv/ndjson 拍平成一行)。
|
||||
runtime.OutFormat(triageOutput{
|
||||
Messages: messages,
|
||||
Total: len(messages),
|
||||
HasMore: hasMore,
|
||||
PageToken: nextPageToken,
|
||||
}, nil, func(w io.Writer) {
|
||||
if len(messages) == 0 {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
|
||||
return nil
|
||||
fmt.Fprintln(w, "No messages found.")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, msg := range messages {
|
||||
@@ -314,29 +325,31 @@ var MailTriage = common.Shortcut{
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(runtime.IO().Out, rows)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\n%d message(s)\n", len(messages))
|
||||
if hasMore && nextPageToken != "" {
|
||||
var hint strings.Builder
|
||||
hint.WriteString("next page: mail +triage")
|
||||
if mailbox != "me" {
|
||||
hint.WriteString(" --mailbox " + shellQuote(mailbox))
|
||||
}
|
||||
if query != "" {
|
||||
hint.WriteString(" --query " + shellQuote(query))
|
||||
}
|
||||
if filterStr := runtime.Str("filter"); filterStr != "" {
|
||||
hint.WriteString(" --filter " + shellQuote(filterStr))
|
||||
}
|
||||
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
|
||||
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
})
|
||||
|
||||
// 人类导航提示走 stderr(所有格式一致,不污染 stdout 的数据)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%d message(s)\n", len(messages))
|
||||
if hasMore && nextPageToken != "" {
|
||||
var hint strings.Builder
|
||||
hint.WriteString("next page: mail +triage")
|
||||
if mailbox != "me" {
|
||||
quotedMailbox := shellQuote(mailbox)
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
|
||||
} else {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
|
||||
hint.WriteString(" --mailbox " + shellQuote(mailbox))
|
||||
}
|
||||
if query != "" {
|
||||
hint.WriteString(" --query " + shellQuote(query))
|
||||
}
|
||||
if filterStr := runtime.Str("filter"); filterStr != "" {
|
||||
hint.WriteString(" --filter " + shellQuote(filterStr))
|
||||
}
|
||||
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
|
||||
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
|
||||
}
|
||||
if mailbox != "me" {
|
||||
quotedMailbox := shellQuote(mailbox)
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
|
||||
} else {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -1548,9 +1548,9 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
wantCount: 2,
|
||||
},
|
||||
{
|
||||
name: "list data public mailbox",
|
||||
name: "list json public mailbox",
|
||||
mailbox: "shared@company.com",
|
||||
format: "data",
|
||||
format: "json",
|
||||
args: []string{"--filter", `{"folder_id":"INBOX"}`},
|
||||
register: func(reg *httpmock.Registry, mailbox string) {
|
||||
registerMailTriageListStub(reg, mailbox, []string{"msg_pub_001"}, false, "")
|
||||
@@ -1574,7 +1574,7 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
wantNotice: "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
},
|
||||
{
|
||||
name: "empty list json keeps top-level mailbox",
|
||||
name: "empty list json returns empty data array with count 0",
|
||||
mailbox: "me",
|
||||
format: "json",
|
||||
args: []string{"--filter", `{"folder_id":"INBOX"}`},
|
||||
@@ -1587,7 +1587,7 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
tt.register(reg, tt.mailbox)
|
||||
@@ -1603,11 +1603,8 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
}
|
||||
|
||||
data := decodeMailTriageJSONOutput(t, stdout)
|
||||
if data["mailbox_id"] != tt.mailbox {
|
||||
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
|
||||
}
|
||||
if tt.wantNotice != "" && data["notice"] != tt.wantNotice {
|
||||
t.Fatalf("notice mismatch: got %v, want %q", data["notice"], tt.wantNotice)
|
||||
if tt.wantNotice != "" && !strings.Contains(stderr.String(), "notice: "+tt.wantNotice) {
|
||||
t.Fatalf("notice mismatch: got %q, want %q in stderr", stderr.String(), tt.wantNotice)
|
||||
}
|
||||
messages := mailTriageMessagesFromOutput(t, data)
|
||||
if len(messages) != tt.wantCount {
|
||||
@@ -1622,6 +1619,69 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 空收件箱也必须显式返回 data.total=0(data 为普通 map,total 恒在),
|
||||
// 调用方可稳定读取 .data.total,无需区分“缺失”与“0”。
|
||||
func TestMailTriageEmptyResultEmitsDataTotalZero(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
registerMailTriageListStub(reg, "me", nil, false, "")
|
||||
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--format", "json", "--filter", `{"folder_id":"INBOX"}`,
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeMailTriageJSONOutput(t, stdout)
|
||||
if messages := mailTriageMessagesFromOutput(t, data); len(messages) != 0 {
|
||||
t.Fatalf("expected empty messages array, got %d", len(messages))
|
||||
}
|
||||
dataObj := mailTriageDataObjFromOutput(t, data)
|
||||
total, ok := dataObj["total"]
|
||||
if !ok {
|
||||
t.Fatalf("empty result must still include data.total, got data=%#v", dataObj)
|
||||
}
|
||||
if total != float64(0) {
|
||||
t.Fatalf("empty result data.total must be 0, got %v", total)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:--format ndjson 必须逐条输出 message(ExtractItems 从 data 对象提取
|
||||
// messages 数组),而不是把整个 {messages,total,...} 包装体打成一行。若 messages
|
||||
// 不是 []interface{},FindArrayField 探测不到数组字段,会退化成整包一行。
|
||||
func TestMailTriageNdjsonEmitsOneMessagePerLine(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
registerMailTriageListStub(reg, "me", []string{"m1", "m2"}, false, "")
|
||||
registerMailTriageBatchStub(reg, "me", []map[string]interface{}{
|
||||
mailTriageBatchMessage("m1", "Subject One"),
|
||||
mailTriageBatchMessage("m2", "Subject Two"),
|
||||
})
|
||||
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--format", "ndjson", "--filter", `{"folder_id":"INBOX"}`,
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected 2 ndjson lines (one per message), got %d:\n%s", len(lines), stdout.String())
|
||||
}
|
||||
for i, line := range lines {
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||||
t.Fatalf("ndjson line %d is not valid JSON: %v\n%s", i, err, line)
|
||||
}
|
||||
if _, ok := obj["message_id"]; !ok {
|
||||
t.Fatalf("ndjson line %d must be a message object with message_id, not the wrapper: %s", i, line)
|
||||
}
|
||||
if _, isWrapper := obj["messages"]; isWrapper {
|
||||
t.Fatalf("ndjson line must be a single message, not the {messages,...} wrapper: %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageMissingMessageMetadataStillGetsMailboxID verifies fallback rows keep mailbox IDs.
|
||||
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
@@ -1655,6 +1715,40 @@ func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageDefaultFormatIsJSON verifies that with no --format flag the
|
||||
// command defaults to json and prints the paginated object to stdout, not the
|
||||
// human table.
|
||||
func TestMailTriageDefaultFormatIsJSON(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
registerMailTriageListStub(reg, "me", []string{"msg_ok"}, false, "")
|
||||
registerMailTriageBatchStub(reg, "me", []map[string]interface{}{
|
||||
mailTriageBatchMessage("msg_ok", "Present"),
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage",
|
||||
"--filter", `{"folder_id":"INBOX"}`,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeMailTriageJSONOutput(t, stdout)
|
||||
if data["ok"] != true {
|
||||
t.Fatalf("default output must be ok/data envelope, got %#v", data)
|
||||
}
|
||||
messages := mailTriageMessagesFromOutput(t, data) // reads .data.messages
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(messages))
|
||||
}
|
||||
dataObj := mailTriageDataObjFromOutput(t, data)
|
||||
if _, ok := dataObj["total"]; !ok {
|
||||
t.Fatalf("data.total missing: %#v", dataObj)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageTableOutputPreservesMailboxContext verifies public mailbox table hints.
|
||||
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -1678,7 +1772,7 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
|
||||
mailTriageBatchMessage("msg_001", "Table message"),
|
||||
})
|
||||
|
||||
args := []string{"+triage", "--max", "1", "--filter", `{"folder_id":"INBOX"}`}
|
||||
args := []string{"+triage", "--format", "pretty", "--max", "1", "--filter", `{"folder_id":"INBOX"}`}
|
||||
if tt.mailbox != "me" {
|
||||
args = append(args, "--mailbox", tt.mailbox)
|
||||
}
|
||||
@@ -1719,6 +1813,7 @@ func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) {
|
||||
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage",
|
||||
"--format", "pretty",
|
||||
"--query", strings.Repeat("q", 81),
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -1742,24 +1837,37 @@ func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }
|
||||
return data
|
||||
}
|
||||
|
||||
// mailTriageMessagesFromOutput extracts triage messages as object maps.
|
||||
// mailTriageMessagesFromOutput extracts triage messages as object maps from
|
||||
// the envelope's data object (.data.messages).
|
||||
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
|
||||
t.Helper()
|
||||
rawMessages, ok := data["messages"].([]interface{})
|
||||
dataObj := mailTriageDataObjFromOutput(t, data)
|
||||
rawMessages, ok := dataObj["messages"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("messages type mismatch: %T", data["messages"])
|
||||
t.Fatalf("data.messages type mismatch: %T", dataObj["messages"])
|
||||
}
|
||||
messages := make([]map[string]interface{}, 0, len(rawMessages))
|
||||
for i, item := range rawMessages {
|
||||
msg, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("messages[%d] type mismatch: %T", i, item)
|
||||
t.Fatalf("data.messages[%d] type mismatch: %T", i, item)
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// mailTriageDataObjFromOutput extracts the envelope's data object
|
||||
// ({messages, total, has_more, page_token}); pagination lives here (im-style).
|
||||
func mailTriageDataObjFromOutput(t *testing.T, data map[string]interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
dataObj, _ := data["data"].(map[string]interface{})
|
||||
if dataObj == nil {
|
||||
t.Fatalf("data object missing in envelope: %#v", data)
|
||||
}
|
||||
return dataObj
|
||||
}
|
||||
|
||||
func registerMailTriageListStub(reg *httpmock.Registry, mailbox string, items []string, hasMore bool, pageToken string) {
|
||||
data := map[string]interface{}{
|
||||
"items": items,
|
||||
@@ -1960,7 +2068,8 @@ func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
|
||||
if len(messages) != 5 {
|
||||
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
|
||||
}
|
||||
if got := data["has_more"]; got != false {
|
||||
dataObj := mailTriageDataObjFromOutput(t, data)
|
||||
if got, ok := dataObj["has_more"]; ok && got == true {
|
||||
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
|
||||
}
|
||||
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are
|
||||
|
||||
@@ -99,7 +99,7 @@ var MailWatch = common.Shortcut{
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "data", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "format", Default: "json", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
|
||||
{Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"},
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
@@ -332,7 +332,7 @@ var MailWatch = common.Shortcut{
|
||||
output.PrintError(errOut, fmt.Sprintf("failed to write event file: %v", writeErr))
|
||||
}
|
||||
}
|
||||
output.PrintJson(out, failureData)
|
||||
output.PrintNdjson(out, watchFailureOutputValue(outFormat, string(runtime.As()), failureData))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -385,12 +385,7 @@ var MailWatch = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
switch outFormat {
|
||||
case "json", "":
|
||||
output.PrintNdjson(out, output.Envelope{OK: true, Identity: string(runtime.As()), Data: outputData})
|
||||
case "data":
|
||||
output.PrintNdjson(out, outputData)
|
||||
}
|
||||
output.PrintNdjson(out, watchOutputValue(outFormat, string(runtime.As()), outputData))
|
||||
}
|
||||
|
||||
rawHandler := func(ctx context.Context, event *larkevent.EventReq) error {
|
||||
@@ -687,6 +682,33 @@ func minimalWatchMessage(message map[string]interface{}) map[string]interface{}
|
||||
return out
|
||||
}
|
||||
|
||||
// watchOutputValue selects the per-event value that +watch prints as NDJSON:
|
||||
// "data" emits the bare payload; every other format (json — the default) wraps
|
||||
// it in an ok/identity/data envelope. Extracted from Execute so the default
|
||||
// envelope behavior is unit-testable without a live WebSocket.
|
||||
func watchOutputValue(outFormat, identity string, outputData interface{}) interface{} {
|
||||
if outFormat == "data" {
|
||||
return outputData
|
||||
}
|
||||
return output.Envelope{OK: true, Identity: identity, Data: outputData}
|
||||
}
|
||||
|
||||
// watchFailureOutputValue frames a fetch-failure payload for the watch stream,
|
||||
// mirroring watchOutputValue so the default json stream stays one JSON object
|
||||
// per line: bare payload for --format data, identity-tagged single line for the
|
||||
// default json envelope. failureData already carries {ok:false, error, ...}.
|
||||
func watchFailureOutputValue(outFormat, identity string, failureData map[string]interface{}) interface{} {
|
||||
if outFormat == "data" || identity == "" {
|
||||
return failureData
|
||||
}
|
||||
enriched := make(map[string]interface{}, len(failureData)+1)
|
||||
for k, v := range failureData {
|
||||
enriched[k] = v
|
||||
}
|
||||
enriched["identity"] = identity
|
||||
return enriched
|
||||
}
|
||||
|
||||
func watchFetchFailureValue(messageID, fetchFormat string, err error, eventBody map[string]interface{}) map[string]interface{} {
|
||||
payload := map[string]interface{}{
|
||||
"ok": false,
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -885,3 +886,145 @@ func dryRunAPIsForMailWatchTest(t *testing.T, dry *common.DryRunAPI) []struct {
|
||||
}
|
||||
return payload.API
|
||||
}
|
||||
|
||||
// TestWatchOutputValueDefaultIsJSONEnvelope verifies +watch's default format
|
||||
// (json) wraps each event in an ok/identity/data envelope, while --format data
|
||||
// emits the bare payload. Covers the default-format behavior without a live
|
||||
// WebSocket (addresses the coderabbitai review ask).
|
||||
func TestWatchOutputValueDefaultIsJSONEnvelope(t *testing.T) {
|
||||
payload := map[string]interface{}{"message": map[string]interface{}{"message_id": "m1"}}
|
||||
|
||||
// default (json) → ok/identity/data envelope
|
||||
b, err := json.Marshal(watchOutputValue("json", "user", payload))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json value: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(b, &env); err != nil {
|
||||
t.Fatalf("unmarshal json value: %v", err)
|
||||
}
|
||||
if env["ok"] != true {
|
||||
t.Fatalf("default json must have ok:true, got %s", b)
|
||||
}
|
||||
if env["identity"] != "user" {
|
||||
t.Fatalf("default json must carry identity, got %s", b)
|
||||
}
|
||||
if _, ok := env["data"]; !ok {
|
||||
t.Fatalf("default json must have data field, got %s", b)
|
||||
}
|
||||
|
||||
// empty format (safety) also defaults to the json envelope
|
||||
b3, err := json.Marshal(watchOutputValue("", "bot", payload))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal empty-format value: %v", err)
|
||||
}
|
||||
var env3 map[string]interface{}
|
||||
if err := json.Unmarshal(b3, &env3); err != nil {
|
||||
t.Fatalf("unmarshal empty-format value: %v", err)
|
||||
}
|
||||
if env3["ok"] != true {
|
||||
t.Fatalf("empty format should default to json envelope, got %s", b3)
|
||||
}
|
||||
|
||||
// --format data → bare payload, no envelope
|
||||
b2, err := json.Marshal(watchOutputValue("data", "user", payload))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal data value: %v", err)
|
||||
}
|
||||
var bare map[string]interface{}
|
||||
if err := json.Unmarshal(b2, &bare); err != nil {
|
||||
t.Fatalf("unmarshal data value: %v", err)
|
||||
}
|
||||
if _, hasOK := bare["ok"]; hasOK {
|
||||
t.Fatalf("--format data must be bare (no envelope), got %s", b2)
|
||||
}
|
||||
if _, hasMsg := bare["message"]; !hasMsg {
|
||||
t.Fatalf("--format data payload should carry message, got %s", b2)
|
||||
}
|
||||
}
|
||||
|
||||
// P1: fetch 失败分支必须与成功分支同一 NDJSON 框架 —— 默认 json 输出一行
|
||||
// ok:false + identity + error,--format data 输出一行裸 failureData;绝不能走
|
||||
// PrintJson 的多行 pretty(会破坏默认 json 的 NDJSON 流)。
|
||||
func TestWatchFailureOutputValueDefaultIsJSONEnvelope(t *testing.T) {
|
||||
failure := map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": map[string]interface{}{"type": "fetch_message_failed", "message_id": "m1"},
|
||||
}
|
||||
|
||||
// default (json) → identity-tagged single-line failure envelope, ok:false preserved
|
||||
b, err := json.Marshal(watchFailureOutputValue("json", "user", failure))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json failure value: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(b, &env); err != nil {
|
||||
t.Fatalf("unmarshal json failure value: %v", err)
|
||||
}
|
||||
if env["ok"] != false {
|
||||
t.Fatalf("failure json must keep ok:false, got %s", b)
|
||||
}
|
||||
if env["identity"] != "user" {
|
||||
t.Fatalf("failure json must carry identity, got %s", b)
|
||||
}
|
||||
if _, ok := env["error"]; !ok {
|
||||
t.Fatalf("failure json must carry error, got %s", b)
|
||||
}
|
||||
|
||||
// --format data → bare failure payload, no identity injected
|
||||
b2, err := json.Marshal(watchFailureOutputValue("data", "user", failure))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal data failure value: %v", err)
|
||||
}
|
||||
var bare map[string]interface{}
|
||||
if err := json.Unmarshal(b2, &bare); err != nil {
|
||||
t.Fatalf("unmarshal data failure value: %v", err)
|
||||
}
|
||||
if _, hasIdentity := bare["identity"]; hasIdentity {
|
||||
t.Fatalf("--format data failure must not inject identity, got %s", b2)
|
||||
}
|
||||
if bare["ok"] != false {
|
||||
t.Fatalf("--format data failure should carry ok:false payload, got %s", b2)
|
||||
}
|
||||
}
|
||||
|
||||
// P1: 通过真实的 output.PrintNdjson(335 行调用的同一函数)验证失败分支输出恰好
|
||||
// 一行合法 NDJSON —— 守住"失败行不破坏流"的契约。若生产端把 PrintNdjson 换回
|
||||
// 多行 PrintJson,这里断言的行数就会 >1 而失败(上面的 value 级测试抓不到这点,
|
||||
// 因为它自己 json.Marshal,不经过打印函数)。
|
||||
func TestWatchFailurePrintsSingleNDJSONLine(t *testing.T) {
|
||||
failure := map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": map[string]interface{}{"type": "fetch_message_failed", "message_id": "m1"},
|
||||
}
|
||||
|
||||
// default json:一行带 identity 的 ok:false 信封
|
||||
var buf bytes.Buffer
|
||||
output.PrintNdjson(&buf, watchFailureOutputValue("json", "user", failure))
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("failure output must be exactly one NDJSON line, got %d:\n%s", len(lines), buf.String())
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(lines[0]), &env); err != nil {
|
||||
t.Fatalf("failure line must be valid JSON: %v\n%s", err, lines[0])
|
||||
}
|
||||
if env["ok"] != false || env["identity"] != "user" || env["error"] == nil {
|
||||
t.Fatalf("failure line must carry ok:false + identity + error, got: %s", lines[0])
|
||||
}
|
||||
|
||||
// --format data:一行裸 failureData,不注入 identity
|
||||
buf.Reset()
|
||||
output.PrintNdjson(&buf, watchFailureOutputValue("data", "user", failure))
|
||||
dataLines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
if len(dataLines) != 1 {
|
||||
t.Fatalf("--format data failure must be exactly one NDJSON line, got %d:\n%s", len(dataLines), buf.String())
|
||||
}
|
||||
var bare map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dataLines[0]), &bare); err != nil {
|
||||
t.Fatalf("data failure line must be valid JSON: %v\n%s", err, dataLines[0])
|
||||
}
|
||||
if _, hasIdentity := bare["identity"]; hasIdentity {
|
||||
t.Fatalf("--format data failure line must not inject identity, got: %s", dataLines[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
# 默认:收件箱邮件(默认 20 条,默认table 格式)
|
||||
# 默认:收件箱邮件(默认 20 条,默认 json 信封输出)
|
||||
lark-cli mail +triage
|
||||
|
||||
# 查看收件箱未读
|
||||
@@ -31,12 +31,12 @@ lark-cli mail +triage --filter '{"folder":"flagged"}'
|
||||
lark-cli mail +triage --filter '{"label":"important"}'
|
||||
lark-cli mail +triage --filter '{"label":"重要邮件"}'
|
||||
|
||||
# json/data 格式可配合 jq 处理
|
||||
lark-cli mail +triage --format json | jq '.messages[].subject'
|
||||
# json 输出配合 jq(消息数组在 .data.messages)
|
||||
lark-cli mail +triage --format json | jq '.data.messages[].subject'
|
||||
|
||||
# 分页:先取 10 条,再用 page_token 翻页
|
||||
lark-cli mail +triage --max 10 --format json
|
||||
# 输出中包含 page_token,传入下一次请求
|
||||
# 输出 data 中包含 page_token,传入下一次请求
|
||||
lark-cli mail +triage --page-token 'list:FfccvoqPd...' --max 10 --format json
|
||||
|
||||
# --page-size 是 --max 的别名
|
||||
@@ -49,7 +49,7 @@ lark-cli mail +triage --page-size 10
|
||||
|------|------|------|
|
||||
| `--filter <json>` | — | 筛选条件(见下方字段说明) |
|
||||
| `--query <text>` | — | 全文搜索关键词 |
|
||||
| `--format <mode>` | `table` | `table` / `json` / `data`(`json` 和 `data` 均输出含分页信息的对象) |
|
||||
| `--format <mode>` | `json` | `json`(默认,`{ok,data}` 信封,消息在 `data.messages`)/ `pretty`(人类表格)/ `table`·`csv`·`ndjson`(通用渲染);非 Enum 值报错 |
|
||||
| `--max <n>` | `20` | 最大返回条数(1-400),内部自动分页拉取 |
|
||||
| `--page-size <n>` | — | `--max` 的别名,两者含义相同;同时指定时 `--page-size` 优先 |
|
||||
| `--page-token <token>` | — | 上一次响应返回的分页令牌,传入后从该位置继续拉取。令牌带 `search:` 或 `list:` 前缀,标识来源路径,不可混用 |
|
||||
@@ -78,48 +78,46 @@ lark-cli mail +triage --page-size 10
|
||||
|
||||
## 输出
|
||||
|
||||
### `--format json` / `--format data`
|
||||
### `--format json`(默认)
|
||||
|
||||
两者输出格式相同,均为含分页信息的对象:
|
||||
`{ok,data}` 信封(与 im 等 list 命令一致);`data` 为对象,消息数组在 `data.messages`,分页信息作为 `data` 的兄弟字段;无 `meta`:
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"message_id": "SEU2...",
|
||||
"mailbox_id": "me",
|
||||
"date": "Fri, 21 Mar 2026 11:40:00 +0800",
|
||||
"from": "Alice <alice@example.com>",
|
||||
"subject": "Weekly update",
|
||||
"labels": "INBOX,UNREAD"
|
||||
}
|
||||
],
|
||||
"mailbox_id": "me",
|
||||
"count": 20,
|
||||
"has_more": true,
|
||||
"page_token": "list:FfccvoqPd_loLhtcRx8cx..."
|
||||
"ok": true,
|
||||
"data": {
|
||||
"messages": [
|
||||
{
|
||||
"message_id": "SEU2...",
|
||||
"mailbox_id": "me",
|
||||
"date": "Fri, 21 Mar 2026 11:40:00 +0800",
|
||||
"from": "Alice <alice@example.com>",
|
||||
"subject": "Weekly update",
|
||||
"labels": "INBOX,UNREAD"
|
||||
}
|
||||
],
|
||||
"total": 20,
|
||||
"has_more": true,
|
||||
"page_token": "list:FfccvoqPd_loLhtcRx8cx..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `mailbox_id`:当前邮箱标识,用于传递给 `mail +message --mailbox` 以保持公共邮箱上下文
|
||||
- `has_more`:是否还有下一页
|
||||
- `page_token`:传入 `--page-token` 可获取下一页;为空字符串表示已到末尾
|
||||
- token 前缀 `search:` / `list:` 标识来源 API 路径,不可混用
|
||||
- `data.messages[].mailbox_id`:邮箱标识,传给 `mail +message --mailbox` 以保持公共邮箱上下文
|
||||
- `data.total`:本次返回条数(空结果时为 `0`,始终返回,可稳定读取)
|
||||
- `data.has_more`:是否还有下一页
|
||||
- `data.page_token`:传入 `--page-token` 获取下一页;前缀 `search:` / `list:` 标识来源路径,不可混用
|
||||
- **迁移**:旧的顶层 `.messages` / `.count` / `.has_more` / `.page_token` 已迁到 `.data.messages` / `.data.total` / `.data.has_more` / `.data.page_token`;`--format data` 已移除(用默认或 `--format json`)
|
||||
|
||||
### `table` 格式
|
||||
### `pretty` / `table` / `csv` / `ndjson`
|
||||
|
||||
`page_token` 信息输出在 stderr,自动携带 `--query`/`--filter`/`--mailbox` 参数方便续页:
|
||||
`--format pretty` 输出精排人类表格;`--format table` / `csv` / `ndjson` 用通用格式化器渲染 `data.messages`(`ExtractItems` 自动从 data 对象提取 messages 数组)为表格 / CSV / NDJSON。导航提示(计数、下一页、读全文 tip)统一输出到 **stderr**(所有格式一致,不污染 stdout 数据):
|
||||
```text
|
||||
15 message(s)
|
||||
next page: mail +triage --query '合同审批' --page-token 'search:abc123...'
|
||||
tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>
|
||||
```
|
||||
|
||||
公共邮箱场景下,`--mailbox` 会自动出现在续页和 tip 中:
|
||||
```text
|
||||
next page: mail +triage --mailbox 'shared@example.com' --query '合同审批' --page-token 'search:abc123...'
|
||||
tip: read full content: single message use mail +message --mailbox 'shared@example.com' --message-id <id>; multiple messages use mail +messages --mailbox 'shared@example.com' --message-ids <id1>,<id2>,<id3>
|
||||
```
|
||||
公共邮箱场景下,`--mailbox` 会自动出现在续页和 tip 中。
|
||||
|
||||
### 搜索分页注意事项
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 默认:表格输出 message 元数据
|
||||
# 默认:json 信封(NDJSON 流)输出 message 元数据
|
||||
lark-cli mail +watch
|
||||
|
||||
# 仅输出 message 数据(jq 友好)
|
||||
@@ -47,7 +47,7 @@ lark-cli mail +watch --print-output-schema
|
||||
|------|------|------|
|
||||
| `--mailbox <id>` | `me` | 订阅目标邮箱 |
|
||||
| `--msg-format <mode>` | `metadata` | 输出模式:`metadata` / `minimal` / `plain_text_full` / `full` / `event` |
|
||||
| `--format <mode>` | `data` | 输出样式:`json`(带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
|
||||
| `--format <mode>` | `json` | 输出样式:`json`(默认,带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
|
||||
| `--folder-ids <json-array>` | — | 文件夹 ID 过滤,如 `["INBOX","SENT"]` |
|
||||
| `--folders <json-array>` | — | 文件夹名称过滤(与 `--folder-ids` 取并集) |
|
||||
| `--label-ids <json-array>` | — | 标签 ID 过滤,如 `["FLAGGED","IMPORTANT"]` |
|
||||
|
||||
Reference in New Issue
Block a user