Compare commits

..

1 Commits

Author SHA1 Message Date
anguohui
4d3c709914 chore: add PPE headers and pin miaoda-cli alpha for testing 2026-07-13 17:00:39 +08:00
66 changed files with 387 additions and 4272 deletions

View File

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -96,40 +96,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
resolved := payload["resolved_output_schema"].(map[string]interface{})
props := resolved["properties"].(map[string]interface{})
for _, field := range []string{
"root_id",
"thread_id",
"reply_to",
"sender_type",
"mentions",
} {
if _, ok := props[field]; !ok {
t.Errorf("receive schema missing field %q", field)
}
}
msgDesc := props["message_id"].(map[string]interface{})["description"].(string)
if !strings.Contains(msgDesc, "Recommended idempotency key") {
t.Errorf("message_id description should guide deduplication, got %q", msgDesc)
}
eventDesc := props["event_id"].(map[string]interface{})["description"].(string)
if strings.Contains(eventDesc, "safe for deduplication") {
t.Errorf("event_id description should not recommend deduplication, got %q", eventDesc)
}
}
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})

View File

@@ -13,29 +13,17 @@ import (
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
type ImMessageReceiveOutput struct {
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
EventID string `json:"event_id,omitempty" desc:"Event delivery ID. Do not use as the message deduplication key; use message_id instead."`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers." kind:"message_id"`
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
UpdateTime string `json:"update_time,omitempty" desc:"Message update time (ms timestamp string); emitted only when different from create_time" kind:"timestamp_ms"`
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
SenderType string `json:"sender_type,omitempty" desc:"Sender type" enum:"user,bot"`
RootID string `json:"root_id,omitempty" desc:"Root message ID of the reply/thread context, when present" kind:"message_id"`
ThreadID string `json:"thread_id,omitempty" desc:"Thread ID, when present"`
ReplyTo string `json:"reply_to,omitempty" desc:"Parent message ID of the direct reply context, when present" kind:"message_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
Mentions []MentionOutput `json:"mentions,omitempty" desc:"Compact mentions aligned with im +messages-mget"`
}
type MentionOutput struct {
Key string `json:"key,omitempty" desc:"Mention placeholder key, for example @_user_1"`
ID string `json:"id,omitempty" desc:"Mentioned user open_id; prefixed with ou_" kind:"open_id"`
Name string `json:"name,omitempty" desc:"Mentioned display name"`
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -48,20 +36,15 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
Event struct {
Message struct {
MessageID string `json:"message_id"`
RootID string `json:"root_id"`
ParentID string `json:"parent_id"`
ThreadID string `json:"thread_id"`
ChatID string `json:"chat_id"`
ChatType string `json:"chat_type"`
MessageType string `json:"message_type"`
Content string `json:"content"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Mentions []interface{} `json:"mentions"`
} `json:"message"`
Sender struct {
SenderType string `json:"sender_type"`
SenderID struct {
SenderID struct {
OpenID string `json:"open_id"`
} `json:"sender_id"`
} `json:"sender"`
@@ -98,54 +81,7 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
ChatType: msg.ChatType,
MessageType: msg.MessageType,
SenderID: envelope.Event.Sender.SenderID.OpenID,
SenderType: envelope.Event.Sender.SenderType,
RootID: msg.RootID,
ThreadID: msg.ThreadID,
ReplyTo: msg.ParentID,
Content: content,
Mentions: compactMentions(msg.Mentions),
}
if msg.UpdateTime != "" && msg.UpdateTime != msg.CreateTime {
out.UpdateTime = msg.UpdateTime
}
return json.Marshal(out)
}
func compactMentions(mentions []interface{}) []MentionOutput {
if len(mentions) == 0 {
return nil
}
out := make([]MentionOutput, 0, len(mentions))
for _, raw := range mentions {
item, _ := raw.(map[string]interface{})
mention := MentionOutput{
Key: stringField(item, "key"),
ID: mentionOpenID(item["id"]),
Name: stringField(item, "name"),
}
if mention.Key != "" || mention.ID != "" || mention.Name != "" {
out = append(out, mention)
}
}
if len(out) == 0 {
return nil
}
return out
}
func stringField(m map[string]interface{}, key string) string {
v, _ := m[key].(string)
return v
}
func mentionOpenID(raw interface{}) string {
switch v := raw.(type) {
case map[string]interface{}:
openID, _ := v["open_id"].(string)
return openID
case string:
return v
default:
return ""
}
}

View File

@@ -84,32 +84,19 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
},
"event": {
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_text_001",
"root_id": "om_root_001",
"parent_id": "om_parent_001",
"thread_id": "omt_thread_001",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1776409468987",
"update_time": "1776409469999",
"content": "{\"text\":\"hello @_user_1\"}",
"mentions": [
{
"key": "@_user_1",
"id": {"open_id": "ou_mentioned"},
"name": "Alice"
}
]
"content": "{\"text\":\"hello there\"}"
}
}
}`
out := runReceive(t, payload)
outMap := runReceiveMap(t, payload)
if out.Type != "im.message.receive_v1" {
t.Errorf("Type = %q", out.Type)
@@ -123,69 +110,12 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
if out.SenderID != "ou_sender" {
t.Errorf("SenderID = %q", out.SenderID)
}
if out.Content != "hello @Alice" {
t.Errorf("Content = %q, want \"hello @Alice\"", out.Content)
if out.Content != "hello there" {
t.Errorf("Content = %q, want \"hello there\"", out.Content)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
for field, want := range map[string]string{
"sender_type": "user",
"root_id": "om_root_001",
"thread_id": "omt_thread_001",
"reply_to": "om_parent_001",
"update_time": "1776409469999",
} {
if got, _ := outMap[field].(string); got != want {
t.Errorf("%s = %q, want %q", field, got, want)
}
}
mentions, _ := outMap["mentions"].([]interface{})
if len(mentions) != 1 {
t.Fatalf("mentions length = %d, want 1: %#v", len(mentions), outMap["mentions"])
}
mention, _ := mentions[0].(map[string]interface{})
for field, want := range map[string]string{
"key": "@_user_1",
"id": "ou_mentioned",
"name": "Alice",
} {
if got, _ := mention[field].(string); got != want {
t.Errorf("mentions[0].%s = %q, want %q", field, got, want)
}
}
}
func TestProcessImMessageReceive_OmitsUnchangedUpdateTime(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_test_text",
"event_type": "im.message.receive_v1",
"create_time": "1776409469273",
"app_id": "cli_test"
},
"event": {
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_text_001",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1776409468987",
"update_time": "1776409468987",
"content": "{\"text\":\"hello there\"}"
}
}
}`
outMap := runReceiveMap(t, payload)
if _, ok := outMap["update_time"]; ok {
t.Errorf("update_time should be omitted when it equals create_time: %#v", outMap)
}
}
func TestProcessImMessageReceive_Interactive(t *testing.T) {
@@ -258,22 +188,3 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
}
return out
}
func runReceiveMap(t *testing.T, payload string) map[string]interface{} {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "im.message.receive_v1",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
return out
}

View File

@@ -248,18 +248,10 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow special-cases scopes absent from scope_priorities.json
// (application v7 is not in the platform catalog yet) so interactive
// login's "common scopes" tier still offers them. Only the read scope is
// admitted: write stays out of the recommended tier by design.
if !allowSet["application:app_slash_command:read"] {
t.Error("expected application:app_slash_command:read in override allow set")
}
if allowSet["application:app_slash_command:write"] {
t.Error("write scope must NOT be in the recommended tier")
}
if len(allowSet) != 1 {
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
}
}

View File

@@ -12,9 +12,7 @@
"vc:meeting.meetingevent:read": 75
},
"recommend": {
"allow": [
"application:app_slash_command:read"
],
"allow": [],
"deny": [
"im:chat",
"im:message.send_as_user"

View File

@@ -3,10 +3,6 @@
"en": { "title": "Approval", "description": "Approval instance, and task management" },
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
},
"application": {
"en": { "title": "Application", "description": "Open Platform app self-management: slash commands for the currently bound app" },
"zh": { "title": "应用管理", "description": "开放平台应用自管理:当前绑定应用的斜杠指令管理" }
},
"apps": {
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }

View File

@@ -1,18 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application provides shortcuts for Open Platform app
// self-management (slash commands of the current bound app).
package application
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all shortcuts of the application domain.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlashCommandList,
SlashCommandCreate,
SlashCommandUpdate,
SlashCommandDelete,
}
}

View File

@@ -1,105 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
)
// slashCommandBasePath is the raw v7 endpoint (not in meta_data.json / SDK).
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
// clientCacheHint is printed to stderr after every successful write.
const clientCacheHint = "note: changes take ~5 minutes to appear in Feishu clients (client-side cache); the server state is already updated - list reflects it immediately."
// parseDescriptionI18n parses repeated --description-i18n values ("<lang>=<text>",
// split on the FIRST '='). Returns nil for empty input. Duplicate langs rejected.
func parseDescriptionI18n(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
m := make(map[string]string, len(values))
for _, v := range values {
idx := strings.Index(v, "=")
if idx <= 0 || idx == len(v)-1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --description-i18n value %q: expected <lang>=<text> (e.g. zh_cn=你好)", v).
WithParam("--description-i18n")
}
lang := strings.TrimSpace(v[:idx])
text := v[idx+1:]
if lang == "" || strings.TrimSpace(text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --description-i18n value %q: language and text must be non-empty", v).
WithParam("--description-i18n")
}
if _, dup := m[lang]; dup {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"duplicate language %q in --description-i18n", lang).
WithParam("--description-i18n")
}
m[lang] = text
}
return m, nil
}
// validateCommandName rejects empty and slash-prefixed command names.
func validateCommandName(name, flagName string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s must not be empty", flagName).WithParam(flagName)
}
if strings.HasPrefix(trimmed, "/") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s must not start with \"/\" - the slash is implied (use %q)",
flagName, strings.TrimPrefix(trimmed, "/")).WithParam(flagName)
}
return nil
}
// encodeCommandIDPathSegment applies the same normalization and escaping to
// command IDs in dry-run output and real requests.
func encodeCommandIDPathSegment(id string) string {
return validate.EncodePathSegment(strings.TrimSpace(id))
}
// buildSlashCommandBody assembles a create/update request body. Only provided
// fields are included: PATCH is field-level partial (absent top-level fields
// are preserved server-side; a provided i18n map REPLACES the whole map).
// icon sits at the top level, sibling of description (verified live; the
// official create sample nesting icon inside description is a doc bug).
func buildSlashCommandBody(command, description string, i18n map[string]string, iconKey string) map[string]interface{} {
body := map[string]interface{}{}
if command != "" {
body["command"] = command
}
if description != "" || len(i18n) > 0 {
desc := map[string]interface{}{}
if description != "" {
desc["default_value"] = description
}
if len(i18n) > 0 {
desc["i18n"] = i18n
}
body["description"] = desc
}
if iconKey != "" {
body["icon"] = map[string]interface{}{"icon_key": iconKey}
}
return body
}
// isCommandExists reports whether err is the server-side name-collision error
// (code=40000000, message contains "command already exists"; verified live).
func isCommandExists(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
return p.Code == 40000000 && strings.Contains(p.Message, "command already exists")
}

View File

@@ -1,197 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
func TestParseDescriptionI18n_OK(t *testing.T) {
m, err := parseDescriptionI18n([]string{"zh_cn=你好", "en_us=Hello=World"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m["zh_cn"] != "你好" {
t.Errorf("zh_cn = %q", m["zh_cn"])
}
// 只按首个 = 分割:值内可含 =
if m["en_us"] != "Hello=World" {
t.Errorf("en_us = %q", m["en_us"])
}
}
func TestParseDescriptionI18n_Empty(t *testing.T) {
m, err := parseDescriptionI18n(nil)
if err != nil || m != nil {
t.Fatalf("nil input: m=%v err=%v", m, err)
}
}
func TestParseDescriptionI18n_BadFormat(t *testing.T) {
for _, bad := range []string{"zh_cn", "=text", "zh_cn=", " =x"} {
_, err := parseDescriptionI18n([]string{bad})
if err == nil {
t.Errorf("%q: expected error", bad)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("%q: expected validation problem, got %v", bad, err)
}
}
}
func TestParseDescriptionI18n_DuplicateLang(t *testing.T) {
_, err := parseDescriptionI18n([]string{"zh_cn=a", "zh_cn=b"})
if err == nil {
t.Fatal("expected duplicate language error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation/invalid_argument, got %v", err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--description-i18n" {
t.Fatalf("expected param --description-i18n, got %#v", validationErr)
}
}
func TestValidateCommandName(t *testing.T) {
if err := validateCommandName("greet", "--command"); err != nil {
t.Fatalf("greet: %v", err)
}
for _, bad := range []string{"", " ", "/greet"} {
if err := validateCommandName(bad, "--command"); err == nil {
t.Errorf("%q: expected error", bad)
}
}
}
func TestBuildSlashCommandBody(t *testing.T) {
body := buildSlashCommandBody("greet", "hi", map[string]string{"zh_cn": "你好"}, "skill_outlined")
if body["command"] != "greet" {
t.Errorf("command = %v", body["command"])
}
desc := body["description"].(map[string]interface{})
if desc["default_value"] != "hi" {
t.Errorf("default_value = %v", desc["default_value"])
}
if desc["i18n"].(map[string]string)["zh_cn"] != "你好" {
t.Errorf("i18n = %v", desc["i18n"])
}
// icon 与 description 顶层平级(实测钉死,文档 create 示例是笔误)
if body["icon"].(map[string]interface{})["icon_key"] != "skill_outlined" {
t.Errorf("icon = %v", body["icon"])
}
// partial不提供的字段不出现PATCH 语义依赖)
partial := buildSlashCommandBody("", "", nil, "skill_outlined")
if _, has := partial["command"]; has {
t.Error("empty command must be omitted")
}
if _, has := partial["description"]; has {
t.Error("empty description must be omitted")
}
}
func TestIsCommandExists(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "matching code and message",
err: errs.NewAPIError(errs.SubtypeUnknown,
"Invalid Param 'command'. command already exists.").WithCode(40000000),
want: true,
},
{
name: "same message with different code",
err: errs.NewAPIError(errs.SubtypeUnknown,
"Invalid Param 'command'. command already exists.").WithCode(40000031),
},
{
name: "same code with different message",
err: errs.NewAPIError(errs.SubtypeUnknown,
"Invalid Param 'icon_key'. icon_key is invalid.").WithCode(40000000),
},
{name: "nil error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isCommandExists(tt.err); got != tt.want {
t.Fatalf("isCommandExists() = %v, want %v", got, tt.want)
}
})
}
}
// TestSlashCommandShortcuts_SharedScopesAcrossIdentities locks in the
// reversal of the OAuth-isolation design: all four slash-command shortcuts
// declare identical scopes for the bot and user identities (plain Scopes /
// ConditionalScopes, no per-identity overrides), so a user-identity
// pre-flight sees the same scope set a bot identity would.
func TestSlashCommandShortcuts_SharedScopesAcrossIdentities(t *testing.T) {
cases := []struct {
name string
shortcut common.Shortcut
wantScope string
wantConditional string
hasConditional bool
}{
{
name: "list",
shortcut: SlashCommandList,
wantScope: "application:app_slash_command:read",
},
{
name: "create",
shortcut: SlashCommandCreate,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
{
name: "update",
shortcut: SlashCommandUpdate,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
{
name: "delete",
shortcut: SlashCommandDelete,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for _, identity := range []string{"user", "bot"} {
declared := tc.shortcut.DeclaredScopesForIdentity(identity)
if !containsStr(declared, tc.wantScope) {
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain %q", tc.name, identity, declared, tc.wantScope)
}
if tc.hasConditional && !containsStr(declared, tc.wantConditional) {
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain conditional %q", tc.name, identity, declared, tc.wantConditional)
}
}
})
}
}
func containsStr(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}

View File

@@ -1,118 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandCreate registers a new slash command on the current bound app.
var SlashCommandCreate = common.Shortcut{
Service: "application",
Command: "+slash-command-create",
Description: "Register a slash command (/ command) on the current bound Open Platform app; --force converts a name collision into an update (idempotent re-run)",
Risk: "write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --force collision path lists to resolve the id
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command", Desc: "command name WITHOUT the leading slash (server enforces uniqueness per app; max 100 commands)", Required: true},
{Name: "description", Desc: "default description shown in the client command panel (description.default_value)", Required: true},
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable, format <lang>=<text> (e.g. zh_cn=发送问候); language codes are passed through to the server"},
{Name: "icon-key", Desc: "icon key (server default: skill_outlined; invalid keys are rejected server-side with code 40000031)"},
{Name: "force", Type: "bool", Desc: "on name collision, resolve the existing command by name and update it in place"},
},
Tips: []string{
`lark-cli application +slash-command-create --command greet --description "say hi" --description-i18n zh_cn=问候 --as bot`,
"changes take ~5 minutes to appear in clients (client-side cache); the server updates immediately",
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:write",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateCommandName(runtime.Str("command"), "--command"); err != nil {
return err
}
if len(strings.TrimSpace(runtime.Str("description"))) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description must not be blank").WithParam("--description")
}
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
// The CLI validates first; keep this guard for direct DryRun callers.
return common.NewDryRunAPI().Set("error", err.Error())
}
name := strings.TrimSpace(runtime.Str("command"))
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
d := common.NewDryRunAPI().
Desc("Create a slash command on the current bound app").
POST(slashCommandBasePath).
Body(body)
if runtime.Bool("force") {
d.Desc("--force: on 'command already exists' (code 40000000), GET list to resolve command_id then PATCH the same body")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
name := strings.TrimSpace(runtime.Str("command"))
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return err
}
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err := runtime.CallAPITyped("POST", slashCommandBasePath, nil, body)
action := "created"
if err != nil {
if !isCommandExists(err) {
return err
}
if !runtime.Bool("force") {
p, _ := errs.ProblemOf(err)
rewrapped := errs.NewAPIError(errs.SubtypeAlreadyExists, "slash command %q already exists", name).
WithHint("rerun with --force to update it, or use `lark-cli application +slash-command-update --command %q`", name).
WithCause(err)
if p.Code != 0 {
rewrapped = rewrapped.WithCode(p.Code)
}
if p.LogID != "" {
rewrapped = rewrapped.WithLogID(p.LogID)
}
return rewrapped
}
// --force: name collision -> resolve id -> PATCH (idempotent re-run).
id, rerr := resolveCommandID(runtime, name)
if rerr != nil {
return rerr
}
patchBody := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err = runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, patchBody)
if err != nil {
return err
}
action = "updated"
}
if data == nil {
data = map[string]interface{}{}
}
data["action"] = action
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s /%v (command_id: %v)\n", action, data["command"], data["command_id"])
})
return nil
},
}

