Compare commits

...

51 Commits

Author SHA1 Message Date
houzhicong
579316d537 docs: update vc agent meeting skill guidance 2026-05-12 14:09:12 +08:00
houzhicong
98d557cb1c fix(vc): address review feedback 2026-05-11 20:35:44 +08:00
houzhicong
6f7da6df76 fix(ci): stabilize vc output and skill frontmatter 2026-05-11 20:08:08 +08:00
houzhicong
8539c97587 docs(vc-agent): centralize gray guidance 2026-05-11 19:45:05 +08:00
houzhicong
370f631efe docs(vc-agent): refine gray guidance flow 2026-05-11 17:48:01 +08:00
houzhicong
0547310e03 chore(vc-agent): update gray guide and boe endpoints 2026-05-11 17:48:01 +08:00
houzhicong
072e9233d1 chore(env): switch endpoints to boe for agent meeting gray testing 2026-05-11 17:48:01 +08:00
houzhicong
7319a3db4d docs(skill): guide users to early-bird group on agent meeting gray miss
Teach the lark-vc-agent skill to recognize OAPI's new gray-miss signal for
the three agent meeting commands (`+meeting-join`, `+meeting-leave`,
`+meeting-events`) and route the user to the early-bird group instead of
treating it as a permission error.

When CLI stderr JSON returns `error.code=20017 / ErrNotInGray`, the agent
renders the fixed early-bird invite link
`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`.
The user manual is intentionally not surfaced yet.

