feat(im): add copyable Tips examples to high-frequency IM shortcuts

This commit is contained in:
luozhixiong
2026-07-09 16:20:47 +08:00
parent 1f565a290b
commit dc7046596e
14 changed files with 264 additions and 0 deletions

View File

@@ -40,6 +40,10 @@ var ImChatCreate = common.Shortcut{
{Name: "chat-mode", Default: "group", Desc: "group mode (\"topic\" creates a topic chat; differs from a normal group in topic-message mode)", Enum: []string{"group", "topic"}},
{Name: "set-bot-manager", Type: "bool", Desc: "set the bot that creates this chat as manager (bot identity only)"},
},
Tips: []string{
`Example: lark-cli im +chat-create --name "project chat"`,
`Example: lark-cli im +chat-create --name "project chat" --users <open_id1>,<open_id2>`,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCreateChatBody(runtime)
params := map[string]interface{}{"user_id_type": "open_id"}

View File

@@ -55,6 +55,10 @@ var ImChatList = common.Shortcut{
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
Tips: []string{
`Example: lark-cli im +chat-list`,
`Example: lark-cli im +chat-list --sort active_time`,
},
// DryRun previews the GET /open-apis/im/v1/chats request without executing.
// When bot identity strips p2p from --types, emits the same stderr warning
// Execute would emit, so DryRun output truthfully reflects what the API

View File

@@ -55,6 +55,8 @@ var ImChatMembersList = common.Shortcut{
{Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"},
},
Tips: []string{
`Example: lark-cli im +chat-members-list --chat-id <chat_id>`,
`Example: lark-cli im +chat-members-list --chat-id <chat_id> --page-all`,
"Default fetches a single page; pass --page-all to walk every page.",
"With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.",
"truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.",

View File

@@ -39,6 +39,10 @@ var ImChatMessageList = common.Shortcut{
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
downloadResourcesFlag,
},
Tips: []string{
`Example: lark-cli im +chat-messages-list --chat-id <chat_id>`,
`Example: lark-cli im +chat-messages-list --chat-id <chat_id> --start 2026-07-01 --end 2026-07-08 --order asc`,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
chatId, err := resolveChatIDForMessagesList(runtime, true)

View File

@@ -41,6 +41,9 @@ var ImChatSearch = common.Shortcut{
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
Tips: []string{
`Example: lark-cli im +chat-search --query "project"`,
},
// DryRun previews the POST /open-apis/im/v2/chats/search request without executing.
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildSearchChatBody(runtime)

View File

@@ -28,6 +28,9 @@ var ImChatUpdate = common.Shortcut{
{Name: "name", Desc: "group name (max 60 chars)"},
{Name: "description", Desc: "group description (max 100 chars)"},
},
Tips: []string{
`Example: lark-cli im +chat-update --chat-id <chat_id> --name "new name"`,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatID := runtime.Str("chat-id")
body := buildUpdateChatBody(runtime)

View File

@@ -32,6 +32,9 @@ var ImMessagesMGet = common.Shortcut{
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
downloadResourcesFlag,
},
Tips: []string{
`Example: lark-cli im +messages-mget --message-ids <message_id1>,<message_id2>`,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids := common.SplitCSV(runtime.Str("message-ids"))
d := common.NewDryRunAPI().GET(buildMGetURL(ids))

View File

@@ -37,6 +37,10 @@ var ImMessagesReply = common.Shortcut{
{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)"},
},
Tips: []string{
`Example: lark-cli im +messages-reply --message-id <message_id> --text "reply"`,
`Example: lark-cli im +messages-reply --message-id <message_id> --text "reply" --reply-in-thread`,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
msgType := runtime.Str("msg-type")

View File

@@ -34,6 +34,10 @@ var ImMessagesResourcesDownload = common.Shortcut{
{Name: "type", Desc: "resource type (image or file)", Required: true, Enum: []string{"image", "file"}},
{Name: "output", Desc: "local save path (relative only, no .. traversal); when omitted, uses the server's Content-Disposition filename if available, otherwise file_key; extension is inferred from Content-Disposition or Content-Type if not provided"},
},
Tips: []string{
`Example: lark-cli im +messages-resources-download --message-id <message_id> --file-key <file_key> --type file`,
`Example: lark-cli im +messages-resources-download --message-id <message_id> --file-key <image_key> --type image --output ./downloads/pic.png`,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileKey := runtime.Str("file-key")
outputPath := runtime.Str("output")

View File

@@ -51,6 +51,10 @@ var ImMessagesSearch = common.Shortcut{
{Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
Tips: []string{
`Example: lark-cli im +messages-search --query "keyword"`,
`Example: lark-cli im +messages-search --query "keyword" --chat-id <chat_id> --start 2026-07-01 --end 2026-07-08`,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
req, err := buildMessagesSearchRequest(runtime)
if err != nil {

View File

@@ -39,6 +39,11 @@ var ImMessagesSend = 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},
},
Tips: []string{
`Example: lark-cli im +messages-send --chat-id <chat_id> --text "hello"`,
`Example: lark-cli im +messages-send --user-id <open_id> --text "hello"`,
`Example: lark-cli im +messages-send --chat-id <chat_id> --markdown "## update"`,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatFlag := runtime.Str("chat-id")
userFlag := runtime.Str("user-id")

View File

@@ -38,6 +38,9 @@ var ImThreadsMessagesList = common.Shortcut{
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
downloadResourcesFlag,
},
Tips: []string{
`Example: lark-cli im +threads-messages-list --thread <thread_id>`,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
threadFlag := runtime.Str("thread")
dir := resolveThreadsOrder(runtime)

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"regexp"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// The 12 high-frequency IM shortcuts covered by the governance closeout.
// Every entry must carry at least one copyable "Example:" tip locked by the
// tests below; other IM shortcuts (feed/flag series) are intentionally exempt.
var tipsExampleTargets = []string{
"+messages-send", "+messages-search", "+chat-messages-list", "+messages-reply",
"+chat-search", "+chat-list", "+messages-mget", "+threads-messages-list",
"+messages-resources-download", "+chat-create", "+chat-update", "+chat-members-list",
}
var exampleFlagTokenRe = regexp.MustCompile(`--[a-z][a-z0-9-]*`)
// Flags injected by the shortcut runner framework rather than declared in
// Shortcut.Flags. --format comes with HasFormat, --json with HasJSON.
var frameworkInjectedFlags = map[string]bool{
"--json": true, "--dry-run": true, "--as": true, "--yes": true, "--format": true,
}
func shortcutByCommand(t *testing.T, command string) common.Shortcut {
t.Helper()
for _, sc := range Shortcuts() {
if sc.Command == command {
return sc
}
}
t.Fatalf("shortcut %s not registered in Shortcuts()", command)
return common.Shortcut{}
}
// exampleCommands returns the command lines of "Example: ..." tips, with the
// "Example: " prefix stripped.
func exampleCommands(sc common.Shortcut) []string {
prefix := "Example: lark-cli im " + sc.Command
var out []string
for _, tip := range sc.Tips {
if strings.HasPrefix(tip, prefix+" ") || tip == prefix {
out = append(out, strings.TrimPrefix(tip, "Example: "))
}
}
return out
}
func TestIMTipsExamplesPresent(t *testing.T) {
for _, cmd := range tipsExampleTargets {
sc := shortcutByCommand(t, cmd)
examples := exampleCommands(sc)
if len(examples) < 1 {
t.Errorf("%s: want >=1 tip starting with %q, got none (tips=%q)",
cmd, "Example: lark-cli im "+cmd, sc.Tips)
}
if len(examples) > 3 {
t.Errorf("%s: want <=3 examples to keep help focused, got %d", cmd, len(examples))
}
}
}
func TestIMTipsExampleFlagsExist(t *testing.T) {
for _, cmd := range tipsExampleTargets {
sc := shortcutByCommand(t, cmd)
declared := map[string]bool{}
for _, f := range sc.Flags {
declared["--"+f.Name] = true
}
for _, example := range exampleCommands(sc) {
for _, tok := range exampleFlagTokenRe.FindAllString(example, -1) {
if !declared[tok] && !frameworkInjectedFlags[tok] {
t.Errorf("%s: example uses %s which is neither a declared flag nor framework-injected\nexample: %s",
cmd, tok, example)
}
}
}
}
}
func TestIMTipsFirstExampleCoversRequired(t *testing.T) {
for _, cmd := range tipsExampleTargets {
sc := shortcutByCommand(t, cmd)
examples := exampleCommands(sc)
if len(examples) == 0 {
continue // reported by TestIMTipsExamplesPresent
}
for _, f := range sc.Flags {
if !f.Required {
continue
}
if !strings.Contains(examples[0], "--"+f.Name) {
t.Errorf("%s: first example must cover required flag --%s\nexample: %s",
cmd, f.Name, examples[0])
}
}
}
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
imshortcuts "github.com/larksuite/cli/shortcuts/im"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
// Placeholder substitutions turning copyable help examples into syntactically
// valid dry-run invocations. IDs are obvious fakes; --dry-run never hits the API.
var tipsPlaceholderValues = map[string]string{
"<chat_id>": "oc_e2etest000000000000000000",
"<open_id>": "ou_e2etest000000000000000000",
"<message_id>": "om_e2etest000000000000000000",
"<thread_id>": "omt_e2etest00000000000000000",
"<file_key>": "file_v3_e2etest0000000000000",
"<image_key>": "img_v3_e2etest00000000000000",
"<open_id1>": "ou_e2etest000000000000000001",
"<open_id2>": "ou_e2etest000000000000000002",
"<message_id1>": "om_e2etest000000000000000001",
"<message_id2>": "om_e2etest000000000000000002",
}
// firstExampleArgs extracts the first "Example:" tip of the shortcut, replaces
// placeholders, and returns the argv after "lark-cli".
func firstExampleArgs(t *testing.T, command string) []string {
t.Helper()
for _, sc := range imshortcuts.Shortcuts() {
if sc.Command != command {
continue
}
prefix := "Example: lark-cli "
for _, tip := range sc.Tips {
if !strings.HasPrefix(tip, prefix) {
continue
}
line := strings.TrimPrefix(tip, prefix)
for ph, v := range tipsPlaceholderValues {
line = strings.ReplaceAll(line, ph, v)
}
return splitExampleArgs(t, line)
}
t.Fatalf("%s has no Example tip", command)
}
t.Fatalf("shortcut %s not found", command)
return nil
}
// splitExampleArgs splits a shell-like example line on spaces, honoring
// double-quoted segments (the only quoting style used in Tips examples).
func splitExampleArgs(t *testing.T, line string) []string {
t.Helper()
var args []string
var cur strings.Builder
inQuote := false
for _, r := range line {
switch {
case r == '"':
inQuote = !inQuote
case r == ' ' && !inQuote:
if cur.Len() > 0 {
args = append(args, cur.String())
cur.Reset()
}
default:
cur.WriteRune(r)
}
}
if inQuote {
t.Fatalf("unbalanced quotes in example: %s", line)
}
if cur.Len() > 0 {
args = append(args, cur.String())
}
return args
}
func runFirstExampleDryRun(t *testing.T, command string, wantAPIPath string) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "im_tips_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "im_tips_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
args := append(firstExampleArgs(t, command), "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: args,
DefaultAs: "bot",
WorkDir: t.TempDir(),
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, wantAPIPath,
"dry-run output should reference the upstream API path")
}
func TestIMTipsFirstExampleDryRunMessagesSend(t *testing.T) {
runFirstExampleDryRun(t, "+messages-send", "/open-apis/im/v1/messages")
}
func TestIMTipsFirstExampleDryRunChatMessagesList(t *testing.T) {
runFirstExampleDryRun(t, "+chat-messages-list", "/open-apis/im/v1/messages")
}
func TestIMTipsFirstExampleDryRunResourcesDownload(t *testing.T) {
runFirstExampleDryRun(t, "+messages-resources-download", "/open-apis/im/v1/messages/")
}