mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
feat(im): add copyable Tips examples to high-frequency IM shortcuts
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
104
shortcuts/im/tips_examples_test.go
Normal file
104
shortcuts/im/tips_examples_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
tests/cli_e2e/im/tips_examples_dryrun_test.go
Normal file
117
tests/cli_e2e/im/tips_examples_dryrun_test.go
Normal 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/")
|
||||
}
|
||||
Reference in New Issue
Block a user