Scope-related errors still follow the existing `auth login --scope` flow
with no early-bird copy mixed in. lark-shared and other skills are not
touched, so the guidance stays scoped to the agent meeting commands only.
2026-05-11 17:48:01 +08:00
houzhicong
beae889d7d chore(env): switch default feishu endpoints to pre 2026-05-11 17:48:01 +08:00
houzhicong
122a6a5e9f revert(env): remove default ppe request header 2026-05-11 17:48:01 +08:00
houzhicong
006d7ef349 docs(vc): use explicit date in recording example 2026-05-11 17:48:01 +08:00
houzhicong
6ebc78ad84 fix(env): use feishu accounts host 2026-05-11 17:48:01 +08:00
houzhicong
0018aa00f6 chore(env): switch default feishu endpoints to pre 2026-05-11 17:48:01 +08:00
houzhicong
6eb8ea5994 docs(skill): require reading shared docs for meeting summaries 2026-05-11 17:48:01 +08:00
houzhicong
121e3a3200 docs(skill): tighten vc agent meeting guidance 2026-05-11 17:48:01 +08:00
houzhicong
192261b86e refactor(vc): simplify meeting events pagination 2026-05-11 17:48:01 +08:00
houzhicong
9d36cee84e docs(skill): tighten vc agent meeting events workflow 2026-05-11 17:48:01 +08:00
houzhicong
29ffddacf5 fix(vc): keep meeting event count aligned with events list 2026-05-11 17:48:01 +08:00
houzhicong
4bffde3bd4 docs(skill): refine vc meeting events paging guidance 2026-05-11 17:48:01 +08:00
renaocheng
f1c1208c0b Revert "docs: tighten vc-agent references - remove redundancy and fix vague wording"
This reverts commit 9845fc40622c65b0811da1c9ae4902434377f33e.
2026-05-11 17:48:01 +08:00
renaocheng
ab7f2a13d8 docs: tighten vc-agent references - remove redundancy and fix vague wording 2026-05-11 17:48:01 +08:00
renaocheng
beb6e2a565 docs: tighten meeting-join risk warning to single sentence 2026-05-11 17:48:01 +08:00
renaocheng
0846d76172 docs: replace inaccurate no-replay warning with real social-cost risk 2026-05-11 17:48:01 +08:00
renaocheng
4df1bb0120 revert: restore CRITICAL banner in lark-vc-agent to match repo convention 2026-05-11 17:48:01 +08:00
houzhicong
fdc2f82f63 docs(skill): refine vc agent meeting guidance 2026-05-11 17:48:01 +08:00
houzhicong
81f8ba4872 feat(vc): print meeting event page token in pretty output 2026-05-11 17:48:01 +08:00
renaocheng
ef1e3edcf7 docs: systematic review of lark-vc-agent SKILL for clarity and precision 2026-05-11 17:48:01 +08:00
renaocheng
35af2ca930 docs: clarify pretty vs json format choice by processing depth 2026-05-11 17:48:01 +08:00
renaocheng
a19ba5ee50 docs: downgrade dry-run from mandatory to optional for vc-agent writes 2026-05-11 17:48:01 +08:00
renaocheng
0f2e9e9303 fix: use Chinese quotes in vc/vc-agent description YAML frontmatter 2026-05-11 17:48:01 +08:00
renaocheng
f7980d0967 docs: tighten lark-vc-agent description to descriptive neutral tone 2026-05-11 17:48:01 +08:00
renaocheng
e400a45de5 docs: rewrite lark-vc-agent description in user-facing language 2026-05-11 17:48:01 +08:00
houzhicong
5ec895833f fix(vc): send meeting join password at top level 2026-05-11 17:48:01 +08:00
renaocheng
60f2f3155c docs: fix cross-links in lark-vc-agent references after split 2026-05-11 17:48:01 +08:00
renaocheng
eb1885d813 docs: drop nonexistent workflow skill reference and fix identity 2026-05-11 17:48:01 +08:00
renaocheng
87089bccd7 refactor: split lark-vc-agent from lark-vc 2026-05-11 17:48:01 +08:00
renaocheng
4bee0c3742 docs: clarify participant-snapshot vs meeting-events routing 2026-05-11 17:48:01 +08:00
houzhicong
9686fd8d9c docs(skill): clarify vc meeting events output guidance 2026-05-11 17:48:01 +08:00
houzhicong
7acf9d57ba docs(skill): add vc meeting events shortcut guide 2026-05-11 17:48:01 +08:00
houzhicong
21f837c589 feat(vc): refine meeting events pretty output 2026-05-11 17:48:01 +08:00
houzhicong
ba73deeb57 feat(vc): improve meeting events pretty timeline 2026-05-11 17:48:01 +08:00
renaocheng
0607a1ab5b test: add unit tests for vc +meeting-join and +meeting-leave shortcuts 2026-05-11 17:48:01 +08:00
houzhicong
e9eb91d49c feat(vc): refine meeting events pagination and output 2026-05-11 17:48:01 +08:00
houzhicong
97ef2bcfb8 feat(vc): add meeting events shortcut
Add vc +meeting-events for bot meeting activity queries with page-all pagination support and tested pretty/json output.
2026-05-11 17:48:01 +08:00
renaocheng
d5327aa7e9 docs: add skill references for vc +meeting-join and +meeting-leave 2026-05-11 17:48:01 +08:00
zhaolei.vc
4626955139 feat(vc): agent join meeting basic shortcuts structure
Change-Id: Ic5d64067eb48670fa6636841cd00cbfa9b0bf3e7
2026-05-11 17:48:01 +08:00
shifengjuan-dev
25c72ced6f docs(im): name --query/--member-ids in +chat-search shortcut row (#812)
The +chat-search row in lark-im SKILL.md described the search as
"by keyword and/or member open_ids", which doesn't match the real
flag names (--query, --member-ids). Naming them inline avoids
agents guessing --keyword from the prose, matching the style
already used by +chat-messages-list.

Change-Id: Ife8668d9b13ee66711bc4e81a7b2bcc7f05d9586
2026-05-11 16:22:12 +08:00
SunPeiYang996
0ed63b02e4 chore(doc): inject docs scene into v2 requests (#808)
Change-Id: I4f23880e24164c8b229a5403942bfa1b7ddb0ce6
2026-05-11 14:35:00 +08:00
Yuxuan Zhao
5352e6a90a test: drop stale yes flags from e2e (#815) 2026-05-11 13:49:43 +08:00
seemslike
16f1a0f320 feat: add flag shortcuts for im (#770)
Add IM flag shortcut commands to lark-cli, enabling users to create, list, and cancel bookmarks on messages and threads via +flag-create, +flag-list, and +flag-cancel.

Change-Id: I8f87f0eadf83fb59b024a3b9fe67b23d363abe0a
2026-05-11 11:32:06 +08:00
Yuxuan Zhao
4d625420b0 test: drop stale e2e yes flags (#794) 2026-05-11 10:48:46 +08:00
35 changed files with 6767 additions and 13 deletions

View File

@@ -67,6 +67,7 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
if v := runtime.Str("parent-position"); v != "" {
body["parent_position"] = v
}
injectDocsScene(runtime, body)
return body
}

View File

@@ -109,6 +109,7 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
if ro := buildReadOption(runtime); ro != nil {
body["read_option"] = ro
}
injectDocsScene(runtime, body)
return body
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBuildFetchBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, " DoubaoCLI ")
runtime := newFetchBodyTestRuntime(ctx)
body := buildFetchBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
runtime := newCreateBodyTestRuntime(ctx)
body := buildCreateBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
runtime := newUpdateBodyTestRuntime(ctx)
body := buildUpdateBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
body := buildFetchBody(runtime)
if _, ok := body["scene"]; ok {
t.Fatalf("did not expect empty scene in fetch body: %#v", body)
}
}
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("detail", "simple", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")
cmd.Flags().String("end-block-id", "", "")
cmd.Flags().String("keyword", "", "")
cmd.Flags().Int("context-before", 0, "")
cmd.Flags().Int("context-after", 0, "")
cmd.Flags().Int("max-depth", -1, "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+create"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("content", "<title>hello</title>", "")
cmd.Flags().String("parent-token", "", "")
cmd.Flags().String("parent-position", "", "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+update"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("command", "append", "")
cmd.Flags().Int("revision-id", 0, "")
cmd.Flags().String("content", "<p>hello</p>", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("block-id", "", "")
cmd.Flags().String("src-block-ids", "", "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}

View File

@@ -162,5 +162,6 @@ func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
if v := runtime.Str("src-block-ids"); v != "" {
body["src_block_ids"] = v
}
injectDocsScene(runtime, body)
return body
}

View File

@@ -4,6 +4,7 @@
package doc
import (
"context"
"encoding/json"
"strings"
@@ -11,6 +12,10 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// docsSceneContextKey lets in-process embedders pass a server-owned docs_ai
// scene without exposing it as a user-controlled CLI flag.
const docsSceneContextKey = "lark_cli_docs_scene"
type documentRef struct {
Kind string
Token string
@@ -65,6 +70,20 @@ func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body inter
return runtime.DoAPIJSONWithLogID(method, apiPath, nil, body)
}
func docsSceneFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
scene, _ := ctx.Value(docsSceneContextKey).(string)
return strings.TrimSpace(scene)
}
func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{}) {
if scene := docsSceneFromContext(runtime.Ctx()); scene != "" {
body["scene"] = scene
}
}
func buildDriveRouteExtra(docID string) (string, error) {
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
if err != nil {

View File

@@ -20,6 +20,8 @@ import (
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -32,6 +34,18 @@ var mentionFixRe = regexp.MustCompile(`<at\s+(id|open_id|user_id)=("?)([^"\s/>]+
var threadIDRe = regexp.MustCompile(`^omt_`)
var messageIDRe = regexp.MustCompile(`^om_`)
func flagMessageID(rt *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(rt.Str("message-id"))
if id == "" {
return "", output.ErrValidation("--message-id is required")
}
if strings.HasPrefix(id, "omt_") {
return "", output.ErrValidation(
"invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id)
}
return validateMessageID(id)
}
func normalizeAtMentions(content string) string {
return mentionFixRe.ReplaceAllString(content, `<at user_id="$3">`)
}
@@ -1432,3 +1446,222 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
}
return fileKey, nil
}
// FlagType enumerates the kind of bookmark.
// Aligned with server-side constants: Unknown=0, Feed=1, Message=2.
type FlagType int
const (
FlagTypeUnknown FlagType = 0
FlagTypeFeed FlagType = 1
FlagTypeMessage FlagType = 2
)
// ItemType enumerates the kind of thing being bookmarked.
// Server-side constants (only the types used by IM flags):
//
// default=0, thread=4, msg_thread=11.
//
// Note on the two thread-shaped item types:
// - ItemTypeThread (4) — thread inside a topic-style chat
// - ItemTypeMsgThread (11) — thread inside a regular chat
type ItemType int
const (
ItemTypeDefault ItemType = 0
ItemTypeThread ItemType = 4 // thread in a topic-style chat
ItemTypeMsgThread ItemType = 11 // thread in a regular chat
)
const (
flagWriteScope = "im:feed.flag:write"
flagReadScope = "im:feed.flag:read"
)
var (
flagWriteLookupScopes = append([]string{flagWriteScope}, flagLookupScopes...)
flagMessageReadScopes = []string{
"im:message.group_msg:get_as_user",
"im:message.p2p_msg:get_as_user",
}
flagLookupScopes = []string{
"im:message.group_msg:get_as_user",
"im:message.p2p_msg:get_as_user",
"im:chat:read",
}
)
func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, required []string) error {
if len(required) == 0 {
return nil
}
result, err := rt.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(rt.As(), rt.Config.AppID))
if err != nil {
return output.ErrWithHint(output.ExitAuth, "auth",
fmt.Sprintf("cannot verify required scope(s): %v", err),
flagScopeLoginHint(required))
}
if result == nil || result.Scopes == "" {
fmt.Fprintf(rt.IO().ErrOut,
"warning: cannot verify required scope(s) because token scope metadata is unavailable; API may fail if missing: %s\n",
strings.Join(required, " "))
return nil
}
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
flagScopeLoginHint(missing))
}
return nil
}
func flagScopeLoginHint(scopes []string) string {
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(scopes, " "))
}
// flagItem is one entry in the flags API body. The server expects numeric
// enums serialized as strings.
type flagItem struct {
ItemID string `json:"item_id"`
ItemType string `json:"item_type"`
FlagType string `json:"flag_type"`
}
// parseItemID inspects an om_ prefix and returns a best-guess
// (itemType, flagType) pair. Used when the user omits the explicit enums.
// - om_xxx → (default, message)
func parseItemID(id string) (ItemType, FlagType, error) {
id = strings.TrimSpace(id)
switch {
case strings.HasPrefix(id, "om_"):
return ItemTypeDefault, FlagTypeMessage, nil
case id == "":
return 0, 0, output.ErrValidation("--message-id cannot be empty")
default:
return 0, 0, output.ErrValidation(
"cannot infer item type from id %q: expected om_ (message) prefix; "+
"pass --item-type and --flag-type explicitly if you are using a different id format", id)
}
}
// parseItemType converts a user-facing string to the server enum.
func parseItemType(s string) (ItemType, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "default":
return ItemTypeDefault, nil
case "thread":
return ItemTypeThread, nil
case "msg_thread":
return ItemTypeMsgThread, nil
}
return 0, output.ErrValidation("invalid --item-type %q: expected one of default|thread|msg_thread", s)
}
// parseFlagType converts a user-facing string to the server enum.
func parseFlagType(s string) (FlagType, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "message":
return FlagTypeMessage, nil
case "feed":
return FlagTypeFeed, nil
}
return 0, output.ErrValidation("invalid --flag-type %q: expected one of message|feed", s)
}
// isValidCombo checks if the (ItemType, FlagType) pair is accepted by the server.
// Note: (ItemType, FlagType) is shorthand for (item_type, flag_type) — the two
// enum fields that determine which layer the flag operates on.
//
// Valid combinations are:
// - (default, message) — regular chat message (message-layer flag)
// - (thread, feed) — thread as feed-layer flag (topic-style chat)
// - (msg_thread, feed) — message-thread as feed-layer flag (regular chat)
func isValidCombo(it ItemType, ft FlagType) bool {
return (it == ItemTypeDefault && ft == FlagTypeMessage) ||
(it == ItemTypeThread && ft == FlagTypeFeed) ||
(it == ItemTypeMsgThread && ft == FlagTypeFeed)
}
// parseItemTypeFromRaw parses a stringified numeric item_type back to ItemType.
// Used when re-parsing the serialized enum for combo-validity checks.
// Note: Unknown values return ItemTypeDefault (0). This is safe because:
// 1. This function only parses values we serialized ourselves via newFlagItem
// 2. Unknown server values would fail combo validation or be rejected by the server
func parseItemTypeFromRaw(s string) ItemType {
switch s {
case "0":
return ItemTypeDefault
case "4":
return ItemTypeThread
case "11":
return ItemTypeMsgThread
}
return ItemTypeDefault
}
// parseFlagTypeFromRaw parses a stringified numeric flag_type back to FlagType.
// Used when re-parsing the serialized enum for combo-validity checks.
func parseFlagTypeFromRaw(s string) FlagType {
switch s {
case "1":
return FlagTypeFeed
case "2":
return FlagTypeMessage
}
return FlagTypeUnknown
}
// newFlagItem builds a payload entry with numeric-stringified enums.
func newFlagItem(itemID string, it ItemType, ft FlagType) flagItem {
return flagItem{
ItemID: itemID,
ItemType: fmt.Sprintf("%d", int(it)),
FlagType: fmt.Sprintf("%d", int(ft)),
}
}
// getMessageChatID queries the message API to get the chat_id.
// Used by flag-create to determine the chat type for feed-layer flags.
func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, error) {
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil)
if err != nil {
return "", err
}
items, ok := data["items"].([]any)
if !ok || len(items) == 0 {
return "", output.ErrValidation("message not found or unexpected API response format")
}
msg, ok := items[0].(map[string]any)
if !ok {
return "", output.ErrValidation("unexpected message format in API response")
}
chatID, ok := msg["chat_id"].(string)
if !ok {
return "", output.ErrValidation("message response missing chat_id field")
}
return chatID, nil
}
// resolveThreadFeedItemType determines the correct feed-layer ItemType for a thread
// by querying the chat API for chat_mode.
// - topic-style chat → ItemTypeThread
// - regular chat → ItemTypeMsgThread
//
// Returns an error if the chat query fails, since guessing the wrong item_type
// can cause silent failures in flag operations.
func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemType, error) {
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil)
if err != nil {
return ItemTypeDefault, fmt.Errorf("failed to query chat_mode for chat %s: %w", chatID, err)
}
// DoAPIJSON returns envelope.Data, so chat_mode is at the top level
chatMode, _ := data["chat_mode"].(string)
if chatMode == "topic" {
return ItemTypeThread, nil
}
return ItemTypeMsgThread, nil
}

View File

@@ -868,6 +868,9 @@ func TestShortcuts(t *testing.T) {
"+messages-search",
"+messages-send",
"+threads-messages-list",
"+flag-create",
"+flag-cancel",
"+flag-list",
}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)

View File

@@ -0,0 +1,247 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFlagCancel provides the +flag-cancel shortcut for removing a bookmark.
// When no --flag-type is given, it performs double-cancel: removes both message and feed layers.
var ImFlagCancel = common.Shortcut{
Service: "im",
Command: "+flag-cancel",
Description: "Cancel (remove) a bookmark. When no --flag-type is given, " +
"performs double-cancel: removes both message and feed layers",
Risk: "write",
UserScopes: flagWriteLookupScopes,
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-id", Desc: "message ID (om_xxx)"},
{Name: "item-type", Desc: "item type override: default|thread|msg_thread"},
{Name: "flag-type", Desc: "flag type override: message|feed; omit to double-cancel both layers"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := buildCancelItemsForPreview(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
items, _, err := buildCancelItemsForPreview(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
d := common.NewDryRunAPI().
POST("/open-apis/im/v1/flags/cancel").
Body(map[string]any{"flag_items": items})
if len(items) > 1 {
d.Desc("double-cancel: tries both message and feed layers (best-effort); feed-layer skipped if chat_type undeterminable")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
items, err := buildCancelItems(runtime)
if err != nil {
return err
}
// Make separate API calls for each item so they are independent.
// If one fails, the other can still succeed.
results := make([]map[string]any, 0, len(items))
var lastErr error
for _, item := range items {
itemType := itemTypeString(parseItemTypeFromRaw(item.ItemType))
flagType := flagTypeString(parseFlagTypeFromRaw(item.FlagType))
result := map[string]any{
"item_id": item.ItemID,
"item_type": itemType,
"flag_type": flagType,
}
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags/cancel", nil,
map[string]any{"flag_items": []flagItem{item}})
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: cancel failed for %s/%s: %v\n",
itemType, flagType, err)
result["status"] = "failed"
result["error"] = err.Error()
lastErr = err
} else {
result["status"] = "ok"
result["response"] = data
}
results = append(results, result)
}
runtime.Out(map[string]any{"results": results}, nil)
return lastErr
},
}
// buildCancelItemsForPreview builds cancel items without API calls.
// It shows double-cancel when no explicit flags are provided.
// DryRun cannot query chat_mode, so feed-layer item_type is represented with
// the same auto-detect placeholder used by +flag-create.
func buildCancelItemsForPreview(rt *common.RuntimeContext) ([]any, bool, error) {
id, err := flagMessageID(rt)
if err != nil {
return nil, false, err
}
itOverride := strings.TrimSpace(rt.Str("item-type"))
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
// Explicit override provided → single targeted delete
if itOverride != "" || ftOverride != "" {
item, err := buildSingleCancelItem(id, itOverride, ftOverride)
if err != nil {
return nil, false, err
}
return []any{item}, false, nil
}
// No override: show double-cancel (message + feed layers)
// Dry-run shows both layers; actual execution is best-effort.
return []any{
newFlagItem(id, ItemTypeDefault, FlagTypeMessage),
map[string]string{
"item_id": id,
"item_type": "<auto:thread|msg_thread>",
"flag_type": fmt.Sprintf("%d", int(FlagTypeFeed)),
},
}, true, nil
}
// buildCancelItems picks the (item_type, flag_type) pairs to cancel.
//
// Logic:
// 1. If --flag-type is explicitly provided, do a single targeted delete.
// 2. Otherwise, perform double-cancel: remove both message layer and feed layer.
// - Message layer is always included (uses known message_id with ItemTypeDefault)
// - Feed layer is best-effort: if chat_type cannot be determined, skip with warning
// - Each layer is independent; failure to cancel one doesn't block the other
func buildCancelItems(rt *common.RuntimeContext) ([]flagItem, error) {
id, err := flagMessageID(rt)
if err != nil {
return nil, err
}
itOverride := strings.TrimSpace(rt.Str("item-type"))
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
// Explicit override provided → single targeted delete
if itOverride != "" || ftOverride != "" {
item, err := buildSingleCancelItem(id, itOverride, ftOverride)
if err != nil {
return nil, err
}
return []flagItem{item}, nil
}
// Double-cancel: message layer + feed layer (best effort)
// Message layer is always included - we have the message_id and know the combo is valid.
items := []flagItem{newFlagItem(id, ItemTypeDefault, FlagTypeMessage)}
// Feed layer: try to determine chat_type, but don't fail if we can't.
// Most messages only have one layer flagged, so this is best-effort cleanup.
chatID, err := getMessageChatID(rt, id)
if err != nil {
// Can't get chat_id, warn and skip feed layer
fmt.Fprintf(rt.IO().ErrOut, "warning: cannot determine feed-layer item_type: %v; skipping feed-layer cancel\n", err)
return items, nil
}
feedIT, err := resolveThreadFeedItemType(rt, chatID)
if err != nil {
// Can't determine chat_type, warn and skip feed layer
fmt.Fprintf(rt.IO().ErrOut, "warning: cannot determine feed-layer item_type: %v; skipping feed-layer cancel\n", err)
return items, nil
}
// Include feed layer
items = append(items, newFlagItem(id, feedIT, FlagTypeFeed))
return items, nil
}
// buildSingleCancelItem builds a single cancel item when user provides explicit flags.
func buildSingleCancelItem(id, itOverride, ftOverride string) (flagItem, error) {
var itemType ItemType
var flagType FlagType
if itOverride != "" {
it, err := parseItemType(itOverride)
if err != nil {
return flagItem{}, err
}
itemType = it
}
if ftOverride != "" {
ft, err := parseFlagType(ftOverride)
if err != nil {
return flagItem{}, err
}
flagType = ft
}
if itOverride == "" || ftOverride == "" {
inferIT, inferFT, err := parseItemID(id)
if err != nil {
return flagItem{}, err
}
if itOverride == "" {
itemType = inferIT
}
if ftOverride == "" {
flagType = inferFT
}
}
if !isValidCombo(itemType, flagType) {
// Provide more specific hints for common mistakes
if itOverride != "" && ftOverride == "" {
if itemType == ItemTypeThread || itemType == ItemTypeMsgThread {
return flagItem{}, output.ErrValidation(
"invalid combination: --item-type=%s requires --flag-type=feed (feed-layer flags are the only valid type for threads)",
itOverride)
}
return flagItem{}, output.ErrValidation(
"invalid combination: --item-type=%s with inferred --flag-type=%s; specify --flag-type explicitly to override",
itOverride, flagTypeString(flagType))
}
if itOverride == "" && ftOverride != "" {
return flagItem{}, output.ErrValidation(
"invalid combination: --flag-type=%s with inferred --item-type=%s; specify --item-type explicitly to override",
ftOverride, itemTypeString(itemType))
}
return flagItem{}, output.ErrValidation(
"invalid --item-type/--flag-type combination: supported pairs are default+message, thread+feed, and msg_thread+feed")
}
return newFlagItem(id, itemType, flagType), nil
}
// itemTypeString converts ItemType to a user-facing string.
func itemTypeString(it ItemType) string {
switch it {
case ItemTypeDefault:
return "default"
case ItemTypeThread:
return "thread"
case ItemTypeMsgThread:
return "msg_thread"
}
return "unknown"
}
// flagTypeString converts FlagType to a user-facing string.
func flagTypeString(ft FlagType) string {
switch ft {
case FlagTypeFeed:
return "feed"
case FlagTypeMessage:
return "message"
}
return "unknown"
}

View File

@@ -0,0 +1,212 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFlagCreate provides the +flag-create shortcut for creating a bookmark on a message.
var ImFlagCreate = common.Shortcut{
Service: "im",
Command: "+flag-create",
Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed to create feed-layer flag (auto-detects chat type)",
Risk: "write",
UserScopes: flagWriteLookupScopes,
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-id", Desc: "message ID (om_xxx)"},
{Name: "item-type", Desc: "item type override: default|thread|msg_thread (rarely needed)"},
{Name: "flag-type", Desc: "flag type: message (default) or feed"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildCreateItemForPreview(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
item, err := buildCreateItemForPreview(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
d := common.NewDryRunAPI().
POST("/open-apis/im/v1/flags").
Body(map[string]any{"flag_items": []any{item}})
if m, ok := item.(map[string]string); ok && m["item_type"] == "<auto:thread|msg_thread>" {
d.Desc("feed-layer item_type is auto-detected at execution time by reading the message chat and chat_mode")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
item, err := buildCreateItem(runtime)
if err != nil {
return err
}
// Combo validation already done in Validate, but double-check as a safety net.
if !isValidCombo(parseItemTypeFromRaw(item.ItemType), parseFlagTypeFromRaw(item.FlagType)) {
return output.ErrValidation(
"invalid (item_type=%s, flag_type=%s) combination; the server only accepts "+
"(default, message), (thread, feed), or (msg_thread, feed)",
item.ItemType, item.FlagType)
}
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags", nil,
map[string]any{"flag_items": []flagItem{item}})
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// buildCreateItemForPreview derives a preview payload without making network calls.
// Feed-layer execution auto-detects item_type from chat_mode, but dry-run must
// not query the message or chat APIs, so it uses an explicit placeholder.
func buildCreateItemForPreview(rt *common.RuntimeContext) (any, error) {
id, err := flagMessageID(rt)
if err != nil {
return nil, err
}
itOverride := strings.TrimSpace(rt.Str("item-type"))
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
combo, err := parseExplicitFlagCombo(itOverride, ftOverride)
if err != nil {
return nil, err
}
flagType := FlagTypeMessage
if combo.FlagTypeSet {
flagType = combo.FlagType
}
if flagType == FlagTypeMessage {
return newFlagItem(id, ItemTypeDefault, FlagTypeMessage), nil
}
if combo.ItemTypeSet {
return newFlagItem(id, combo.ItemType, FlagTypeFeed), nil
}
return map[string]string{
"item_id": id,
"item_type": "<auto:thread|msg_thread>",
"flag_type": fmt.Sprintf("%d", int(FlagTypeFeed)),
}, nil
}
// buildCreateItem derives a flagItem for the create path.
//
// Resolution logic:
// 1. No --flag-type or --flag-type=message → (default, message)
// 2. --flag-type=feed (no --item-type) → query message to get chat_id,
// then query chat_mode to determine: topic-style → (thread, feed), regular → (msg_thread, feed)
// 3. Both --item-type and --flag-type provided → honor verbatim (for edge cases)
func buildCreateItem(rt *common.RuntimeContext) (flagItem, error) {
id, err := flagMessageID(rt)
if err != nil {
return flagItem{}, err
}
itOverride := strings.TrimSpace(rt.Str("item-type"))
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
combo, err := parseExplicitFlagCombo(itOverride, ftOverride)
if err != nil {
return flagItem{}, err
}
flagType := FlagTypeMessage
if combo.FlagTypeSet {
flagType = combo.FlagType
}
// Message-layer flag: always (default, message)
if flagType == FlagTypeMessage {
return newFlagItem(id, ItemTypeDefault, FlagTypeMessage), nil
}
// Feed-layer flag: need to determine item_type from chat_mode
if combo.ItemTypeSet {
// User explicitly specified item-type, honor it
return newFlagItem(id, combo.ItemType, FlagTypeFeed), nil
}
chatID, err := getMessageChatID(rt, id)
if err != nil {
return flagItem{}, output.ErrValidation(
"failed to query message for feed-layer flag: %v; if you know the chat type, specify --item-type explicitly", err)
}
if chatID == "" {
return flagItem{}, output.ErrValidation(
"message does not belong to a chat; feed-layer flags are only for messages in chats")
}
feedIT, err := resolveThreadFeedItemType(rt, chatID)
if err != nil {
return flagItem{}, output.ErrValidation(
"failed to determine chat type: %v; if you know the chat type, specify --item-type explicitly", err)
}
return newFlagItem(id, feedIT, FlagTypeFeed), nil
}
type explicitFlagCombo struct {
ItemType ItemType
FlagType FlagType
ItemTypeSet bool
FlagTypeSet bool
}
func parseExplicitFlagCombo(itOverride, ftOverride string) (explicitFlagCombo, error) {
itOverride = strings.TrimSpace(itOverride)
ftOverride = strings.TrimSpace(ftOverride)
var combo explicitFlagCombo
if itOverride != "" {
it, err := parseItemType(itOverride)
if err != nil {
return explicitFlagCombo{}, err
}
combo.ItemType = it
combo.ItemTypeSet = true
}
if ftOverride != "" {
ft, err := parseFlagType(ftOverride)
if err != nil {
return explicitFlagCombo{}, err
}
combo.FlagType = ft
combo.FlagTypeSet = true
}
if combo.ItemTypeSet && !combo.FlagTypeSet {
switch combo.ItemType {
case ItemTypeThread, ItemTypeMsgThread:
return explicitFlagCombo{}, output.ErrValidation(
"--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride)
case ItemTypeDefault:
return explicitFlagCombo{}, output.ErrValidation(
"--item-type=default requires --flag-type=message; or omit both to use default behavior")
}
}
if combo.ItemTypeSet && combo.FlagTypeSet && !isValidCombo(combo.ItemType, combo.FlagType) {
return explicitFlagCombo{}, output.ErrValidation(
"invalid --item-type=%s --flag-type=%s combination; supported pairs are default+message, thread+feed, and msg_thread+feed",
itOverride, ftOverride)
}
return combo, nil
}
// validateExplicitCombo validates the (item_type, flag_type) combination when
// the user explicitly provides flags. It does not make API calls - it only
// validates the logic for what the user explicitly specified.
func validateExplicitCombo(itOverride, ftOverride string) error {
_, err := parseExplicitFlagCombo(itOverride, ftOverride)
return err
}

View File

@@ -0,0 +1,300 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"fmt"
"strconv"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ImFlagList provides the +flag-list shortcut for listing bookmarks.
// Feed-type thread entries are auto-enriched with message content.
var ImFlagList = common.Shortcut{
Service: "im",
Command: "+flag-list",
Description: "List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination",
Risk: "read",
UserScopes: []string{flagReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
{Name: "enrich-feed-thread", Type: "bool", Default: "true", Desc: "fetch message content for feed-type thread entries (default true; may call messages/mget and require im:message.group_msg:get_as_user/im:message.p2p_msg:get_as_user; use --enrich-feed-thread=false to avoid extra scopes)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateListOptions(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if err := validateListOptions(runtime); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
d := common.NewDryRunAPI().
GET("/open-apis/im/v1/flags").
Params(map[string]any{
"page_size": strconv.Itoa(runtime.Int("page-size")),
"page_token": runtime.Str("page-token"),
})
if runtime.Bool("enrich-feed-thread") {
d.Desc("conditional enrichment: if feed/thread flag items are missing message content, execution may also call GET /open-apis/im/v1/messages/mget and requires scopes im:message.group_msg:get_as_user im:message.p2p_msg:get_as_user; pass --enrich-feed-thread=false to skip this extra call and extra scopes")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
// When --page-token is explicitly provided, the user wants a specific page —
// no auto-pagination regardless of --page-all.
if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") {
return executeListAllPages(runtime)
}
data, err := runtime.DoAPIJSON("GET", "/open-apis/im/v1/flags", listQuery(runtime), nil)
if err != nil {
return err
}
if runtime.Bool("enrich-feed-thread") {
if err := enrichFeedThreadItems(runtime, data); err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: feed-thread enrichment failed: %v\n", err)
}
}
runtime.Out(data, nil)
return nil
},
}
func validateListOptions(rt *common.RuntimeContext) error {
if n := rt.Int("page-size"); n < 1 || n > 50 {
return output.ErrValidation("--page-size must be an integer between 1 and 50")
}
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
return output.ErrValidation("--page-limit must be an integer between 1 and 1000")
}
return nil
}
// listQuery builds the query parameters for the flag list API call.
// page_token is required by the server even on the first page — pass empty
// string when the user hasn't supplied one.
func listQuery(rt *common.RuntimeContext) larkcore.QueryParams {
return larkcore.QueryParams{
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
"page_token": []string{rt.Str("page-token")},
}
}
// enrichFeedThreadItems attaches message body to feed-shape thread entries
// by calling messages/mget. The list API returns only IDs for feed-shape entries,
// so this enrichment is needed to provide full message content.
//
// NOTE: This function modifies data["flag_items"] in place by adding a "message" key
// to each feed-thread entry.
func enrichFeedThreadItems(rt *common.RuntimeContext, data map[string]any) error {
// Only enrich active flags (flag_items), not canceled flags (delete_flag_items).
// Canceled message-type flags don't show message content, so thread-type flags don't need it either.
items, _ := data["flag_items"].([]any)
if len(items) == 0 {
return nil
}
// Index any messages the server already returned — saves a mget round-trip
// (ItemType=default+FlagType=Message responses already carry the message body).
byID := make(map[string]map[string]any)
if inline, ok := data["messages"].([]any); ok {
for _, m := range inline {
mm, _ := m.(map[string]any)
if mm == nil {
continue
}
if id := asString(mm["message_id"]); id != "" {
byID[id] = mm
}
}
}
// Collect feed-thread ids whose message body wasn't inlined — dedup to cut mget calls.
need := map[string]bool{}
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil {
continue
}
ft := asString(m["flag_type"])
itStr := asString(m["item_type"])
if ft != strconv.Itoa(int(FlagTypeFeed)) {
continue
}
if itStr != strconv.Itoa(int(ItemTypeThread)) && itStr != strconv.Itoa(int(ItemTypeMsgThread)) {
continue
}
id := asString(m["item_id"])
if id == "" {
continue
}
if _, inlined := byID[id]; !inlined {
need[id] = true
}
}
if len(need) > 0 {
if err := checkFlagRequiredScopes(rt.Ctx(), rt, flagMessageReadScopes); err != nil {
return err
}
ids := make([]string, 0, len(need))
for id := range need {
ids = append(ids, id)
}
// /messages/mget accepts max 50 IDs per request — batch if needed.
const mgetBatchSize = 50
for i := 0; i < len(ids); i += mgetBatchSize {
end := i + mgetBatchSize
if end > len(ids) {
end = len(ids)
}
batch := ids[i:end]
got, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/mget",
larkcore.QueryParams{"message_ids": batch}, nil)
if err != nil {
return err
}
fetched, _ := got["items"].([]any)
for _, m := range fetched {
mm, _ := m.(map[string]any)
if mm == nil {
continue
}
if id := asString(mm["message_id"]); id != "" {
byID[id] = mm
}
}
}
}
if len(byID) == 0 {
return nil
}
// Attach message payload to the matching list entries.
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil {
continue
}
ft := asString(m["flag_type"])
itType := asString(m["item_type"])
if ft != strconv.Itoa(int(FlagTypeFeed)) {
continue
}
if itType != strconv.Itoa(int(ItemTypeThread)) && itType != strconv.Itoa(int(ItemTypeMsgThread)) {
continue
}
if msg, ok := byID[asString(m["item_id"])]; ok {
m["message"] = msg
}
}
return nil
}
// asString converts an arbitrary value to its string representation.
// Handles string, float64, int, int64, and json.Number types; returns empty string for other types.
func asString(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
case int:
return strconv.Itoa(x)
case int64:
return strconv.FormatInt(x, 10)
case json.Number:
return x.String()
}
return ""
}
// executeListAllPages fetches all pages and merges the results into a single response.
// The flag list API returns items sorted by update_time ascending, so the last page
// contains the newest items.
func executeListAllPages(rt *common.RuntimeContext) error {
maxPages := rt.Int("page-limit")
if maxPages < 1 {
maxPages = 20
}
if maxPages > 1000 {
maxPages = 1000
}
// Use make([]any, 0) to ensure empty arrays serialize as [] not null
allFlagItems := make([]any, 0)
allDeleteFlagItems := make([]any, 0)
allMessages := make([]any, 0)
var lastHasMore bool
var lastPageToken string
prevPageToken := "__START__" // Sentinel to detect unchanged token
for page := 0; page < maxPages; page++ {
token := ""
if page > 0 {
token = lastPageToken
}
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/flags",
larkcore.QueryParams{
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
"page_token": []string{token},
}, nil)
if err != nil {
return err
}
if v, ok := data["flag_items"].([]any); ok {
allFlagItems = append(allFlagItems, v...)
}
if v, ok := data["delete_flag_items"].([]any); ok {
allDeleteFlagItems = append(allDeleteFlagItems, v...)
}
if v, ok := data["messages"].([]any); ok {
allMessages = append(allMessages, v...)
}
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
// Progress output to stderr
fmt.Fprintf(rt.IO().ErrOut, "page %d: %d flags, %d deleted\n",
page+1, len(allFlagItems), len(allDeleteFlagItems))
if !lastHasMore || lastPageToken == "" {
break
}
// Detect server anomaly: same token returned twice means infinite loop
if lastPageToken == prevPageToken {
fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n")
break
}
prevPageToken = lastPageToken
}
merged := map[string]any{
"flag_items": allFlagItems,
"delete_flag_items": allDeleteFlagItems,
"messages": allMessages,
"has_more": lastHasMore,
"page_token": lastPageToken,
}
if rt.Bool("enrich-feed-thread") {
if err := enrichFeedThreadItems(rt, merged); err != nil {
fmt.Fprintf(rt.IO().ErrOut, "warning: feed-thread enrichment failed: %v\n", err)
}
}
rt.Out(merged, nil)
return nil
}

1812
shortcuts/im/im_flag_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -18,5 +18,8 @@ func Shortcuts() []common.Shortcut {
ImMessagesSearch,
ImMessagesSend,
ImThreadsMessagesList,
ImFlagCreate,
ImFlagCancel,
ImFlagList,
}
}

View File

@@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut {
VCSearch,
VCNotes,
VCRecording,
VCMeetingJoin,
VCMeetingLeave,
VCMeetingEvents,
}
}

View File

@@ -0,0 +1,984 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"unicode"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
vcMeetingEventsAPIPath = "/open-apis/vc/v1/bots/events"
defaultVCMeetingEventsSize = 20
minVCMeetingEventsPageSize = 20
maxVCMeetingEventsPageSize = 100
maxVCMeetingEventsPages = 200
)
var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60)
// toUnixSeconds converts a supported CLI time input into a Unix seconds string.
func toUnixSeconds(input string, hint ...string) (string, error) {
ts, err := common.ParseTime(input, hint...)
if err != nil {
return "", err
}
if _, err := strconv.ParseInt(ts, 10, 64); err != nil {
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
}
return ts, nil
}
// VCMeetingEvents lists bot meeting events for a meeting.
var VCMeetingEvents = common.Shortcut{
Service: "vc",
Command: "+meeting-events",
Description: "List bot meeting events by meeting ID",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to query"},
{Name: "start", Desc: "time lower bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"},
{Name: "end", Desc: "time upper bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"},
{Name: "page-token", Desc: "page token for the next page"},
{Name: "page-size", Default: "20", Desc: "page size, 20-100 (default 20)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all available pages"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil {
return err
}
if _, err := meetingEventsPageSize(runtime); err != nil {
return err
}
if _, _, err := parseMeetingEventsTimeRange(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
startTime, endTime, err := parseMeetingEventsTimeRange(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params, err := buildMeetingEventsParams(runtime, startTime, endTime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dryRun := common.NewDryRunAPI()
if runtime.Bool("page-all") {
dryRun = dryRun.Desc("Auto-paginates through all available pages")
}
dryRun = dryRun.GET(vcMeetingEventsAPIPath)
if flat := flattenQueryParams(params); len(flat) > 0 {
dryRun.Params(flat)
}
return dryRun
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
startTime, endTime, err := parseMeetingEventsTimeRange(runtime)
if err != nil {
return err
}
data, events, hasMore, pageToken, err := fetchMeetingEvents(ctx, runtime, startTime, endTime)
if err != nil {
return err
}
events = compactMeetingEvents(events)
outData := map[string]interface{}{
"events": events,
"has_more": data["has_more"],
"page_token": data["page_token"],
}
timeline := buildMeetingEventTimeline(events)
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
if len(timeline.entries) == 0 {
fmt.Fprintln(w, "No meeting events.")
return
}
io.WriteString(w, renderMeetingEventsPretty(timeline))
})
if runtime.Format == "pretty" && pageToken != "" {
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
if hasMore {
fmt.Fprintln(runtime.IO().Out, "more available")
}
}
return nil
},
}
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
if runtime.Bool("page-all") {
return maxVCMeetingEventsPageSize, nil
}
pageSizeStr := strings.TrimSpace(runtime.Str("page-size"))
if pageSizeStr == "" {
return defaultVCMeetingEventsSize, nil
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil {
return 0, common.FlagErrorf("invalid --page-size %q: must be an integer", pageSizeStr)
}
if pageSize < minVCMeetingEventsPageSize {
return minVCMeetingEventsPageSize, nil
}
if pageSize > maxVCMeetingEventsPageSize {
return maxVCMeetingEventsPageSize, nil
}
return pageSize, nil
}
func meetingEventsPaginationConfig(runtime *common.RuntimeContext) (bool, int) {
if !runtime.Bool("page-all") {
return false, 0
}
return true, maxVCMeetingEventsPages
}
func validateMeetingEventsMeetingID(meetingID string) error {
meetingID = strings.TrimSpace(meetingID)
if meetingID == "" {
return common.FlagErrorf("--meeting-id is required")
}
value, err := strconv.ParseInt(meetingID, 10, 64)
if err != nil || value <= 0 {
return common.FlagErrorf("--meeting-id must be a positive integer, got %q", meetingID)
}
return nil
}
// parseMeetingEventsTimeRange validates --start/--end and returns Unix seconds strings.
func parseMeetingEventsTimeRange(runtime *common.RuntimeContext) (string, string, error) {
start := strings.TrimSpace(runtime.Str("start"))
end := strings.TrimSpace(runtime.Str("end"))
if start == "" && end == "" {
return "", "", nil
}
var startTime, endTime string
if start != "" {
parsed, err := toUnixSeconds(start)
if err != nil {
return "", "", output.ErrValidation("--start: %v", err)
}
startTime = parsed
}
if end != "" {
parsed, err := toUnixSeconds(end, "end")
if err != nil {
return "", "", output.ErrValidation("--end: %v", err)
}
endTime = parsed
}
if startTime != "" && endTime != "" {
startValue, _ := strconv.ParseInt(startTime, 10, 64)
endValue, _ := strconv.ParseInt(endTime, 10, 64)
if startValue > endValue {
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
}
}
return startTime, endTime, nil
}
func buildMeetingEventsParams(runtime *common.RuntimeContext, startTime, endTime string) (larkcore.QueryParams, error) {
pageSize, err := meetingEventsPageSize(runtime)
if err != nil {
return nil, err
}
params := make(larkcore.QueryParams)
params.Set("meeting_id", strings.TrimSpace(runtime.Str("meeting-id")))
params.Set("page_size", strconv.Itoa(pageSize))
if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" {
params.Set("page_token", pageToken)
}
if startTime != "" {
params.Set("start_time", startTime)
}
if endTime != "" {
params.Set("end_time", endTime)
}
return params, nil
}
func fetchMeetingEvents(ctx context.Context, runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, []interface{}, bool, string, error) {
params, err := buildMeetingEventsParams(runtime, startTime, endTime)
if err != nil {
return nil, nil, false, "", err
}
autoPaginate, pageLimit := meetingEventsPaginationConfig(runtime)
if !autoPaginate {
data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
if err != nil {
return nil, nil, false, "", err
}
if data == nil {
data = map[string]interface{}{}
}
events := common.GetSlice(data, "events")
hasMore, _ := data["has_more"].(bool)
pageToken, _ := data["page_token"].(string)
return data, events, hasMore, pageToken, nil
}
var (
allEvents []interface{}
lastData map[string]interface{}
lastPageToken string
lastHasMore bool
)
for page := 0; page < pageLimit; page++ {
data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
if err != nil {
return nil, nil, false, "", err
}
if data == nil {
data = map[string]interface{}{}
}
lastData = data
events := common.GetSlice(data, "events")
allEvents = append(allEvents, events...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
params.Set("page_token", lastPageToken)
}
if lastData == nil {
lastData = map[string]interface{}{}
}
lastData["events"] = allEvents
lastData["has_more"] = lastHasMore
lastData["page_token"] = lastPageToken
return lastData, allEvents, lastHasMore, lastPageToken, nil
}
func flattenQueryParams(params larkcore.QueryParams) map[string]interface{} {
if len(params) == 0 {
return nil
}
flat := make(map[string]interface{}, len(params))
for key, values := range params {
switch len(values) {
case 0:
continue
case 1:
flat[key] = values[0]
default:
copied := make([]string, len(values))
copy(copied, values)
flat[key] = copied
}
}
return flat
}
func compactMeetingEvents(events []interface{}) []interface{} {
compacted := make([]interface{}, 0, len(events))
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if event == nil {
continue
}
if payload := common.GetMap(event, "payload"); payload != nil {
event["payload"] = compactMeetingPayload(payload)
}
compacted = append(compacted, event)
}
return compacted
}
func compactMeetingPayload(payload map[string]interface{}) map[string]interface{} {
if payload == nil {
return nil
}
compacted := make(map[string]interface{}, len(payload))
for key, value := range payload {
if items, ok := value.([]interface{}); ok && len(items) == 0 {
continue
}
compacted[key] = value
}
return compacted
}
type meetingTimeline struct {
topic string
startTime time.Time
hasStart bool
endTime time.Time
hasEnd bool
entries []meetingTimelineEntry
}
type meetingTimelineEntry struct {
when time.Time
hasWhen bool
sequence int
group int
subject string
description string
details []string
}
func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
timeline := meetingTimeline{}
var sequence int
var group int
for _, raw := range events {
event, _ := raw.(map[string]interface{})
if event == nil {
continue
}
payload := common.GetMap(event, "payload")
if payload == nil {
continue
}
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
}
for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) {
timeline.entries = append(timeline.entries, entry)
}
group++
}
sort.SliceStable(timeline.entries, func(i, j int) bool {
left := timeline.entries[i]
right := timeline.entries[j]
switch {
case left.hasWhen && right.hasWhen:
if left.when.Equal(right.when) {
return left.sequence < right.sequence
}
return left.when.Before(right.when)
case left.hasWhen:
return true
case right.hasWhen:
return false
default:
return left.sequence < right.sequence
}
})
return timeline
}
func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) {
if timeline == nil || meeting == nil {
return
}
if timeline.topic == "" {
timeline.topic = common.GetString(meeting, "topic")
}
if !timeline.hasStart {
if parsed, ok := parseFlexibleTime(common.GetString(meeting, "start_time")); ok {
timeline.startTime = parsed
timeline.hasStart = true
}
}
if !timeline.hasEnd {
if parsed, ok := parseFlexibleTime(common.GetString(meeting, "end_time")); ok {
timeline.endTime = parsed
timeline.hasEnd = true
}
}
}
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry {
payload := common.GetMap(event, "payload")
if payload == nil {
return nil
}
eventType := meetingEventType(event)
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
switch eventType {
case "participant_joined":
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group)
case "participant_left":
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group)
case "transcript_received":
return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group)
case "chat_received":
return chatEntries(payload, eventTime, eventTimeOK, sequence, group)
case "magic_share_started":
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group)
case "magic_share_ended":
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group)
default:
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
}
}
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "participant_joined_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "加入了会议", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "join_time"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "participant"))
if subject == "" {
subject = "未知参会人"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil))
}
return entries
}
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "participant_left_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "离开了会议", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "leave_time"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "participant"))
if subject == "" {
subject = "未知参会人"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil))
}
return entries
}
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "transcript_received_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "产生了转写", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "start_time_ms"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "speaker"))
if subject == "" {
subject = "未知发言人"
}
text := strings.TrimSpace(common.GetString(item, "text"))
description := "产生了转写"
if text != "" {
description = text
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
}
return entries
}
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "chat_received_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "发送了消息", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "send_time"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
if subject == "" {
subject = "未知发送者"
}
typeLabel := chatMessageTypeLabel(item)
description := strings.TrimSpace(common.GetString(item, "content"))
if description == "" {
description = fmt.Sprintf("[%s] 发送了消息", typeLabel)
} else {
description = fmt.Sprintf("[%s] %s", typeLabel, description)
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
}
return entries
}
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "magic_share_started_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "开始共享内容", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "time"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
if subject == "" {
subject = "未知用户"
}
title := strings.TrimSpace(common.GetString(common.GetMap(item, "share_doc"), "title"))
url := strings.TrimSpace(common.GetString(common.GetMap(item, "share_doc"), "url"))
description := "开始共享内容"
if title != "" {
description = fmt.Sprintf("开始共享「%s」", title)
}
var details []string
if url != "" {
details = append(details, "URL: "+url)
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details))
}
return entries
}
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
items := common.GetSlice(payload, "magic_share_ended_items")
if len(items) == 0 {
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "结束共享", nil)}
}
entries := make([]meetingTimelineEntry, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
when, ok := parseFlexibleTime(common.GetString(item, "time"))
if !ok {
when, ok = fallbackTime, fallbackOK
}
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
if subject == "" {
subject = "未知用户"
}
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil))
}
return entries
}
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry {
entry := meetingTimelineEntry{
when: when,
hasWhen: hasWhen,
sequence: *sequence,
group: group,
subject: subject,
description: description,
details: details,
}
*sequence = *sequence + 1
return entry
}
func parseFlexibleTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
if ts, err := strconv.ParseInt(raw, 10, 64); err == nil {
switch {
case ts > 1_000_000_000_000:
return time.UnixMilli(ts), true
case ts > 0:
return time.Unix(ts, 0), true
}
}
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return parsed, true
}
return time.Time{}, false
}
func renderMeetingEventsPretty(timeline meetingTimeline) string {
var b strings.Builder
if timeline.topic != "" {
fmt.Fprintf(&b, "会议主题:%s\n", escapePrettyText(timeline.topic))
}
if timeline.hasStart || timeline.hasEnd {
fmt.Fprintf(&b, "会议时间:%s\n", escapePrettyText(formatMeetingWindow(timeline.startTime, timeline.hasStart, timeline.endTime, timeline.hasEnd)))
}
if b.Len() > 0 {
b.WriteString("\n")
}
for _, entry := range timeline.entries {
fmt.Fprintf(&b, "[%s] ", formatTimelineOffset(entry.when, entry.hasWhen, timeline.startTime, timeline.hasStart))
if entry.subject != "" {
if entry.description == "" {
fmt.Fprintln(&b, escapePrettyText(entry.subject))
for _, detail := range entry.details {
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
}
continue
}
if needsColon(entry.description) {
fmt.Fprintf(&b, "%s: %s\n", escapePrettyText(entry.subject), escapePrettyText(entry.description))
} else {
fmt.Fprintf(&b, "%s %s\n", escapePrettyText(entry.subject), escapePrettyText(entry.description))
}
for _, detail := range entry.details {
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
}
continue
}
fmt.Fprintln(&b, escapePrettyText(entry.description))
for _, detail := range entry.details {
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
}
}
if b.Len() == 0 {
return ""
}
return b.String()
}
func escapePrettyText(s string) string {
if s == "" {
return ""
}
var b strings.Builder
for _, r := range s {
switch r {
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
if unicode.IsControl(r) {
fmt.Fprintf(&b, "\\u%04X", r)
continue
}
b.WriteRune(r)
}
}
return b.String()
}
func formatMeetingWindow(start time.Time, hasStart bool, end time.Time, hasEnd bool) string {
switch {
case hasStart && hasEnd:
if !end.After(start) {
return fmt.Sprintf("%s进行中", start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"))
}
return fmt.Sprintf("%s - %s", start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"), end.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"))
case hasStart:
return start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05")
case hasEnd:
return end.In(meetingDisplayLocation).Format("2006-01-02 15:04:05")
default:
return ""
}
}
func formatTimelineOffset(when time.Time, hasWhen bool, meetingStart time.Time, hasMeetingStart bool) string {
if hasWhen && hasMeetingStart {
diff := when.Sub(meetingStart)
if diff < 0 {
diff = 0
}
totalSeconds := int(diff.Seconds())
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
seconds := totalSeconds % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
if hasWhen {
return when.In(meetingDisplayLocation).Format("15:04:05")
}
return "??:??:??"
}
func needsColon(description string) bool {
switch description {
case "发送了消息", "产生了转写":
return false
default:
return !strings.HasPrefix(description, "加入了") &&
!strings.HasPrefix(description, "离开了") &&
!strings.HasPrefix(description, "被移出") &&
!strings.HasPrefix(description, "会议结束") &&
!strings.HasPrefix(description, "开始共享") &&
!strings.HasPrefix(description, "结束共享")
}
}
func leaveAction(item map[string]interface{}) string {
switch int(common.GetFloat(item, "leave_reason")) {
case 2:
return "因会议结束离开了会议"
case 3:
return "被移出了会议"
default:
return "离开了会议"
}
}
func meetingEventUserWithID(user map[string]interface{}) string {
if user == nil {
return ""
}
userID := common.GetString(user, "id")
userName := common.GetString(user, "user_name")
switch {
case userName != "" && userID != "":
return fmt.Sprintf("%s(%s)", userName, userID)
case userName != "":
return userName
case userID != "":
return userID
default:
return ""
}
}
func meetingEventType(event map[string]interface{}) string {
if eventType := common.GetString(event, "event_type"); eventType != "" {
return eventType
}
return common.GetString(common.GetMap(event, "payload"), "activity_event_type")
}
func meetingEventSummary(event map[string]interface{}) string {
payload := common.GetMap(event, "payload")
eventType := meetingEventType(event)
switch eventType {
case "participant_joined":
return participantJoinedSummary(payload)
case "participant_left":
return participantLeftSummary(payload)
case "transcript_received":
return transcriptReceivedSummary(payload)
case "chat_received":
return chatReceivedSummary(payload)
case "magic_share_started":
return magicShareStartedSummary(payload)
case "magic_share_ended":
return magicShareEndedSummary(payload)
default:
return fallbackMeetingEventSummary(payload, eventType)
}
}
func participantJoinedSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "participant_joined_items")
switch len(items) {
case 0:
return "participant joined"
case 1:
user := common.GetMap(firstSliceMap(payload, "participant_joined_items"), "participant")
if label := meetingEventUserLabel(user); label != "" {
return fmt.Sprintf("participant %s joined", label)
}
return "participant joined"
default:
return fmt.Sprintf("%d participants joined", len(items))
}
}
func participantLeftSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "participant_left_items")
switch len(items) {
case 0:
return "participant left"
case 1:
user := common.GetMap(firstSliceMap(payload, "participant_left_items"), "participant")
if label := meetingEventUserLabel(user); label != "" {
return fmt.Sprintf("participant %s left", label)
}
return "participant left"
default:
return fmt.Sprintf("%d participants left", len(items))
}
}
func transcriptReceivedSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "transcript_received_items")
if len(items) > 1 {
return fmt.Sprintf("%d transcript items", len(items))
}
item := firstSliceMap(payload, "transcript_received_items")
text := common.GetString(item, "text")
speaker := meetingEventUserLabel(common.GetMap(item, "speaker"))
switch {
case speaker != "" && text != "":
return fmt.Sprintf("speaker %s: %s", speaker, text)
case speaker != "":
return fmt.Sprintf("speaker %s transcript received", speaker)
case text != "":
return fmt.Sprintf("transcript: %s", text)
default:
return "transcript received"
}
}
func chatReceivedSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "chat_received_items")
switch len(items) {
case 0:
return "chat received"
case 1:
item := firstSliceMap(payload, "chat_received_items")
content := common.GetString(item, "content")
operator := meetingEventUserDisplayName(common.GetMap(item, "operator"))
switch {
case operator != "" && content != "":
return fmt.Sprintf("%s: %s", operator, content)
case operator != "":
return fmt.Sprintf("message by %s", operator)
case content != "":
return fmt.Sprintf("message: %s", content)
default:
return "chat received"
}
default:
count, operator := summarizeChatOperators(items)
switch {
case count == 1 && operator != "":
return fmt.Sprintf("%d messages by %s", len(items), operator)
case count > 1:
return fmt.Sprintf("%d messages by %d users", len(items), count)
default:
return fmt.Sprintf("%d messages", len(items))
}
}
}
func magicShareStartedSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "magic_share_started_items")
if len(items) > 1 {
return fmt.Sprintf("%d share start events", len(items))
}
item := firstSliceMap(payload, "magic_share_started_items")
shareID := common.GetString(item, "share_id")
title := common.GetString(common.GetMap(item, "share_doc"), "title")
switch {
case shareID != "" && title != "":
return fmt.Sprintf("share %s started: %s", shareID, title)
case shareID != "":
return fmt.Sprintf("share %s started", shareID)
case title != "":
return fmt.Sprintf("share started: %s", title)
default:
return "share started"
}
}
func magicShareEndedSummary(payload map[string]interface{}) string {
items := common.GetSlice(payload, "magic_share_ended_items")
if len(items) > 1 {
return fmt.Sprintf("%d share end events", len(items))
}
item := firstSliceMap(payload, "magic_share_ended_items")
if shareID := common.GetString(item, "share_id"); shareID != "" {
return fmt.Sprintf("share %s ended", shareID)
}
return "share ended"
}
func fallbackMeetingEventSummary(payload map[string]interface{}, eventType string) string {
meeting := common.GetMap(payload, "meeting")
if topic := common.GetString(meeting, "topic"); topic != "" {
if eventType != "" {
return fmt.Sprintf("%s: %s", eventType, topic)
}
return topic
}
if eventType != "" {
return eventType
}
return "meeting event"
}
func firstSliceMap(payload map[string]interface{}, key string) map[string]interface{} {
items := common.GetSlice(payload, key)
if len(items) == 0 {
return nil
}
first, _ := items[0].(map[string]interface{})
return first
}
func meetingEventUserLabel(user map[string]interface{}) string {
if user == nil {
return ""
}
userID := common.GetString(user, "id")
userName := common.GetString(user, "user_name")
switch {
case userID != "" && userName != "":
return fmt.Sprintf("%s (%s)", userID, userName)
case userID != "":
return userID
case userName != "":
return userName
default:
return ""
}
}
func meetingEventUserDisplayName(user map[string]interface{}) string {
if user == nil {
return ""
}
if userName := common.GetString(user, "user_name"); userName != "" {
return userName
}
return common.GetString(user, "id")
}
func chatMessageTypeLabel(item map[string]interface{}) string {
code := int(common.GetFloat(item, "message_type"))
switch code {
case 1:
return "text"
case 2:
return "system"
case 3:
return "reaction"
case 4:
return "encrypted"
default:
return "unknown"
}
}
func summarizeChatOperators(items []interface{}) (int, string) {
seen := make(map[string]struct{}, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
operator := meetingEventUserDisplayName(common.GetMap(item, "operator"))
if operator == "" {
continue
}
seen[operator] = struct{}{}
}
if len(seen) != 1 {
return len(seen), ""
}
for operator := range seen {
return 1, operator
}
return 0, ""
}

View File

@@ -0,0 +1,931 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"reflect"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
func newMeetingEventsRuntime() *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().String("page-size", "", "")
cmd.Flags().Bool("page-all", false, "")
return common.TestNewRuntimeContext(cmd, defaultConfig())
}
func mustSetMeetingEventsFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
t.Helper()
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
t.Fatalf("Flags().Set(%q, %q) error = %v", name, value, err)
}
}
func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: vcMeetingEventsAPIPath,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"total": len(events),
"has_more": hasMore,
"page_token": pageToken,
"events": events,
},
},
}
}
func participantJoinedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-1",
"event_type": "participant_joined",
"event_time": "2026-04-17T08:00:00Z",
"payload": map[string]interface{}{
"activity_event_type": "participant_joined",
"meeting": map[string]interface{}{
"id": "7628568141510692381",
"topic": "项目例会",
"meeting_no": "724939760",
"start_time": "1776407700",
"end_time": "1776411300",
},
"participant_joined_items": []interface{}{
map[string]interface{}{
"participant": map[string]interface{}{
"id": "bot_001",
"user_name": "Demo Bot",
},
"join_time": "2026-04-17T08:00:00Z",
},
},
},
}
}
func participantJoinedEventOngoing() map[string]interface{} {
event := participantJoinedEvent()
payload := common.GetMap(event, "payload")
meeting := common.GetMap(payload, "meeting")
meeting["start_time"] = "1776410100"
meeting["end_time"] = "1776410100"
return event
}
func chatReceivedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-2",
"event_type": "chat_received",
"event_time": "2026-04-17T08:05:00Z",
"payload": map[string]interface{}{
"activity_event_type": "chat_received",
"meeting": map[string]interface{}{
"id": "7628568141510692381",
"topic": "项目例会",
"meeting_no": "724939760",
"start_time": "1776407700",
"end_time": "1776411300",
},
"participant_joined_items": []interface{}{},
"participant_left_items": []interface{}{},
"transcript_received_items": []interface{}{},
"magic_share_started_items": []interface{}{},
"magic_share_ended_items": []interface{}{},
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "hello",
"message_type": 3,
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
},
},
}
}
func multiChatReceivedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-3",
"event_type": "chat_received",
"event_time": "2026-04-17T08:06:00Z",
"payload": map[string]interface{}{
"activity_event_type": "chat_received",
"meeting": map[string]interface{}{
"id": "7628568141510692381",
"topic": "项目例会",
"meeting_no": "724939760",
"start_time": "1776407700",
"end_time": "1776411300",
},
"chat_received_items": []interface{}{
map[string]interface{}{
"content": "第一条\n第二行",
"message_type": 3,
"send_time": "1776408061000",
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
map[string]interface{}{
"content": "第二条",
"message_type": 3,
"send_time": "1776408062000",
"operator": map[string]interface{}{
"id": "u1",
"user_name": "Alice",
},
},
},
},
}
}
func magicShareStartedEvent() map[string]interface{} {
return map[string]interface{}{
"event_id": "event-4",
"event_type": "magic_share_started",
"event_time": "2026-04-17T08:07:00Z",
"payload": map[string]interface{}{
"activity_event_type": "magic_share_started",
"meeting": map[string]interface{}{
"id": "7628568141510692381",
"topic": "项目例会",
"meeting_no": "724939760",
"start_time": "1776407700",
"end_time": "1776411300",
},
"magic_share_started_items": []interface{}{
map[string]interface{}{
"time": "1776408123000",
"operator": map[string]interface{}{
"id": "u2",
"user_name": "Bob",
},
"share_doc": map[string]interface{}{
"title": "共享文档",
"url": "https://example.com/doc",
},
},
},
},
}
}
func TestChatReceivedSummary_MultipleItems(t *testing.T) {
payload := map[string]interface{}{
"chat_received_items": []interface{}{
map[string]interface{}{"content": "hello"},
map[string]interface{}{"content": "world"},
},
}
got := chatReceivedSummary(payload)
if got != "2 messages" {
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "2 messages")
}
}
func TestChatReceivedSummary_MultipleItemsSameOperator(t *testing.T) {
payload := map[string]interface{}{
"chat_received_items": []interface{}{
map[string]interface{}{"content": "hello", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
map[string]interface{}{"content": "world", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
},
}
got := chatReceivedSummary(payload)
if got != "2 messages by Alice" {
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "2 messages by Alice")
}
}
func TestChatReceivedSummary_MultipleItemsMultipleOperators(t *testing.T) {
payload := map[string]interface{}{
"chat_received_items": []interface{}{
map[string]interface{}{"content": "hello", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
map[string]interface{}{"content": "world", "operator": map[string]interface{}{"id": "u2", "user_name": "Bob"}},
map[string]interface{}{"content": "again", "operator": map[string]interface{}{"id": "u3", "user_name": "Carol"}},
},
}
got := chatReceivedSummary(payload)
if got != "3 messages by 3 users" {
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "3 messages by 3 users")
}
}
func TestParticipantJoinedSummary_MultipleItems(t *testing.T) {
payload := map[string]interface{}{
"participant_joined_items": []interface{}{
map[string]interface{}{"participant": map[string]interface{}{"id": "u1", "user_name": "User 1"}},
map[string]interface{}{"participant": map[string]interface{}{"id": "u2", "user_name": "User 2"}},
},
}
got := participantJoinedSummary(payload)
if got != "2 participants joined" {
t.Fatalf("participantJoinedSummary() = %q, want %q", got, "2 participants joined")
}
}
func TestMeetingEvents_Validation_InvalidMeetingID(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "not-a-number")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for invalid meeting ID")
}
if !strings.Contains(err.Error(), "positive integer") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestMeetingEvents_Validation_InvalidTimeRange(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "start", "200")
mustSetMeetingEventsFlag(t, runtime, "end", "100")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for invalid time range")
}
if !strings.Contains(err.Error(), "after --end") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestMeetingEvents_Validation_PageSizeBelowMinDoesNotError(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no validation error for page-size clamp, got: %v", err)
}
}
func TestMeetingEvents_Validation_PageAllIgnoresInvalidPageSize(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-all", "true")
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no validation error when page-all ignores page-size, got: %v", err)
}
}
func TestMeetingEvents_Validation_InvalidPageSizeReturnsFlagError(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-size", "foo")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for non-integer page-size")
}
if !strings.Contains(err.Error(), "invalid --page-size") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBuildMeetingEventsParams(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-size", "40")
mustSetMeetingEventsFlag(t, runtime, "page-token", "1710000000000000000")
params, err := buildMeetingEventsParams(runtime, "1710000000", "1710003600")
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["meeting_id"][0]; got != "7628568141510692381" {
t.Fatalf("meeting_id = %q, want %q", got, "7628568141510692381")
}
if got := params["page_size"][0]; got != "40" {
t.Fatalf("page_size = %q, want %q", got, "40")
}
if got := params["page_token"][0]; got != "1710000000000000000" {
t.Fatalf("page_token = %q, want %q", got, "1710000000000000000")
}
if got := params["start_time"][0]; got != "1710000000" {
t.Fatalf("start_time = %q, want %q", got, "1710000000")
}
if got := params["end_time"][0]; got != "1710003600" {
t.Fatalf("end_time = %q, want %q", got, "1710003600")
}
}
func TestBuildMeetingEventsParams_PageSizeBelowMinClampsToMin(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
params, err := buildMeetingEventsParams(runtime, "", "")
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"][0]; got != "20" {
t.Fatalf("page_size = %q, want %q when below min", got, "20")
}
}
func TestBuildMeetingEventsParams_PageSizeAboveMaxClampsToMax(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-size", "999")
params, err := buildMeetingEventsParams(runtime, "", "")
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"][0]; got != "100" {
t.Fatalf("page_size = %q, want %q when above max", got, "100")
}
}
func TestBuildMeetingEventsParams_PageAllUsesMaxPageSize(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-all", "true")
mustSetMeetingEventsFlag(t, runtime, "page-size", "50")
params, err := buildMeetingEventsParams(runtime, "", "")
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"][0]; got != "100" {
t.Fatalf("page_size = %q, want %q when page-all is set", got, "100")
}
}
func TestMeetingEvents_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--page-token", "1710000000000000000",
"--page-size", "40",
"--start", "1710000000",
"--end", "1710003600",
"--dry-run",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
vcMeetingEventsAPIPath,
`"meeting_id": "7628568141510692381"`,
`"page_token": "1710000000000000000"`,
`"page_size": "40"`,
`"start_time": "1710000000"`,
`"end_time": "1710003600"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q: %s", want, out)
}
}
}
func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--page-all",
"--dry-run",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "Auto-paginates through all available pages") {
t.Fatalf("dry-run output missing auto-pagination description: %s", stdout.String())
}
}
func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2"))
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--page-all",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 {
t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String())
}
if !strings.Contains(out, `"has_more":false`) {
t.Fatalf("expected final has_more=false: %s", stdout.String())
}
}
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := strings.ReplaceAll(stdout.String(), " ", "")
out = strings.ReplaceAll(out, "\n", "")
for _, want := range []string{
`"event_type":"participant_joined"`,
`"has_more":true`,
`"page_token":"1710000000000000000"`,
`"events":[`,
} {
if !strings.Contains(out, want) {
t.Fatalf("json output missing %q: %s", want, stdout.String())
}
}
}
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := stdout.String()
for _, unwanted := range []string{
`"participant_joined_items": []`,
`"participant_left_items": []`,
`"transcript_received_items": []`,
`"magic_share_started_items": []`,
`"magic_share_ended_items": []`,
} {
if strings.Contains(out, unwanted) {
t.Fatalf("json output should not contain %q: %s", unwanted, out)
}
}
if !strings.Contains(out, `"message_type": 3`) {
t.Fatalf("json output should keep numeric fields: %s", out)
}
}
func TestMeetingEvents_ExecutePretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := stdout.String()
for _, want := range []string{
"会议主题:项目例会",
"会议时间2026-04-17 15:15:00进行中",
"Demo Bot(bot_001) 加入了会议",
"Alice(u1): [reaction] 第一条\\n第二行",
"Alice(u1): [reaction] 第二条",
"Bob(u2) 开始共享「共享文档」",
"URL: https://example.com/doc",
"page_token: 1710000000000000000",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q: %s", want, out)
}
}
if strings.Contains(out, "第二条\n\n[") {
t.Fatalf("pretty output should not insert blank lines between event entries: %s", out)
}
if !strings.Contains(out, "第二条\n[") {
t.Fatalf("pretty output should keep event entries contiguous: %s", out)
}
}
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := stdout.String()
if !strings.Contains(out, "page_token: pt_last") {
t.Fatalf("pretty output should print page_token even when has_more is false: %s", out)
}
if strings.Contains(out, "more available") {
t.Fatalf("pretty output should not print more-available hint when has_more is false: %s", out)
}
}
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub(nil, false, ""))
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "pretty",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
if !strings.Contains(stdout.String(), "No meeting events.") {
t.Fatalf("unexpected output: %s", stdout.String())
}
}
func TestParseFlexibleTime(t *testing.T) {
t.Run("unix seconds", func(t *testing.T) {
got, ok := parseFlexibleTime("1776410100")
if !ok {
t.Fatal("parseFlexibleTime() ok = false, want true")
}
if want := time.Unix(1776410100, 0); !got.Equal(want) {
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
}
})
t.Run("unix millis", func(t *testing.T) {
got, ok := parseFlexibleTime("1776408061000")
if !ok {
t.Fatal("parseFlexibleTime() ok = false, want true")
}
if want := time.UnixMilli(1776408061000); !got.Equal(want) {
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
}
})
t.Run("rfc3339", func(t *testing.T) {
got, ok := parseFlexibleTime("2026-04-17T08:00:00Z")
if !ok {
t.Fatal("parseFlexibleTime() ok = false, want true")
}
if want, _ := time.Parse(time.RFC3339, "2026-04-17T08:00:00Z"); !got.Equal(want) {
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
}
})
t.Run("invalid", func(t *testing.T) {
if _, ok := parseFlexibleTime("not-a-time"); ok {
t.Fatal("parseFlexibleTime() ok = true, want false")
}
})
}
func TestFormatMeetingWindow(t *testing.T) {
start := time.Unix(1776410100, 0)
end := time.Unix(1776413700, 0)
tests := []struct {
name string
start time.Time
hasStart bool
end time.Time
hasEnd bool
want string
}{
{
name: "ongoing",
start: start,
hasStart: true,
end: start,
hasEnd: true,
want: "2026-04-17 15:15:00进行中",
},
{
name: "finished range",
start: start,
hasStart: true,
end: end,
hasEnd: true,
want: "2026-04-17 15:15:00 - 2026-04-17 16:15:00",
},
{
name: "only start",
start: start,
hasStart: true,
want: "2026-04-17 15:15:00",
},
{
name: "only end",
end: end,
hasEnd: true,
want: "2026-04-17 16:15:00",
},
{
name: "empty",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatMeetingWindow(tt.start, tt.hasStart, tt.end, tt.hasEnd); got != tt.want {
t.Fatalf("formatMeetingWindow() = %q, want %q", got, tt.want)
}
})
}
}
func TestFormatTimelineOffset(t *testing.T) {
start := time.Unix(1776410100, 0)
later := start.Add(90 * time.Second)
earlier := start.Add(-5 * time.Minute)
tests := []struct {
name string
when time.Time
hasWhen bool
meetingStart time.Time
hasMeetingStart bool
want string
}{
{
name: "with meeting start",
when: later,
hasWhen: true,
meetingStart: start,
hasMeetingStart: true,
want: "00:01:30",
},
{
name: "negative diff clamps to zero",
when: earlier,
hasWhen: true,
meetingStart: start,
hasMeetingStart: true,
want: "00:00:00",
},
{
name: "without meeting start uses wall clock",
when: later,
hasWhen: true,
want: "15:16:30",
},
{
name: "missing when",
want: "??:??:??",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatTimelineOffset(tt.when, tt.hasWhen, tt.meetingStart, tt.hasMeetingStart); got != tt.want {
t.Fatalf("formatTimelineOffset() = %q, want %q", got, tt.want)
}
})
}
}
func TestFlattenQueryParams(t *testing.T) {
params := larkcore.QueryParams{
"one": []string{"1"},
"many": []string{"2", "3"},
"empty": []string{},
}
got := flattenQueryParams(params)
want := map[string]interface{}{
"one": "1",
"many": []string{"2", "3"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("flattenQueryParams() = %#v, want %#v", got, want)
}
}
func TestCompactMeetingPayload_DropsOnlyEmptySlices(t *testing.T) {
got := compactMeetingPayload(map[string]interface{}{
"empty_items": []interface{}{},
"items": []interface{}{"x"},
"zero": 0,
"text": "ok",
})
if _, ok := got["empty_items"]; ok {
t.Fatalf("compactMeetingPayload() should drop empty_items: %#v", got)
}
if !reflect.DeepEqual(got["items"], []interface{}{"x"}) {
t.Fatalf("compactMeetingPayload() items = %#v, want %#v", got["items"], []interface{}{"x"})
}
if got["zero"] != 0 || got["text"] != "ok" {
t.Fatalf("compactMeetingPayload() preserved fields mismatch: %#v", got)
}
}
func TestCompactMeetingEvents_IgnoresNonMapsAndCompactsPayload(t *testing.T) {
got := compactMeetingEvents([]interface{}{
"skip-me",
map[string]interface{}{
"event_type": "chat_received",
"payload": map[string]interface{}{
"chat_received_items": []interface{}{"x"},
"empty_items": []interface{}{},
},
},
})
if len(got) != 1 {
t.Fatalf("len(compactMeetingEvents()) = %d, want 1", len(got))
}
event, _ := got[0].(map[string]interface{})
payload := common.GetMap(event, "payload")
if _, ok := payload["empty_items"]; ok {
t.Fatalf("compactMeetingEvents() should prune empty payload slices: %#v", payload)
}
}
func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
got := Shortcuts()
var commands []string
for _, shortcut := range got {
commands = append(commands, shortcut.Command)
}
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-events"}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
}
}
func TestLeaveAction(t *testing.T) {
tests := []struct {
name string
item map[string]interface{}
want string
}{
{name: "meeting ended", item: map[string]interface{}{"leave_reason": 2}, want: "因会议结束离开了会议"},
{name: "kicked", item: map[string]interface{}{"leave_reason": 3}, want: "被移出了会议"},
{name: "default", item: map[string]interface{}{"leave_reason": 1}, want: "离开了会议"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := leaveAction(tt.item); got != tt.want {
t.Fatalf("leaveAction() = %q, want %q", got, tt.want)
}
})
}
}
func TestMeetingEventUserWithID(t *testing.T) {
tests := []struct {
name string
user map[string]interface{}
want string
}{
{name: "nil", want: ""},
{name: "name and id", user: map[string]interface{}{"user_name": "Alice", "id": "u1"}, want: "Alice(u1)"},
{name: "name only", user: map[string]interface{}{"user_name": "Alice"}, want: "Alice"},
{name: "id only", user: map[string]interface{}{"id": "u1"}, want: "u1"},
{name: "empty", user: map[string]interface{}{}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := meetingEventUserWithID(tt.user); got != tt.want {
t.Fatalf("meetingEventUserWithID() = %q, want %q", got, tt.want)
}
})
}
}
func TestMeetingEventSummary(t *testing.T) {
tests := []struct {
name string
event map[string]interface{}
want string
}{
{
name: "participant joined count",
event: map[string]interface{}{
"event_type": "participant_joined",
"payload": map[string]interface{}{
"participant_joined_items": []interface{}{
map[string]interface{}{},
map[string]interface{}{},
},
},
},
want: "2 participants joined",
},
{
name: "participant left with label",
event: map[string]interface{}{
"event_type": "participant_left",
"payload": map[string]interface{}{
"participant_left_items": []interface{}{
map[string]interface{}{"participant": map[string]interface{}{"user_name": "Bob", "id": "u2"}},
},
},
},
want: "participant u2 (Bob) left",
},
{
name: "fallback unknown event",
event: map[string]interface{}{
"event_type": "mystery_event",
"payload": map[string]interface{}{},
},
want: "mystery_event",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := meetingEventSummary(tt.event); got != tt.want {
t.Fatalf("meetingEventSummary() = %q, want %q", got, tt.want)
}
})
}
}
func TestEscapePrettyText(t *testing.T) {
got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07)))
want := `line1\nline2\t\r\u0007`
if got != want {
t.Fatalf("escapePrettyText() = %q, want %q", got, want)
}
}
func TestNeedsColon(t *testing.T) {
tests := []struct {
description string
want bool
}{
{description: "发送了消息", want: false},
{description: "加入了会议", want: false},
{description: "离开了会议", want: false},
{description: "开始共享「文档」", want: false},
{description: "[text] hello", want: true},
}
for _, tt := range tests {
if got := needsColon(tt.description); got != tt.want {
t.Fatalf("needsColon(%q) = %v, want %v", tt.description, got, tt.want)
}
}
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"regexp"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var meetingNumberRe = regexp.MustCompile(`^\d{9}$`)
// validMeetingNumber checks whether s is a valid 9-digit meeting number.
func validMeetingNumber(s string) bool {
return meetingNumberRe.MatchString(s)
}
// VCMeetingJoin joins a meeting by meeting number via /vc/v1/bots/join.
var VCMeetingJoin = common.Shortcut{
Service: "vc",
Command: "+meeting-join",
Description: "Join a meeting by meeting number (bot join)",
Risk: "write",
Scopes: []string{"vc:meeting.bot.join:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-number", Required: true, Desc: "meeting number to join"},
{Name: "password", Desc: "meeting password (if required)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
mn := strings.TrimSpace(runtime.Str("meeting-number"))
if !validMeetingNumber(mn) {
return common.FlagErrorf("--meeting-number must be exactly 9 digits, got %q", mn)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildMeetingJoinBody(runtime)
return common.NewDryRunAPI().
POST("/open-apis/vc/v1/bots/join").
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := buildMeetingJoinBody(runtime)
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/join", nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
runtime.OutFormat(data, nil, func(w io.Writer) {
meeting, _ := data["meeting"].(map[string]interface{})
if meeting == nil {
fmt.Fprintln(w, "Joined meeting (no meeting info returned).")
return
}
fmt.Fprintf(w, "Joined meeting successfully.\n")
if id := common.GetString(meeting, "id"); id != "" {
fmt.Fprintf(w, " Meeting ID: %s\n", id)
}
if no := common.GetString(meeting, "meeting_no"); no != "" {
fmt.Fprintf(w, " Meeting No: %s\n", no)
}
if topic := common.GetString(meeting, "topic"); topic != "" {
fmt.Fprintf(w, " Topic: %s\n", topic)
}
if startTime := common.GetString(meeting, "start_time"); startTime != "" {
fmt.Fprintf(w, " Start Time: %s\n", startTime)
}
})
return nil
},
}
func buildMeetingJoinBody(runtime *common.RuntimeContext) map[string]interface{} {
meetingNo := strings.TrimSpace(runtime.Str("meeting-number"))
body := map[string]interface{}{
"join_type": 1,
"join_identify": map[string]interface{}{
"meeting_no": meetingNo,
},
}
if pw := strings.TrimSpace(runtime.Str("password")); pw != "" {
body["password"] = pw
}
return body
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// VCMeetingLeave leaves a meeting via /vc/v1/bots/leave.
var VCMeetingLeave = common.Shortcut{
Service: "vc",
Command: "+meeting-leave",
Description: "Leave a meeting by meeting ID",
Risk: "write",
Scopes: []string{"vc:meeting.bot.join:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to leave"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("meeting-id")) == "" {
return common.FlagErrorf("--meeting-id is required")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/vc/v1/bots/leave").
Body(map[string]interface{}{
"meeting_id": strings.TrimSpace(runtime.Str("meeting-id")),
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
meetingID := strings.TrimSpace(runtime.Str("meeting-id"))
body := map[string]interface{}{
"meeting_id": meetingID,
}
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/leave", nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "Left meeting %s successfully.\n", meetingID)
})
return nil
},
}

View File

@@ -0,0 +1,536 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// Unit tests: pure functions
// ---------------------------------------------------------------------------
func TestValidMeetingNumber(t *testing.T) {
tests := []struct {
name string
in string
want bool
}{
{"9 digits", "123456789", true},
{"9 digits leading zero", "012345678", true},
{"empty", "", false},
{"8 digits", "12345678", false},
{"10 digits", "1234567890", false},
{"with space", "12345 678", false},
{"letters mixed", "12345678a", false},
{"pure letters", "abcdefghi", false},
{"with dash", "123-456-789", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validMeetingNumber(tt.in); got != tt.want {
t.Errorf("validMeetingNumber(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
func TestBuildMeetingJoinBody_WithoutPassword(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
if body["join_type"] != 1 {
t.Errorf("join_type = %v, want 1", body["join_type"])
}
ji, ok := body["join_identify"].(map[string]interface{})
if !ok {
t.Fatalf("join_identify missing or wrong type: %v", body["join_identify"])
}
if ji["meeting_no"] != "123456789" {
t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"])
}
if _, exists := body["password"]; exists {
t.Errorf("password should be omitted when empty, got %v", body["password"])
}
}
func TestBuildMeetingJoinBody_WithPassword(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
_ = cmd.Flags().Set("password", "secret")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
if body["password"] != "secret" {
t.Errorf("password = %v, want secret", body["password"])
}
}
func TestBuildMeetingJoinBody_TrimsWhitespace(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", " 123456789 ")
_ = cmd.Flags().Set("password", " pw ")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body := buildMeetingJoinBody(runtime)
ji, _ := body["join_identify"].(map[string]interface{})
if ji["meeting_no"] != "123456789" {
t.Errorf("meeting_no should be trimmed, got %q", ji["meeting_no"])
}
if body["password"] != "pw" {
t.Errorf("password should be trimmed, got %q", body["password"])
}
}
// ---------------------------------------------------------------------------
// Validate tests: VCMeetingJoin
// ---------------------------------------------------------------------------
func TestMeetingJoin_Validate_MissingNumber(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
// cobra MarkFlagRequired should reject missing --meeting-number
err := mountAndRun(t, VCMeetingJoin, []string{"+meeting-join", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected error when --meeting-number is missing")
}
if !strings.Contains(err.Error(), "meeting-number") {
t.Errorf("error should mention meeting-number, got: %v", err)
}
}
func TestMeetingJoin_Validate_InvalidFormat(t *testing.T) {
tests := []struct {
name string
num string
}{
{"too short", "12345678"},
{"too long", "1234567890"},
{"with letters", "12345abcd"},
{"empty after trim", " "},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", tt.num)
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := VCMeetingJoin.Validate(context.Background(), runtime)
if err == nil {
t.Fatalf("expected validation error for %q", tt.num)
}
if !strings.Contains(err.Error(), "9 digits") {
t.Errorf("error should mention '9 digits', got: %v", err)
}
})
}
}
func TestMeetingJoin_Validate_Valid(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", "123456789")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
if err := VCMeetingJoin.Validate(context.Background(), runtime); err != nil {
t.Errorf("unexpected validation error: %v", err)
}
}
// ---------------------------------------------------------------------------
// DryRun tests: VCMeetingJoin
// ---------------------------------------------------------------------------
func TestMeetingJoin_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789", "--password", "pw123",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/vc/v1/bots/join") {
t.Errorf("dry-run should include API path, got: %s", out)
}
if !strings.Contains(out, "123456789") {
t.Errorf("dry-run should include meeting number, got: %s", out)
}
if !strings.Contains(out, "pw123") {
t.Errorf("dry-run should include password, got: %s", out)
}
}
// ---------------------------------------------------------------------------
// Execute tests: VCMeetingJoin
// ---------------------------------------------------------------------------
func TestMeetingJoin_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"meeting": map[string]interface{}{
"id": "69999999",
"meeting_no": "123456789",
"topic": "Weekly Sync",
"start_time": "1700000000",
},
},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// verify captured request body
if len(stub.CapturedBody) == 0 {
t.Fatal("expected request body to be captured")
}
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
if req["join_type"].(float64) != 1 {
t.Errorf("join_type = %v, want 1", req["join_type"])
}
ji, _ := req["join_identify"].(map[string]interface{})
if ji["meeting_no"] != "123456789" {
t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"])
}
if _, exists := ji["password"]; exists {
t.Errorf("password should be omitted when not provided, got %v", ji["password"])
}
// verify response envelope carries meeting info under data.meeting
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse stdout: %v", err)
}
data, _ := resp["data"].(map[string]any)
meeting, _ := data["meeting"].(map[string]any)
if meeting["id"] != "69999999" {
t.Errorf("meeting.id = %v, want 69999999 (envelope: %s)", meeting["id"], stdout.String())
}
}
func TestMeetingJoin_Execute_WithPassword_CapturesBody(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "987654321", "--password", "s3cret",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
ji, _ := req["join_identify"].(map[string]interface{})
if req["password"] != "s3cret" {
t.Errorf("password = %v, want s3cret", req["password"])
}
if ji["meeting_no"] != "987654321" {
t.Errorf("meeting_no = %v, want 987654321", ji["meeting_no"])
}
}
func TestMeetingJoin_Execute_PrettyOutput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"meeting": map[string]interface{}{
"id": "69999999",
"meeting_no": "123456789",
"topic": "Weekly Sync",
"start_time": "1700000000",
},
},
},
})
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"Joined meeting successfully", "69999999", "123456789", "Weekly Sync", "1700000000"} {
if !strings.Contains(out, want) {
t.Errorf("pretty output missing %q, got: %s", want, out)
}
}
}
func TestMeetingJoin_Execute_PrettyOutput_NoMeetingInfo(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "no meeting info returned") {
t.Errorf("pretty output should fall back to 'no meeting info' notice, got: %s", stdout.String())
}
}
func TestMeetingLeave_Execute_PrettyOutput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999",
"--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "Left meeting 69999999 successfully") {
t.Errorf("pretty output should confirm leave, got: %s", out)
}
}
func TestMeetingJoin_Execute_APIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/join",
Body: map[string]interface{}{"code": 190001, "msg": "invalid meeting number"},
})
err := mountAndRun(t, VCMeetingJoin, []string{
"+meeting-join", "--meeting-number", "123456789",
"--as", "user",
}, f, &bytes.Buffer{})
if err == nil {
t.Fatal("expected error for API failure")
}
if !strings.Contains(err.Error(), "invalid meeting number") {
t.Errorf("error should surface API message, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// Validate tests: VCMeetingLeave
// ---------------------------------------------------------------------------
func TestMeetingLeave_Validate_MissingID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingLeave, []string{"+meeting-leave", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected error when --meeting-id is missing")
}
if !strings.Contains(err.Error(), "meeting-id") {
t.Errorf("error should mention meeting-id, got: %v", err)
}
}
func TestMeetingLeave_Validate_WhitespaceOnly(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
_ = cmd.Flags().Set("meeting-id", " ")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := VCMeetingLeave.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected error for whitespace-only meeting-id")
}
if !strings.Contains(err.Error(), "meeting-id") {
t.Errorf("error should mention meeting-id, got: %v", err)
}
}
func TestMeetingLeave_Validate_Valid(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
_ = cmd.Flags().Set("meeting-id", "69999999")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
if err := VCMeetingLeave.Validate(context.Background(), runtime); err != nil {
t.Errorf("unexpected validation error: %v", err)
}
}
// ---------------------------------------------------------------------------
// DryRun tests: VCMeetingLeave
// ---------------------------------------------------------------------------
func TestMeetingLeave_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/vc/v1/bots/leave") {
t.Errorf("dry-run should include API path, got: %s", out)
}
if !strings.Contains(out, "69999999") {
t.Errorf("dry-run should include meeting-id, got: %s", out)
}
}
// ---------------------------------------------------------------------------
// Execute tests: VCMeetingLeave
// ---------------------------------------------------------------------------
func TestMeetingLeave_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// verify captured request body
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
if req["meeting_id"] != "69999999" {
t.Errorf("meeting_id = %v, want 69999999", req["meeting_id"])
}
}
func TestMeetingLeave_Execute_TrimsMeetingID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", " 69999999 ",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
if req["meeting_id"] != "69999999" {
t.Errorf("meeting_id should be trimmed, got %q", req["meeting_id"])
}
}
func TestMeetingLeave_Execute_APIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/vc/v1/bots/leave",
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
})
err := mountAndRun(t, VCMeetingLeave, []string{
"+meeting-leave", "--meeting-id", "69999999", "--as", "user",
}, f, &bytes.Buffer{})
if err == nil {
t.Fatal("expected error for API failure")
}
if !strings.Contains(err.Error(), "no permission") {
t.Errorf("error should surface API message, got: %v", err)
}
}