View File

@@ -1,229 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func createOKStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": sampleItem("greet", "id-new"),
},
}
}
func createConflictStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 40000000, "msg": "Invalid Param 'command'. command already exists.",
},
}
}
func patchOKStub(id string) *httpmock.Stub {
return &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/application/v7/app_slash_commands/" + id,
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": sampleItem("greet", id),
},
}
}
func TestSlashCommandCreate_OK(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createOKStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi",
"--description-i18n", "zh_cn=你好", "--description-i18n", "en_us=Hello",
"--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\n%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
if data["action"] != "created" {
t.Fatalf("action = %v", data["action"])
}
if data["command_id"] != "id-new" {
t.Fatalf("command_id = %v", data["command_id"])
}
}
func TestSlashCommandCreate_ValidateRejects(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
cases := [][]string{
{"+slash-command-create", "--command", "/greet", "--description", "hi", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "bad", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "zh_cn=a", "--description-i18n", "zh_cn=b", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", " ", "--as", "bot"},
}
for i, args := range cases {
err := mountAndRun(t, SlashCommandCreate, args, f, stdout)
if err == nil {
t.Errorf("case %d: expected validation error", i)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("case %d: expected validation problem, got %v", i, err)
}
}
}
func TestSlashCommandCreate_ConflictNoForce(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createConflictStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected conflict error")
}
p, _ := errs.ProblemOf(err)
if p == nil || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeAlreadyExists || p.Code != 40000000 {
t.Fatalf("expected api/already_exists code 40000000, got %#v", p)
}
if !strings.Contains(p.Hint, "--force") || !strings.Contains(p.Hint, "+slash-command-update") {
t.Fatalf("hint must offer --force and update, got %q", p.Hint)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("rewrapped error must be *errs.APIError, got %T", err)
}
if errors.Unwrap(apiErr) == nil {
t.Fatal("rewrapped conflict error must preserve the original cause via WithCause")
}
}
func TestSlashCommandCreate_ForceConvertsToUpdate(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createConflictStub())
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
reg.Register(patchOKStub("id-exist"))
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi2", "--force", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["action"] != "updated" {
t.Fatalf("action = %v (force must convert to update)", data["action"])
}
}
func TestSlashCommandCreate_TrimsCommandBeforeCreateAndForceResolution(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
conflict := createConflictStub()
reg.Register(conflict)
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
reg.Register(patchOKStub("id-exist"))
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", " greet ", "--description", "hi", "--force", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(conflict.CapturedBody, &body); err != nil {
t.Fatalf("decode captured create body: %v", err)
}
if body["command"] != "greet" {
t.Fatalf("command = %q, want trimmed value %q", body["command"], "greet")
}
}
func createIconInvalidStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 40000031, "msg": "Invalid Param 'icon_key'. icon_key is invalid.",
},
}
}
// TestSlashCommandCreate_ForceDoesNotConvertNonConflict guards against --force
// blindly treating ANY POST failure as a name collision: only the
// "command already exists" (40000000) shape may fall through to the
// GET+PATCH idempotent-update path. No PATCH stub is registered here, so if
// the code mistakenly attempted a PATCH, the httpmock registry would fail
// the unexpected request and surface a different (registry) error instead
// of the original icon_key failure asserted below.
func TestSlashCommandCreate_ForceDoesNotConvertNonConflict(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createIconInvalidStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--icon-key", "bogus", "--force", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected the original icon_key error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryAPI || p.Subtype == errs.SubtypeAlreadyExists || p.Code != 40000031 {
t.Fatalf("expected original API error code 40000031 without collision reclassification, got %#v", p)
}
}
func TestSlashCommandCreate_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
if err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--icon-key", "skill_outlined", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "POST") || !strings.Contains(out, slashCommandBasePath) {
t.Fatalf("dry-run must show POST path: %s", out)
}
// icon 顶层dry-run body 里 icon 不嵌套在 description 内
if !strings.Contains(out, "icon_key") {
t.Fatalf("dry-run must include body: %s", out)
}
}
func TestSlashCommandCreate_ForceHelpHasNoMetavar(t *testing.T) {
parent := &cobra.Command{Use: "application"}
SlashCommandCreate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
forceFlag := cmd.Flags().Lookup("force")
if forceFlag == nil {
t.Fatal("missing --force flag")
}
placeholder, usage := pflag.UnquoteUsage(forceFlag)
if placeholder != "" {
t.Fatalf("boolean --force must not render a value placeholder, got %q", placeholder)
}
if !strings.Contains(usage, "update it in place") || strings.Contains(usage, "gh ") {
t.Fatalf("unexpected --force help: %q", usage)
}
if help := cmd.Flags().FlagUsages(); !strings.Contains(help, "--force") || !strings.Contains(help, "update it in place") {
t.Fatalf("rendered help missing --force description:\n%s", help)
}
}

View File

@@ -1,85 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandDelete removes a slash command (irreversible; command_id is not
// reused - recreating the same name yields a NEW id).
var SlashCommandDelete = common.Shortcut{
Service: "application",
Command: "+slash-command-delete",
Description: "Delete a slash command from the current bound app (high-risk: irreversible; recreating the same name yields a new command_id)",
Risk: "high-risk-write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --command by-name path
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command-id", Desc: "target command_id; mutually exclusive with --command"},
{Name: "command", Desc: "target command name WITHOUT leading slash (resolved via live list, needs read scope); mutually exclusive with --command-id"},
},
Tips: []string{
"lark-cli application +slash-command-delete --command greet --yes --as bot",
"deleted commands may linger in clients for ~5 minutes (client cache)",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if (id == "") == (name == "") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide exactly one of --command-id or --command").WithParam("--command-id")
}
if name != "" {
return validateCommandName(name, "--command")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI().Desc("HIGH-RISK: delete a slash command (irreversible; same-name recreate gets a NEW command_id)")
target := strings.TrimSpace(runtime.Str("command-id"))
if target == "" {
name := strings.TrimSpace(runtime.Str("command"))
d.GET(slashCommandBasePath).
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
target = "<resolved_command_id>"
} else {
target = encodeCommandIDPathSegment(target)
}
return d.DELETE(slashCommandBasePath + "/" + target)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if id == "" {
resolved, err := resolveCommandID(runtime, name)
if err != nil {
return err
}
id = resolved
}
if _, err := runtime.CallAPITyped("DELETE", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, nil); err != nil {
return err
}
out := map[string]interface{}{"action": "deleted", "command_id": id}
if name != "" {
out["command"] = name
}
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
fmt.Fprintln(runtime.IO().ErrOut, "note: recreating the same command name will yield a NEW command_id.")
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "deleted command_id %s\n", id)
})
return nil
},
}

View File