View File

@@ -4,6 +4,7 @@
- **Chat**: A group chat or P2P conversation, identified by `chat_id` (oc_xxx).
- **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx).
- **Reaction**: An emoji reaction on a message.
- **Flag**: A bookmark on a message or thread.
## Resource Relationships
@@ -35,3 +36,14 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
### Card Messages (Interactive)
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
### Flag Types
Flags support two layers:
- **Message-layer flag**: `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark
- **Feed-layer flag**: `(ItemTypeThread/ItemTypeMsgThread, FlagTypeFeed)` — thread as feed-layer bookmark
Item types for feed-layer flags:
- **ItemTypeThread** (4) = thread in a topic-style chat
- **ItemTypeMsgThread** (11) = thread in a regular chat

View File

@@ -1,7 +1,7 @@
---
name: lark-im
version: 1.0.0
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员时使用。"
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、管理标记数据时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -18,6 +18,7 @@ metadata:
- **Chat**: A group chat or P2P conversation, identified by `chat_id` (oc_xxx).
- **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx).
- **Reaction**: An emoji reaction on a message.
- **Flag**: A bookmark on a message or thread.
## Resource Relationships
@@ -50,6 +51,17 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
### Flag Types
Flags support two layers:
- **Message-layer flag**: `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark
- **Feed-layer flag**: `(ItemTypeThread/ItemTypeMsgThread, FlagTypeFeed)` — thread as feed-layer bookmark
Item types for feed-layer flags:
- **ItemTypeThread** (4) = thread in a topic-style chat
- **ItemTypeMsgThread** (11) = thread in a regular chat
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。有 Shortcut 的操作优先使用。
@@ -58,7 +70,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | 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 |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by keyword and/or member open_ids (e.g. look up chat_id by group name); user/bot; supports member/type filters, sorting, and pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by `--query` keyword and/or `--member-ids`; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, and pagination |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
@@ -66,6 +78,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message or thread; user-only; defaults to message-layer flag; feed-layer flag requires explicit --item-type + --flag-type |
| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, checks if the message is a thread root message; if so, cancels both message and feed layers |
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination |
## API Resources
@@ -86,7 +101,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
### chat.members
- `bots` — 获取群内机器人列表。 Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
- `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
- `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
- `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
- `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
@@ -140,3 +155,4 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `pins.create` | `im:message.pins:write_only` |
| `pins.delete` | `im:message.pins:write_only` |
| `pins.list` | `im:message.pins:read` |

View File

@@ -0,0 +1,67 @@
# im +flag-cancel
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
This skill maps to shortcut: `lark-cli im +flag-cancel`. Underlying API: `POST /open-apis/im/v1/flags/cancel`.
## Double-Cancel Behavior (Important)
A message can have flags on both layers simultaneously:
- Message layer: `(default, message)`
- Feed layer: `(thread, feed)` or `(msg_thread, feed)` depending on chat type
**When no `--flag-type` is specified, the shortcut performs double-cancel**: removes both message layer and feed layer flags. The server handles cancel requests for non-existent flags idempotently, so this is safe.
**Feed layer item_type is determined by chat_mode**:
- Topic-style chat (`chat_mode=topic`) → `item_type=thread`
- Regular chat (`chat_mode=group`) → `item_type=msg_thread`
## Commands
```bash
# Double-cancel both layers (recommended default)
lark-cli im +flag-cancel --as user --message-id om_xxx
# Only cancel message layer
lark-cli im +flag-cancel --as user --message-id om_xxx --flag-type message
# Only cancel feed layer (need to specify item-type)
lark-cli im +flag-cancel --as user --message-id om_xxx --item-type thread --flag-type feed
# Preview request
lark-cli im +flag-cancel --as user --message-id om_xxx --dry-run
```
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--message-id <om_xxx>` | Required | Message ID |
| `--flag-type <name>` | No | `message` or `feed`; **when omitted, double-cancels both layers** |
| `--item-type <name>` | No | `default\|thread\|msg_thread`; required when `--flag-type feed` |
| `--as user` | Required | Currently only supports user identity |
## Idempotency
The server doesn't return an error for cancel requests when the flag doesn't exist, so repeated `+cancel` calls are idempotent.
## Permissions
- Required scopes: `im:feed.flag:write`, `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user`, `im:chat:read`
- The message/chat read scopes are used by the default double-cancel path to auto-detect the feed-layer item type.
## Note
- **Do not call +flag-list for verification**: If the cancel API returns success, the flag is removed. Calling +flag-list to verify is expensive (requires full pagination) and unnecessary.
## Finding Message ID Efficiently
If you have message content but not the message ID:
1. **Use `+messages-search`** to find the message by content, then extract `message_id` from the result
2. **Do NOT use `+flag-list`** to find the message — it requires full pagination and is very inefficient
```bash
# Search by message content to find message_id
lark-cli im +messages-search --as user --query "message content here" -q '.data.items[0].message_id'
```

View File

@@ -0,0 +1,67 @@
# im +flag-create
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
This skill maps to shortcut: `lark-cli im +flag-create`. Underlying API: `POST /open-apis/im/v1/flags`.
## Default Behavior
- **Message-layer flag** (default): `item_type=default, flag_type=message`
- **Feed-layer flag**: Use `--flag-type feed` — automatically detects chat type to determine `item_type`:
- Topic-style chat (`chat_mode=topic`) → `item_type=thread`
- Regular chat (`chat_mode=group`) → `item_type=msg_thread`
## Commands
```bash
# Flag a message (default: message-layer)
lark-cli im +flag-create --as user --message-id om_xxx
# Create feed-layer flag (auto-detects chat type)
lark-cli im +flag-create --as user --message-id om_xxx --flag-type feed
# Explicit item-type override (rarely needed)
lark-cli im +flag-create --as user --message-id om_xxx --item-type thread --flag-type feed
# Preview request (dry-run, doesn't send)
lark-cli im +flag-create --as user --message-id om_xxx --dry-run
```
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--message-id <om_xxx>` | Required | Message ID |
| `--flag-type <name>` | No | `message` (default) or `feed` |
| `--item-type <name>` | No | Override auto-detection: `default\|thread\|msg_thread` (rarely needed) |
| `--as user` | Required | Currently only supports user identity |
## Valid Combinations
The server only accepts these `(item_type, flag_type)` pairs:
- `(default, message)` — regular message flag
- `(thread, feed)` — feed flag in topic-style chat
- `(msg_thread, feed)` — feed flag in regular chat
## Permissions
- Required scopes: `im:feed.flag:write`, `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user`, `im:chat:read`
- The message/chat read scopes are used when `--flag-type feed` is used without explicit `--item-type` so the CLI can auto-detect chat type.
- If missing, CLI will prompt with `lark-cli auth login --scope "..."`
## Note
- **Do not call +flag-list for verification**: If the create API returns success, the flag is created. Calling +flag-list to verify is expensive (requires full pagination) and unnecessary.
## Finding Message ID Efficiently
If you have message content but not the message ID:
1. **Use `+messages-search`** to find the message by content, then extract `message_id` from the result
2. **Do NOT use `+flag-list`** to find the message — it requires full pagination and is very inefficient
```bash
# Search by message content to find message_id
lark-cli im +messages-search --as user --query "message content here" -q '.data.items[0].message_id'
```