@@ -1,132 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func deleteOKStub(id string) *httpmock.Stub {
return &httpmock.Stub{
Method: "DELETE",
URL: slashCommandBasePath + "/" + id,
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
}
func TestSlashCommandDelete_RequiresYes(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", "id1", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected confirmation_required without --yes")
}
if errs.CategoryOf(err) != errs.CategoryConfirmation {
t.Fatalf("expected confirmation category, got %v (%v)", errs.CategoryOf(err), err)
}
}
func TestSlashCommandDelete_ByIDWithYes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(deleteOKStub("id1"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", "id1", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
// 上游 DELETE 返回空对象CLI 必须补 action/command_id写操作返回资源 ID
if data["action"] != "deleted" || data["command_id"] != "id1" {
t.Fatalf("data = %v", data)
}
}
func TestSlashCommandDelete_ByNameWithYes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id7")}))
reg.Register(deleteOKStub("id7"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command", "greet", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["command"] != "greet" || data["command_id"] != "id7" {
t.Fatalf("data = %v", data)
}
}
func TestSlashCommandDelete_ByNameDryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command", "greet", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got struct {
Description string `json:"description"`
API []struct {
Desc string `json:"desc"`
Method string `json:"method"`
} `json:"api"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
if !strings.Contains(got.Description, "HIGH-RISK") || strings.Contains(got.Description, "resolve command_id") {
t.Fatalf("top-level description must contain only the risk context: %q", got.Description)
}
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
t.Fatalf("first call must describe name resolution: %#v", got.API)
}
if got.API[1].Method != "DELETE" || strings.Contains(got.API[1].Desc, "resolve command_id") {
t.Fatalf("second call must be the delete without the resolve description: %#v", got.API)
}
}
func TestSlashCommandDelete_Validate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
for _, args := range [][]string{
{"+slash-command-delete", "--yes", "--as", "bot"},
{"+slash-command-delete", "--command-id", "id1", "--command", "greet", "--yes", "--as", "bot"},
} {
err := mountAndRun(t, SlashCommandDelete, args, f, stdout)
if err == nil {
t.Errorf("%v: expected validation error", args)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("%v: expected validation problem, got %v", args, err)
}
}
}
func TestSlashCommandDelete_ByIDEncodesTrimmedPathSegment(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(deleteOKStub("id%2Fwith%20space%3Fx"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", " id/with space?x ", "--yes", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
}

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandList lists all slash commands of the current bound app.
var SlashCommandList = common.Shortcut{
Service: "application",
Command: "+slash-command-list",
Description: "List all slash commands (/ commands) registered on the currently bound Open Platform app; source of command_id for update/delete",
Risk: "read",
Scopes: []string{"application:app_slash_command:read"},
AuthTypes: []string{"bot", "user"},
Tips: []string{
"lark-cli application +slash-command-list --as bot",
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:read",
"the upstream API returns all commands at once (max 100 per app, no pagination)",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("List all slash commands of the current bound app (read-only)").
GET(slashCommandBasePath)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
if items == nil {
items = []interface{}{}
}
out := map[string]interface{}{"items": items, "count": len(items)}
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d slash command(s)\n", len(items))
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
desc := ""
if d, ok := m["description"].(map[string]interface{}); ok {
desc, _ = d["default_value"].(string)
}
fmt.Fprintf(w, " /%v\t%v\t%s\n", m["command"], m["command_id"], desc)
}
})
return nil
},
}

View File

@@ -1,115 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func appTestConfig() *core.CliConfig {
return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
}
// mountAndRun mounts the shortcut under a parent cobra command and runs it.
// Mirrors shortcuts/contact tests.
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "application"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func listStub(items []interface{}) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"items": items},
},
}
}
func sampleItem(name, id string) map[string]interface{} {
return map[string]interface{}{
"command": name, "command_id": id,
"create_time": "1783318553", "update_time": "1783318553",
"description": map[string]interface{}{"default_value": "desc of " + name},
"icon": map[string]interface{}{"icon_key": "skill_outlined"},
}
}
func TestSlashCommandList_JSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id1"), sampleItem("weather", "id2")}))
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\n%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
items := data["items"].([]interface{})
if len(items) != 2 {
t.Fatalf("items = %d", len(items))
}
if data["count"] != float64(2) {
t.Fatalf("count = %v", data["count"])
}
first := items[0].(map[string]interface{})
for _, k := range []string{"command", "command_id", "description", "icon", "create_time", "update_time"} {
if _, ok := first[k]; !ok {
t.Errorf("missing item key %q", k)
}
}
}
func TestSlashCommandList_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub(nil))
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
items, ok := data["items"].([]interface{})
if !ok || len(items) != 0 {
t.Fatalf("empty list must be [] not %v", data["items"])
}
if data["count"] != float64(0) {
t.Fatalf("count = %v", data["count"])
}
}
func TestSlashCommandList_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/application/v7/app_slash_commands") || !strings.Contains(out, "GET") {
t.Fatalf("dry-run must show GET path, got %s", out)
}
}

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// matchCommandID finds the command_id of the item whose "command" equals
// name (exact match - the server enforces name uniqueness, so first hit is the
// only hit).
func matchCommandID(items []interface{}, name string) string {
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
if m["command"] == name {
id, _ := m["command_id"].(string)
if id != "" {
return id
}
}
}
return ""
}
// commandNotFoundError reports a resolution miss against the live list as an
// API-category not-found error (the name is a valid argument shape; the
// resource simply does not exist server-side - this is not a validation
// failure of caller input).
func commandNotFoundError(name string) error {
return errs.NewAPIError(errs.SubtypeNotFound,
"slash command %q not found in the current bound app", name).
WithHint("run `lark-cli application +slash-command-list` to see registered commands")
}
// resolveCommandID resolves a command name to its command_id via the live
// list endpoint (in-memory only; never touches local files). Requires the
// read scope on the current identity.
func resolveCommandID(runtime *common.RuntimeContext, name string) (string, error) {
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
if err != nil {
return "", err
}
items, _ := data["items"].([]interface{})
id := matchCommandID(items, name)
if id == "" {
return "", commandNotFoundError(name)
}
return id, nil
}

View File

@@ -1,41 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"testing"
"github.com/larksuite/cli/errs"
)
func TestMatchCommandID(t *testing.T) {
items := []interface{}{
sampleItem("greet", "id1"),
sampleItem("weather", "id2"),
}
id := matchCommandID(items, "weather")
if id != "id2" {
t.Fatalf("got id=%q", id)
}
id = matchCommandID(items, "nope")
if id != "" {
t.Fatalf("miss should return empty, got id=%q", id)
}
// 精确匹配:大小写与空白不做宽容
id = matchCommandID(items, "Greet")
if id != "" {
t.Fatalf("match must be exact, got %q", id)
}
}
func TestResolveNotFoundErrorShape(t *testing.T) {
err := commandNotFoundError("nope")
if err == nil {
t.Fatalf("err = %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
t.Fatalf("expected api/not_found, got %#v", p)
}
}

View File

@@ -1,124 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// validateUpdateTarget enforces: exactly one of --command-id/--command, and at
// least one editable field; --description-i18n requires --description (PATCH
// replaces the whole description object - sending i18n alone would drop
// default_value, so both values must be provided together).
func validateUpdateTarget(runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if (id == "") == (name == "") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide exactly one of --command-id or --command").WithParam("--command-id")
}
if name != "" {
if err := validateCommandName(name, "--command"); err != nil {
return err
}
}
hasDesc := strings.TrimSpace(runtime.Str("description")) != ""
hasI18n := len(runtime.StrArray("description-i18n")) > 0
hasIcon := strings.TrimSpace(runtime.Str("icon-key")) != ""
if !hasDesc && !hasI18n && !hasIcon {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide at least one of --description / --description-i18n / --icon-key").WithParam("--description")
}
if hasI18n && !hasDesc {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description-i18n requires --description: PATCH replaces the whole description object, so default_value must be provided together").WithParam("--description-i18n")
}
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
return err
}
return nil
}
// SlashCommandUpdate updates description/i18n/icon of an existing slash command.
var SlashCommandUpdate = common.Shortcut{
Service: "application",
Command: "+slash-command-update",
Description: "Update description / localized descriptions / icon of a slash command on the current bound app, addressed by --command-id or by name via --command",
Risk: "write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --command by-name path lists to resolve the id
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command-id", Desc: "target command_id (from +slash-command-list or create output); mutually exclusive with --command"},
{Name: "command", Desc: "target command name WITHOUT leading slash; resolved via live list (needs read scope); mutually exclusive with --command-id"},
{Name: "description", Desc: "new default description (description.default_value)"},
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable <lang>=<text>; REPLACES the whole i18n map (missing languages are dropped); requires --description"},
{Name: "icon-key", Desc: "new icon key (invalid keys rejected server-side with code 40000031)"},
},
Tips: []string{
`lark-cli application +slash-command-update --command greet --description "new text" --as bot`,
"PATCH is field-level partial: fields you do not pass are preserved server-side",
"the command NAME itself cannot be changed (API limitation): rename = delete + create (new command_id)",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateUpdateTarget(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
// The CLI validates first; keep this guard for direct DryRun callers.
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
d := common.NewDryRunAPI()
target := strings.TrimSpace(runtime.Str("command-id"))
if target == "" {
name := strings.TrimSpace(runtime.Str("command"))
d.GET(slashCommandBasePath).
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
target = "<resolved_command_id>"
} else {
target = encodeCommandIDPathSegment(target)
}
return d.PATCH(slashCommandBasePath + "/" + target).
Desc("Update a slash command by command_id").
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
if id == "" {
resolved, err := resolveCommandID(runtime, strings.TrimSpace(runtime.Str("command")))
if err != nil {
return err
}
id = resolved
}
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return err
}
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err := runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
data["action"] = "updated"
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "updated /%v (command_id: %v)\n", data["command"], data["command_id"])
})
return nil
},
}

View File

@@ -1,144 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
)
func TestSlashCommandUpdate_ByID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(patchOKStub("id1"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command-id", "id1", "--description", "new", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["action"] != "updated" {
t.Fatalf("action = %v", data["action"])
}
}
func TestSlashCommandUpdate_ByName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id9")}))
reg.Register(patchOKStub("id9"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", "greet", "--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
}
func TestSlashCommandUpdate_ByNameNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub(nil))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", "nope", "--description", "x", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected not-found error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
t.Fatalf("expected api/not_found, got %#v", p)
}
}
func TestSlashCommandUpdate_Validate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
cases := []struct {
name string
args []string
}{
{"both id and name", []string{"+slash-command-update", "--command-id", "id1", "--command", "greet", "--description", "x", "--as", "bot"}},
{"neither id nor name", []string{"+slash-command-update", "--description", "x", "--as", "bot"}},
{"no editable field", []string{"+slash-command-update", "--command-id", "id1", "--as", "bot"}},
{"i18n without description", []string{"+slash-command-update", "--command-id", "id1", "--description-i18n", "zh_cn=x", "--as", "bot"}},
}
for _, c := range cases {
err := mountAndRun(t, SlashCommandUpdate, c.args, f, stdout)
if err == nil {
t.Errorf("%s: expected validation error", c.name)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("%s: expected validation problem, got %v", c.name, err)
}
}
}
func TestSlashCommandUpdate_ByIDEncodesTrimmedPathSegment(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(patchOKStub("id%2Fwith%20space%3Fx"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command-id", " id/with space?x ", "--description", "new", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
}
func TestSlashCommandUpdate_ByNameDryRunDescriptions(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", " greet ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got struct {
Description string `json:"description"`
API []struct {
Desc string `json:"desc"`
Method string `json:"method"`
} `json:"api"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
if strings.Contains(got.Description, "resolve command_id") {
t.Fatalf("resolve description must be attached to GET, not top-level: %q", got.Description)
}
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
t.Fatalf("first call must describe name resolution: %#v", got.API)
}
if got.API[1].Method != "PATCH" || !strings.Contains(got.API[1].Desc, "Update a slash command") {
t.Fatalf("second call must describe update: %#v", got.API)
}
}
func TestSlashCommandUpdate_ByIDDryRunEncodesTrimmedPathSegment(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command-id", " id/with space?x ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got struct {
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
} `json:"api"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
wantURL := slashCommandBasePath + "/id%2Fwith%20space%3Fx"
if len(got.API) != 1 || got.API[0].URL != wantURL || got.API[0].Desc == "" {
t.Fatalf("dry-run call = %#v, want encoded URL %q with description", got.API, wantURL)
}
}

View File

@@ -39,7 +39,7 @@ const (
)
const (
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
miaodaCLIPkg = "@lark-apaas/miaoda-cli@0.1.20-alpha.dd573f8"
npmRegistry = "https://registry.npmmirror.com"
metaRelPath = ".spark/meta.json"
steeringRelPath = ".agent/skills/steering"

View File

@@ -431,6 +431,11 @@ func (ctx *RuntimeContext) buildRequest(method, url string, params map[string]in
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
req.ExtraOpts = append(req.ExtraOpts, optFn)
}
// TODO: remove PPE headers once testing is complete and promoted to production.
ppeHeaders := http.Header{}
ppeHeaders.Set("x-use-ppe", "1")
ppeHeaders.Set("x-tt-env", "ppe_miaoda_lark_cli")
req.ExtraOpts = append(req.ExtraOpts, larkcore.WithHeaders(ppeHeaders))
return req
}

View File

@@ -39,7 +39,7 @@ func wrapExportContextErr(err error) error {
var DriveExport = common.Shortcut{
Service: "drive",
Command: "+export",
Description: "Export a doc/docx/sheet/bitable/slides or wiki document to a local file with limited polling",
Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling",
Risk: "read",
Scopes: []string{
"docs:document.content:read",
@@ -47,12 +47,10 @@ var DriveExport = common.Shortcut{
"docx:document:readonly",
"drive:drive.metadata:readonly",
},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "source document URL; doc type and token are inferred, and wiki URLs are resolved to the underlying document"},
{Name: "token", Desc: "source document token; bare tokens require --doc-type, and wiki tokens should use --doc-type wiki"},
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides | wiki (required only when --token is a bare token)", Enum: []string{"doc", "docx", "sheet", "bitable", "slides", "wiki"}},
{Name: "token", Desc: "source document token", Required: true},
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
@@ -77,7 +75,6 @@ var DriveExport = common.Shortcut{
// task and poll, but do not download" — callers that only need the ready file
// token / status get it back without writing a local file.
type ExportParams struct {
URL string
Token string
DocType string
FileExtension string
@@ -90,7 +87,6 @@ type ExportParams struct {
func (p ExportParams) spec() driveExportSpec {
return driveExportSpec{
URL: p.URL,
Token: p.Token,
DocType: p.DocType,
FileExtension: p.FileExtension,
@@ -110,7 +106,6 @@ func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
outputDir = "."
}
return ExportParams{
URL: runtime.Str("url"),
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
@@ -132,90 +127,60 @@ func validateExport(p ExportParams) error {
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
spec, source, err := normalizeDriveExportSpecInput(p.spec())
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dry := common.NewDryRunAPI()
if source.Type == "wiki" {
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[0] Resolve wiki node to underlying document token").
Params(map[string]interface{}{"token": source.Token})
spec.Token = "obj_token_from_step_0"
if spec.DocType == "" {
spec.DocType = "obj_type_from_step_0"
}
dry.Set("wiki_token", source.Token)
}
spec := p.spec()
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
desc := "2-step orchestration: fetch docx markdown -> write local file"
if source.Type == "wiki" {
desc = "3-step orchestration: resolve wiki -> fetch docx markdown -> write local file"
}
dry.Desc(desc).
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dry
return dr
}
desc := "3-step orchestration: create export task -> limited polling -> download file"
if source.Type == "wiki" {
desc = "4-step orchestration: resolve wiki -> create export task -> limited polling -> download file"
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
dry.Desc(desc).
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(buildDriveExportTaskBody(spec)).
Body(body).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dry
return dr
}
// RunExport drives create export task -> bounded poll -> optional download. It
// is the shared core behind both drive +export and sheets +workbook-export. An
// empty p.OutputDir skips the download step and returns the ready file token.
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
spec, source, err := normalizeDriveExportSpecInput(p.spec())
if err != nil {
return err
}
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
return err
}
spec := p.spec()
outputDir := p.OutputDir
preferredFileName := strings.TrimSpace(p.FileName)
overwrite := p.Overwrite
var wikiResolution driveExportWikiResolution
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
if source.Type == "wiki" {
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
if err != nil {
return err
}
spec = resolvedSpec
wikiResolution = resolution
}
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
@@ -257,23 +222,21 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
return err
}
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, wikiResolution), nil)
}, nil)
return nil
}
ticket, resolvedSpec, resolution, err := createDriveExportTaskResolvingWiki(ctx, runtime, spec, source)
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
spec = resolvedSpec
wikiResolution = resolution
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
@@ -311,7 +274,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
// no local download (e.g. sheets +workbook-export without an output
// path). Skip the download and return the status envelope.
if strings.TrimSpace(outputDir) == "" {
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
runtime.Out(map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
@@ -321,7 +284,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
"file_size": status.FileSize,
"ready": true,
"downloaded": false,
}, wikiResolution), nil)
}, nil)
return nil
}
@@ -344,7 +307,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(annotateDriveExportWikiOutput(out, wikiResolution), nil)
runtime.Out(out, nil)
return nil
}
@@ -394,19 +357,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(annotateDriveExportWikiOutput(result, wikiResolution), nil)
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
}
func annotateDriveExportWikiOutput(out map[string]interface{}, resolution driveExportWikiResolution) map[string]interface{} {
if !resolution.Resolved {
return out
}
out["wiki_token"] = resolution.WikiToken
out["wiki_node"] = map[string]interface{}{
"obj_token": resolution.ObjToken,
"obj_type": resolution.ObjType,
}
return out
}

View File

@@ -27,16 +27,9 @@ var (
driveExportPollInterval = 5 * time.Second
)
const (
driveExportResolvedDocTypeValues = "doc, docx, sheet, bitable, slides"
driveExportInputDocTypeValues = driveExportResolvedDocTypeValues + ", wiki"
driveExportFileExtensionValues = "docx, pdf, xlsx, csv, markdown, base, pptx"
)
// driveExportSpec contains the normalized export request understood by the
// shortcut and the underlying export task APIs.
type driveExportSpec struct {
URL string
Token string
DocType string
FileExtension string
@@ -44,19 +37,6 @@ type driveExportSpec struct {
OnlySchema bool
}
type driveExportInputSource struct {
Type string
Token string
Param string
}
type driveExportWikiResolution struct {
Resolved bool
WikiToken string
ObjToken string
ObjType string
}
// driveExportTaskResultCommand prints the resume command shown when bounded
// export polling times out locally.
func driveExportTaskResultCommand(ticket, docToken string) string {
@@ -147,49 +127,45 @@ func (s driveExportStatus) StatusLabel() string {
// validateDriveExportSpec enforces shortcut-level export constraints before any
// backend request is sent.
func validateDriveExportSpec(spec driveExportSpec) error {
normalized, source, err := normalizeDriveExportSpecInput(spec)
if err != nil {
return err
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return validateDriveExportNormalizedSpecForSource(normalized, source)
}
func validateDriveExportNormalizedSpec(spec driveExportSpec) error {
switch spec.DocType {
case "doc", "docx", "sheet", "bitable", "slides":
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are %s", spec.DocType, driveExportInputDocTypeValues).
WithParam("--doc-type").
WithHint("use --url when you have a document URL; use --doc-type wiki only with a bare Wiki node token so the CLI can resolve the underlying document type")
}
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type")
}
switch spec.FileExtension {
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
WithParam("--file-extension").
WithHint("choose an export format supported by the source type; common choices are docx/pdf for docs, xlsx/csv for sheets, xlsx/csv/base for bitable, and pptx/pdf for slides")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension")
}
if err := validateDriveExportFormatCompatibility(spec); err != nil {
return err
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx")
}
if spec.FileExtension == "base" && spec.DocType != "bitable" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
}
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
WithParam("--only-schema").
WithHint("retry with --doc-type bitable --file-extension base, or remove --only-schema")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
}
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
}
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf")
}
if strings.TrimSpace(spec.SubID) != "" {
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
WithParam("--sub-id").
WithHint("remove --sub-id, or retry with --doc-type sheet|bitable --file-extension csv")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id")
}
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
@@ -197,212 +173,15 @@ func validateDriveExportNormalizedSpec(spec driveExportSpec) error {
}
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").
WithParam("--sub-id").
WithHint("retry with --sub-id <sheet_id_or_table_id>; if you need the whole workbook, use --file-extension xlsx instead")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id")
}
return nil
}
func validateDriveExportFormatCompatibility(spec driveExportSpec) error {
if driveExportFileExtensionAllowedForDocType(spec.DocType, spec.FileExtension) {
return nil
}
allowed := strings.Join(driveExportAllowedFileExtensions(spec.DocType), ", ")
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported export format: --doc-type %s cannot be exported as %s",
spec.DocType,
spec.FileExtension,
).
WithParam("--file-extension").
WithHint("retry with --file-extension %s. If the token came from a URL, prefer --url so the CLI infers the correct source type before validating the export format", allowed)
}
func driveExportFileExtensionAllowedForDocType(docType, fileExtension string) bool {
for _, allowed := range driveExportAllowedFileExtensions(docType) {
if fileExtension == allowed {
return true
}
}
return false
}
func driveExportAllowedFileExtensions(docType string) []string {
switch normalizeDriveExportDocType(docType) {
case "doc":
return []string{"docx", "pdf"}
case "docx":
return []string{"docx", "pdf", "markdown"}
case "sheet":
return []string{"xlsx", "csv"}
case "bitable":
return []string{"xlsx", "csv", "base"}
case "slides":
return []string{"pptx", "pdf"}
default:
return []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}
}
}
func validateDriveExportNormalizedSpecForSource(spec driveExportSpec, source driveExportInputSource) error {
if source.Type == "wiki" && spec.DocType == "" {
return validateDriveExportPendingWikiSpec(spec, source)
}
return validateDriveExportNormalizedSpec(spec)
}
func validateDriveExportPendingWikiSpec(spec driveExportSpec, source driveExportInputSource) error {
param := source.Param
if param == "" {
param = "--token"
}
if err := validate.ResourceName(spec.Token, param); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(param)
}
switch spec.FileExtension {
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
WithParam("--file-extension").
WithHint("Wiki export format is validated after resolving the Wiki node; choose a format normally supported by the underlying document type")
}
if spec.OnlySchema && spec.FileExtension != "base" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
WithParam("--only-schema").
WithHint("retry with --file-extension base, or remove --only-schema")
}
if strings.TrimSpace(spec.SubID) != "" && spec.FileExtension != "csv" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
WithParam("--sub-id").
WithHint("remove --sub-id, or retry with --file-extension csv if the Wiki node resolves to a sheet/bitable")
}
if strings.TrimSpace(spec.SubID) != "" {
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
}
}
return nil
}
func normalizeDriveExportSpecInput(spec driveExportSpec) (driveExportSpec, driveExportInputSource, error) {
spec.URL = strings.TrimSpace(spec.URL)
spec.Token = strings.TrimSpace(spec.Token)
spec.DocType = strings.ToLower(strings.TrimSpace(spec.DocType))
spec.FileExtension = strings.ToLower(strings.TrimSpace(spec.FileExtension))
if spec.Token == "" && spec.URL == "" {
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "either --url or --token is required").WithParam("--url")
}
if spec.Token != "" && spec.URL != "" {
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive").WithParam("--url")
}
source := driveExportInputSource{
Type: spec.DocType,
Token: spec.Token,
Param: "--token",
}
rawInput := spec.Token
inputParam := "--token"
if spec.URL != "" {
rawInput = spec.URL
inputParam = "--url"
}
if ref, ok := common.ParseResourceURL(rawInput); ok {
refType := normalizeDriveExportDocType(ref.Type)
source = driveExportInputSource{
Type: refType,
Token: ref.Token,
Param: inputParam,
}
spec.Token = ref.Token
if refType != "wiki" {
if !isDriveExportDocType(refType) {
return spec, source, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"%s URL type %q is not supported by drive +export; use a doc/docx/sheet/base/slides/wiki URL or token",
inputParam,
ref.Type,
).WithParam(inputParam)
}
if spec.DocType == "wiki" {
return spec, source, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--doc-type wiki conflicts with %s URL type %q",
inputParam,
refType,
).
WithParam("--doc-type").
WithHint("remove --doc-type when passing --url; the CLI will infer %q from the URL", refType)
}
if spec.DocType != "" && spec.DocType != refType {
return spec, source, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--doc-type %q conflicts with %s URL type %q",
spec.DocType,
inputParam,
refType,
).WithParam("--doc-type")
}
spec.DocType = refType
} else if spec.DocType == "wiki" {
spec.DocType = ""
}
return spec, source, nil
}
if strings.Contains(rawInput, "://") {
return spec, source, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s URL %q: use a recognized Lark document URL",
inputParam,
rawInput,
).WithParam(inputParam)
}
if spec.URL != "" {
return spec, source, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported --url %q: use a recognized Lark document URL",
spec.URL,
).WithParam("--url")
}
if spec.DocType == "" {
return spec, source, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type is required when --token is a bare token (allowed: %s)", driveExportInputDocTypeValues).
WithParam("--doc-type").
WithHint("if you have the original document link, prefer --url <document_url>; if this is a Wiki node token, use --doc-type wiki")
}
if spec.DocType == "wiki" {
source.Type = "wiki"
source.Token = spec.Token
spec.DocType = ""
}
return spec, source, nil
}
func normalizeDriveExportDocType(docType string) string {
switch strings.ToLower(strings.TrimSpace(docType)) {
case "base":
return "bitable"
default:
return strings.ToLower(strings.TrimSpace(docType))
}
}
func isDriveExportDocType(docType string) bool {
switch normalizeDriveExportDocType(docType) {
case "doc", "docx", "sheet", "bitable", "slides":
return true
default:
return false
}
}
func buildDriveExportTaskBody(spec driveExportSpec) map[string]interface{} {
// createDriveExportTask starts the asynchronous export job and returns its
// ticket for subsequent polling.
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
@@ -414,13 +193,8 @@ func buildDriveExportTaskBody(spec driveExportSpec) map[string]interface{} {
if spec.OnlySchema {
body["only_schema"] = true
}
return body
}
// createDriveExportTask starts the asynchronous export job and returns its
// ticket for subsequent polling.
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, buildDriveExportTaskBody(spec))
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
if err != nil {
return "", err
}
@@ -432,79 +206,6 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
return ticket, nil
}
func resolveDriveExportWikiSource(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, wikiToken string) (driveExportSpec, driveExportWikiResolution, error) {
wikiToken = strings.TrimSpace(wikiToken)
if err := validate.ResourceName(wikiToken, "--token"); err != nil {
return spec, driveExportWikiResolution{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node for export: %s\n", common.MaskToken(wikiToken))
data, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
return runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": wikiToken},
nil,
)
})
if err != nil {
return spec, driveExportWikiResolution{}, err
}
node := common.GetMap(data, "node")
objType := normalizeDriveExportDocType(common.GetString(node, "obj_type"))
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return spec, driveExportWikiResolution{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
}
if !isDriveExportDocType(objType) {
return spec, driveExportWikiResolution{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki resolved to %q, but drive +export only supports doc, docx, sheet, bitable, and slides",
objType,
).WithParam("--token")
}
if spec.DocType != "" && spec.DocType != objType {
return spec, driveExportWikiResolution{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki resolved to %q, but --doc-type is %q; use --doc-type %s",
objType,
spec.DocType,
objType,
).WithParam("--doc-type")
}
spec.Token = objToken
spec.DocType = objType
if err := validateDriveExportNormalizedSpec(spec); err != nil {
return spec, driveExportWikiResolution{}, err
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
return spec, driveExportWikiResolution{
Resolved: true,
WikiToken: wikiToken,
ObjToken: objToken,
ObjType: objType,
}, nil
}
func createDriveExportTaskResolvingWiki(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, source driveExportInputSource) (string, driveExportSpec, driveExportWikiResolution, error) {
if source.Type == "wiki" {
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
if err != nil {
return "", spec, resolution, err
}
ticket, err := createDriveExportTask(runtime, resolvedSpec)
return ticket, resolvedSpec, resolution, err
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return "", spec, driveExportWikiResolution{}, err
}
return ticket, spec, driveExportWikiResolution{}, nil
}
// getDriveExportStatus fetches the current backend state for a previously
// created export task.
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {

View File

@@ -33,36 +33,10 @@ func TestValidateDriveExportSpec(t *testing.T) {
name: "markdown docx ok",
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"},
},
{
name: "docx url infers doc type",
spec: driveExportSpec{URL: "https://example.feishu.cn/docx/docxURL123", FileExtension: "pdf"},
},
{
name: "wiki url can defer doc type until resolution",
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", FileExtension: "pdf"},
},
{
name: "wiki url with doc-type wiki can defer doc type until resolution",
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", DocType: "wiki", FileExtension: "pdf"},
},
{
name: "wiki token with doc-type wiki can defer doc type until resolution",
spec: driveExportSpec{Token: "wiki123", DocType: "wiki", FileExtension: "pdf"},
},
{
name: "bare token requires doc type",
spec: driveExportSpec{Token: "docx123", FileExtension: "pdf"},
wantErr: "--doc-type is required",
},
{
name: "markdown non docx rejected",
spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"},
wantErr: "cannot be exported as markdown",
},
{
name: "docx csv rejected",
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "csv"},
wantErr: "cannot be exported as csv",
wantErr: "only supports --doc-type docx",
},
{
name: "csv without sub id rejected",
@@ -98,27 +72,17 @@ func TestValidateDriveExportSpec(t *testing.T) {
{
name: "base non bitable rejected",
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
wantErr: "cannot be exported as base",
},
{
name: "sheet pdf rejected",
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "pdf"},
wantErr: "cannot be exported as pdf",
},
{
name: "bitable pdf rejected",
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "pdf"},
wantErr: "cannot be exported as pdf",
wantErr: "only supports --doc-type bitable",
},
{
name: "pptx non slides rejected",
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
wantErr: "cannot be exported as pptx",
wantErr: "only supports --doc-type slides",
},
{
name: "slides csv rejected",
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
wantErr: "cannot be exported as csv",
wantErr: "slides only supports",
},
{
name: "unknown doc type rejected",
@@ -149,29 +113,6 @@ func TestValidateDriveExportSpec(t *testing.T) {
}
}
func TestValidateDriveExportUnsupportedFormatHasHint(t *testing.T) {
t.Parallel()
err := validateDriveExportSpec(driveExportSpec{
Token: "docx123",
DocType: "docx",
FileExtension: "csv",
})
if err == nil {
t.Fatal("expected unsupported format error, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if valErr.Param != "--file-extension" {
t.Fatalf("param = %q, want --file-extension", valErr.Param)
}
if !strings.Contains(valErr.Hint, "docx, pdf, markdown") || !strings.Contains(valErr.Hint, "--url") {
t.Fatalf("hint = %q, want allowed formats and URL retry guidance", valErr.Hint)
}
}
func TestDriveExportMarkdownWritesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
fetchStub := &httpmock.Stub{
@@ -499,76 +440,6 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
}
}
func TestDriveExportURLInfersDocType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_url"},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_url",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_url",
"file_name": "url-report",
"file_extension": "pdf",
"type": "docx",
"file_size": 3,
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_url/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="url-report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--url", "https://example.feishu.cn/docx/docxURL123",
"--file-extension", "pdf",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var createBody map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
t.Fatalf("unmarshal export_tasks body: %v", err)
}
if createBody["token"] != "docxURL123" {
t.Fatalf("export_tasks body token = %v, want token from URL", createBody["token"])
}
if createBody["type"] != "docx" {
t.Fatalf("export_tasks body type = %v, want inferred docx", createBody["type"])
}
}
func TestDriveExportAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -639,266 +510,6 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
func TestDriveExportWikiURLResolvesBeforeAsyncTask(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxResolved",
},
},
},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_wiki"},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_wiki",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_wiki",
"file_name": "wiki-report",
"file_extension": "pdf",
"type": "docx",
"file_size": 3,
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="wiki-report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--url", "https://example.feishu.cn/wiki/wikiNode123",
"--file-extension", "pdf",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var createBody map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
t.Fatalf("unmarshal export_tasks body: %v", err)
}
if createBody["token"] != "docxResolved" {
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
}
if createBody["type"] != "docx" {
t.Fatalf("export_tasks body type = %v, want docx", createBody["type"])
}
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNode123"`) {
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
}
}
func TestDriveExportBareWikiTypeResolvesBeforeAsyncTask(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxResolved",
},
},
},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_wiki_token"},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_wiki_token",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_wiki_token",
"file_name": "wiki-token-report",
"file_extension": "pdf",
"type": "docx",
"file_size": 3,
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki_token/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="wiki-token-report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "wikiNodeBare",
"--doc-type", "wiki",
"--file-extension", "pdf",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var createBody map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
t.Fatalf("unmarshal export_tasks body: %v", err)
}
if createBody["token"] != "docxResolved" {
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
}
if createBody["type"] != "docx" {
t.Fatalf("export_tasks body type = %v, want resolved docx type", createBody["type"])
}
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
}
}
func TestDriveExportBareWikiTokenFileTokenInvalidDoesNotFallback(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
firstCreate := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Status: 404,
Body: map[string]interface{}{
"code": 1069914,
"msg": "file token invalid",
"log_id": "20260708000000TEST",
},
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"token":"wikiNodeBare"`)
},
}
reg.Register(firstCreate)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "wikiNodeBare",
"--doc-type", "docx",
"--file-extension", "pdf",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected file token invalid error, got nil")
}
if len(firstCreate.CapturedBody) == 0 {
t.Fatal("first export task request was not sent with the original token")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed API error, got %T: %v", err, err)
}
if problem.Code != 1069914 {
t.Fatalf("error code = %d, want 1069914", problem.Code)
}
if strings.Contains(stderr.String(), "Resolving wiki node for export") {
t.Fatalf("stderr unexpectedly contains wiki resolution log: %s", stderr.String())
}
}
func TestDriveExportWikiResolvedTypeMismatch(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": "shtResolved",
},
},
},
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "https://example.feishu.cn/wiki/wikiSheet123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected type mismatch error, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(valErr.Message, `wiki resolved to "sheet"`) {
t.Fatalf("error message = %q, want resolved type", valErr.Message)
}
}
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
// explicit empty --output-dir must still download to the current directory
// (normalized to "."), not trigger the export-only no-download path that the

View File

@@ -1,328 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveListCommentsDefaultPageSize = 50
driveListCommentsDefaultSolvedStatus = "false"
driveListCommentsDefaultScope = "all"
)
var driveListCommentsTypes = []string{"doc", "docx", "sheet", "file", "slides", "bitable", "base", "wiki"}
type driveListCommentsRef struct {
Token string
Type string
SourceFlag string
}
type driveListCommentsTarget struct {
FileToken string
FileType string
}
type driveListCommentsSpec struct {
Ref driveListCommentsRef
PageSize int
PageToken string
SolvedStatus string
CommentScope string
NeedReaction bool
NeedRelation bool
}
// DriveListComments lists document comments through the Drive comments API,
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
var DriveListComments = common.Shortcut{
Service: "drive",
Command: "+list-comments",
Description: "List comments for doc/docx/sheet/file/slides/base(bitable), with URL parsing and Wiki token unwrapping",
Risk: "read",
Scopes: []string{"docs:document.comment:read"},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/slides/base/bitable/wiki); Wiki URLs are unwrapped automatically"},
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveListCommentsTypes},
{Name: "solved-status", Default: driveListCommentsDefaultSolvedStatus, Desc: "comment solved filter: false=unresolved, true=solved, all=all comments", Enum: []string{"false", "true", "all"}},
{Name: "comment-scope", Default: driveListCommentsDefaultScope, Desc: "comment scope filter: all=all comments, whole=full-document comments, partial=local/selection comments", Enum: []string{"all", "whole", "partial"}},
{Name: "need-reaction", Type: "bool", Desc: "include reaction data on comment cards"},
{Name: "need-relation", Type: "bool", Desc: "include docx comment relation data; ignored for non-docx targets"},
{Name: "page-size", Type: "int", Default: "50", Desc: "page size, 1-100"},
{Name: "page-token", Desc: "pagination token from previous response"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return err
}
return validateDriveListCommentsSpec(spec)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if err := validateDriveListCommentsSpec(spec); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveListCommentsDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return err
}
if err := validateDriveListCommentsSpec(spec); err != nil {
return err
}
target, err := resolveDriveListCommentsTarget(ctx, runtime, spec.Ref)
if err != nil {
return err
}
params := buildDriveListCommentsParams(spec, target.FileType)
path := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments", validate.EncodePathSegment(target.FileToken))
data, err := runtime.CallAPITyped("GET", path, params, nil)
if err != nil {
return err
}
runtime.Out(buildDriveListCommentsOutput(target, data), nil)
return nil
},
}
func readDriveListCommentsSpec(runtime *common.RuntimeContext) (driveListCommentsSpec, error) {
ref, err := resolveDriveListCommentsInput(runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveListCommentsSpec{}, err
}
return driveListCommentsSpec{
Ref: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
SolvedStatus: strings.TrimSpace(runtime.Str("solved-status")),
CommentScope: strings.TrimSpace(runtime.Str("comment-scope")),
NeedReaction: runtime.Bool("need-reaction"),
NeedRelation: runtime.Bool("need-relation"),
}, nil
}
func validateDriveListCommentsSpec(spec driveListCommentsSpec) error {
if spec.PageSize < 1 || spec.PageSize > 100 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 100").WithParam("--page-size")
}
if _, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --solved-status %q; allowed: false, true, all", spec.SolvedStatus).WithParam("--solved-status")
}
if _, ok := driveListCommentsScopeParam(spec.CommentScope); !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --comment-scope %q; allowed: all, whole, partial", spec.CommentScope).WithParam("--comment-scope")
}
return nil
}
func resolveDriveListCommentsInput(urlInput, tokenInput, explicitType string) (driveListCommentsRef, error) {
urlInput = strings.TrimSpace(urlInput)
tokenInput = strings.TrimSpace(tokenInput)
if urlInput != "" && tokenInput != "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
}
if urlInput == "" && tokenInput == "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
}
raw := urlInput
sourceFlag := "--url"
if raw == "" {
raw = tokenInput
sourceFlag = "--token"
}
inputType := normalizeDriveListCommentsType(strings.ToLower(strings.TrimSpace(explicitType)))
if ref, ok := common.ParseResourceURL(raw); ok {
refType := normalizeDriveListCommentsType(ref.Type)
if inputType != "" && inputType != refType {
return driveListCommentsRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
refType,
).WithParam("--type")
}
if !driveListCommentsTypeSupported(refType) {
return driveListCommentsRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s resource type %q; comments list supports doc, docx, sheet, file, slides, bitable/base, and wiki",
sourceFlag,
refType,
).WithParam(sourceFlag)
}
return driveListCommentsRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
}
if strings.Contains(raw, "://") {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
}
if strings.ContainsAny(raw, "/?#") {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
}
if inputType == "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, slides, bitable, base, wiki)", sourceFlag).WithParam("--type")
}
if !driveListCommentsTypeSupported(inputType) {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, slides, bitable, base, wiki", inputType).WithParam("--type")
}
return driveListCommentsRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
}
func normalizeDriveListCommentsType(docType string) string {
switch strings.TrimSpace(docType) {
case "base":
return "bitable"
default:
return strings.TrimSpace(docType)
}
}
func driveListCommentsTypeSupported(docType string) bool {
switch normalizeDriveListCommentsType(docType) {
case "doc", "docx", "sheet", "file", "slides", "bitable", "wiki":
return true
default:
return false
}
}
func resolveDriveListCommentsTarget(ctx context.Context, runtime *common.RuntimeContext, ref driveListCommentsRef) (driveListCommentsTarget, error) {
if ref.Type != "wiki" {
return driveListCommentsTarget{FileToken: ref.Token, FileType: ref.Type}, nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(ref.Token))
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": ref.Token},
nil,
)
if err != nil {
return driveListCommentsTarget{}, err
}
node := common.GetMap(data, "node")
objType := normalizeDriveListCommentsType(common.GetString(node, "obj_type"))
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return driveListCommentsTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if !driveListCommentsTypeSupported(objType) || objType == "wiki" {
return driveListCommentsTarget{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki resolved to %q, but comments list only supports doc, docx, sheet, file, slides, and bitable",
objType,
).WithParam(ref.SourceFlag)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
return driveListCommentsTarget{FileToken: objToken, FileType: objType}, nil
}
func buildDriveListCommentsDryRun(spec driveListCommentsSpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
params := buildDriveListCommentsParams(spec, "<obj_type from step 1>")
if spec.NeedRelation {
params["need_relation"] = "<sent only when obj_type is docx>"
}
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> list comments").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
GET("/open-apis/drive/v1/files/<obj_token from step 1>/comments").
Desc("[2] List comments on resolved document").
Params(params)
}
return common.NewDryRunAPI().
Desc("1-step request: list comments").
GET("/open-apis/drive/v1/files/:file_token/comments").
Params(buildDriveListCommentsParams(spec, spec.Ref.Type)).
Set("file_token", spec.Ref.Token)
}
func buildDriveListCommentsParams(spec driveListCommentsSpec, fileType string) map[string]interface{} {
params := map[string]interface{}{
"file_type": fileType,
"page_size": spec.PageSize,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
if value, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); ok && value != nil {
params["is_solved"] = *value
}
if value, ok := driveListCommentsScopeParam(spec.CommentScope); ok && value != nil {
params["is_whole"] = *value
}
if spec.NeedReaction {
params["need_reaction"] = true
}
if spec.NeedRelation && fileType == "docx" {
params["need_relation"] = true
}
return params
}
func driveListCommentsSolvedStatusParam(status string) (*bool, bool) {
switch strings.TrimSpace(status) {
case "false", "":
value := false
return &value, true
case "true":
value := true
return &value, true
case "all":
return nil, true
default:
return nil, false
}
}
func driveListCommentsScopeParam(scope string) (*bool, bool) {
switch strings.TrimSpace(scope) {
case "all", "":
return nil, true
case "whole":
value := true
return &value, true
case "partial":
value := false
return &value, true
default:
return nil, false
}
}
func buildDriveListCommentsOutput(target driveListCommentsTarget, data map[string]interface{}) map[string]interface{} {
items := common.GetSlice(data, "items")
return map[string]interface{}{
"file_token": target.FileToken,
"file_type": target.FileType,
"items": items,
"has_more": common.GetBool(data, "has_more"),
"page_token": common.GetString(data, "page_token"),
"count": len(items),
}
}

View File

@@ -1,355 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestResolveDriveListCommentsInput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
urlInput string
rawInput string
docType string
wantResource string
wantType string
wantErr string
wantParam string
}{
{
name: "url docx",
urlInput: "https://example.larksuite.com/docx/docxResource?from=wiki",
wantResource: "docxResource",
wantType: "docx",
},
{
name: "token flag also accepts url",
rawInput: "https://example.larksuite.com/base/bitableResource",
wantResource: "bitableResource",
wantType: "bitable",
},
{
name: "bare wiki token",
rawInput: "wikiResource",
docType: "wiki",
wantResource: "wikiResource",
wantType: "wiki",
},
{
name: "url and token mutually exclusive",
urlInput: "https://example.larksuite.com/docx/docxResource",
rawInput: "docxResource",
wantErr: "mutually exclusive",
wantParam: "--url",
},
{
name: "bare token needs type",
rawInput: "docxResource",
wantErr: "--type is required",
wantParam: "--type",
},
{
name: "type conflicts with url",
urlInput: "https://example.larksuite.com/wiki/wikiResource",
docType: "docx",
wantErr: "conflicts",
wantParam: "--type",
},
{
name: "unsupported url type",
urlInput: "https://example.larksuite.com/drive/folder/folderResource",
wantErr: "unsupported",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveDriveListCommentsInput(tt.urlInput, tt.rawInput, tt.docType)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveListCommentsValidationError(t, err, tt.wantParam)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Token != tt.wantResource || got.Type != tt.wantType {
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantResource, tt.wantType)
}
})
}
}
func TestValidateDriveListCommentsSpec(t *testing.T) {
t.Parallel()
valid := driveListCommentsSpec{
PageSize: 50,
SolvedStatus: "false",
CommentScope: "all",
}
tests := []struct {
name string
mutate func(*driveListCommentsSpec)
wantParam string
}{
{
name: "invalid page size",
mutate: func(spec *driveListCommentsSpec) {
spec.PageSize = 0
},
wantParam: "--page-size",
},
{
name: "invalid solved status",
mutate: func(spec *driveListCommentsSpec) {
spec.SolvedStatus = "open"
},
wantParam: "--solved-status",
},
{
name: "invalid comment scope",
mutate: func(spec *driveListCommentsSpec) {
spec.CommentScope = "inline"
},
wantParam: "--comment-scope",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
spec := valid
tt.mutate(&spec)
err := validateDriveListCommentsSpec(spec)
if err == nil {
t.Fatal("expected validation error, got nil")
}
assertDriveListCommentsValidationError(t, err, tt.wantParam)
})
}
}
func assertDriveListCommentsValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", validationErr.Category, errs.CategoryValidation)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
}
if cause := errors.Unwrap(err); cause != nil {
t.Fatalf("unexpected cause on direct validation error: %v", cause)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected errs.ProblemOf to recognize typed error: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("problem category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestBuildDriveListCommentsParams(t *testing.T) {
t.Parallel()
defaultSpec := driveListCommentsSpec{
PageSize: 50,
SolvedStatus: "false",
CommentScope: "all",
}
defaultParams := buildDriveListCommentsParams(defaultSpec, "docx")
if got := defaultParams["is_solved"]; got != false {
t.Fatalf("default is_solved = %#v, want false", got)
}
if _, ok := defaultParams["is_whole"]; ok {
t.Fatalf("default params should omit is_whole: %#v", defaultParams)
}
if _, ok := defaultParams["user_id_type"]; ok {
t.Fatalf("default params should omit user_id_type: %#v", defaultParams)
}
allPartialSpec := driveListCommentsSpec{
PageSize: 100,
PageToken: "next",
SolvedStatus: "all",
CommentScope: "partial",
NeedReaction: true,
NeedRelation: true,
}
allPartialParams := buildDriveListCommentsParams(allPartialSpec, "docx")
if _, ok := allPartialParams["is_solved"]; ok {
t.Fatalf("solved-status all should omit is_solved: %#v", allPartialParams)
}
if got := allPartialParams["is_whole"]; got != false {
t.Fatalf("comment-scope partial is_whole = %#v, want false", got)
}
if got := allPartialParams["need_reaction"]; got != true {
t.Fatalf("need_reaction = %#v, want true", got)
}
if got := allPartialParams["need_relation"]; got != true {
t.Fatalf("need_relation = %#v, want true for docx", got)
}
if got := allPartialParams["page_token"]; got != "next" {
t.Fatalf("page_token = %#v, want next", got)
}
sheetParams := buildDriveListCommentsParams(allPartialSpec, "sheet")
if _, ok := sheetParams["need_relation"]; ok {
t.Fatalf("need_relation should be ignored for non-docx: %#v", sheetParams)
}
}
func TestDriveListCommentsExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
if got := query.Get("is_solved"); got != "false" {
t.Errorf("is_solved = %q, want false", got)
}
if got := query.Get("is_whole"); got != "" {
t.Errorf("is_whole = %q, want omitted", got)
}
if got := query.Get("user_id_type"); got != "" {
t.Errorf("user_id_type = %q, want omitted", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{
{"comment_id": "comment_1", "is_solved": false},
},
"has_more": true,
"page_token": "next",
},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxResource" {
t.Fatalf("file_token = %q, want docxResource", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
t.Fatalf("file_type = %q, want docx", got)
}
if got := data["count"]; got != float64(1) {
t.Fatalf("count = %#v, want 1", got)
}
}
func TestDriveListCommentsExecuteWikiResolvesToDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("token"); got != "wikiResource" {
t.Errorf("wiki token = %q, want wikiResource", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxFromWikiResource",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxFromWikiResource/comments",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("is_solved"); got != "" {
t.Errorf("is_solved = %q, want omitted for solved-status all", got)
}
if got := query.Get("is_whole"); got != "true" {
t.Errorf("is_whole = %q, want true", got)
}
if got := query.Get("need_relation"); got != "true" {
t.Errorf("need_relation = %q, want true for resolved docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--token", "wikiResource",
"--type", "wiki",
"--solved-status", "all",
"--comment-scope", "whole",
"--need-relation",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWikiResource" {
t.Fatalf("file_token = %q, want docxFromWikiResource", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
t.Fatalf("file_type = %q, want docx", got)
}
}

View File

@@ -15,7 +15,6 @@ func Shortcuts() []common.Shortcut {
DrivePreview,
DriveCover,
DriveAddComment,
DriveListComments,
DriveExport,
DriveExportDownload,
DriveImport,

View File

@@ -20,15 +20,14 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+download",
"+preview",
"+cover",
"+add-comment",
"+list-comments",
"+export",
"+export-download",
"+import",
"+version-history",
"+version-get",
"+version-revert",
"+version-delete",
"+add-comment",
"+export",
"+export-download",
"+import",
"+move",
"+delete",
"+status",

View File

@@ -34,53 +34,6 @@ func extractUserIDs(users []interface{}) []string {
return ids
}
// stringField safely extracts a string value from a map.
func stringField(m map[string]interface{}, key string) string {
v, _ := m[key].(string)
return v
}
// mentionOpenID extracts open_id from a mention id field (nested object or plain string).
func mentionOpenID(raw interface{}) string {
switch v := raw.(type) {
case map[string]interface{}:
openID, _ := v["open_id"].(string)
return openID
case string:
return v
default:
return ""
}
}
// compactMentions converts the raw mentions array into a compact form with key, id, name.
func compactMentions(mentions []interface{}) []map[string]interface{} {
if len(mentions) == 0 {
return nil
}
out := make([]map[string]interface{}, 0, len(mentions))
for _, raw := range mentions {
item, _ := raw.(map[string]interface{})
m := map[string]interface{}{}
if k := stringField(item, "key"); k != "" {
m["key"] = k
}
if id := mentionOpenID(item["id"]); id != "" {
m["id"] = id
}
if n := stringField(item, "name"); n != "" {
m["name"] = n
}
if len(m) > 0 {
out = append(out, m)
}
}
if len(out) == 0 {
return nil
}
return out
}
// compactBase builds the common compact output fields shared by all IM event processors.
// Every compact output includes: type (event_type), event_id, and timestamp (header create_time).
func compactBase(raw *RawEvent) map[string]interface{} {

View File

@@ -16,13 +16,9 @@ import (
// ImMessageProcessor handles im.message.receive_v1 events.
//
// Compact output fields:
// - type, event_id, timestamp
// - id, message_id, create_time, update_time
// - chat_id, chat_type, message_type
// - sender_id, sender_type
// - root_id, thread_id, reply_to
// - content: human-readable text converted via convertlib
// - mentions: compact mentions array with key, id, name
// - type, id, message_id, create_time, timestamp
// - chat_id, chat_type, message_type, sender_id
// - content: human-readable text converted via convertlib (supports text, post, image, file, card, etc.)
type ImMessageProcessor struct{}
func (p *ImMessageProcessor) EventType() string { return "im.message.receive_v1" }
@@ -36,20 +32,15 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
var ev struct {
Message struct {
MessageID string `json:"message_id"`
RootID string `json:"root_id"`
ParentID string `json:"parent_id"`
ThreadID string `json:"thread_id"`
ChatID string `json:"chat_id"`
ChatType string `json:"chat_type"`
MessageType string `json:"message_type"`
Content string `json:"content"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Mentions []interface{} `json:"mentions"`
} `json:"message"`
Sender struct {
SenderType string `json:"sender_type"`
SenderID struct {
SenderID struct {
OpenID string `json:"open_id"`
} `json:"sender_id"`
} `json:"sender"`
@@ -76,9 +67,6 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
out := map[string]interface{}{
"type": raw.Header.EventType,
}
if raw.Header.EventID != "" {
out["event_id"] = raw.Header.EventID
}
if ev.Message.MessageID != "" {
out["id"] = ev.Message.MessageID
out["message_id"] = ev.Message.MessageID
@@ -92,9 +80,6 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
} else if ev.Message.CreateTime != "" {
out["timestamp"] = ev.Message.CreateTime
}
if ev.Message.UpdateTime != "" && ev.Message.UpdateTime != ev.Message.CreateTime {
out["update_time"] = ev.Message.UpdateTime
}
if ev.Message.ChatID != "" {
out["chat_id"] = ev.Message.ChatID
}
@@ -107,24 +92,9 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
if ev.Sender.SenderID.OpenID != "" {
out["sender_id"] = ev.Sender.SenderID.OpenID
}
if ev.Sender.SenderType != "" {
out["sender_type"] = ev.Sender.SenderType
}
if ev.Message.RootID != "" {
out["root_id"] = ev.Message.RootID
}
if ev.Message.ThreadID != "" {
out["thread_id"] = ev.Message.ThreadID
}
if ev.Message.ParentID != "" {
out["reply_to"] = ev.Message.ParentID
}
if content != "" {
out["content"] = content
}
if mentions := compactMentions(ev.Message.Mentions); len(mentions) > 0 {
out["mentions"] = mentions
}
return out
}