View File

@@ -0,0 +1,100 @@
# im +flag-list
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
This skill maps to shortcut: `lark-cli im +flag-list`. Underlying API: `GET /open-apis/im/v1/flags`.
## Sorting Rules (Important)
The API returns data sorted by `update_time` in **ascending order**, meaning **oldest first, newest last**. When `has_more=true`, you cannot simply take the first page's items as the latest flags — you must paginate through all pages and take the last item on the last page as the newest.
Recommended: use `--page-all` for auto-pagination to get the complete list, then use `-q '.data.flag_items[-1]'` to get the latest item.
## Commands
```bash
# Fetch first page (default page-size=50)
lark-cli im +flag-list --as user
# Manual pagination with custom page size
lark-cli im +flag-list --as user --page-size 30 --page-token <page_token>
# Auto-paginate to get all flags (recommended)
lark-cli im +flag-list --as user --page-all
# Auto-paginate + get the latest flag
lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]'
# Auto-paginate + get only item_id list
lark-cli im +flag-list --as user --page-all -q '.data.flag_items[].item_id'
# Disable auto-enrichment of message content (enabled by default)
lark-cli im +flag-list --as user --page-all --enrich-feed-thread=false
# Limit max pages (default 20, max 1000)
lark-cli im +flag-list --as user --page-all --page-limit 10
```
## Parameters
| Parameter | Default | Description |
|------|------|------|
| `--page-size <n>` | 50 | Range 1-50 (server max is 50) |
| `--page-token <token>` | empty | Pagination token from previous page; empty string must still be provided |
| `--page-all` | false | Auto-paginate to fetch all pages and merge results |
| `--page-limit <n>` | 20 | Max pages in `--page-all` mode (max 1000) |
| `--enrich-feed-thread` | true | Auto-enrich feed-layer thread entries with message content (calls `im.messages.mget`) |
| `--as user` | Required | Currently only supports user identity |
## Response Structure
The response has `data` as the main body, with fields described below:
| Field | Type | Description |
|------|------|------|
| `flag_items` | array | List of currently existing (not canceled) flags, sorted by `update_time` ascending |
| `delete_flag_items` | array | List of previously canceled flags, sorted by `update_time` ascending |
| `messages` | array | Message content inlined by the server for `(default, message)` type flags |
| `has_more` | boolean | Whether there's a next page |
| `page_token` | string | Pagination token for the next page |
Note: `(thread, feed)` / `(msg_thread, feed)` entries are automatically enriched via `mget` by the shortcut, and written to the corresponding entry's `message` field.
## Limitations
- **delete_flag_items are not enriched**: Message content is only fetched for active flags (`flag_items`), not canceled flags (`delete_flag_items`). If you need message content for a canceled flag, query the message separately using `+messages-mget --message-ids <item_id>`.
## Response Example (Sanitized)
```json
{
"data": {
"delete_flag_items": [
{
"create_time": "xxx",
"flag_type": "xxx",
"item_id": "xxx",
"item_type": "xxx",
"update_time": "xxx"
}
],
"flag_items": [
{
"create_time": "xxx",
"flag_type": "xxx",
"item_id": "xxx",
"item_type": "xxx",
"update_time": "xxx"
}
],
"has_more": false,
"messages": [],
"page_token": "xxx"
}
}
```
## Permissions
- Base scope: `im:feed.flag:read`
- Additional scopes only when `--enrich-feed-thread=true` needs to fetch missing message content: `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user`

View File

@@ -0,0 +1,122 @@
---
name: lark-vc-agent
version: 1.0.0
description: "飞书视频会议:让机器人代当前用户加入/离开正在进行的会议并读取会议期间的实时事件参会人加入与离开、发言、聊天、屏幕共享等。1. 用户提供 9 位会议号、要求代为入会或离会时使用 +meeting-join / +meeting-leave——会真实产生入会/离会记录。2. 会议进行中用户想知道“谁加入了”“谁离开了”“谁在发言”“有人共享屏幕吗”等会中动态时,机器人入会后用 +meeting-events 读取事件时间线。3. 典型场景:参会机器人、会中助手、代为旁听、代为参会。前提:机器人只能读到它自己参会过且仍在进行中的会议的事件;查询已结束会议的参会名单、纪要或逐字稿请使用 lark-vc 技能。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli vc --help"
---
# vc-agent (v1)
**CRITICAL — 开始前 MUST 先用 Read 工具读取以下两份 skill 文档:**
- [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、身份切换、权限处理
- [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) — 视频会议的核心概念Meeting / Note / Minutes 等),本 skill 直接复用,不再重复定义
## 内测提示
- 当前功能正在内测中,仅少数用户可用。忽略 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 中的权限申请流程,以下方的指引为准。
- 如果 `lark-cli` 提示 `missing required scope(s)` / `permission_violations`,不要申请对应权限,而是提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`
- 如果 `lark-cli` 返回 `error.code=20017` / `ErrNotInGray`,这不是普通 scope 缺失;提示用户该能力仍在内测中,需要加入早鸟群或换已开通内测资格的用户重试。
- 上述规则适用于 `+meeting-join``+meeting-events``+meeting-leave` 三个命令。
## 定位
本 skill 与 [`lark-vc`](../lark-vc/SKILL.md) 并列:
- **`lark-vc`** **负责"会后查询"**:搜索历史会议、参会人快照、纪要/逐字稿/录制
- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 机器人离会
按此分工路由,避免两个 skill 语义混淆。
| 用户意图示例 | 应路由到 |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| "帮我入会 123456789"、"代我参会"、"让机器人进会旁听" | **本 skill** `+meeting-join` |
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"**进行中会议**,且**机器人已入会** | **本 skill** `+meeting-events` |
| "退出会议"、"让机器人离开" | **本 skill** `+meeting-leave` |
| "昨天那场会有谁参加过"、"搜昨天的会"、"查纪要/逐字稿/录制" | [`lark-vc`](../lark-vc/SKILL.md) |
| "帮我参会,结束后把纪要发到群" 等跨阶段场景 | 按序编排:本 skill入会 → 读事件 → 离会)→ [`lark-vc`](../lark-vc/SKILL.md) / [`lark-minutes`](../lark-minutes/SKILL.md)(拉纪要)→ [`lark-im`](../lark-im/SKILL.md)(发群) |
## 核心场景
### 1. 加入正在进行的会议(写操作)
1. 只有用户明确表达"让 Agent **真实入会**"(参会机器人、会中助手、代为旁听、代参会)时才用 `+meeting-join`。只是查数据不要入会。
2. `+meeting-join --meeting-number` 只接受 **9 位纯数字**会议号,不是会议链接整串、也不是 `meeting_id`
3. 返回体中的 `meeting.id` **必须立刻记录**——后续 `+meeting-events` / `+meeting-leave` 都靠它,**不能用 9 位会议号替代**。
4. 入会对所有参会人可见,执行前核实 9 位会议号来源,避免误入错会。
5. 仅支持 `user` 身份,需提前 `lark-cli auth login`
6. 若入会失败,优先查看 `+meeting-join` reference 的错误排查段落,重点确认会议号、密码、会议状态、等候室 / 审批以及会议是否禁止当前身份加入。
### 2. 感知会中事件(读操作)
1. 用户要看"会议里正在发生什么"(参会人加入/离开、聊天、转写、屏幕共享)时,用 `+meeting-events`
2. 输入是 **`meeting_id`**(长数字 ID不是 9 位会议号。
3. Bot 必须**真实参会过**(先 `+meeting-join`),否则事件流通常不可见。具体的状态边界、结束后宽限窗口与错误码(如 `10005 / 20001 / 20002`)请查看 `+meeting-events` reference。
4. **不能做会后复盘****不能替代参会人快照查询**。如果会议已结束:
- 想拿纪要文档或逐字稿文档 token`lark-cli vc +notes --meeting-ids <meeting.id>`
- 想拿 AI 产物summary / todos / chapters或导出逐字稿文件先用 `lark-cli vc +recording --meeting-ids <meeting.id>``minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`
- 想看参会人快照:用 `vc meeting get --with-participants`(见 [`lark-vc`](../lark-vc/SKILL.md)
5. **默认必须使用** **`--page-all`**,除非用户明确要求“只查一页”,或确实需要控制返回体大小。
6. 输出格式默认优先 `--format pretty`(时间线更易读);只有在需要完整保留原始消息流与结构化字段时,才使用 `--format json`
7. **必须识别分页信号**:只要响应里出现 `has_more=true`、pretty 里的 `more available`,或返回了非空 `page_token`,就不能把当前结果当作完整事件流;默认应继续分页,或明确告诉用户当前只是部分结果。
8. 保留响应里的 `page_token`,下次增量拉取直接续,不要从头再拉。
9. **只要你是基于** **`+meeting-events`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
### 3. 离开会议(写操作)
1. 任务完成、或用户要求结束时,用 `+meeting-leave --meeting-id <从 +meeting-join 拿到的 meeting.id>`
2. `--meeting-id` **必须**是 `+meeting-join` 返回的长数字 `meeting.id`**不接受 9 位会议号**
3. 离会**立即生效**,机器人从会议的参会人列表中消失,对其他参会人可见;若需要重新入会,再跑一次 `+meeting-join` 即可(非真正"不可逆")。
4. 仅支持 `user` 身份。
### 4. Agent 参会最小闭环示范
```bash
# 1. 入会,捕获 meeting.id
JOIN=$(lark-cli vc +meeting-join --meeting-number 123456789 --format json)
MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
# 2. 会中轮询事件
# 默认用 --page-all 拉全当前可见事件;下次增量优先复用 page_token
# 典型间隔 10-30 秒
lark-cli vc +meeting-events --meeting-id "$MID" --page-all --format pretty
# 3. 任务完成或用户要求结束时离会
lark-cli vc +meeting-leave --meeting-id "$MID"
# 4. 会后可选:取纪要 / 逐字稿(跨到 lark-vc
lark-cli vc +notes --meeting-ids "$MID"
```
## Shortcuts
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
| Shortcut | 类型 | 说明 |
| --------------------------------------------------------------- | -- | -------------------------------------------------------------------------- |
| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number |
| [`+meeting-events`](references/lark-vc-agent-meeting-events.md) | 读 | List bot meeting events (participant joined/left, transcript, chat, share) |
| [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md) | 写 | Leave a meeting by meeting\_id |
- 使用 `+meeting-join` 前**必须**阅读 [references/lark-vc-agent-meeting-join.md](references/lark-vc-agent-meeting-join.md),了解入参格式与写操作可见性风险。
- 使用 `+meeting-events` 前**必须**阅读 [references/lark-vc-agent-meeting-events.md](references/lark-vc-agent-meeting-events.md),了解 `meeting_id` 来源、分页、错误码10005 / 20001 / 20002与 "bot 仍在会中" 硬约束。
- 使用 `+meeting-leave` 前**必须**阅读 [references/lark-vc-agent-meeting-leave.md](references/lark-vc-agent-meeting-leave.md),了解 `meeting_id` 的来源与写操作可见性。
## 权限表
| Shortcut | 所需 scope |
| ----------------- | ------------------------------ |
| `+meeting-join` | `vc:meeting.bot.join:write` |
| `+meeting-events` | `vc:meeting.meetingevent:read` |
| `+meeting-leave` | `vc:meeting.bot.join:write` |
## 延伸
- 查已结束会议、参会人快照、搜索历史会议 → [`lark-vc`](../lark-vc/SKILL.md)
- 会议纪要、逐字稿 → [`lark-vc`](../lark-vc/SKILL.md) 的 `+notes`
- 妙记产物AI 总结 / 转写 / 章节)→ [`lark-minutes`](../lark-minutes/SKILL.md)
- 会后把产物发到群 / 私聊 → [`lark-im`](../lark-im/SKILL.md)
- 认证、身份切换、scope 管理 → [`lark-shared`](../lark-shared/SKILL.md)

View File

@@ -0,0 +1,247 @@
# vc +meeting-events
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查询当前 bot 在一场正在进行的视频会议中收到的会中事件列表。该命令是**读操作**。对进行中会议,要求 bot 当前仍在会中;对已结束会议,存在一个**结束后 5 分钟内的宽限窗口**,只要 bot 曾经在这场会里出现过,仍可继续拉取事件。
本 skill 对应 shortcut`lark-cli vc +meeting-events`(调用 `GET /open-apis/vc/v1/bots/events`)。
## 命令
```bash
# 默认用法:全量拉取当前可见事件
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty
# 指定时间范围,并拉全该时间窗内当前可见事件
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --page-all --format pretty
# 基于上一次保存的 page_token 继续查新增事件
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-token <last_page_token> --page-all --format pretty
# 调试或控制返回体大小时,显式只查一页
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json
# 预览 API 调用(不实际请求)
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--meeting-id <id>` | 是 | 会议 ID长数字 ID不是 9 位会议号) |
| `--start <time>` | 否 | 起始时间,支持 ISO 8601 / `YYYY-MM-DD` / Unix 秒 |
| `--end <time>` | 否 | 结束时间,支持 ISO 8601 / `YYYY-MM-DD` / Unix 秒 |
| `--page-token <token>` | 否 | 从指定分页游标继续拉取下一页 |
| `--page-size <n>` | 否 | 单页模式每页大小。CLI 会自动夹紧到 `20-100`;传 `--page-all` 时固定使用 `100` |
| `--page-all` | 否 | 自动分页,直到没有更多页面为止(内部有安全上限) |
| `--format <fmt>` | 否 | 输出格式json (CLI 默认) / pretty本 skill 推荐默认) / table / ndjson / csv |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 核心约束
### 1. 输入必须是 meeting_id不是 9 位会议号
`--meeting-id` 必须是会议的长数字 ID。它通常来自
- `+meeting-join` 返回体中的 `meeting.id`
- `+search` 结果中的 `id`
**不要**把 9 位会议号(`--meeting-number`)传给这个命令。
### 2. 仅支持 user 身份
该命令仅支持 `user` 身份。
### 3. bot 必须在会中,或在会议结束后的 5 分钟宽限窗口内曾经在会中
这是查询“bot 在会中观察到的事件”的接口。若 bot 已离会、未入会、或会议已经无法再判断 bot 身份,后端通常会报:
- `bot is not in meeting, no permission`
因此,最稳妥的调用顺序通常是:
```bash
# 先入会
lark-cli vc +meeting-join --meeting-number 123456789
# 记录返回的 meeting.id
# 再查询事件
lark-cli vc +meeting-events --meeting-id <meeting.id>
```
更精确地说,后端当前的判断规则是:
- **会议进行中**:要求 bot **当前仍在会中**
- **会议已结束后的 5 分钟内**:只要 bot **曾经在这场会中出现过**,仍可拉取事件
- **会议结束超过 5 分钟**:按会议结束处理,通常不再返回事件流
- **bot 从未真实入会过**:即使会议仍在进行或刚结束,也会返回 `10005 bot is not in meeting`
### 4. 自动分页规则
- **先分清两层默认值**
- shortcut 本身:不传 `--page-all` 时,只查 1 页。
- 本 skill 的默认策略:除非用户明确要求只看一页,或你确实需要控制返回体大小,否则默认**必须主动带 `--page-all`**,把当前可见事件尽量一次拉全。
-`--page-all`:开启自动分页,直到没有更多页面为止。
- `--page-all`CLI 固定使用最大 `page_size=100`
执行准则:
- **默认命令模板**`lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty`
- 如果你发现自己执行成了不带 `--page-all` 的单页查询,而响应里又出现 `has_more=true` / `more available` / 非空 `page_token`,应立刻意识到这只是部分结果。
- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --meeting-id <meeting.id> --page-token <returned_page_token> --page-all --format pretty`
- 只有在用户明确要求“就看第一页”“先不要翻页”时,才不要默认带 `--page-all`
- 只要你是基于 `+meeting-events` 来回答一场**正在进行中的会议内容**,就不能直接复用上一次查询结果。无论用户是在问“现在是谁在说话”“刚刚发生了什么”“最新事件有哪些”,还是让你“总结一下这个会议讲什么”,都必须先重新执行一次 `+meeting-events`,确认拿到的是最新事件流,再回答用户。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
### 5. pretty / json 输出差异
- `--format pretty`:输出会议主题、会议时间和逐条时间线,适合快速理解“发生了什么”,也是本 skill 的默认推荐格式。
- `--format json`:保留完整原始 `events[]` 结构——参会人 open_id、聊天原文、share_doc、分页字段都在原始响应里适合提取字段、联动其他命令或做进一步程序处理。
**选型原则**:只要目标是告诉用户“发生了什么”,默认就用 `--page-all --format pretty`;只有在需要完整原始消息流和结构化字段时,才改用 `json`
> **注意**pretty 输出中的正文文本会做单行转义,真实换行会显示为 `\n`,避免打乱时间线布局。
### 6. 内容理解模式:共享文档不能只看标题
当用户意图是:
- “总结这个会议”
- “这个会议讲了什么”
- “有哪些结论 / 待办 / 关键讨论”
- “共享文档里在讲什么”
不要只基于事件时间线直接回答。此时 `+meeting-events` 只是**线索发现器**,不是最终信息源。
执行准则:
- 这类问题默认先用 `lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format json` 拉取最新事件流。
- 如果事件中出现共享文档线索,例如:
- `magic_share_started`
- `share_doc.title`
- `share_doc.url`
- 必须继续读取共享文档内容,再生成总结,不能只根据“开始共享了某文档”这条事件和文档标题来概括会议内容。
- 若存在多个共享文档,优先读取**最近一次共享**的文档。
- 若文档读取失败,必须明确说明“以下总结仅基于会中事件流,未成功读取共享文档内容”。
### 7. 关于 `page_token` 的返回与续拉
- 不管这次是只查 1 页,还是通过 `--page-all` 已经把当前可见事件都拿完,都应把最后拿到的 `page_token` 一并保留下来并返回给用户。
- 只要响应里出现 `has_more=true`、pretty 里出现 `more available`,或返回了非空 `page_token`,就必须先判断当前结果是否完整;默认情况下,这意味着你还需要继续分页。
- 如果没有使用 `--page-all`,但出现了上述分页信号,默认应继续用返回的 `page_token` 拉下一页,而不是直接结束。只有在用户明确不要继续翻页时,才可以停止并明确说明当前结果不完整。
- 下次继续“查新增事件”时,应优先复用上一次保存的 `page_token`,而不是从头全量再拉一次。
- 只有在用户明确要求“从头回放全部事件”时,才忽略历史 `page_token`,重新从第一页开始。
- 但如果用户要你回答的是**当前这场会正在讲什么**,而不是“上一次之后新增了什么”,也要先做一次新的事件查询,再决定是否需要基于旧 `page_token` 继续补拉。
## 返回结构
常见顶层字段:
| 字段 | 说明 |
|------|------|
| `events` | 事件列表 |
| `has_more` | 是否还有下一页 |
| `page_token` | 下一页游标 |
事件 `event_type` 常见类型:
| event_type | 含义 |
|-----------|------|
| `participant_joined` | 有参会人加入会议 |
| `participant_left` | 有参会人离开会议 |
| `chat_received` | 收到会中聊天消息 |
| `transcript_received` | 收到转写文本 |
| `magic_share_started` | 开始共享内容 / 文档 |
| `magic_share_ended` | 结束共享 |
## pretty 输出示例
```text
会议主题:张三的视频会议
会议时间2026-04-17 15:28:52进行中
[00:00:33] 明日之虾BOE(ou_xxx) 加入了会议
[00:00:41] 张三(ou_xxx): [text] 6666
[00:00:44] 张三(ou_xxx) 开始共享《智能纪要飞书20251022-140223 2026年3月9日》
URL: https://...
[00:01:32] 张三(ou_xxx): [reaction] JIAYI
```
## 如何获取输入参数
| 输入参数 | 获取方式 |
|---------|---------|
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+search` 结果中的 `id` |
| `start` / `end` | 用户给出的时间范围;如未给出则默认取全量可见事件 |
| `page-token` | 上一页或上一次查询结果中保存的 `page_token`;建议持久化保存,便于下次继续拉取新增事件 |
## Agent 组合场景
### 场景 1入会后查看会中发生了什么
```bash
# 第 1 步:加入会议,记录返回的 meeting.id
lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:查询事件流
lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty
```
### 场景 2过滤某段时间内的事件
```bash
lark-cli vc +meeting-events \
--meeting-id <meeting.id> \
--start 2026-04-17T15:00:00+08:00 \
--end 2026-04-17T16:00:00+08:00 \
--page-all \
--format pretty
```
### 场景 3基于上一次的 `page_token` 继续查新增事件
```bash
# 上一次查询结束后,保留最后返回的 page_token
# 这次直接从该游标继续拉新增事件
lark-cli vc +meeting-events \
--meeting-id <meeting.id> \
--page-token <last_page_token> \
--page-all \
--format pretty
```
适用规则:
- 当用户说“继续看新事件”“看上次之后新增了什么”时,优先使用上一次保存的 `page_token`
- 如果这次返回里仍有 `has_more=true`、pretty 里出现 `more available`,或又返回了新的 `page_token`,说明新增事件还没拉完,应继续分页,而不是把当前页误当成完整增量结果。
- 只有在用户明确要求“从头回放全部事件”时,才忽略已有 `page_token`,重新从第一页开始。
## 常见错误与排查
| 错误现象 | 根本原因 | 解决方案 |
|---------|---------|---------|
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入长数字 `meeting.id` |
| `10005 bot is not in meeting` | bot 从未真实入会该会议;或会议已结束但 bot 从未在会中出现过 | 先 `+meeting-join --meeting-number <9位号>` 真实入会再查;如果会议已经结束且当时 bot 没进过会,本接口也拉不到数据。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`**(不依赖 bot 身份参会) |
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。若要拿纪要文档或逐字稿 token`lark-cli vc +notes --meeting-ids <meeting.id>`;若要拿 AI 产物summary / todos / chapters或导出逐字稿文件先用 `lark-cli vc +recording --meeting-ids <meeting.id>``minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
| `20002 meeting not exist` | `meeting_id` 错误,或会议实例当前已不可获取(常见于把 9 位会议号当 meeting_id 传) | 确认传入的是长数字 `meeting_id`,不是 9 位会议号 |
| `HTTP 404` / `HTTP 500` | 服务端当前无法找到或处理该会议实例 | 换一个正在进行且 bot 可见的 meeting_id或排查后端问题 |
## 提示
- 这是**会中事件流**查询,不适合拿来搜历史会议记录;搜历史会议请用 `+search`
- 如果会议已经结束,不要卡在 `+meeting-events`
- 想拿纪要文档或逐字稿 token`lark-cli vc +notes --meeting-ids <meeting.id>`
- 想拿 AI 产物summary / todos / chapters或导出逐字稿文件先用 `lark-cli vc +recording --meeting-ids <meeting.id>``minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`
- 事件列表是否完整,取决于 bot 何时入会、何时离会,以及后端当前可见的会中事件范围。对于已结束会议,通常只在**结束后 5 分钟内**、且 bot **曾经在会中**时还能继续拉到事件。
- 查询"谁参加过某会议"请用 `vc meeting get --params '{"meeting_id":"<id>","with_participants":true}'`——这是参会人**快照** API不依赖 bot 是否参会,对已结束会议也可查;**不要** 用 `+meeting-events` 做参会人查询。
## 参考
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 先真实入会
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 完成任务后离会
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域Meeting / Note 等核心概念)
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,151 @@
# vc +meeting-join
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
通过 9 位会议号加入一场正在进行的视频会议bot join。这是一次**写操作**,会实际让当前身份加入会议。
本 skill 对应 shortcut`lark-cli vc +meeting-join`(调用 `POST /open-apis/vc/v1/bots/join`)。
## 命令
```bash
# 仅指定会议号(无密码)
lark-cli vc +meeting-join --meeting-number 123456789
# 指定会议号 + 密码
lark-cli vc +meeting-join --meeting-number 123456789 --password 8888
# 输出格式
lark-cli vc +meeting-join --meeting-number 123456789 --format json
# 预览 API 调用(不实际加入会议)
lark-cli vc +meeting-join --meeting-number 123456789 --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--meeting-number <no>` | 是 | 会议号,必须为 **9 位纯数字** |
| `--password <pw>` | 否 | 会议密码,仅在该会议设置了入会密码时传入 |
| `--format <fmt>` | 否 | 输出格式json (默认) / pretty / table / ndjson / csv |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 核心约束
### 1. 仅支持 user 身份
该命令仅支持 `user` 身份。
### 2. 会议号格式严格校验
`--meeting-number` 必须是 9 位纯数字,否则本地校验直接报错:
`--meeting-number must be exactly 9 digits`
常见错误来源:
- 把会议链接整条粘进来(应仅取尾部的 9 位数字)
-`meeting_id`(长数字 ID当成会议号传入两者不是同一个东西
### 3. 会议必须已开始且允许入会
- 会议必须处于**进行中**状态bot 无法加入尚未开始或已结束的会议。
- 若会议设置了**等候室 / 入会审批**bot 可能需要主持人放行后才真正入会。
- 若返回 `HTTP 403: no permission`(错误码 `121003`),不要只理解成“账号没权限”。这类报错更常见的原因是:会议参数或会控配置当前不满足入会条件,例如会议号填错、密码未传或错误、会议尚未开始、等候室 / 入会审批未放行、会议禁止外部/特定身份加入等。应先确认这些配置项,再重试。
### 4. 机器人入会后对其他参会人可见
这是一次真实入会操作,机器人会立即出现在参会人列表中,其他参会人可见,并产生会议日志。误入错会的社交成本高于技术成本——执行前优先确认 9 位会议号的来源(用户输入 / 会议链接末尾),不要臆造。参数格式有疑问时可用 `--dry-run` 预览请求体。
## 输出结果
接口返回会议基本信息,字段视具体响应而定,常见字段:
| 字段 | 说明 |
|------|------|
| `meeting.id` | 会议 ID可后续传给 `+meeting-leave --meeting-id` |
| `meeting.meeting_no` | 会议号(与入参一致) |
| `meeting.topic` | 会议主题 |
| `meeting.start_time` | 会议开始时间 |
> **重要**:拿到 `meeting.id` 后务必保留,退出会议(`+meeting-leave`)需要使用它,而不是会议号。
## 如何获取输入参数
| 输入参数 | 获取方式 |
|---------|---------|
| `meeting-number` | 会议号由主持人分享;也可从会议链接尾部解析 9 位数字 |
| `password` | 若会议设置了入会密码,由主持人提供 |
## Agent 组合场景
### 场景 1加入会议 → 离开会议(最小闭环)
```bash
# 第 1 步:加入会议,记录返回的 meeting.id
lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:完成任务后,使用上一步返回的 meeting.id 离开会议
lark-cli vc +meeting-leave --meeting-id <meeting.id>
```
### 场景 2同时加入多场会议
可以帮助用户同时加入多场会议,但 `+meeting-join` 是单会议 shortcut一次命令只接收一个 `--meeting-number`,不接受逗号分隔的多个会议号。用户给出多个会议号时,由 agent 按会议号逐个调用 `+meeting-join` 并聚合结果;每成功加入一场,都必须分别记录该场返回的 `meeting.id`,不要把 9 位会议号当作后续 `meeting_id` 使用。
```bash
# 对每个 9 位会议号分别调用一次
lark-cli vc +meeting-join --meeting-number 123456789
lark-cli vc +meeting-join --meeting-number 987654321
# 后续按每场会议自己的 meeting.id 分别查事件和离会
lark-cli vc +meeting-events --meeting-id <meeting.id-1> --page-all --format pretty
lark-cli vc +meeting-events --meeting-id <meeting.id-2> --page-all --format pretty
lark-cli vc +meeting-leave --meeting-id <meeting.id-1>
lark-cli vc +meeting-leave --meeting-id <meeting.id-2>
```
多场会议的事件查询和离会都必须按 `meeting.id` 分开处理:对每场会议分别执行 `+meeting-events`,保留各自的 `page_token`;任务结束时对每场会议分别执行 `+meeting-leave`
### 场景 3加入会议 → 会后拉取纪要 / 录制
```bash
# 第 1 步:加入并参会
lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:离会
lark-cli vc +meeting-leave --meeting-id <meeting.id>
# 第 3 步:会议结束后,查询录制(拿到 minute_token
lark-cli vc +recording --meeting-ids <meeting.id>
# 第 4 步:查询会议纪要(总结 / 待办 / 章节 / 逐字稿)
lark-cli vc +notes --meeting-ids <meeting.id>
```
## 常见错误与排查
| 错误现象 | 根本原因 | 解决方案 |
|---------|---------|---------|
| `--meeting-number must be exactly 9 digits` | 会议号不是 9 位纯数字 | 检查是否误传了会议链接或 meeting_id |
| 会议密码错误 | `--password` 错误或未提供 | 向主持人确认会议密码 |
| 会议不存在 / 已结束 | 会议号错误或会议未进行中 | 确认会议正在进行中 |
| `HTTP 403: no permission` / `121003` | 入会前置条件不满足,通常不是单纯 scope 问题 | 依次确认1会议允许智能体加入2会议号正确3如有密码已正确传入 `--password`4会议已开始5等候室 / 入会审批已放行6会议未禁止当前身份加入如限制外部、限制 bot、仅特定成员可入会确认后重试 |
| 入会被拒绝 | 等候室 / 入会审批 / 限制外部入会 | 联系主持人放行或调整会议设置 |
## 提示
- 仅在 Agent 需要**真实加入**会议(例如参会机器人、会中助手)时使用;只拉取会议数据不需要入会。
- 入会会让机器人立即出现在参会列表;若要回退,直接 `+meeting-leave` 即可。参数格式不确定时可选 `--dry-run` 预览,但不是必经步骤。
- 执行成功后,立即记录返回的 `meeting.id`,用于后续 `+meeting-leave` / `+meeting-events`
## 参考
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 对应的离会命令
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议记录
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域Meeting / Note 等核心概念)
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,111 @@
# vc +meeting-leave
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
通过 `meeting_id` 离开当前身份所在的视频会议bot leave。这是一次**写操作**,会实际把当前身份从会议中移出。
本 skill 对应 shortcut`lark-cli vc +meeting-leave`(调用 `POST /open-apis/vc/v1/bots/leave`)。
## 命令
```bash
# 通过 meeting_id 离会
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28
# 输出格式
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --format json
# 预览 API 调用(不实际离会)
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--meeting-id <id>` | 是 | 会议 ID**不是 9 位会议号** |
| `--format <fmt>` | 否 | 输出格式json (默认) / pretty / table / ndjson / csv |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 核心约束
### 1. 入参是 meeting_id不是会议号
`--meeting-id` 必须是会议的长数字 ID通常由 `+meeting-join` 返回体中的 `meeting.id` 提供,也可从 `+search` 结果中的 `id` 字段获取。**传 9 位会议号会失败**。
### 2. 仅支持 user 身份
该命令仅支持 `user` 身份。只能让当前身份自己离会,无法强制移出其他参会人。
### 3. 当前身份必须在会议中
必须先通过 `+meeting-join` 或其他方式在该会议中,否则接口会报错。
### 4. 离会立即生效,对其他参会人可见
机器人会立刻从参会列表消失;若会议启用了录制/纪要bot 的参会时段到此截止。确认任务完成再调用;如需要重新入会,再跑 `+meeting-join` 即可(非真正"不可逆")。
## 输出结果
接口成功返回时,默认输出:`Left meeting <meeting-id> successfully.`
`--format json` 返回 API 原始响应体。
## 如何获取输入参数
| 输入参数 | 获取方式 |
|---------|---------|
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+search` 结果中的 `id` 字段 |
## Agent 组合场景
### 场景 1加入 → 完成任务 → 离开(最小闭环)
```bash
# 第 1 步:加入会议,记录 meeting.id
lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:在会中完成任务(如监听发言、记录信息等)
# ...
# 第 3 步:使用上一步记录的 meeting.id 离会
lark-cli vc +meeting-leave --meeting-id <meeting.id>
```
### 场景 2会后补拉产物
```bash
# 第 1 步:离会后会议仍在进行或已结束
lark-cli vc +meeting-leave --meeting-id <meeting.id>
# 第 2 步:会议结束后查询录制
lark-cli vc +recording --meeting-ids <meeting.id>
# 第 3 步:查询会议纪要
lark-cli vc +notes --meeting-ids <meeting.id>
```
## 常见错误与排查
| 错误现象 | 根本原因 | 解决方案 |
|---------|---------|---------|
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入从 `+meeting-join` 得到的 `meeting.id` |
| `meeting not found` / `invalid meeting_id` | 误传了 9 位会议号 | 必须使用 `meeting.id`,不是会议号 |
| `not in meeting` | 当前身份并不在该会议中 | 确认先 `+meeting-join` 成功 |
## 提示
- 离会会让机器人从参会列表消失,对其他参会人可见;若需要重新入会直接再 `+meeting-join`,不是真正的"不可逆"。参数格式不确定时可选 `--dry-run` 预览。
-`+meeting-join` 成对使用:能 join 的身份才能 leave。
- `meeting_id` 必须来自 `+meeting-join` 的返回值,不要用 9 位会议号。
## 参考
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 对应的入会命令
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域Meeting / Note 等核心概念)
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -1,7 +1,7 @@
---
name: lark-vc
version: 1.0.0
description: "飞书视频会议:查询会议记录、获取会议纪要产物总结、待办、章节、逐字稿。1. 查询已经结束的会议数量或详情时使用本技能(如历史日期| 昨天 | 上周 | 今天已经开过的会议等场景),查询未开始的会议日程使用 lark-calendar 技能。2. 支持通过关键词、时间范围、组织者、参与者、会议室等筛选条件搜索会议记录。3. 获取或整理会议纪要时使用本技能。"
description: "飞书视频会议:搜索历史会议、查询会议纪要产物(总结、待办、章节、逐字稿)、查询会议参会人快照。1. 查询已经结束的会议数量或详情时使用本技能如历史日期|昨天|上周|今天已经开过的会议等场景,查询未开始的会议日程使用 lark-calendar 技能。2. 支持通过关键词、时间范围、组织者、参与者、会议室等筛选条件搜索会议。3. 获取或整理会议纪要、逐字稿、录制产物时使用本技能。4. 查询“谁参加过某会议”“参会人列表”等参会人快照信息用 vc meeting get --with-participants任意时点可查含已结束会议。注意**Agent 真实入会/离会、感知正在进行中会议的实时事件**请使用 lark-vc-agent 技能,本技能不覆盖写操作和会中事件流。"
metadata:
requires:
bins: ["lark-cli"]
@@ -14,8 +14,7 @@ metadata:
## 核心概念
- **视频会议Meeting**:飞书视频会议实例,通过 meeting\_id 标识。
- **会议记录Meeting Record**:视频会议结束后生成的记录,支持通过关键词、时间段、参会人、组织者、会议室等筛选条件搜索会议室。
- **视频会议Meeting**:飞书视频会议实例,通过 meeting\_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。
- **会议纪要Note**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结、待办、章节)和逐字稿文档。
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写和会议纪要,通过 minute\_token 标识。
- **纪要文档MainDoc**AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`
@@ -67,6 +66,23 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
```
### 4. 查询参会人快照(读操作)
用户问"谁参加过这场会议""这个会议有哪些参会人""某某参会了吗"等**参会人快照**类问题时,使用 **`vc meeting get --with-participants`**:这是参会人服务端快照 API不依赖 bot 身份参会,**已结束会议也可查**
```bash
lark-cli vc meeting get --params '{"meeting_id":"<meeting_id>","with_participants":true}'
```
选型判断表:
| 用户意图 | 推荐命令 | 所在 skill |
|---------|---------|--------|
| 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill |
| 已结束会议的发言内容 | `vc +notes``verbatim_doc_token``docs +fetch` | 本 skill |
| **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `vc +meeting-events` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
| **Agent 真实入会 / 离会** | `vc +meeting-join` / `vc +meeting-leave` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
## 资源关系
```
@@ -109,6 +125,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
- 使用 `+notes` 命令时,必须阅读 [references/lark-vc-notes.md](references/lark-vc-notes.md),了解查询参数、产物类型和返回值结构。
- 使用 `+recording` 命令时,必须阅读 [references/lark-vc-recording.md](references/lark-vc-recording.md),了解查询参数和返回值结构。
> **Agent 参会相关命令已独立**`+meeting-join` / `+meeting-leave` / `+meeting-events` 请使用 [`lark-vc-agent`](../lark-vc-agent/SKILL.md) 技能。
## API Resources
```bash
@@ -146,3 +164,5 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa
| `+recording --calendar-event-ids` | `vc:record:readonly``calendar:calendar:read``calendar:calendar.event:read` |
| `+search` | `vc:meeting.search:read` |
| `meeting.get` | `vc:meeting.meetingevent:read` |
> Agent 参会相关 scope`vc:meeting.bot.join:write` / `vc:meeting.meetingevent:read`)见 [`lark-vc-agent`](../lark-vc-agent/SKILL.md)。