View File

@@ -792,6 +792,7 @@ func TestImMessageProcessor_CompactInteractiveFallsBackToRaw(t *testing.T) {
t.Fatalf("stderr hint = %q, want interactive fallback message", string(hint))
}
}
func TestGenericProcessor_CompactUnmarshalError(t *testing.T) {
p := &GenericProcessor{}
raw := makeRawEvent("some.type", `not valid json`)

View File

@@ -504,84 +504,6 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("validateIdempotencyKey empty string passes", func(t *testing.T) {
if err := validateIdempotencyKey(""); err != nil {
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
}
})
t.Run("validateIdempotencyKey 50 chars passes", func(t *testing.T) {
if err := validateIdempotencyKey(strings.Repeat("a", 50)); err != nil {
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
}
})
t.Run("validateIdempotencyKey 51 chars fails", func(t *testing.T) {
err := validateIdempotencyKey(strings.Repeat("a", 51))
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
t.Fatalf("validateIdempotencyKey() error = %v", err)
}
})
t.Run("validateIdempotencyKey 50 Chinese chars passes", func(t *testing.T) {
if err := validateIdempotencyKey(strings.Repeat("中", 50)); err != nil {
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
}
})
t.Run("validateIdempotencyKey 51 Chinese chars fails", func(t *testing.T) {
err := validateIdempotencyKey(strings.Repeat("中", 51))
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
t.Fatalf("validateIdempotencyKey() error = %v", err)
}
})
t.Run("ImMessagesSend idempotency key too long", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"text": "hello",
"idempotency-key": strings.Repeat("a", 51),
}, nil)
err := ImMessagesSend.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
}
})
t.Run("ImMessagesSend idempotency key valid", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"text": "hello",
"idempotency-key": "my-key-001",
}, nil)
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSend.Validate() unexpected error = %v", err)
}
})
t.Run("ImMessagesReply idempotency key too long", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "om_123",
"text": "hello",
"idempotency-key": strings.Repeat("b", 51),
}, nil)
err := ImMessagesReply.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
}
})
t.Run("ImMessagesReply idempotency key valid", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "om_123",
"text": "hello",
"idempotency-key": "reply-key-001",
}, nil)
if err := ImMessagesReply.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesReply.Validate() unexpected error = %v", err)
}
})
t.Run("ImMessagesReply invalid message id", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "bad_id",
@@ -957,7 +879,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
"message-ids": "om_1,om_2",
}, nil)
got := mustMarshalDryRun(t, ImMessagesMGet.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/messages/mget?card_msg_content_type=raw_card_content\u0026with_sender_name=true\u0026message_ids=om_1\u0026message_ids=om_2"`) {
if !strings.Contains(got, `"/open-apis/im/v1/messages/mget?card_msg_content_type=raw_card_content\u0026message_ids=om_1\u0026message_ids=om_2"`) {
t.Fatalf("ImMessagesMGet.DryRun() = %s", got)
}
})