View File

@@ -109,7 +109,7 @@ lark-cli vc +notes --minute-tokens <minute_token>
```bash
# 第 1 步:搜索历史会议,拿到 meeting_ids
lark-cli vc +search --query "周会" --start yesterday
lark-cli vc +search --query "周会" --start 2026-03-10
# 第 2 步:使用上一步返回的 meeting_ids 查询录制,拿到 minute_tokens
lark-cli vc +recording --meeting-ids <ids>

View File

@@ -88,7 +88,17 @@ lark-cli vc +search --query "周会" --format json
当返回 `has_more=true` 时,使用响应中的 `page_token` 配合 `--page-token` 获取下一页结果。
### 5. 日期型 `--end` 包含当天整天
### 5. 机器人可同时加入多个会议
机器人支持同时加入多个正在进行中的会议;加入新会议前,不需要先退出已经在会中的其他会议。
这意味着:
- 不要假设 bot 一次只能在一个会议中
- 如果用户要求 bot 再加入另一场会,可以直接继续执行对应的入会命令
- 只有在用户明确要求结束某一场会中的 bot 参会时,才调用对应的离会命令
### 6. 日期型 `--end` 包含当天整天
`--end` 传入的是仅日期格式(如 `2026-03-10`CLI 会将它解释为当天 `23:59:59`,而不是当天 `00:00:00`

View File

@@ -100,7 +100,6 @@ func TestCalendar_CreateEvent(t *testing.T) {
"calendar_id": calendarID,
"event_id": eventID,
},
Yes: true,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -52,7 +52,6 @@ func TestCalendar_ManageCalendar(t *testing.T) {
"summary": calendarSummary,
"description": calendarDescription,
},
Yes: true,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -116,7 +115,6 @@ func TestCalendar_ManageCalendar(t *testing.T) {
Data: map[string]any{
"summary": updatedCalendarSummary,
},
Yes: true,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -0,0 +1,304 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_FlagWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-flag-" + suffix
messageText := "flag-test-msg-" + suffix
var chatID string
var messageID string
t.Run("create chat as user", func(t *testing.T) {
chatID = createChatAs(t, parentT, ctx, chatName, "user")
})
t.Run("send message as user", func(t *testing.T) {
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
})
t.Run("create flag as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-create",
"--message-id", messageID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("list flags as user", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "10",
"--page-all",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
// Check if our message is in the list
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Verify our flagged message is in the list
var found bool
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
found = true
// Verify it's a message-type flag (flag_type=2)
require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)")
break
}
}
require.True(t, found, "expected message %s in flag list", messageID)
})
t.Run("cancel flag as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-cancel",
"--message-id", messageID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("verify flag removed", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "10",
"--page-all",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
// Check if our message is still in the list
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
return true // Still there, retry
}
}
return false // Not found, success
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Verify our message is NOT in the list
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
require.NotEqual(t, messageID, item.Get("item_id").String(), "message should not be in flag list after cancel")
}
})
}
func TestIM_FlagCreateWithExplicitTypeAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-flag-explicit-" + suffix
messageText := "flag-explicit-msg-" + suffix
var chatID string
var messageID string
t.Run("create chat as user", func(t *testing.T) {
chatID = createChatAs(t, parentT, ctx, chatName, "user")
})
t.Run("send message as user", func(t *testing.T) {
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
})
t.Run("create flag with explicit types as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-create",
"--message-id", messageID,
"--item-type", "default",
"--flag-type", "message",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("list flags to verify explicit types as user", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "10",
"--page-all",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Verify explicit types were applied
var found bool
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
found = true
require.Equal(t, "0", item.Get("item_type").String(), "expected item_type=0 (default)")
require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)")
break
}
}
require.True(t, found, "expected message %s in flag list", messageID)
})
t.Run("cancel flag with explicit types as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-cancel",
"--message-id", messageID,
"--flag-type", "message",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}
func TestIM_FlagListPaginationAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
t.Run("list flags with page-all as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "5",
"--page-all",
"--page-limit", "3",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}
func TestIM_FlagDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "fake_user_token")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
t.Run("create flag dry-run", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-create",
"--message-id", "om_test_dry_run",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags")
require.Contains(t, result.Stdout, "flag_items")
require.Contains(t, result.Stdout, "om_test_dry_run")
})
t.Run("cancel flag dry-run with om", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-cancel",
"--message-id", "om_test_dry_run",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags/cancel")
require.Contains(t, result.Stdout, "flag_items")
require.Contains(t, result.Stdout, "om_test_dry_run")
})
t.Run("list flag dry-run", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "GET")
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags")
require.Contains(t, result.Stdout, "page_size")
})
}

View File

@@ -190,7 +190,6 @@ func TestSheets_SpreadsheetsResource(t *testing.T) {
"title": "lark-cli-e2e-sheets-resource-" + suffix,
"folder_token": folderToken,
},
Yes: true,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -94,7 +94,6 @@ func TestSheets_FilterWorkflow(t *testing.T) {
"sheet_id": sheetID,
},
Data: filterData,
Yes: true,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)