View File

@@ -6,11 +6,14 @@ package convertlib
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ParseJSONObject parses a raw JSON string into a map.
@@ -69,66 +72,161 @@ func formatTimestamp(ts string) string {
return time.Unix(n, 0).Local().Format("2006-01-02 15:04:05")
}
// pickSenderName returns the server-provided display name from a message sender:
// the plain `sender_name` (the server's default-locale name). Callers wanting a
// specific locale should read the full `sender_i18n_names` map, which is preserved
// on the sender. Returns "" when the server supplied no name, so the caller can
// fall back to the raw id.
func pickSenderName(sender map[string]interface{}) string {
name, _ := sender["sender_name"].(string)
return name
}
// ResolveSenderNames harvests the server-provided sender_name for each message
// sender into the shared cache (keyed by sender id), so a sender appearing across
// the render tree (e.g. merge_forward sub-items, thread replies) resolves once.
// The message read API is the single source of truth for names (opt in via
// with_sender_name=true); there is NO contact/mention fallback — a sender the
// server did not name resolves to its id downstream. Pass an empty map if none exists.
func ResolveSenderNames(_ *common.RuntimeContext, messages []map[string]interface{}, cache map[string]string) map[string]string {
// ResolveSenderNames batch-resolves sender open_ids to display names.
// The cache map is used to share already-resolved IDs across calls; newly resolved
// names are written back into it. Pass an empty map if no prior cache exists.
//
// Step 1: extract names from message mentions (free, no API call).
// Step 2: for remaining unresolved IDs, call contact batch API (requires contact:user.base:readonly).
// Silently returns partial results on API error.
//
// [#22] Changed from variadic `cache ...map[string]string` to a required parameter.
// The variadic form was misleading: every caller passed exactly one map, and the function
// body both modified it and returned it, making the dual semantics confusing.
func ResolveSenderNames(runtime *common.RuntimeContext, messages []map[string]interface{}, cache map[string]string) map[string]string {
nameMap := cache
if nameMap == nil {
nameMap = make(map[string]string)
}
// Step 1: extract names from mentions (free)
for _, msg := range messages {
switch mentions := msg["mentions"].(type) {
case []interface{}:
for _, raw := range mentions {
m, _ := raw.(map[string]interface{})
id, _ := m["id"].(string)
name, _ := m["name"].(string)
if id != "" && name != "" && strings.HasPrefix(id, "ou_") {
nameMap[id] = name
}
}
case []map[string]interface{}:
// Backward-compatible path for tests/callers that construct typed slices.
for _, m := range mentions {
id, _ := m["id"].(string)
name, _ := m["name"].(string)
if id != "" && name != "" && strings.HasPrefix(id, "ou_") {
nameMap[id] = name
}
}
}
}
// Collect sender IDs still missing a name
seen := make(map[string]bool)
var missingIDs []string
for _, msg := range messages {
sender, ok := msg["sender"].(map[string]interface{})
if !ok {
continue
}
id, _ := sender["id"].(string)
if id == "" {
senderType, _ := sender["sender_type"].(string)
if senderType != "user" {
continue
}
if name := pickSenderName(sender); name != "" {
nameMap[id] = name
id, _ := sender["id"].(string)
if id == "" || !strings.HasPrefix(id, "ou_") || seen[id] || nameMap[id] != "" {
continue
}
seen[id] = true
missingIDs = append(missingIDs, id)
}
if len(missingIDs) == 0 {
return nameMap
}
// Step 2: batch resolve remaining via contact API.
// Use basic_batch for user identity (lighter permission requirement),
// full batch for bot identity.
if runtime.As().IsBot() {
batchResolveUsers(runtime, missingIDs, nameMap)
} else {
batchResolveByBasicContact(runtime, missingIDs, nameMap)
}
return nameMap
}
// AttachSenderNames enriches message sender objects with a single resolved display
// name in `name`, taken from the server-provided sender_name (via the sender itself
// or the shared cache). Senders the server did not name keep no `name` (id is
// preserved for downstream id fallback) — there is no contact/mention lookup.
//
// The raw `sender_name` is stripped from the output because it exactly duplicates
// `name`; `sender_i18n_names` (the full i18n set, all locales) and `open_bot_id`
// are preserved for consumers that need a specific locale or the id alignment.
// batchResolveByBasicContact resolves user names via POST /contact/v3/users/basic_batch.
// This API has lighter permission requirements and works with user identity
// even when the target user is not in the app's visible range.
// Response uses "users" (not "items") and "user_id" (not "open_id").
// The basic_batch endpoint caps user_ids at 10 per request.
func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []string, nameMap map[string]string) {
const batchSize = 10
for i := 0; i < len(missingIDs); i += batchSize {
end := i + batchSize
if end > len(missingIDs) {
end = len(missingIDs)
}
batch := missingIDs[i:end]
data, err := runtime.DoAPIJSONTyped(http.MethodPost,
"/open-apis/contact/v3/users/basic_batch",
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
map[string]interface{}{"user_ids": batch},
)
if err != nil {
break
}
users, _ := data["users"].([]interface{})
for _, item := range users {
user, _ := item.(map[string]interface{})
userID, _ := user["user_id"].(string)
name, _ := user["name"].(string)
if userID != "" && name != "" {
nameMap[userID] = name
}
}
}
}
func batchResolveUsers(runtime *common.RuntimeContext, missingIDs []string, nameMap map[string]string) {
const batchSize = 50
for i := 0; i < len(missingIDs); i += batchSize {
end := i + batchSize
if end > len(missingIDs) {
end = len(missingIDs)
}
batch := missingIDs[i:end]
parts := []string{"user_id_type=open_id"}
for _, uid := range batch {
parts = append(parts, "user_ids="+url.QueryEscape(uid))
}
apiURL := "/open-apis/contact/v3/users/batch?" + strings.Join(parts, "&")
data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiURL, nil, nil)
if err != nil {
break
}
items, _ := data["items"].([]interface{})
for _, item := range items {
user, _ := item.(map[string]interface{})
openID, _ := user["open_id"].(string)
name, _ := user["name"].(string)
if openID != "" && name != "" {
nameMap[openID] = name
}
}
}
}
// AttachSenderNames enriches message sender objects with resolved display names.
// Senders whose name could not be resolved are left unchanged (id is preserved).
func AttachSenderNames(messages []map[string]interface{}, nameMap map[string]string) {
for _, msg := range messages {
sender, ok := msg["sender"].(map[string]interface{})
if !ok {
continue
}
if name := pickSenderName(sender); name != "" {
id, _ := sender["id"].(string)
if name, ok := nameMap[id]; ok {
sender["name"] = name
} else if id, _ := sender["id"].(string); id != "" {
if name, ok := nameMap[id]; ok {
sender["name"] = name
}
}
// sender_name exactly duplicates `name`; drop it. Keep sender_i18n_names + open_bot_id.
delete(sender, "sender_name")
}
}

View File

@@ -4,9 +4,12 @@
package convertlib
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"testing"
"time"
)
@@ -126,172 +129,114 @@ func TestExtractPostBlocksText(t *testing.T) {
}
func TestResolveSenderNames(t *testing.T) {
// Server-provided sender_name is harvested into the cache for both user and bot;
// senders the server did not name are absent (id fallback downstream). There is no
// contact/mention lookup, so no API call is ever made.
rt := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("no API call expected: %s", req.URL.String())
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/batch"):
if got := req.URL.Query()["user_ids"]; !reflect.DeepEqual(got, []string{"ou_api", "ou_missing"}) {
t.Fatalf("contact batch user_ids = %#v, want %#v", got, []string{"ou_api", "ou_missing"})
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"open_id": "ou_api", "name": "API User"},
},
},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
messages := []map[string]interface{}{
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_named", "sender_name": "Named User"}},
{"sender": map[string]interface{}{"sender_type": "app", "id": "cli_bot", "sender_name": "Bot Alpha"}},
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_unnamed"}},
}
got := ResolveSenderNames(rt, messages, nil)
if got["ou_named"] != "Named User" {
t.Fatalf("named user = %#v, want %#v", got["ou_named"], "Named User")
}
if got["cli_bot"] != "Bot Alpha" {
t.Fatalf("named bot = %#v, want %#v", got["cli_bot"], "Bot Alpha")
}
if _, has := got["ou_unnamed"]; has {
t.Fatalf("unnamed sender must not be resolved (no contact fallback), got %#v", got["ou_unnamed"])
}
}
// TestResolveSenderNamesServerNameBeatsMention locks the priority: when a sender's id
// also appears as a mention, the server-provided sender_name must win over the mention
// name (which can be a remark/nickname), and no contact call is made.
func TestResolveSenderNamesServerNameBeatsMention(t *testing.T) {
rt := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("no contact call expected: %s", req.URL.String())
}))
messages := []map[string]interface{}{
{
"sender": map[string]interface{}{"sender_type": "user", "id": "ou_dual", "sender_name": "Server Name"},
"sender": map[string]interface{}{"sender_type": "user", "id": "ou_mention"},
"mentions": []interface{}{
map[string]interface{}{"id": "ou_dual", "name": "Mention Remark"},
map[string]interface{}{"id": "ou_mention", "name": "Mention User"},
},
},
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_api"}},
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_missing"}},
{"sender": map[string]interface{}{"sender_type": "bot", "id": "cli_1"}},
}
got := ResolveSenderNames(rt, messages, nil)
if got["ou_dual"] != "Server Name" {
t.Fatalf("server sender_name must beat mention name: got %#v, want %#v", got["ou_dual"], "Server Name")
got := ResolveSenderNames(runtime, messages, nil)
if got["ou_mention"] != "Mention User" {
t.Fatalf("mention-resolved sender = %#v, want %#v", got["ou_mention"], "Mention User")
}
if got["ou_api"] != "API User" {
t.Fatalf("api-resolved sender = %#v, want %#v", got["ou_api"], "API User")
}
if got["ou_missing"] != "" {
t.Fatalf("missing sender = %#v, want empty", got["ou_missing"])
}
}
// TestFormatMessageItemSenderPassthrough covers AC5: the formatted message must
// carry the sender object through verbatim — retaining open_bot_id and leaving
// id / id_type unchanged after enrichment.
func TestFormatMessageItemSenderPassthrough(t *testing.T) {
func TestBatchResolveByBasicContactRespectsAPILimit(t *testing.T) {
// basic_batch allows at most 10 user_ids per request. Given 25 missing IDs,
// expect three requests with sizes 10 / 10 / 5.
var batchSizes []int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
}))
m := map[string]interface{}{
"message_id": "om_1",
"msg_type": "text",
"body": map[string]interface{}{"content": `{"text":"hi"}`},
"sender": map[string]interface{}{
"id": "cli_bot",
"id_type": "app_id",
"sender_type": "app",
"sender_name": "Bot Alpha",
"open_bot_id": "ou_bot",
},
}
if !strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/basic_batch") {
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
}
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
userIDs, _ := payload["user_ids"].([]interface{})
if len(userIDs) > 10 {
t.Fatalf("batch exceeded API limit: size = %d", len(userIDs))
}
batchSizes = append(batchSizes, len(userIDs))
out := FormatMessageItem(m, runtime)
sender, ok := out["sender"].(map[string]interface{})
if !ok {
t.Fatalf("formatted sender missing/mistyped: %#v", out["sender"])
}
if sender["open_bot_id"] != "ou_bot" {
t.Fatalf("open_bot_id passthrough = %#v, want %#v", sender["open_bot_id"], "ou_bot")
}
if sender["id"] != "cli_bot" || sender["id_type"] != "app_id" {
t.Fatalf("id/id_type must be unchanged, got id=%#v id_type=%#v", sender["id"], sender["id_type"])
}
}
func TestPickSenderName(t *testing.T) {
// Uses the server-provided sender_name.
if got := pickSenderName(map[string]interface{}{"sender_name": "Bot Alpha"}); got != "Bot Alpha" {
t.Fatalf("pickSenderName(sender_name) = %q, want %q", got, "Bot Alpha")
}
// sender_i18n_names is NOT consulted for the display name (it stays in output for
// consumers that want a specific locale); no sender_name -> empty (caller uses id).
i18nOnly := map[string]interface{}{
"sender_i18n_names": map[string]interface{}{"en_us": "Bot Beta", "zh_cn": "机器人乙", "ja_jp": "ロボット"},
}
if got := pickSenderName(i18nOnly); got != "" {
t.Fatalf("pickSenderName(i18n only, no sender_name) = %q, want empty", got)
}
// Empty sender_name -> empty (no i18n fallthrough).
if got := pickSenderName(map[string]interface{}{"sender_name": ""}); got != "" {
t.Fatalf("pickSenderName(empty sender_name) = %q, want empty", got)
}
// Nothing available -> empty (caller falls back to id).
if got := pickSenderName(map[string]interface{}{"id": "cli_x"}); got != "" {
t.Fatalf("pickSenderName(no name) = %q, want empty", got)
}
}
// TestAttachSenderNamesPrefersProducerName covers AC1 (bot display name), AC2
// (user producer name), AC5 (open_bot_id passthrough) and AC3 (id fallback).
func TestAttachSenderNamesPrefersProducerName(t *testing.T) {
i18n := map[string]interface{}{"en_us": "Bot Alpha", "zh_cn": "机器人甲"}
messages := []map[string]interface{}{
// bot sender with producer-filled sender_name (AC1) + sender_i18n_names + open_bot_id (AC5)
{"sender": map[string]interface{}{"sender_type": "app", "id": "cli_bot", "sender_name": "机器人甲", "sender_i18n_names": i18n, "open_bot_id": "ou_bot"}},
// user sender with producer-filled sender_name (AC2, unified read)
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_user1", "sender_name": "Producer User"}},
// user sender without producer name -> resolved from the shared name cache (nameMap)
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_user2"}},
// bot sender without any name -> stays id (AC3)
{"sender": map[string]interface{}{"sender_type": "app", "id": "cli_unknown"}},
}
nameMap := map[string]string{"ou_user2": "Contact User"}
AttachSenderNames(messages, nameMap)
s0 := messages[0]["sender"].(map[string]interface{})
if s0["name"] != "机器人甲" {
t.Fatalf("bot sender name = %#v, want %#v", s0["name"], "机器人甲")
}
if s0["open_bot_id"] != "ou_bot" {
t.Fatalf("bot open_bot_id passthrough = %#v, want %#v", s0["open_bot_id"], "ou_bot")
}
// sender_name is dropped (duplicate of name); sender_i18n_names is kept.
if _, has := s0["sender_name"]; has {
t.Fatalf("sender_name should be stripped from output, got %#v", s0["sender_name"])
}
if _, has := s0["sender_i18n_names"]; !has {
t.Fatalf("sender_i18n_names should be preserved in output")
}
if s := messages[1]["sender"].(map[string]interface{}); s["name"] != "Producer User" {
t.Fatalf("user producer name = %#v, want %#v", s["name"], "Producer User")
}
if s := messages[2]["sender"].(map[string]interface{}); s["name"] != "Contact User" {
t.Fatalf("user contact-fallback name = %#v, want %#v", s["name"], "Contact User")
}
if s := messages[3]["sender"].(map[string]interface{}); s["name"] != nil {
t.Fatalf("unresolved bot sender should keep no name (id fallback), got %#v", s["name"])
}
}
// TestSystemMessageNeedsNoName documents that system messages — identified by
// msg_type=="system", not by any sender id — need no display name: the producer
// fills none and their sender carries no ou_ id, so they never hit the contact API
// and are left without a name (no error). An empty sender name is normal here.
func TestSystemMessageNeedsNoName(t *testing.T) {
failIfContactCalled := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("system message must not trigger any API call: %s", req.URL.String())
users := make([]interface{}, 0, len(userIDs))
for _, raw := range userIDs {
id, _ := raw.(string)
users = append(users, map[string]interface{}{
"user_id": id,
"name": "name-" + id,
})
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"users": users},
}), nil
}))
messages := []map[string]interface{}{
{"msg_type": "system", "sender": map[string]interface{}{"sender_type": "system"}},
{"msg_type": "system"}, // system message without a sender object at all
missingIDs := make([]string, 25)
for i := range missingIDs {
missingIDs[i] = fmt.Sprintf("ou_%02d", i)
}
nameMap := map[string]string{}
batchResolveByBasicContact(runtime, missingIDs, nameMap)
got := ResolveSenderNames(failIfContactCalled, messages, nil)
if len(got) != 0 {
t.Fatalf("system messages resolved names = %#v, want empty", got)
if want := []int{10, 10, 5}; !reflect.DeepEqual(batchSizes, want) {
t.Fatalf("batch sizes = %v, want %v", batchSizes, want)
}
AttachSenderNames(messages, got)
if s := messages[0]["sender"].(map[string]interface{}); s["name"] != nil {
t.Fatalf("system message sender should keep no name, got %#v", s["name"])
if len(nameMap) != 25 {
t.Fatalf("resolved name count = %d, want 25", len(nameMap))
}
}
func TestResolveSenderNamesAPIFailure(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/batch"):
return nil, fmt.Errorf("contact api failed")
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
got := ResolveSenderNames(runtime, []map[string]interface{}{
{"sender": map[string]interface{}{"sender_type": "user", "id": "ou_fail"}},
}, map[string]string{})
if got["ou_fail"] != "" {
t.Fatalf("failed sender resolution = %#v, want empty", got["ou_fail"])
}
}

View File

@@ -209,10 +209,6 @@ func fetchMergeForwardSubMessages(messageID string, runtime *common.RuntimeConte
data, err := runtime.DoAPIJSONTyped(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
"user_id_type": []string{"open_id"},
"card_msg_content_type": []string{"raw_card_content"},
// Opt in to server-side sender names: without it, senders that appear
// only inside this merge_forward carry no sender_name and — since there
// is no contact/mention fallback — render as their raw id.
"with_sender_name": []string{"true"},
}, nil)
if err != nil {
return nil, err

View File

@@ -65,12 +65,6 @@ func TestFetchMergeForwardSubMessages(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_root"):
// Sub-item senders that appear only inside the merge_forward
// have no name unless we opt into server-side sender names;
// there is no contact/mention fallback anymore.
if got := req.URL.Query().Get("with_sender_name"); got != "true" {
t.Fatalf("with_sender_name = %q, want %q", got, "true")
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{

View File

@@ -258,10 +258,6 @@ func fetchThreadReplies(runtime *common.RuntimeContext, threadID string, limit i
"sort_type": []string{"ByCreateTimeAsc"},
"page_size": []string{fmt.Sprint(limit)},
"card_msg_content_type": []string{"raw_card_content"},
// Opt in to server-side sender names: without it, reply senders that
// appear only inside this thread carry no sender_name and — since there
// is no contact/mention fallback — render as their raw id.
"with_sender_name": []string{"true"},
}, nil)
if err != nil {
return nil, false, fmt.Errorf("fetch thread replies for %s: %w", threadID, err) //nolint:forbidigo // best-effort internal thread fetch; never surfaced as a final shortcut error (ExpandThreadReplies is void)

View File

@@ -18,12 +18,6 @@ func TestExpandThreadReplies(t *testing.T) {
if req.URL.Query().Get("container_id") != "omt_1" {
return nil, fmt.Errorf("unexpected thread lookup: %s", req.URL.String())
}
// Reply senders that appear only inside the thread have no name
// unless we opt into server-side sender names; there is no
// contact/mention fallback anymore.
if got := req.URL.Query().Get("with_sender_name"); got != "true" {
t.Fatalf("with_sender_name = %q, want %q", got, "true")
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{

View File

@@ -213,7 +213,6 @@ func TestBuildChatMessageListRequest(t *testing.T) {
"page_size": {"50"},
"only_thread_root_messages": {"true"},
"card_msg_content_type": {"raw_card_content"},
"with_sender_name": {"true"},
"start_time": {"1772294400"},
"end_time": {"1772467199"},
"page_token": {"next"},

View File

@@ -54,33 +54,14 @@ func normalizeAtMentions(content string) string {
// Uses repeated params (?message_ids=x&message_ids=y) — RFC 6570 standard array
// encoding, shorter and more broadly compatible than indexed params ([0]=x).
func buildMGetURL(ids []string) string {
parts := make([]string, 0, len(ids)+2)
parts := make([]string, 0, len(ids)+1)
parts = append(parts, "card_msg_content_type=raw_card_content")
// Opt into server-side sender name filling (user + bot); without it the server
// omits sender_name / sender_i18n_names / open_bot_id. Used by messages-mget and
// the mget step of messages-search.
parts = append(parts, "with_sender_name=true")
for _, id := range ids {
parts = append(parts, "message_ids="+url.QueryEscape(id))
}
return "/open-apis/im/v1/messages/mget?" + strings.Join(parts, "&")
}
// senderDisplay returns the human-readable sender column value: the resolved
// display name (set by convertlib.AttachSenderNames from the producer-filled
// sender_name / sender_i18n_names or contact enrichment) when available,
// otherwise the sender id as a fallback (AC3). System messages carrying neither
// yield an empty string — an absent sender name is normal, not an error.
func senderDisplay(sender map[string]interface{}) string {
if name, _ := sender["name"].(string); name != "" {
return name
}
if id, _ := sender["id"].(string); id != "" {
return id
}
return ""
}
func validateMessageID(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {

View File

@@ -675,27 +675,3 @@ func TestShortcuts(t *testing.T) {
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)
}
}
// TestSenderDisplay covers the human-readable sender column: a resolved name wins,
// otherwise the sender id is shown (AC3 fallback), and a system/senderless message
// with neither yields an empty string (no name is normal, not an error).
func TestSenderDisplay(t *testing.T) {
cases := []struct {
name string
sender map[string]interface{}
want string
}{
{"name wins", map[string]interface{}{"name": "Bot Alpha", "id": "cli_bot"}, "Bot Alpha"},
{"id fallback when no name (AC3)", map[string]interface{}{"id": "cli_bot", "open_bot_id": "ou_bot"}, "cli_bot"},
{"user id fallback", map[string]interface{}{"id": "ou_user"}, "ou_user"},
{"empty name falls back to id", map[string]interface{}{"name": "", "id": "cli_x"}, "cli_x"},
{"no name no id (system)", map[string]interface{}{"sender_type": "system"}, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := senderDisplay(c.sender); got != c.want {
t.Fatalf("senderDisplay(%#v) = %q, want %q", c.sender, got, c.want)
}
})
}
}

View File

@@ -23,7 +23,7 @@ var ImChatMessageList = common.Shortcut{
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"},
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,
@@ -168,8 +168,8 @@ var ImChatMessageList = common.Shortcut{
"type": msg["msg_type"],
}
if sender, ok := msg["sender"].(map[string]interface{}); ok {
if disp := senderDisplay(sender); disp != "" {
row["sender"] = disp
if name, _ := sender["name"].(string); name != "" {
row["sender"] = name
}
}
if content, _ := msg["content"].(string); content != "" {
@@ -206,9 +206,6 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
"page_size": []string{strconv.Itoa(pageSize)},
"card_msg_content_type": []string{"raw_card_content"},
"only_thread_root_messages": []string{"true"},
// Opt into server-side sender name filling (sender_name / sender_i18n_names /
// open_bot_id) for both user and bot senders; without it the server omits them.
"with_sender_name": []string{"true"},
}
}

View File

@@ -23,8 +23,8 @@ var ImMessagesMGet = common.Shortcut{
Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "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{
@@ -112,8 +112,8 @@ var ImMessagesMGet = common.Shortcut{
"type": msg["msg_type"],
}
if sender, ok := msg["sender"].(map[string]interface{}); ok {
if disp := senderDisplay(sender); disp != "" {
row["sender"] = disp
if name, _ := sender["name"].(string); name != "" {
row["sender"] = name
}
}
if content, _ := msg["content"].(string); content != "" {

View File

@@ -35,7 +35,7 @@ var ImMessagesReply = common.Shortcut{
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: audioMessageInputDesc},
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
{Name: "idempotency-key", Desc: "idempotency key, max 50 characters (prevents duplicate sends)"},
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
@@ -85,7 +85,6 @@ var ImMessagesReply = common.Shortcut{
content := runtime.Str("content")
text := runtime.Str("text")
markdown := runtime.Str("markdown")
idempotencyKey := runtime.Str("idempotency-key")
imageKey := runtime.Str("image")
fileKey := runtime.Str("file")
videoKey := runtime.Str("video")
@@ -115,9 +114,6 @@ var ImMessagesReply = common.Shortcut{
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
}
if err := validateIdempotencyKey(idempotencyKey); err != nil {
return err
}
if content != "" && !json.Valid([]byte(content)) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
}

View File

@@ -30,7 +30,7 @@ var ImMessagesSearch = common.Shortcut{
Command: "+messages-search",
Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
Risk: "read",
Scopes: []string{"search:message", "im:message.reactions:read"},
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
@@ -227,8 +227,8 @@ var ImMessagesSearch = common.Shortcut{
"type": msg["msg_type"],
}
if sender, ok := msg["sender"].(map[string]interface{}); ok {
if disp := senderDisplay(sender); disp != "" {
row["sender"] = disp
if name, _ := sender["name"].(string); name != "" {
row["sender"] = name
}
}
if chatName, ok := msg["chat_name"].(string); ok && chatName != "" {

View File

@@ -32,7 +32,7 @@ var ImMessagesSend = common.Shortcut{
{Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"},
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
{Name: "idempotency-key", Desc: "idempotency key, max 50 characters (prevents duplicate sends)"},
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
@@ -97,7 +97,6 @@ var ImMessagesSend = common.Shortcut{
content := runtime.Str("content")
text := runtime.Str("text")
markdown := runtime.Str("markdown")
idempotencyKey := runtime.Str("idempotency-key")
imageKey := runtime.Str("image")
fileKey := runtime.Str("file")
videoKey := runtime.Str("video")
@@ -136,9 +135,6 @@ var ImMessagesSend = common.Shortcut{
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, msg)
}
if err := validateIdempotencyKey(idempotencyKey); err != nil {
return err
}
if content != "" && !json.Valid([]byte(content)) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
}
@@ -215,15 +211,6 @@ var ImMessagesSend = common.Shortcut{
},
}
const maxIdempotencyKeyChars = 50
func validateIdempotencyKey(value string) error {
if chars := len([]rune(value)); chars > maxIdempotencyKeyChars {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotency-key exceeds the maximum of %d characters (got %d)", maxIdempotencyKeyChars, chars).WithParam("--idempotency-key")
}
return nil
}
// isMediaKey returns true if the value looks like an existing API key rather than a local file path.
func isMediaKey(value string) bool {
return strings.HasPrefix(value, "img_") || strings.HasPrefix(value, "file_")

View File

@@ -25,8 +25,8 @@ var ImThreadsMessagesList = common.Shortcut{
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "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{
@@ -142,8 +142,8 @@ var ImThreadsMessagesList = common.Shortcut{
"type": msg["msg_type"],
}
if sender, ok := msg["sender"].(map[string]interface{}); ok {
if disp := senderDisplay(sender); disp != "" {
row["sender"] = disp
if name, _ := sender["name"].(string); name != "" {
row["sender"] = name
}
}
if content, _ := msg["content"].(string); content != "" {
@@ -176,8 +176,6 @@ func buildThreadsMessagesListParams(dir, containerID string, pageSize int, pageT
"sort_type": {sortType},
"page_size": {strconv.Itoa(pageSize)},
"card_msg_content_type": {"raw_card_content"},
// Opt into server-side sender name filling (user + bot); see buildChatMessageListParams.
"with_sender_name": {"true"},
}
if pageToken != "" {
params["page_token"] = []string{pageToken}

View File

@@ -1,26 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"strings"
"testing"
)
// TestReadRequestsSendWithSenderName asserts every message-read request opts into
// server-side sender name filling by sending with_sender_name=true. Without this
// the producer never fills sender_name/sender_i18n_names/open_bot_id, so bot names
// never appear (AC1/AC5). Covers chat-messages-list, threads-messages-list, and the
// shared mget URL used by messages-mget and messages-search.
func TestReadRequestsSendWithSenderName(t *testing.T) {
if got := buildChatMessageListParams("desc", "50", "oc_x")["with_sender_name"]; len(got) != 1 || got[0] != "true" {
t.Fatalf("chat-messages-list with_sender_name = %#v, want [true]", got)
}
if got := buildThreadsMessagesListParams("desc", "t_x", 50, "")["with_sender_name"]; len(got) != 1 || got[0] != "true" {
t.Fatalf("threads-messages-list with_sender_name = %#v, want [true]", got)
}
if u := buildMGetURL([]string{"om_1"}); !strings.Contains(u, "with_sender_name=true") {
t.Fatalf("buildMGetURL = %q, want to contain with_sender_name=true", u)
}
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/application"
"github.com/larksuite/cli/shortcuts/apps"
"github.com/larksuite/cli/shortcuts/base"
"github.com/larksuite/cli/shortcuts/calendar"
@@ -62,7 +61,6 @@ var allShortcuts []common.Shortcut
func init() {
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, application.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...)

View File

@@ -27,14 +27,13 @@ Chat (oc_xxx)
- `--as bot` means **bot identity** and uses `tenant_access_token`. Calls run as the app bot, so behavior depends on the bot's membership, app visibility, availability range, and bot-specific scopes.
- If an IM API says it supports both `user` and `bot`, the token type changes who the operator is. The same API can succeed with one identity and fail with the other because owner/admin status, chat membership, tenant boundary, or app availability are checked against the current caller.
### Sender Name Resolution
### Sender Name Resolution with Bot Identity
When fetching messages (`+chat-messages-list`, `+threads-messages-list`, `+messages-mget`, `+messages-search`), the CLI shows a display name for both user and bot senders:
When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-list`, `+threads-messages-list`, `+messages-mget`), sender names may not be resolved (shown as open_id instead of display name). This happens when the bot cannot access the user's contact info.
- **Server-provided name**: the read APIs return `sender_name` (plus the full-i18n `sender_i18n_names` map) on each message `sender`; the CLI surfaces it as the sender's `name` for users and bots alike. No name lookup and no extra permission are needed — **no contact scope** and no `application:bot.basic_info:read`.
- **Fallback to id**: when the server does not provide a name, the sender is shown by its id and the command still exits 0. There is no contact-directory fallback.
**Root cause**: The bot's app visibility settings do not include the message sender, so the contact API returns no name.
The raw `sender_name` is not duplicated in output (its value is in `name`); the full `sender_i18n_names` map (all locales) is preserved for consumers that need a specific language, alongside an optional `open_bot_id` (`ou_`) for bot senders aligned with the message-receive event channel. System messages (`msg_type: system`) have no sender name — that is normal, not an error.
**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)

View File

@@ -26,8 +26,7 @@ metadata:
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `drive +list-comments --need-relation` 返回评论位置,其他类型会静默忽略该参数;具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解
- 用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时仍按资源类型、URL 路径和 token 路由到本 skill不要因为域名不是飞书而回退到 WebFetch。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
@@ -40,7 +39,6 @@ metadata:
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`详细参数、Wiki token 和错误码处理见 [`references/lark-drive-export.md`](references/lark-drive-export.md)。
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
- 用户给的是 wiki URL / token且后续还没明确底层资源类型时先用 `lark-cli drive +inspect` 解包;`+inspect` 失败后不要自动切到别的写接口继续尝试先按错误提示处理权限、scope 或链接问题。
@@ -82,14 +80,13 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
| 列出文档评论 | URL 或 `file_token` | 优先使用 `drive +list-comments --url '<url>'`wiki URL/token 会自动解析到底层真实 token/type |
| 列出文档评论 | `file_token` | 同添加评论 |
### 评论能力入口
- 添加评论优先使用 [`+add-comment`](references/lark-drive-add-comment.md)review / 审阅 / 校对场景默认尽量创建局部评论,不要把多个可定位问题合并为一条全文评论。
- 获取评论列表优先使用 [`+list-comments`](references/lark-drive-list-comments.md):推荐传 `--url`,支持 wiki 自动解包;参数细节见 reference。
- 评论查询、统计、排序、回复限制,先读 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`;其他文档类型会静默忽略该参数
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md);其他文档类型暂不支持返回定位字段
- reaction / 表情相关操作先读 [`lark-drive-reactions.md`](references/lark-drive-reactions.md);只有用户明确需要 reaction 信息时才带 `need_reaction=true`
- `drive +add-comment``--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`CLI 会将其拆分后写入 `anchor.block_id``anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis``--full-comment`
@@ -142,7 +139,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 |
| [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides/base(bitable) 添加评论,也支持解析到这些类型的 wiki URL评论统计、回复和 reaction 细则见 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。 |
| [`+list-comments`](references/lark-drive-list-comments.md) | 获取 doc/docx/sheet/file/slides/base(bitable) 评论列表;优先传 URL支持 wiki 自动解包。 |
| [`+export`](references/lark-drive-export.md) | 将 doc/docx/sheet/bitable/slides 导出为本地文件。 |
| [`+export-download`](references/lark-drive-export-download.md) | 根据导出产物的 file_token 下载文件。 |
| [`+import`](references/lark-drive-import.md) | 将本地文件导入为飞书在线文档、表格、多维表格或幻灯片。 |

View File

@@ -1,27 +1,15 @@
# 文档评论定位字段
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,优先使用 `drive +list-comments --need-relation` 查询 docx 评论位置
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,docx 文档的评论查询必须带 `need_relation=true`
## 适用范围
- 当前只有 `file_type=docx` 支持通过 `need_relation=true` 查询评论的位置,并返回可用于定位正文 block 的 `relation``parent_type``parent_token` 等字段。
- `drive +list-comments` 会在目标不是 docx 时静默忽略 `--need-relation`,避免把无效参数传给 OpenAPI。遇到 sheet、bitable、slides、普通文件等类型的评论时不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
- 其他文件类型暂不支持通过 `need_relation` 查询评论位置。遇到 sheet、bitable、slides、普通文件等类型的评论时不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
## 调用方式
分页列出评论时,优先传 URLWiki URL / Wiki token 会自动解析到底层真实 token/type
```bash
lark-cli drive +list-comments --url '<docx_or_wiki_url>' --need-relation
```
如果只有 Wiki token显式传 `--type wiki`
```bash
lark-cli drive +list-comments --token '<wiki_token>' --type wiki --need-relation
```
只有在需要未被 shortcut 暴露的底层参数时,才直接调用 raw OpenAPI。此时把 `need_relation` 放在 query params
分页列出评论时,`need_relation` 放在 query params
```bash
lark-cli drive file.comments list \
@@ -138,7 +126,7 @@ lark-cli docs +fetch --doc '<doc_token_or_url>' --detail with-ids
## 定位流程
1. 确认目标是 `file_type=docx`;只有 docx 文档支持通过 `need_relation` 查询评论位置。
2.`drive +list-comments --need-relation` 获取评论;已知评论 ID 且需要批量查询时,可用 `drive file.comments batch_query` 并带 `need_relation=true`raw `drive file.comments list` 仅作为低层参数兜底。
2.`drive file.comments list` `drive file.comments batch_query` 获取评论,并带 `need_relation=true`
3.`docs +fetch --detail with-ids` 获取文档内容。
4. 对每条评论先看 `relation`
- 如果存在 `relation.relation`,解析这个 JSON 字符串。

View File

@@ -1,6 +1,6 @@
# Drive 评论查询、统计与回复指南
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md)获取评论列表优先使用 [`lark-drive-list-comments.md`](lark-drive-list-comments.md)reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md)reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
## 评论模式
@@ -16,21 +16,14 @@
## 查询默认口径
优先使用 `drive +list-comments`,不要优先手写 `drive file.comments list`。shortcut 默认 `--solved-status false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论仅当用户明确要求包含已解决评论时,才`--solved-status all`。只查已解决评论时传 `--solved-status true`
`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数
```bash
# 默认查询:仅未解决评论
lark-cli drive +list-comments --url '<DOC_URL>'
# 全部评论:包含已解决和未解决
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status all
# 已解决评论
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status true
# 裸 wiki token
lark-cli drive +list-comments --token '<WIKI_TOKEN>' --type wiki
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx","is_solved":false}'
# 包含已解决评论:仅当用户明确要求时使用
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx"}'
```
## 评论卡片与统计
@@ -60,13 +53,12 @@ lark-cli drive +list-comments --token '<WIKI_TOKEN>' --type wiki
## batch_query 与 list
- `drive file.comments batch_query` 用于已知评论 ID 后的批量查询,需要传入具体评论 ID 列表。
- `drive +list-comments` 用于分页获取评论列表;如果要统计全量评论数、遍历包含已解决评论在内的所有评论、获取全量最新评论或最后 N 条评论,请先传 `--solved-status all` 并拉完所有分页。它会处理 URL、wiki token 和 token/type 匹配问题
- `drive file.comments list` 是原生命令。需要 shortcut 未暴露的字段时才使用。
- `drive file.comments list` 用于分页获取评论列表,适合统计评论数、遍历所有评论、获取最新或最后 N 条评论等场景
## 评论定位字段
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`
- `--need-relation` 仅 docx 生效;其他文档类型会静默忽略
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。
- 其他文档类型暂不支持返回定位字段
## 原生 API

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
`doc` / `docx` / `sheet` / `bitable` / `slides`(也支持 Wiki URL / Wiki node token 自动解包)导出到本地文件。这个 shortcut 内置有限轮询:
`doc` / `docx` / `sheet` / `bitable` / `slides` 导出到本地文件。这个 shortcut 内置有限轮询:
- 如果导出任务在轮询窗口内完成,会直接下载到本地目录
- 如果轮询结束仍未完成,会返回 `ticket``ready=false``timed_out=true``next_command`
@@ -13,22 +13,6 @@
## 命令
```bash
# 推荐:直接传 URLCLI 自动解析类型和 token
lark-cli drive +export \
--url "https://example.feishu.cn/docx/<DOCX_TOKEN>" \
--file-extension pdf
# Wiki URL 也推荐直接传CLI 会先解析到底层 obj_token/obj_type
lark-cli drive +export \
--url "https://example.feishu.cn/wiki/<WIKI_NODE_TOKEN>" \
--file-extension pdf
# 只有裸 Wiki node token 时,显式传 --doc-type wiki让 CLI 先解析到底层文档类型
lark-cli drive +export \
--token "<WIKI_NODE_TOKEN>" \
--doc-type wiki \
--file-extension pdf
# 导出新版文档为 pdf默认保存到当前目录
lark-cli drive +export \
--token "<DOCX_TOKEN>" \
@@ -112,9 +96,8 @@ lark-cli drive +export \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 与 `--token` 二选一 | 源文档 URL推荐优先使用CLI 自动解析类型和 tokenWiki URL 会解析到底层 `obj_token/obj_type` |
| `--token` | 与 `--url` 二选一 | 源文档裸 token裸 token 必须同时传 `--doc-type`。裸 Wiki node token 必须传 `--doc-type wiki`CLI 会先解析到底层 `obj_token/obj_type` |
| `--doc-type` | 条件必填 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` / `wiki`;仅当使用裸 `--token` 时必填,使用 `--url` 时自动推断。`wiki` 只用于裸 Wiki node token解析后会按真实底层类型发起导出 |
| `--token` | 是 | 源文档 token |
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` |
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` / `pptx` |
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
| `--only-schema` | 否 | 仅当 `--doc-type bitable --file-extension base` 时可用;只导出多维表格结构,不导出记录数据 |
@@ -124,34 +107,22 @@ lark-cli drive +export \
## 关键约束
- 推荐优先传 `--url`,不要从 URL 手工拆 token 和 type尤其是 Wiki URLCLI 会自动解包到底层资源
- `--url``--token` 互斥
-`--token` 必须传 `--doc-type`;裸 Wiki node token 使用 `--doc-type wiki`
- `doc` 支持导出为 `docx` / `pdf`
- `docx` 支持导出为 `docx` / `pdf` / `markdown`
- `sheet` 支持导出为 `xlsx` / `csv`
- `bitable` 支持导出为 `xlsx` / `csv` / `base`
- `slides` 支持导出为 `pptx` / `pdf`
- `csv` 只支持 `sheet` / `bitable`,且必须带 `--sub-id`
- `markdown` 只支持 `docx`
- `base` 只支持 `bitable`
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
- 如果格式不匹配CLI 会返回 typed validation error并在 `hint` 中给出可重试的 `--file-extension` 建议;例如 `docx + csv` 会提示改用 `docx/pdf/markdown`,或改传 sheet/bitable URL
- `pptx` 只支持 `slides`
- `slides` 支持导出为 `pptx` / `pdf`
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`
- shortcut 内部固定有限轮询:最多 10 次,每次间隔 5 秒
- 轮询超时不是失败;会返回 `ticket``timed_out=true``next_command`,供后续继续查询
## 错误码处理
| 错误码 | 含义 | 处理方式 |
|--------|------|----------|
| `1069914` | token 非法或 token/type 不匹配;常见原因是把 Wiki node token 当作底层 `docx` / `sheet` / `bitable` token 使用,没有传 `--doc-type wiki` | 优先改用 `--url <Wiki URL>`;只有裸 Wiki token 时,用 `--token <WIKI_NODE_TOKEN> --doc-type wiki`。不确定 token 类型时,先用 `lark-cli drive +inspect --url <TOKEN> --type wiki` 检查是否能解包为 Wiki node如果不是 Wiki token再检查 token 来源、`--doc-type` 是否与实际资源类型一致 |
| `1069902` | 没有当前导出任务所需权限 | 不要直接重试同一命令;先确认当前 `--as` 身份是否能访问该文档、是否有下载/导出权限,以及文档是否受分享、密级或租户策略限制。需要补权限时,让文档 owner 或管理员授权后再执行 |
| `99991679` | 缺少 OpenAPI scope | 按错误 envelope 中的 `missing_scopes` / `required_scope` / `hint` 补齐授权;常见方式是重新执行 `lark-cli auth login --scope "<缺失 scope>"`。补 scope 前不要反复重试导出命令 |
## 推荐续跑方式
```bash
# 第一步:先尝试直接导出
lark-cli drive +export \
--url "<DOCX_URL>" \
--token "<DOCX_TOKEN>" \
--doc-type docx \
--file-extension pdf \
--file-name "weekly-report.pdf"

View File

@@ -1,116 +0,0 @@
# drive +list-comments
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。
列出 doc/docx/sheet/file/slides/base(bitable) 的评论卡片。优先传用户给出的完整 URLshortcut 会自动识别类型;如果传 wiki URL 或 `--token <wiki_token> --type wiki`,会先解析到真实文档。
## 重要默认口径
- 默认只查未解决评论,即不额外传 `--solved-status` 或显式传 `--solved-status false`。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论。
- 仅当用户明确要求“包含已解决评论”“已解决和未解决都要”“全部历史评论”这类语义时,才传 `--solved-status all`
- 是否还有下一页以输出里的 `has_more` 为准;`page_token` 只作为 `has_more=true` 时续跑下一页的游标。
## 命令
```bash
# 推荐:直接传用户给出的完整 URL。默认只查未解决评论。
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>"
# 只有用户明确要求包含已解决评论时,才查询已解决和未解决的全部评论。
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>" \
--solved-status all
# 查询已解决评论。
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>" \
--solved-status true
# 只查全文评论或局部评论。
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>" \
--comment-scope whole
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>" \
--comment-scope partial
# 电子表格 URL 保留 /sheets/ 路径,直接原样传入;不要把 sheet token 拼成 /docx/<token>。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/sheets/<SHEET_TOKEN>"
# wiki URL 会自动解包。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>"
# 裸 wiki token 也支持,但必须显式声明 --type wiki。
lark-cli drive +list-comments \
--token "<WIKI_TOKEN>" \
--type wiki
# 裸 token 需要声明 token 对应类型;不要默认当作 docx。这里以 sheet 为例。
lark-cli drive +list-comments \
--token "<DOCUMENT_TOKEN>" \
--type sheet \
--page-size 100
# docx 需要评论定位关系时再带 need-relation非 docx 会静默忽略。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--need-relation
# 分页续跑。
# 先看上一页输出的 has_more只有 has_more=true 时,才用返回的 page_token 继续。
lark-cli drive +list-comments \
--url "<DOCUMENT_URL>" \
--page-size 100 \
--page-token "<NEXT_PAGE_TOKEN>"
# 预览请求链路,不发真实请求。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/wiki URLwiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | 传 token 对应类型:`doc``docx``sheet``file``slides``bitable``base``wiki`。wiki token 使用 `wiki`;传 `base`CLI 会按 `bitable` 类型处理。 |
| `--solved-status` | 否 | `false` / `true` / `all`,默认 `false``false` 查未解决评论;`true` 查已解决评论;`all` 查全部评论。 |
| `--comment-scope` | 否 | `all` / `whole` / `partial`,默认 `all``all` 查全部范围;`whole` 查全文评论;`partial` 查局部评论。 |
| `--need-reaction` | 否 | 是否返回评论卡片上的 reaction 数据;只有用户明确需要 reaction 时才带。 |
| `--need-relation` | 否 | docx 评论定位关系字段;仅 docx 生效,非 docx 静默忽略。需要定位正文时先读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。 |
| `--page-size` | 否 | 默认 50最大 100。 |
| `--page-token` | 否 | 分页游标;本 shortcut 不自动翻页,按返回的 `page_token` 继续请求下一页。 |
## 行为说明
- `--comment-scope all` 查全部范围;`whole` 查全文评论;`partial` 查局部/选区评论。
- 当用户已经给出完整 URL 时,原样传给 `--url`;不要先提取 token 再重组成其他类型 URL。比如 sheet 保留 `/sheets/<token>`wiki 保留 `/wiki/<token>`
- URL 输入时不需要传 `--type`;如果 URL 类型和显式 `--type` 冲突shortcut 会返回 validation error建议移除 `--type`
- wiki 输入会自动解析到真实文档再查询评论列表。JSON 输出不额外返回 wiki token 或 wiki node。
- 输出中的 `items` 保留评论卡片字段,外层补充 `file_token``file_type``has_more``page_token``count``count` 是当前页返回的评论卡片数。是否继续分页以 `has_more` 为准,而不是只看 `page_token` 是否存在。
- 如果需要批量按评论 ID 查询、获取更多回复、创建/编辑/删除回复,继续使用原生 `drive file.comments batch_query``drive file.comment.replys.*`
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"items": [],
"has_more": false,
"page_token": "",
"count": 0
}
```
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-drive-comments-guide](lark-drive-comments-guide.md) -- 评论统计、回复限制和原生 API 说明
- [lark-drive-comment-location](lark-drive-comment-location.md) -- 使用 `need_relation` 定位 docx 正文

View File

@@ -41,14 +41,13 @@ Chat (oc_xxx)
- `--as bot` means **bot identity** and uses `tenant_access_token`. Calls run as the app bot, so behavior depends on the bot's membership, app visibility, availability range, and bot-specific scopes.
- If an IM API says it supports both `user` and `bot`, the token type changes who the operator is. The same API can succeed with one identity and fail with the other because owner/admin status, chat membership, tenant boundary, or app availability are checked against the current caller.
### Sender Name Resolution
### Sender Name Resolution with Bot Identity
When fetching messages (`+chat-messages-list`, `+threads-messages-list`, `+messages-mget`, `+messages-search`), the CLI shows a display name for both user and bot senders:
When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-list`, `+threads-messages-list`, `+messages-mget`), sender names may not be resolved (shown as open_id instead of display name). This happens when the bot cannot access the user's contact info.
- **Server-provided name**: the read APIs return `sender_name` (plus the full-i18n `sender_i18n_names` map) on each message `sender`; the CLI surfaces it as the sender's `name` for users and bots alike. No name lookup and no extra permission are needed — **no contact scope** and no `application:bot.basic_info:read`.
- **Fallback to id**: when the server does not provide a name, the sender is shown by its id and the command still exits 0. There is no contact-directory fallback.
**Root cause**: The bot's app visibility settings do not include the message sender, so the contact API returns no name.
The raw `sender_name` is not duplicated in output (its value is in `name`); the full `sender_i18n_names` map (all locales) is preserved for consumers that need a specific language, alongside an optional `open_bot_id` (`ou_`) for bot senders aligned with the message-receive event channel. System messages (`msg_type: system`) have no sender name — that is normal, not an error.
**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)

View File

@@ -188,7 +188,7 @@ lark-cli im +messages-reply --message-id om_xxx --msg-type interactive --content
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`) |
| `--audio <path\|url\|key>` | One content option | Voice-message audio key, URL, or cwd-relative local path. Local paths and URLs must be Opus (`.opus` or Ogg Opus `.ogg`) |
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
| `--idempotency-key <key>` | No | Idempotency key, max 50 characters; the same key sends only one reply within 1 hour |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
| `--dry-run` | No | Print the request only, do not execute it |

View File

@@ -191,7 +191,7 @@ lark-cli im +messages-send --chat-id oc_xxx --msg-type interactive --content '<c
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
| `--audio <path\|url\|key>` | One content option | Voice-message audio key, URL, or cwd-relative local path. Local paths and URLs must be Opus (`.opus` or Ogg Opus `.ogg`) |
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
| `--idempotency-key <key>` | No | Idempotency key, max 50 characters; the same key sends only one message within 1 hour |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
| `--dry-run` | No | Print the request only, do not execute it |

View File

@@ -1,166 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
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"
)
// setSlashCommandDryRunEnv isolates config and supplies stub credentials so
// dry-run / the pre-Execute confirmation gate short-circuit before identity
// resolution touches a real keychain. Mirrors tests/cli_e2e/apps/helpers_test.go
// and tests/cli_e2e/calendar/calendar_update_dryrun_test.go.
func setSlashCommandDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "application_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "application_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
// TestSlashCommandList_DryRunShowsGetPath pins the read-only GET shape for
// `application +slash-command-list --dry-run`.
func TestSlashCommandList_DryRunShowsGetPath(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-list",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
}
// TestSlashCommandCreate_DryRunShowsPostBody pins the POST body shape for
// `application +slash-command-create --dry-run`: icon sits at the TOP LEVEL,
// a sibling of description (not nested inside description) - the official
// create sample nesting icon inside description is a documented doc bug -
// and description.i18n carries the localized map.
func TestSlashCommandCreate_DryRunShowsPostBody(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-create",
"--command", " greet ",
"--description", "say hi",
"--description-i18n", "zh_cn=你好",
"--icon-key", "skill_outlined",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "greet", gjson.Get(out, "api.0.body.command").String(), "stdout:\n%s", out)
assert.Equal(t, "say hi", gjson.Get(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
assert.Equal(t, "你好", gjson.Get(out, "api.0.body.description.i18n.zh_cn").String(), "stdout:\n%s", out)
// icon is a top-level key, sibling of description.
assert.Equal(t, "skill_outlined", gjson.Get(out, "api.0.body.icon.icon_key").String(), "stdout:\n%s", out)
assert.False(t, gjson.Get(out, "api.0.body.description.icon").Exists(), "icon must not be nested inside description:\n%s", out)
}
// TestSlashCommandUpdate_DryRunShowsPatchPath pins the PATCH shape for
// `application +slash-command-update --command-id --dry-run`.
func TestSlashCommandUpdate_DryRunShowsPatchPath(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-update",
"--command-id", " id/with space?x ",
"--description", "updated description",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "PATCH", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath+"/id%2Fwith%20space%3Fx", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "updated description", gjson.Get(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
}
// TestSlashCommandDelete_DryRunShowsDeletePath pins the DELETE shape for
// `application +slash-command-delete --command-id --yes --dry-run`. Dry-run
// short-circuits before the high-risk-write confirmation gate (see
// shortcuts/common/runner.go), but --yes is passed anyway to match the
// eventual real invocation the agent would run.
func TestSlashCommandDelete_DryRunShowsDeletePath(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-delete",
"--command-id", " id/with space?x ",
"--dry-run",
},
Yes: true,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "DELETE", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath+"/id%2Fwith%20space%3Fx", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
}
// TestSlashCommandDelete_WithoutYesRequiresConfirmation asserts the
// high-risk-write gate fires BEFORE any HTTP call: no --dry-run, no --yes ->
// exit 10 (ExitConfirmationRequired) with a confirmation_required envelope on
// stderr (see internal/output/exitcode.go and cmd/root.go handleRootError).
func TestSlashCommandDelete_WithoutYesRequiresConfirmation(t *testing.T) {
setSlashCommandDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"application", "+slash-command-delete",
"--command-id", "id_dry",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 10)
assert.Equal(t, "confirmation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
assert.Equal(t, "confirmation_required", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
}

View File

@@ -1,9 +1,9 @@
# Drive CLI E2E Coverage
## Metrics
- Denominator: 32 leaf commands
- Covered: 11
- Coverage: 34.4%
- Denominator: 31 leaf commands
- Covered: 10
- Coverage: 32.3%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
@@ -12,10 +12,9 @@
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
- TestDriveListCommentsDryRun_DocxDefaults / TestDriveListCommentsDryRun_WikiToken: dry-run coverage for `drive +list-comments`; asserts URL parsing to `files/:token/comments`, default `is_solved=false`, default omitted `is_whole` and `user_id_type`, and Wiki token orchestration (`get_node -> comments.list`) without live API calls.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask / TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, Wiki URL and `--doc-type wiki` token `get_node -> export_tasks` planning, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
@@ -27,11 +26,10 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_DocxDefaults; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_WikiToken; drive_add_comment_workflow_test.go::TestDriveAddCommentMarkdownFileWorkflow | `--url`; `--token + --type wiki`; `--solved-status=false\|all`; `--comment-scope=all\|partial`; `--need-relation`; `--page-size` | dry-run locks URL/token parsing, default unresolved filter, omitted all-scope filter, omitted `user_id_type`, and Wiki unwrap request shape; opt-in live workflow verifies a created file comment can be listed back |
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
| ✕ | drive +import | shortcut | | none | no import workflow yet |
| ✕ | drive +move | shortcut | | none | no move workflow yet |

View File

@@ -81,41 +81,4 @@ func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
if got := gjson.Get(commentResult.Stdout, "data.file_extension").String(); got != ".md" {
t.Fatalf("data.file_extension=%q, want .md\nstdout:\n%s", got, commentResult.Stdout)
}
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+list-comments",
"--token", fileToken,
"--type", "file",
"--solved-status", "all",
"--page-size", "100",
},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || !driveCommentListContainsID(result.Stdout, commentID)
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
if got := gjson.Get(listResult.Stdout, "data.file_token").String(); got != fileToken {
t.Fatalf("list data.file_token=%q, want %q\nstdout:\n%s", got, fileToken, listResult.Stdout)
}
if got := gjson.Get(listResult.Stdout, "data.file_type").String(); got != "file" {
t.Fatalf("list data.file_type=%q, want file\nstdout:\n%s", got, listResult.Stdout)
}
if !driveCommentListContainsID(listResult.Stdout, commentID) {
t.Fatalf("list comments did not include comment_id %q\nstdout:\n%s", commentID, listResult.Stdout)
}
}
func driveCommentListContainsID(stdout, commentID string) bool {
for _, item := range gjson.Get(stdout, "data.items").Array() {
if item.Get("comment_id").String() == commentID {
return true
}
}
return false
}

View File

@@ -61,96 +61,6 @@ func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
}
}
func TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+export",
"--url", "https://example.feishu.cn/wiki/wikiDryRunExport",
"--file-extension", "pdf",
"--file-name", "wiki-report",
"--output-dir", "./exports",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.method").String(); got != "POST" {
t.Fatalf("api.1.method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/export_tasks" {
t.Fatalf("api.1.url=%q, want export_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "file_name").String(); got != "wiki-report.pdf" {
t.Fatalf("file_name=%q, want wiki-report.pdf\nstdout:\n%s", got, out)
}
}
func TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+export",
"--token", "wikiDryRunExport",
"--doc-type", "wiki",
"--file-extension", "pdf",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
}
}
func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
setDriveDryRunConfigEnv(t)

View File

@@ -1,100 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveListCommentsDryRun_DocxDefaults(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+list-comments",
"--url", "https://example.larksuite.com/docx/docxDryRunCommentList",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCommentList/comments" {
t.Fatalf("api.0.url=%q, want comments list\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.file_type").String(); got != "docx" {
t.Fatalf("api.0.params.file_type=%q, want docx\nstdout:\n%s", got, out)
}
isSolved := gjson.Get(out, "api.0.params.is_solved")
if !isSolved.Exists() || isSolved.Bool() {
t.Fatalf("api.0.params.is_solved=%v, want explicit false\nstdout:\n%s", isSolved.Value(), out)
}
if gjson.Get(out, "api.0.params.is_whole").Exists() {
t.Fatalf("api.0.params.is_whole should be omitted by default\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 50 {
t.Fatalf("api.0.params.page_size=%d, want 50\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.params.user_id_type").Exists() {
t.Fatalf("api.0.params.user_id_type should be omitted\nstdout:\n%s", out)
}
}
func TestDriveListCommentsDryRun_WikiToken(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+list-comments",
"--token", "wikiDryRunCommentList",
"--type", "wiki",
"--solved-status", "all",
"--comment-scope", "partial",
"--need-relation",
"--page-size", "99",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunCommentList" {
t.Fatalf("api.0.params.token=%q, want wikiDryRunCommentList\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/<obj_token from step 1>/comments" {
t.Fatalf("api.1.url=%q, want resolved comments list placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.file_type").String(); got != "<obj_type from step 1>" {
t.Fatalf("api.1.params.file_type=%q, want obj_type placeholder\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.1.params.is_solved").Exists() {
t.Fatalf("api.1.params.is_solved should be omitted for solved-status all\nstdout:\n%s", out)
}
isWhole := gjson.Get(out, "api.1.params.is_whole")
if !isWhole.Exists() || isWhole.Bool() {
t.Fatalf("api.1.params.is_whole=%v, want explicit false for partial\nstdout:\n%s", isWhole.Value(), out)
}
if got := gjson.Get(out, "api.1.params.need_relation").String(); got != "<sent only when obj_type is docx>" {
t.Fatalf("api.1.params.need_relation=%q, want conditional placeholder\nstdout:\n%s", got, out)
}
}

View File

@@ -26,14 +26,6 @@ var driveDeleteVisibilityWait = clie2e.WaitOptions{
Interval: driveDeleteVisibilityPoll,
}
var driveDeleteRetry = clie2e.RetryOptions{
Attempts: 6,
InitialDelay: 2 * time.Second,
MaxDelay: 8 * time.Second,
BackoffMultiple: 2,
ShouldRetry: clie2e.ResultHasRetryableError,
}
// CreateDriveFolder creates a Drive folder, optionally under a parent folder, and
// deletes it during parent cleanup.
func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string, parentFolderToken string) string {
@@ -88,10 +80,10 @@ func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs
defaultAs = "bot"
}
deleteResult, deleteErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
deleteResult, deleteErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
DefaultAs: defaultAs,
}, driveDeleteRetry)
})
if deleteErr != nil || deleteResult == nil {
return deleteResult, deleteErr
}

View File

@@ -23,20 +23,6 @@ func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
}
func TestDeleteDriveResourceAndVerify(t *testing.T) {
t.Run("retries retryable delete contention", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
t.Setenv("FAKE_DRIVE_DELETE_RETRYABLE_ATTEMPTS", "2")
t.Setenv("FAKE_DRIVE_DELETE_STATE", filepath.Join(t.TempDir(), "delete-attempts"))
t.Setenv("FAKE_DRIVE_META_EMPTY", "1")
withFastDriveDeleteRetry(t)
result, err := DeleteDriveResourceAndVerify(context.Background(), "fld_retry", "folder", "bot")
require.NotNil(t, result)
require.NoError(t, err)
assert.Equal(t, 0, result.ExitCode)
})
t.Run("successful delete with stale meta returns cleanup warning", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
@@ -65,41 +51,11 @@ func TestDeleteDriveResourceAndVerify(t *testing.T) {
})
}
func withFastDriveDeleteRetry(t *testing.T) {
t.Helper()
original := driveDeleteRetry
driveDeleteRetry = clie2e.RetryOptions{
Attempts: 3,
InitialDelay: time.Millisecond,
MaxDelay: time.Millisecond,
BackoffMultiple: 2,
ShouldRetry: clie2e.ResultHasRetryableError,
}
t.Cleanup(func() {
driveDeleteRetry = original
})
}
func mustWriteDriveCleanupFakeCLI(t *testing.T) string {
t.Helper()
script := `#!/bin/sh
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
if [ -n "$FAKE_DRIVE_DELETE_RETRYABLE_ATTEMPTS" ]; then
state="$FAKE_DRIVE_DELETE_STATE"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
next=$((count + 1))
echo "$next" > "$state"
if [ "$count" -lt "$FAKE_DRIVE_DELETE_RETRYABLE_ATTEMPTS" ]; then
echo "Deleting folder fake..." >&2
echo '{"ok":false,"error":{"type":"api","code":1061045,"message":"resource contention occurred, please retry.","retryable":true}}' >&2
exit 1
fi
fi
if [ "${FAKE_DRIVE_DELETE_EXIT:-0}" != "0" ]; then
echo '{"ok":false,"error":{"type":"api","message":"delete failed"}}' >&2
exit "$FAKE_DRIVE_DELETE_EXIT"
@@ -109,10 +65,6 @@ if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
fi
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
if [ "${FAKE_DRIVE_META_EMPTY:-0}" = "1" ]; then
echo '{"ok":true,"data":{"metas":[]}}'
exit 0
fi
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
exit 0
fi