Compare commits

..

5 Commits

Author SHA1 Message Date
luozhixiong
4fb1d96da3 docs(lark-im): reconcile slimmed skill and thinned references with main
Rebased onto main v1.0.54, which added IM content after this branch forked.
Fold main's new facts into the slimmed/gotcha-only form and fix drift:

- SKILL.md: compact the newly-merged chat.user_setting / chat.managers /
  chat.moderation API resources to the api_compact `action`(identity) form
  (consistent with the rest; regen-equivalent), and note the opt-in
  `--download-resources` flag on the three message-pulling shortcuts.
- chat-search: fix stale `--sort-by` -> `--sort`, add `--chat-modes`, and
  fold the "`--sort` is always descending" caution (#1302/#1317).
- chat-messages-list: note `--order` is the only sort axis (no field sort).
- messages-send: split @mention by message type; interactive cards are not
  normalized and need card-native `<at>` syntax (#1419).
- flag-cancel: document best-effort double-cancel (feed layer skipped with a
  stderr warning when chat type is undeterminable).
- feed-group-list-item / query-item: p2p cards omit chat_name; resolve via
  chats/batch_query -> p2p_target_id -> contact lookup.
- message-enrichment: add the `--download-resources` contract (merge_forward
  container-id 234003 trap, fail-silent isolation, no extra scope) (#1245).

Docs only; no Go/--help/Desc changes.
2026-06-16 15:22:33 +08:00
luozhixiong
9218586733 docs(lark-im): thin remaining 21 references to gotcha-only
Roll out the gotcha-only reference policy across all lark-im references
(after the 3-file proof: messages-send/search, chat-search). USAGE detail
(flag tables, enums, examples, return-field dumps) now lives in the
authoritative source — lark-cli --help, or lark-cli schema for reactions —
and is removed from the references. Each file keeps only what --help/schema
cannot express (routing/sequencing, identity constraints + why,
silent-failure traps, cross-command recipes), plus an explicit HELP-GAP
section for behavior the CLI does not yet surface.

feed.groups: the 6 raw write methods have no resolvable --help/schema
(unregistered in the schema registry), so their request/response shapes,
enums and rule guidance are kept in full under a HELP-GAP banner rather
than deleted; the 3 read methods point to their typed-shortcut siblings.

All 24 references: ~53k -> ~22k tokens (159337 B -> 66126 B, -58%).
Docs only; no Go/--help/Desc changes.
2026-06-16 15:08:46 +08:00
luozhixiong
5790c38e37 docs: trim lark-im chat-search/messages-search/messages-send references to gotcha-only
Proof slice for the reference-optimization phase (case CLI_核心评测_015). Delete USAGE that lark-cli --help already covers (param tables, basic examples, mutual-exclusion/path rules), keep only gotcha (--help can't give: markdown boundaries, @mention format, identity routing, visibility scope, error-code remediation), and mark HELP-GAP sections (content JSON shapes, output fields, enums not yet in schema) to delete once CLI enriches schema. The 3 references 015 reads: 8985 -> 2513 token (-72%); verified each deleted USAGE item is present in the command's --help.
2026-06-16 15:08:46 +08:00
luozhixiong
eaecda034b docs: trim lark-im description under 200-token skill gate
Drop the redundant Feed-pin parenthetical from the SKILL.md frontmatter description so it fits the non-waivable <=200-token skill-quality gate (206 -> 194, cl100k_base). Behavior-neutral: only the WHEN-routing copy is shortened; skill body and commands are unchanged. Mirrors the same one-line trim in larksuite-cli-registry skill-meta.yaml (im.description).
2026-06-16 15:08:46 +08:00
luozhixiong
368da695c9 docs: slim and reorder lark-im SKILL.md (4253 -> 2040 tokens)
Regenerate lark-im SKILL.md (4253 -> 2040 tokens, tiktoken cl100k_base, -52%)
via per-domain gen-skills flags. Drop the scope permission table and the
duplicated schema-usage note, compact API Resources to a terse identity index,
render the Shortcuts table as one-line routing, and move Shortcuts ahead of the
concept sections (executable-first). Full detail stays in --help / schema (the
source of truth); Go Desc and --help are unchanged. Add the NOT boundary, a
good/bad sender example, Flag-type rationale, and anti-hallucination guidance
with a safe fallback (guide users to the Feishu client; do not auto-execute
high-risk raw-API writes).

The master template is variabilized with defaults preserving current output, so
other domains regenerate byte-identical (verified).

Eval (skillave, IM, 9 cases x 3 runs, lark-cli-eval-analyze two-table compare):
mean pass_rate 0.939 -> 0.969, duration -22%, tool-calls -26%, zero structural
regression; anti-hallucination case 0.58 -> 1.00.

Depends on larksuite-cli-registry change (skill-meta.yaml per-domain flags +
gen-skills.py); must merge together with (or before) this PR.
2026-06-16 15:08:46 +08:00
80 changed files with 669 additions and 6849 deletions

View File

@@ -2,20 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.55] - 2026-06-16
### Features
- **vc**: Support agent meeting event workflows (#1483)
- **drive**: Support exporting Base structure snapshots (#1481)
- **doc**: Add docx cover resource commands (#1468)
- **doc**: Support `lang` for docx fetch v2 (#1459)
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
### Bug Fixes
- **drive**: Validate drive import folder target (#1485)
## [v1.0.54] - 2026-06-15
### Features
@@ -1189,7 +1175,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52

View File

@@ -8,7 +8,7 @@ import (
"regexp"
)
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.

View File

@@ -4,117 +4,21 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
// Landing-page contract for the scan-to-enable deep link, verified against the
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
// Note the param is camelCase "clientID" (not snake_case), and the value is the
// consuming app's own ID. Centralized so it can be corrected in one place.
const (
addonsLandingPath = "/page/launcher"
addonsClientIDParam = "clientID"
)
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
// Encoded form: JSON -> gzip -> base64url(no padding).
type ManifestAddons struct {
Scopes *AddonsScopes `json:"scopes,omitempty"`
Events *AddonsEvents `json:"events,omitempty"`
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
}
type AddonsScopes struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsEvents struct {
Items AddonsEventItems `json:"items"`
}
type AddonsEventItems struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsCallbacks struct {
Items []string `json:"items"`
}
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
func encodeAddons(a ManifestAddons) (string, error) {
raw, err := json.Marshal(a)
if err != nil {
return "", err
}
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
if _, err := gw.Write(raw); err != nil {
return "", err
}
if err := gw.Close(); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
}
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
encoded, err := encodeAddons(a)
if err != nil {
return "", err
}
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
host, appID, strings.Join(scopes, ","))
}
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand core.LarkBrand, appID string) string {
// consoleEventSubscriptionURL points at the app's event subscription console page.
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
}
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
url, err := consoleAddonsURL(brand, appID, a)
if err != nil {
return consoleLandingURL(brand, appID)
}
return url
}
// missingScopeAddons routes missing scopes into the identity-appropriate section.
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
// the addons spec treats a missing tenant/user as an empty array.
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
if identity.IsBot() {
s.Tenant = missing
} else {
s.User = missing
}
return ManifestAddons{Scopes: s}
}
// missingSubscriptionAddons routes missing events/callbacks into the right section.
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
if subType == eventlib.SubTypeCallback {
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
}
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
if identity.IsBot() {
ev.Items.Tenant = missing
} else {
ev.Items.User = missing
}
return ManifestAddons{Events: ev}
return fmt.Sprintf("%s/app/%s/event", host, appID)
}

View File

@@ -4,109 +4,33 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
t.Helper()
gz, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("base64url decode: %v", err)
}
zr, err := gzip.NewReader(bytes.NewReader(gz))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
raw, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip: %v", err)
}
var a ManifestAddons
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("json: %v", err)
}
return a
}
func TestEncodeAddons_RoundTrip(t *testing.T) {
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
encoded, err := encodeAddons(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
for _, r := range encoded {
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
}
}
out := decodeAddons(t, encoded)
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
t.Errorf("roundtrip mismatch: %+v", out)
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
"im:message:readonly",
"im:message.group_at_msg",
})
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
}
}
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
if err != nil {
t.Fatalf("url: %v", err)
}
host := core.ResolveEndpoints(core.BrandFeishu).Open
prefix := host + "/page/launcher?clientID=cli_x&addons="
if !strings.HasPrefix(url, prefix) {
t.Errorf("url = %q, want prefix %q", url, prefix)
}
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
t.Errorf("decoded callbacks mismatch: %+v", out)
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
}
}
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
}
user := missingScopeAddons(core.AsUser, []string{"im:message"})
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
}
}
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
}
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
}
}
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
// Unused identity sides must encode as [] (not null) so the launcher page's
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
cases := []ManifestAddons{
missingScopeAddons(core.AsBot, []string{"im:message"}),
missingScopeAddons(core.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
}
for i, a := range cases {
raw, err := json.Marshal(a)
if err != nil {
t.Fatalf("case %d marshal: %v", i, err)
}
if bytes.Contains(raw, []byte("null")) {
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
}
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
t.Errorf("unexpected url: %s", got)
}
}

View File

@@ -146,28 +146,14 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
}
// Callback subscriptions live in application/get, not app_versions; fetch the
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
// leave subscribedCallbacks nil so the callback precheck skips.
var subscribedCallbacks []string
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
if cbErr != nil {
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
} else {
subscribedCallbacks = cbs
}
}
pf := &preflightCtx{
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
subscribedCallbacks: subscribedCallbacks,
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
}
if err := preflightEventTypes(pf); err != nil {
return err
@@ -243,9 +229,6 @@ type preflightCtx struct {
identity core.Identity
keyDef *eventlib.KeyDefinition
appVer *appmeta.AppVersion
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
subscribedCallbacks []string
}
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
@@ -283,66 +266,46 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
// the tenant token carries them. User: the scan link only updates the app
// manifest — the user's own token still lacks the scopes until it is
// re-authorized — so direct the user to re-login instead.
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
if identity.IsBot() {
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
return fmt.Sprintf(
"grant these scopes and publish a new app version at: %s",
consoleScopeGrantURL(brand, appID, missing),
)
}
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(missing, " "))
strings.Join(missing, " "),
)
}
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
// in the app's console 底账 — published app_versions for event subscriptions,
// application/get subscribed_callbacks for callback subscriptions.
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
func preflightEventTypes(pf *preflightCtx) error {
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
return nil
}
var subscribed []string
noun := "event types"
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
if pf.subscribedCallbacks == nil {
return nil
}
subscribed = pf.subscribedCallbacks
noun = "callbacks"
} else {
if pf.appVer == nil {
return nil
}
subscribed = pf.appVer.EventTypes
}
have := make(map[string]bool, len(subscribed))
for _, t := range subscribed {
have[t] = true
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
for _, t := range pf.appVer.EventTypes {
subscribed[t] = true
}
var missing []string
for _, t := range pf.keyDef.RequiredConsoleEvents {
if !have[t] {
if !subscribed[t] {
missing = append(missing, t)
}
}
if len(missing) == 0 {
return nil
}
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"EventKey %s requires %s not subscribed in console: %s",
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
WithHint("subscribe these %s by scanning: %s", noun, url)
"EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")).
WithHint("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID))
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).

View File

@@ -97,9 +97,9 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
}
}
@@ -157,8 +157,9 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
}
hint := permErr.Hint
wantSubstrings := []string{
"grant these scopes by scanning: ",
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",
"token_type=tenant",
}
for _, want := range wantSubstrings {
if !strings.Contains(hint, want) {
@@ -173,109 +174,3 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback")
}
if !strings.Contains(err.Error(), "callbacks not subscribed") {
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %v, want validation/failed_precondition", p)
}
}
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("expected skip (nil), got %v", err)
}
}
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
// console state: a required callback IS missing and must be reported,
// not skipped as a weak dependency.
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{}, // fetched, none subscribed
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback when none are subscribed")
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
}
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
}
}
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
// bot: scan-to-enable link (adds scopes to app manifest)
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
t.Errorf("bot hint should give the scan link, got: %s", bot)
}
// user: re-login (scan link cannot grant scopes to the user's own token)
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
if !strings.Contains(user, "auth login --scope") {
t.Errorf("user hint should direct to auth login, got: %s", user)
}
if strings.Contains(user, "/page/launcher") {
t.Errorf("user hint must NOT use the scan link, got: %s", user)
}
}

View File

@@ -1,47 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"fmt"
)
// FetchSubscribedCallbacks returns the app's currently subscribed callback names
// from application/get. On a successful fetch it always returns a non-nil slice
// (empty when callback_info is absent or lists no callbacks) so callers can
// distinguish "fetched, zero callbacks subscribed" — a definitive console state
// that must fail the precheck — from a fetch error (nil), which is a
// weak-dependency skip. Identity must be bot: the endpoint is app-level.
func FetchSubscribedCallbacks(ctx context.Context, client APIClient, appID string) ([]string, error) {
path := fmt.Sprintf("/open-apis/application/v6/applications/%s?lang=zh_cn", appID)
raw, err := client.CallAPI(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var envelope struct {
Data struct {
App struct {
CallbackInfo *struct {
SubscribedCallbacks []string `json:"subscribed_callbacks"`
} `json:"callback_info"`
} `json:"app"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decode application response: %w", err)
}
// callback_info also carries callback_type (e.g. "websocket"); it is
// intentionally not parsed or validated. Feishu open-platform callbacks are
// delivered over WebSocket only (confirmed), matching the CLI's WebSocket
// event source, so subscribed_callbacks alone is sufficient for the precheck.
// Revisit and validate callback_type if non-WebSocket delivery ever appears.
callbacks := []string{}
if ci := envelope.Data.App.CallbackInfo; ci != nil {
callbacks = append(callbacks, ci.SubscribedCallbacks...)
}
return callbacks, nil
}

View File

@@ -1,101 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"errors"
"testing"
)
var errFakeFetch = errors.New("fake fetch error")
type fakeCallbackClient struct {
raw string
err error
}
func (f fakeCallbackClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if f.err != nil {
return nil, f.err
}
return json.RawMessage(f.raw), nil
}
func TestFetchSubscribedCallbacks_ParsesList(t *testing.T) {
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket","subscribed_callbacks":["card.action.trigger","profile.view.get"]}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
want := []string{"card.action.trigger", "profile.view.get"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestFetchSubscribedCallbacks_NoCallbackInfo(t *testing.T) {
// A successful fetch with no callback_info means "zero callbacks subscribed",
// which must be a non-nil empty slice (distinct from a fetch error's nil) so
// the precheck reports a required callback as missing instead of skipping.
raw := `{"code":0,"data":{"app":{"app_id":"cli_x"}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_FetchError(t *testing.T) {
// A fetch error must return nil so the caller treats it as a weak-dependency skip.
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{err: errFakeFetch}, "cli_x")
if err == nil {
t.Fatal("expected error")
}
if got != nil {
t.Errorf("got %v, want nil on fetch error", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButNull(t *testing.T) {
// callback_info present but subscribed_callbacks explicitly null → must be
// a non-nil empty slice so the precheck reports missing callbacks.
raw := `{"code":0,"data":{"app":{"callback_info":{"subscribed_callbacks":null}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is null")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButOmitted(t *testing.T) {
// callback_info present but subscribed_callbacks omitted → same as null: non-nil empty.
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket"}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is omitted")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}

View File

@@ -265,8 +265,8 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
AppID: app.AppId,
AppSecret: secret,
Brand: app.Brand,
Lang: app.Lang,
DefaultAs: app.DefaultAs,
Lang: app.Lang,
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId

View File

@@ -132,27 +132,6 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
}
}
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: PlainSecret("my-secret"),
Brand: BrandFeishu,
Lang: "en",
},
},
}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Lang != "en" {
t.Errorf("Lang = %q, want %q", cfg.Lang, "en")
}
}
func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) {
// Keychain ref matches appId, so validation passes.
// The subsequent ResolveSecretInput will fail (no real keychain),

View File

@@ -269,26 +269,8 @@ func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.H
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
bc.SetLogger(b.logger)
// SingleConsumer EventKeys allow only one consumer per SubscriptionID: reject extras at handshake.
exclusive := false
if def, ok := event.Lookup(hello.EventKey); ok {
exclusive = def.SingleConsumer
}
var firstForKey bool
if exclusive {
ok, reason := b.hub.TryRegisterExclusive(bc)
if !ok {
if err := bc.writeFrame(protocol.NewHelloAckRejected("v1", reason)); err != nil {
b.logger.Printf("WARN: reject hello_ack write to pid=%d key=%q failed: %v", hello.PID, hello.EventKey, err)
}
bc.Close()
return
}
firstForKey = true
} else {
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
firstForKey = b.hub.RegisterAndIsFirst(bc)
}
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
firstForKey := b.hub.RegisterAndIsFirst(bc)
bc.SetCheckLastForKey(func(scope string) bool {
return b.hub.AcquireCleanupLock(scope)

View File

@@ -5,15 +5,12 @@ package bus
import (
"bufio"
"bytes"
"io"
"log"
"net"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
)
@@ -197,60 +194,3 @@ func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
t.Fatal("HelloAck was empty")
}
}
// TestHandleHello_SingleConsumerRejectsSecond: a SingleConsumer EventKey accepts
// the first consumer and rejects the second for the same SubscriptionID.
func TestHandleHello_SingleConsumerRejectsSecond(t *testing.T) {
const key = "test.handlehello.exclusive"
event.RegisterKey(event.KeyDefinition{
Key: key,
EventType: key,
SingleConsumer: true,
Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
})
defer event.UnregisterKeyForTest(key)
logger := log.New(io.Discard, "", 0)
hub := NewHub()
b := &Bus{
hub: hub,
logger: logger,
conns: make(map[*Conn]struct{}),
idleTimer: time.NewTimer(30 * time.Second),
shutdownCh: make(chan struct{}, 1),
}
readAck := func(t *testing.T, pid int) *protocol.HelloAck {
t.Helper()
server, client := net.Pipe()
t.Cleanup(func() { server.Close(); client.Close() })
hello := &protocol.Hello{PID: pid, EventKey: key, EventTypes: []string{key}}
go b.handleHello(server, bufio.NewReader(server), hello)
line, err := protocol.ReadFrame(bufio.NewReader(client))
if err != nil {
t.Fatalf("read ack (pid %d): %v", pid, err)
}
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
t.Fatalf("decode ack (pid %d): %v", pid, err)
}
ack, ok := msg.(*protocol.HelloAck)
if !ok {
t.Fatalf("got %T, want *HelloAck", msg)
}
return ack
}
ack1 := readAck(t, 100)
if ack1.Rejected {
t.Fatalf("first consumer should be accepted, got rejected: %q", ack1.RejectReason)
}
ack2 := readAck(t, 200)
if !ack2.Rejected {
t.Fatal("second consumer should be rejected")
}
if !strings.Contains(ack2.RejectReason, "already running") {
t.Errorf("reject reason = %q, want mention of 'already running'", ack2.RejectReason)
}
}

View File

@@ -6,34 +6,13 @@ package bus
import (
"fmt"
"log"
"os"
"sync"
"sync/atomic"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
)
// exclusiveCleanupWaitTimeout bounds how long TryRegisterExclusive waits for an
// in-progress cleanup of the same subscription before rejecting, so a stuck
// cleanup can never wedge new consumers forever. Kept below the consumer's
// hello_ack deadline (consume.helloAckTimeout = 5s) so the reject still reaches
// the consumer as a clean failed_precondition instead of a handshake timeout.
// Override with LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT (a Go duration such as
// "2s"); values at or above the 5s handshake deadline are not recommended.
var exclusiveCleanupWaitTimeout = resolveExclusiveCleanupWaitTimeout()
func resolveExclusiveCleanupWaitTimeout() time.Duration {
const def = 3 * time.Second
if v := os.Getenv("LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
return def
}
// Subscriber is the interface a connection must satisfy for Hub registration.
type Subscriber interface {
EventKey() string
@@ -145,63 +124,6 @@ func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
}
}
// TryRegisterExclusive registers s only when no subscriber holds s.SubscriptionID()
// and any in-progress cleanup for that subscription finishes within
// exclusiveCleanupWaitTimeout. On failure it returns (false, reason): either a
// duplicate consumer already holds the subscription, or the cleanup did not
// finish in time — the timeout guarantees a stuck cleanup can never wedge new
// consumers forever. reason is "" on success. Mirrors RegisterAndIsFirst's wait
// on in-progress cleanup, but bounded.
func (h *Hub) TryRegisterExclusive(s Subscriber) (bool, string) {
sid := s.SubscriptionID()
deadline := time.Now().Add(exclusiveCleanupWaitTimeout)
for {
h.mu.Lock()
ch, locked := h.cleanupInProgress[sid]
if locked {
h.mu.Unlock()
remaining := time.Until(deadline)
if remaining <= 0 {
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
}
timer := time.NewTimer(remaining)
select {
case <-ch:
// Stop+drain so a timer that fired concurrently with Stop isn't left on .C.
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
continue
case <-timer.C:
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
}
}
if h.subCounts[sid] != 0 {
pid := h.existingPIDForSubscriptionLocked(sid)
h.mu.Unlock()
return false, fmt.Sprintf("another consumer (pid %d) is already running for this subscription", pid)
}
h.subscribers[s] = struct{}{}
h.subCounts[sid]++
h.mu.Unlock()
return true, ""
}
}
// existingPIDForSubscriptionLocked returns the PID of one subscriber for sid.
// Caller must hold h.mu.
func (h *Hub) existingPIDForSubscriptionLocked(sid string) int {
for sub := range h.subscribers {
if sub.SubscriptionID() == sid {
return sub.PID()
}
}
return 0
}
// Publish fans out a RawEvent to all matching subscribers (non-blocking).
//
// A fresh *protocol.Event is allocated per subscriber so each consumer sees

View File

@@ -6,7 +6,6 @@ package bus
import (
"encoding/json"
"net"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -356,69 +355,3 @@ func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
}
}
func TestHub_TryRegisterExclusive(t *testing.T) {
h := NewHub()
first := newTestConn("k.exclusive", []string{"k.exclusive"})
first.pid = 100
ok, _ := h.TryRegisterExclusive(first)
if !ok {
t.Fatal("first exclusive register should succeed")
}
second := newTestConn("k.exclusive", []string{"k.exclusive"})
second.pid = 200
ok, reason := h.TryRegisterExclusive(second)
if ok {
t.Error("second exclusive register should be rejected")
}
if !strings.Contains(reason, "pid 100") {
t.Errorf("reject reason = %q, want it to name existing pid 100", reason)
}
if got := h.SubCount("k.exclusive"); got != 1 {
t.Errorf("SubCount = %d, want 1 (second not registered)", got)
}
}
func TestHub_TryRegisterExclusive_CleanupWaitTimeout(t *testing.T) {
// A cleanup lock that never releases must not wedge a new exclusive consumer
// forever — TryRegisterExclusive bounds the wait and rejects with a timeout reason.
saved := exclusiveCleanupWaitTimeout
exclusiveCleanupWaitTimeout = 20 * time.Millisecond
defer func() { exclusiveCleanupWaitTimeout = saved }()
h := NewHub()
first := newTestConn("k.timeout", []string{"k.timeout"})
if ok, _ := h.TryRegisterExclusive(first); !ok {
t.Fatal("first exclusive register should succeed")
}
// Hold the cleanup lock and never release it.
if !h.AcquireCleanupLock("k.timeout") {
t.Fatal("AcquireCleanupLock should succeed for the sole subscriber")
}
start := time.Now()
second := newTestConn("k.timeout", []string{"k.timeout"})
ok, reason := h.TryRegisterExclusive(second)
if ok {
t.Error("second exclusive register should be rejected on cleanup-wait timeout")
}
if !strings.Contains(reason, "timed out") {
t.Errorf("reject reason = %q, want a timeout reason", reason)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("wait took %v, want bounded by the ~20ms timeout (no deadlock)", elapsed)
}
}
func TestHub_TryRegisterExclusive_DistinctSubscriptions(t *testing.T) {
h := NewHub()
a := newTestConn("k.a", []string{"k.a"})
b := newTestConn("k.b", []string{"k.b"})
if ok, _ := h.TryRegisterExclusive(a); !ok {
t.Fatal("register a failed")
}
if ok, _ := h.TryRegisterExclusive(b); !ok {
t.Error("distinct subscription b should register")
}
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/transport"
)
@@ -103,9 +102,6 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus handshake failed: %s", err).WithCause(err)
}
if rejErr := rejectionError(ack, opts.EventKey); rejErr != nil {
return rejErr
}
var cleanup func() error
if ack.FirstForKey && keyDef.PreConsume != nil {
@@ -175,17 +171,6 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
}
// rejectionError converts a rejected hello_ack into a structured precondition
// error; returns nil when the ack is absent or not a rejection.
func rejectionError(ack *protocol.HelloAck, eventKey string) error {
if ack == nil || !ack.Rejected {
return nil
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"cannot start consumer: %s", ack.RejectReason).
WithHint("EventKey %s allows only one consumer; run `lark-cli event status` to find the running one, then stop it before retrying", eventKey)
}
func truncateDuration(d time.Duration) time.Duration {
return d.Truncate(time.Second)
}

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event/protocol"
)
func TestRejectionError_Rejected(t *testing.T) {
ack := &protocol.HelloAck{Type: protocol.MsgTypeHelloAck, Rejected: true, RejectReason: "another consumer (pid 9) is already running"}
err := rejectionError(ack, "im.message.receive_v1")
if err == nil {
t.Fatal("expected error for rejected ack")
}
prob, ok := errs.ProblemOf(err)
if !ok || prob.Category != errs.CategoryValidation || prob.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %v, want validation/failed_precondition; err=%q", prob, err.Error())
}
if !strings.Contains(err.Error(), "already running") {
t.Errorf("error = %q, want reject reason", err.Error())
}
}
func TestRejectionError_NotRejected(t *testing.T) {
if err := rejectionError(&protocol.HelloAck{Type: protocol.MsgTypeHelloAck}, "k"); err != nil {
t.Errorf("expected nil for non-rejected ack, got %v", err)
}
if err := rejectionError(nil, "k"); err != nil {
t.Errorf("expected nil for nil ack, got %v", err)
}
}

View File

@@ -43,11 +43,9 @@ type Hello struct {
}
type HelloAck struct {
Type string `json:"type"`
BusVersion string `json:"bus_version"`
FirstForKey bool `json:"first_for_key"`
Rejected bool `json:"rejected,omitempty"`
RejectReason string `json:"reject_reason,omitempty"`
Type string `json:"type"`
BusVersion string `json:"bus_version"`
FirstForKey bool `json:"first_for_key"`
}
// Event: Seq is per-conn monotonic; gaps signal bus drop-oldest backpressure loss.
@@ -119,17 +117,6 @@ func NewHelloAck(busVersion string, firstForKey bool) *HelloAck {
}
}
// NewHelloAckRejected builds a hello_ack that tells the consumer the bus refused
// registration (e.g. a SingleConsumer EventKey already has a running consumer).
func NewHelloAckRejected(busVersion, reason string) *HelloAck {
return &HelloAck{
Type: MsgTypeHelloAck,
BusVersion: busVersion,
Rejected: true,
RejectReason: reason,
}
}
func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.RawMessage) *Event {
return &Event{
Type: MsgTypeEvent,

View File

@@ -105,25 +105,3 @@ func TestReadFrame_PropagatesEOF(t *testing.T) {
t.Errorf("err = %v, want io.EOF", err)
}
}
func TestHelloAckRejected_RoundTrip(t *testing.T) {
ack := NewHelloAckRejected("v1", "another consumer (pid 42) is already running for this subscription")
if !ack.Rejected || ack.RejectReason == "" {
t.Fatalf("NewHelloAckRejected fields: %+v", ack)
}
var buf bytes.Buffer
if err := Encode(&buf, ack); err != nil {
t.Fatalf("encode: %v", err)
}
msg, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
if err != nil {
t.Fatalf("decode: %v", err)
}
got, ok := msg.(*HelloAck)
if !ok {
t.Fatalf("decoded type = %T, want *HelloAck", msg)
}
if !got.Rejected || got.RejectReason != ack.RejectReason {
t.Errorf("roundtrip = %+v, want Rejected with reason", got)
}
}

View File

@@ -26,14 +26,6 @@ func RegisterKey(def KeyDefinition) {
panic(fmt.Sprintf("EventKey %s: EventType must not be empty", def.Key))
}
if def.SubscriptionType == "" {
def.SubscriptionType = SubTypeEvent
}
if def.SubscriptionType != SubTypeEvent && def.SubscriptionType != SubTypeCallback {
panic(fmt.Sprintf("EventKey %s: SubscriptionType must be %q or %q; got %q",
def.Key, SubTypeEvent, SubTypeCallback, def.SubscriptionType))
}
validateSchema(def)
validateParams(def)
validateAuth(def)

View File

@@ -244,58 +244,3 @@ func TestBufferSize_Clamped(t *testing.T) {
t.Errorf("BufferSize = %d, want %d", def.BufferSize, MaxBufferSize)
}
}
func TestRegisterKey_SubscriptionTypeDefaultsToEvent(t *testing.T) {
const key = "test.subtype.default"
RegisterKey(KeyDefinition{
Key: key,
EventType: key,
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
})
defer UnregisterKeyForTest(key)
def, ok := Lookup(key)
if !ok {
t.Fatalf("Lookup(%q) failed", key)
}
if def.SubscriptionType != SubTypeEvent {
t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeEvent)
}
if def.SingleConsumer {
t.Errorf("SingleConsumer = true, want false (default)")
}
}
func TestRegisterKey_SubscriptionTypeCallbackPreserved(t *testing.T) {
const key = "test.subtype.callback"
RegisterKey(KeyDefinition{
Key: key,
EventType: key,
SubscriptionType: SubTypeCallback,
SingleConsumer: true,
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
})
defer UnregisterKeyForTest(key)
def, _ := Lookup(key)
if def.SubscriptionType != SubTypeCallback {
t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeCallback)
}
if !def.SingleConsumer {
t.Errorf("SingleConsumer = false, want true")
}
}
func TestRegisterKey_InvalidSubscriptionTypePanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Errorf("expected panic for invalid SubscriptionType")
}
}()
RegisterKey(KeyDefinition{
Key: "test.subtype.bogus",
EventType: "test.subtype.bogus",
SubscriptionType: "bogus",
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
})
}

View File

@@ -42,18 +42,6 @@ const (
ParamInt ParamType = "int"
)
// SubscriptionType marks whether an EventKey is delivered via Lark event
// subscription or interactive callback subscription. It is a sibling of
// EventType (which holds the concrete Lark event_type string).
type SubscriptionType string
const (
// SubTypeEvent: checked against the published app_versions event_infos.
SubTypeEvent SubscriptionType = "event"
// SubTypeCallback: checked against application/get subscribed_callbacks.
SubTypeCallback SubscriptionType = "callback"
)
// ParamValue.Desc is mandatory so AI consumers can decide which value to pick.
type ParamValue struct {
Value string `json:"value"`
@@ -108,10 +96,6 @@ type KeyDefinition struct {
Description string `json:"description,omitempty"`
EventType string `json:"event_type"`
// SubscriptionType selects which console "底账" the precheck reads.
// Empty is normalized to SubTypeEvent at RegisterKey.
SubscriptionType SubscriptionType `json:"subscription_type,omitempty"`
Params []ParamDef `json:"params,omitempty"`
Schema SchemaDef `json:"schema"`
@@ -164,8 +148,4 @@ type KeyDefinition struct {
BufferSize int `json:"buffer_size,omitempty"`
Workers int `json:"workers,omitempty"`
// SingleConsumer rejects a second consumer for the same SubscriptionID at
// the bus handshake. Default false = unlimited consumers (fan-out).
SingleConsumer bool `json:"single_consumer,omitempty"`
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.55",
"version": "1.0.54",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -1,795 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"fmt"
"io"
"math"
"mime"
"net"
"net/http"
"net/url"
"path"
"path/filepath"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
docCoverResourceType = "cover"
docCoverUploadParent = "docx_image"
docCoverURLMaxBytes = int64(20 * 1024 * 1024)
docCoverDownloadName = "cover"
docCoverURLDownloadName = "cover"
)
type docCoverHTTPStatusCause int
func (c docCoverHTTPStatusCause) Error() string {
return http.StatusText(int(c))
}
type docCoverURLGuardError string
func (e docCoverURLGuardError) Error() string {
return string(e)
}
var docCoverAllowedContentTypes = map[string]string{
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
var DocResourceDownload = common.Shortcut{
Service: "docs",
Command: "resource-download",
Description: "Download a document resource (type=cover downloads the cover image content)",
Risk: "read",
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or document_id", Required: true},
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
{Name: "output", Desc: "local save path", Required: true},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: validateDocCoverType,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
docRef, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
documentID := docRef.Token
d := common.NewDryRunAPI()
if docRef.Kind == "wiki" {
documentID = "<resolved_docx_token>"
d.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to docx document").
Params(map[string]interface{}{"token": docRef.Token})
}
d.GET("/open-apis/docx/v1/documents/:document_id").
Desc("Read document cover metadata").
Set("document_id", documentID)
d.GET("/open-apis/drive/v1/medias/:cover_token/download").
Desc("Download cover image content").
Set("cover_token", "<cover.token>").
Set("output", runtime.Str("output"))
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
if err != nil {
return err
}
outputPath := runtime.Str("output")
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
cover, err := getDocCover(runtime, documentID)
if err != nil {
return err
}
if cover.Token == "" {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "document has no cover (cover is empty): %s", common.MaskToken(documentID)).WithParam("--type")
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading cover: %s\n", common.MaskToken(cover.Token))
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", validate.EncodePathSegment(cover.Token)),
})
if err != nil {
return wrapDocNetworkErr(err, "download cover failed: %v", err)
}
defer resp.Body.Close()
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, "")
if finalPath != outputPath {
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
}
if !runtime.Bool("overwrite") {
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
}
}
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorTyped(err)
}
savedPath, _ := runtime.ResolveSavePath(finalPath)
if savedPath == "" {
savedPath = finalPath
}
runtime.Out(map[string]interface{}{
"document_id": documentID,
"type": docCoverResourceType,
"saved_path": savedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
"cover": cover.toOutput(),
}, nil)
return nil
},
}
var DocResourceUpdate = common.Shortcut{
Service: "docs",
Command: "resource-update",
Description: "Upload and update a document resource (type=cover)",
Risk: "write",
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or document_id", Required: true},
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
{Name: "file", Desc: "local image file path (files > 20MB use multipart upload automatically)"},
{Name: "from-clipboard", Type: "bool", Desc: "read image from system clipboard instead of a local file"},
{Name: "url", Desc: "HTTPS image URL to download and upload"},
{Name: "offset-ratio-x", Type: "float64", Desc: "cover horizontal offset ratio"},
{Name: "offset-ratio-y", Type: "float64", Desc: "cover vertical offset ratio"},
},
Validate: validateDocCoverUpdate,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
docRef, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
documentID := docRef.Token
d := common.NewDryRunAPI()
if docRef.Kind == "wiki" {
documentID = "<resolved_docx_token>"
d.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to docx document").
Params(map[string]interface{}{"token": docRef.Token})
}
source := docCoverDryRunSource(runtime)
d.Desc("upload cover image and update document cover").
POST("/open-apis/drive/v1/medias/upload_all").
Desc("Upload cover image").
Body(map[string]interface{}{
"file": source,
"file_name": "<cover_file_name>",
"parent_type": docCoverUploadParent,
"parent_node": documentID,
"extra": fmt.Sprintf(`{"drive_route_token":"%s"}`, documentID),
})
d.PATCH("/open-apis/docx/v1/documents/:document_id").
Desc("Update document cover").
Body(map[string]interface{}{"update_cover": map[string]interface{}{"cover": buildDocCoverUpdateBody("<file_token>", runtime)}})
d.Set("document_id", documentID)
if runtime.Str("url") != "" {
d.Set("url_safety", "HTTPS only; private/loopback/link-local IPs rejected; max 3 redirects; image content-types only; max 20MiB")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
if err != nil {
return err
}
source, err := readDocCoverUpdateSource(ctx, runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Uploading cover image: %s (%d bytes)\n", source.FileName, source.FileSize)
if source.FileSize > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
uploadCfg := UploadDocMediaFileConfig{
FilePath: source.FilePath,
Reader: source.Reader,
FileName: source.FileName,
FileSize: source.FileSize,
ParentType: docCoverUploadParent,
ParentNode: documentID,
DocID: documentID,
}
fileToken, err := uploadDocMediaFile(runtime, uploadCfg)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "File uploaded: %s\n", common.MaskToken(fileToken))
coverBody := buildDocCoverUpdateBody(fileToken, runtime)
if _, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
nil,
map[string]interface{}{"update_cover": map[string]interface{}{"cover": coverBody}},
); err != nil {
return err
}
runtime.Out(map[string]interface{}{
"document_id": documentID,
"type": docCoverResourceType,
"updated": true,
"source": source.Kind,
"file_token": fileToken,
"cover": coverBody,
}, nil)
return nil
},
}
var DocResourceDelete = common.Shortcut{
Service: "docs",
Command: "resource-delete",
Description: "Delete a document resource (type=cover is idempotent when empty)",
Risk: "write",
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or document_id", Required: true},
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
},
Validate: validateDocCoverType,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
docRef, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
documentID := docRef.Token
d := common.NewDryRunAPI()
if docRef.Kind == "wiki" {
documentID = "<resolved_docx_token>"
d.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to docx document").
Params(map[string]interface{}{"token": docRef.Token})
}
d.GET("/open-apis/docx/v1/documents/:document_id").
Desc("Read document cover metadata for idempotency").
Set("document_id", documentID)
d.PATCH("/open-apis/docx/v1/documents/:document_id").
Desc("Clear document cover when one exists").
Body(map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}})
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
if err != nil {
return err
}
cover, err := getDocCover(runtime, documentID)
if err != nil {
return err
}
if cover.Token == "" {
runtime.Out(map[string]interface{}{
"document_id": documentID,
"type": docCoverResourceType,
"deleted": false,
"already_empty": true,
}, nil)
return nil
}
if _, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
nil,
map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}},
); err != nil {
return err
}
runtime.Out(map[string]interface{}{
"document_id": documentID,
"type": docCoverResourceType,
"deleted": true,
"already_empty": false,
"previous_cover": cover.toOutput(),
}, nil)
return nil
},
}
type docCoverMetadata struct {
Token string
OffsetRatioX *float64
OffsetRatioY *float64
}
func (c docCoverMetadata) toOutput() map[string]interface{} {
out := map[string]interface{}{"token": c.Token}
if c.OffsetRatioX != nil {
out["offset_ratio_x"] = *c.OffsetRatioX
}
if c.OffsetRatioY != nil {
out["offset_ratio_y"] = *c.OffsetRatioY
}
return out
}
type docCoverUpdateSource struct {
Kind string
FilePath string
Reader io.Reader
FileName string
FileSize int64
}
func validateDocCoverType(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("type") != docCoverResourceType {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --type %q, expected cover", runtime.Str("type")).WithParam("--type")
}
docRef, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return err
}
if docRef.Kind == "doc" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
}
return nil
}
func validateDocCoverUpdate(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateDocCoverType(ctx, runtime); err != nil {
return err
}
sourceCount := 0
var params []errs.InvalidParam
if runtime.Str("file") != "" {
sourceCount++
params = append(params, errs.InvalidParam{Name: "--file", Reason: "source flag"})
}
if runtime.Bool("from-clipboard") {
sourceCount++
params = append(params, errs.InvalidParam{Name: "--from-clipboard", Reason: "source flag"})
}
if runtime.Str("url") != "" {
sourceCount++
params = append(params, errs.InvalidParam{Name: "--url", Reason: "source flag"})
}
if sourceCount == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file, --from-clipboard or --url is required").WithParams(
errs.InvalidParam{Name: "--file", Reason: "provide one source"},
errs.InvalidParam{Name: "--from-clipboard", Reason: "provide one source"},
errs.InvalidParam{Name: "--url", Reason: "provide one source"},
)
}
if sourceCount > 1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file, --from-clipboard and --url are mutually exclusive").WithParams(params...)
}
if err := validateCoverOffset(runtime, "offset-ratio-x"); err != nil {
return err
}
if err := validateCoverOffset(runtime, "offset-ratio-y"); err != nil {
return err
}
if rawURL := runtime.Str("url"); rawURL != "" {
if _, err := parseDocCoverURLSyntax(rawURL); err != nil {
return err
}
}
return nil
}
func validateCoverOffset(runtime *common.RuntimeContext, name string) error {
if !runtime.Changed(name) {
return nil
}
value := runtime.Float64(name)
if math.IsNaN(value) || math.IsInf(value, 0) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a finite number", name).WithParam("--" + name)
}
return nil
}
func resolveDocxDocumentIDForResource(runtime *common.RuntimeContext, input string) (string, error) {
docRef, err := parseDocumentRef(input)
if err != nil {
return "", err
}
switch docRef.Kind {
case "docx":
return docRef.Token, nil
case "wiki":
return resolveDocxDocumentID(runtime, input)
case "doc":
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents").WithParam("--doc")
}
}
func getDocCover(runtime *common.RuntimeContext, documentID string) (docCoverMetadata, error) {
data, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
nil, nil)
if err != nil {
return docCoverMetadata{}, err
}
coverData := common.GetMap(data, "document", "cover")
if len(coverData) == 0 {
coverData = common.GetMap(data, "cover")
}
cover := docCoverMetadata{Token: common.GetString(coverData, "token")}
if value, ok := getOptionalFloat(coverData, "offset_ratio_x"); ok {
cover.OffsetRatioX = &value
}
if value, ok := getOptionalFloat(coverData, "offset_ratio_y"); ok {
cover.OffsetRatioY = &value
}
return cover, nil
}
func getOptionalFloat(m map[string]interface{}, key string) (float64, bool) {
if m == nil {
return 0, false
}
switch v := m[key].(type) {
case float64:
return v, true
case int:
return float64(v), true
case int64:
return float64(v), true
}
return 0, false
}
func readDocCoverUpdateSource(ctx context.Context, runtime *common.RuntimeContext) (docCoverUpdateSource, error) {
if runtime.Bool("from-clipboard") {
fmt.Fprintf(runtime.IO().ErrOut, "Reading image from clipboard...\n")
content, err := readClipboardImage()
if err != nil {
return docCoverUpdateSource{}, err
}
return docCoverUpdateSource{
Kind: "clipboard",
Reader: bytes.NewReader(content),
FileName: "clipboard.png",
FileSize: int64(len(content)),
}, nil
}
if rawURL := runtime.Str("url"); rawURL != "" {
content, fileName, err := downloadDocCoverURL(ctx, runtime, rawURL)
if err != nil {
return docCoverUpdateSource{}, err
}
return docCoverUpdateSource{
Kind: "url",
Reader: bytes.NewReader(content),
FileName: fileName,
FileSize: int64(len(content)),
}, nil
}
filePath := runtime.Str("file")
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return docCoverUpdateSource{}, wrapDocInputFileErr(err, "file not found")
}
if !stat.Mode().IsRegular() {
return docCoverUpdateSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
return docCoverUpdateSource{
Kind: "file",
FilePath: filePath,
FileName: filepath.Base(filePath),
FileSize: stat.Size(),
}, nil
}
func buildDocCoverUpdateBody(fileToken string, runtime *common.RuntimeContext) map[string]interface{} {
cover := map[string]interface{}{"token": fileToken}
if runtime.Changed("offset-ratio-x") {
cover["offset_ratio_x"] = runtime.Float64("offset-ratio-x")
}
if runtime.Changed("offset-ratio-y") {
cover["offset_ratio_y"] = runtime.Float64("offset-ratio-y")
}
return cover
}
func docCoverDryRunSource(runtime *common.RuntimeContext) string {
if runtime.Bool("from-clipboard") {
return "<clipboard image>"
}
if rawURL := runtime.Str("url"); rawURL != "" {
return rawURL
}
if filePath := runtime.Str("file"); filePath != "" {
return "@" + filePath
}
return "<cover image>"
}
func downloadDocCoverURL(ctx context.Context, runtime *common.RuntimeContext, raw string) ([]byte, string, error) {
u, err := parseAndValidateDocCoverURL(ctx, raw)
if err != nil {
return nil, "", err
}
baseClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
}
client := newDocCoverHTTPClient(baseClient)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) //nolint:forbidigo // cover --url fetches external user content; RuntimeContext API helpers are Lark-API only.
if err != nil {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --url: %v", err).WithParam("--url").WithCause(err)
}
resp, err := client.Do(req) //nolint:forbidigo // cover --url uses a guarded external downloader, not Lark API transport.
if err != nil {
return nil, "", wrapDocNetworkErr(err, "download cover URL failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
subtype := errs.SubtypeNetworkTransport
if resp.StatusCode >= 500 {
subtype = errs.SubtypeNetworkServer
}
cause := docCoverHTTPStatusCause(resp.StatusCode)
return nil, "", errs.NewNetworkError(subtype, "download cover URL failed: HTTP %d", resp.StatusCode).WithCode(resp.StatusCode).WithCause(cause)
}
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil || mediaType == "" {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL response must include an image Content-Type").WithParam("--url")
}
mediaType = strings.ToLower(mediaType)
ext, ok := docCoverAllowedContentTypes[mediaType]
if !ok {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL Content-Type %q is not supported; expected image/png, image/jpeg, image/gif or image/webp", mediaType).WithParam("--url")
}
limited := io.LimitReader(resp.Body, docCoverURLMaxBytes+1)
content, err := io.ReadAll(limited)
if err != nil {
return nil, "", wrapDocNetworkErr(err, "read cover URL response failed: %v", err)
}
if int64(len(content)) > docCoverURLMaxBytes {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL response exceeds 20MiB limit").WithParam("--url")
}
fileName := docCoverURLFileName(resp.Request.URL, ext)
return content, fileName, nil
}
func parseAndValidateDocCoverURL(ctx context.Context, raw string) (*url.URL, error) {
u, err := parseDocCoverURLSyntax(raw)
if err != nil {
return nil, err
}
if err := validateDocCoverURLHost(ctx, u.Hostname()); err != nil {
return nil, err
}
return u, nil
}
func parseDocCoverURLSyntax(raw string) (*url.URL, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --url: %v", err).WithParam("--url").WithCause(err)
}
if u.Scheme != "https" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must use https").WithParam("--url")
}
if u.User != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not include userinfo").WithParam("--url")
}
host := u.Hostname()
if host == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url host cannot be empty").WithParam("--url")
}
return u, nil
}
func docCoverURLFileName(u *url.URL, ext string) string {
base := path.Base(u.EscapedPath())
if base == "." || base == "/" || base == "" {
return docCoverURLDownloadName + ext
}
unescaped, err := url.PathUnescape(base)
if err == nil {
base = unescaped
}
base = filepath.Base(base)
if strings.TrimSpace(base) == "" || base == "." || base == string(filepath.Separator) {
return docCoverURLDownloadName + ext
}
if filepath.Ext(base) == "" {
base += ext
}
return base
}
func validateDocCoverURLHost(ctx context.Context, host string) error {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url host cannot be empty").WithParam("--url")
}
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
}
if ip := net.ParseIP(host); ip != nil {
if isUnsafeDocCoverIP(ip) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
}
return nil
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to resolve --url host: %v", err).WithParam("--url").WithCause(err)
}
if len(ips) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to resolve --url host: no addresses").WithParam("--url")
}
for _, ip := range ips {
if isUnsafeDocCoverIP(ip) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
}
}
return nil
}
func isUnsafeDocCoverIP(ip net.IP) bool {
if ip == nil {
return true
}
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
if v4 := ip.To4(); v4 != nil {
if v4[0] == 10 || v4[0] == 127 {
return true
}
if v4[0] == 169 && v4[1] == 254 {
return true
}
if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 {
return true
}
if v4[0] == 192 && v4[1] == 168 {
return true
}
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
return true
}
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) {
return true
}
if v4[0] >= 240 {
return true
}
return false
}
return ip.IsPrivate()
}
func newDocCoverHTTPClient(base *http.Client) *http.Client { //nolint:forbidigo // guarded external --url downloader cannot use Lark API runtime helpers.
if base == nil {
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.HttpClient.
}
cloned := *base
if cloned.Timeout == 0 { //nolint:forbidigo // external download timeout guard on cloned client.
cloned.Timeout = 30 * time.Second //nolint:forbidigo // external download timeout guard on cloned client.
}
cloned.Transport = cloneDocCoverTransport(base.Transport) //nolint:forbidigo // external download transport adds proxy/IP guards.
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { //nolint:forbidigo // redirects must be validated for external --url downloads.
if len(via) >= 3 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirects too many times").WithParam("--url")
}
if len(via) > 0 {
prev := via[len(via)-1]
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirect from https to http is not allowed").WithParam("--url")
}
}
_, err := parseAndValidateDocCoverURL(req.Context(), req.URL.String())
return err
}
return &cloned
}
func cloneDocCoverTransport(base http.RoundTripper) *http.Transport { //nolint:forbidigo // external --url downloader wraps caller transport with IP/proxy guards.
var cloned *http.Transport
if src, ok := base.(*http.Transport); ok && src != nil {
cloned = src.Clone()
} else if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil { //nolint:forbidigo // fallback for guarded external downloader only.
cloned = def.Clone()
} else {
cloned = &http.Transport{}
}
cloned.Proxy = nil
origDial := cloned.DialContext
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialDocCoverConn(ctx, origDial, network, addr)
if err != nil {
return nil, err
}
if err := validateDocCoverConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
if cloned.DialTLSContext != nil {
origDialTLS := cloned.DialTLSContext
cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialDocCoverConn(ctx, origDialTLS, network, addr)
if err != nil {
return nil, err
}
if err := validateDocCoverConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
}
return cloned
}
func dialDocCoverConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
if dialFn != nil {
return dialFn(ctx, network, addr)
}
var dialer net.Dialer
return dialer.DialContext(ctx, network, addr)
}
func validateDocCoverConnRemoteIP(conn net.Conn) error {
if conn == nil {
return docCoverURLGuardError("nil connection")
}
addr := conn.RemoteAddr()
if addr == nil {
return docCoverURLGuardError("missing remote address")
}
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
host = addr.String()
}
ip := net.ParseIP(strings.Trim(host, "[]"))
if ip == nil {
return docCoverURLGuardError("invalid remote IP")
}
if isUnsafeDocCoverIP(ip) {
return docCoverURLGuardError("local/internal host is not allowed")
}
return nil
}

View File

@@ -1,712 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDocResourceDownloadCoverDownloadsImageContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-download-app"))
documentID := "doxcnCoverDownload1"
coverToken := "cover_token_download_123"
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{
"token": coverToken,
"offset_ratio_x": 0.25,
"offset_ratio_y": 0.75,
}))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/" + coverToken + "/download",
Status: 200,
Body: []byte("png-data"),
Headers: http.Header{
"Content-Type": []string{"image/png"},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocResourceDownload, []string{
"resource-download",
"--doc", documentID,
"--type", "cover",
"--output", "cover",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeDocResourceOutput(t, stdout)
data := out.Data
if data["type"] != "cover" {
t.Fatalf("type = %v, want cover", data["type"])
}
if data["content_type"] != "image/png" {
t.Fatalf("content_type = %v, want image/png", data["content_type"])
}
if int(data["size_bytes"].(float64)) != len("png-data") {
t.Fatalf("size_bytes = %v", data["size_bytes"])
}
savedPath, _ := data["saved_path"].(string)
if !strings.HasSuffix(savedPath, "cover.png") {
t.Fatalf("saved_path = %q, want cover.png suffix", savedPath)
}
content, err := os.ReadFile(filepath.Join(tmpDir, "cover.png"))
if err != nil {
t.Fatalf("ReadFile(cover.png) error: %v", err)
}
if string(content) != "png-data" {
t.Fatalf("downloaded content = %q", string(content))
}
cover := data["cover"].(map[string]interface{})
if cover["token"] != coverToken {
t.Fatalf("cover.token = %v, want %s", cover["token"], coverToken)
}
}
func TestDocResourceDownloadCoverEmptyReturnsErrorWithoutDownload(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-empty-download-app"))
documentID := "doxcnCoverEmptyDownload1"
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocResourceDownload, []string{
"resource-download",
"--doc", documentID,
"--type", "cover",
"--output", "cover.png",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected empty cover error, got nil")
}
assertValidationContract(t, err, errs.SubtypeFailedPrecondition, "--type")
if _, statErr := os.Stat(filepath.Join(tmpDir, "cover.png")); !os.IsNotExist(statErr) {
t.Fatalf("cover.png should not be created, statErr=%v", statErr)
}
}
func TestDocResourceDeleteCoverEmptyIsIdempotent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-empty-delete-app"))
documentID := "doxcnCoverEmptyDelete1"
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
err := mountAndRunDocs(t, DocResourceDelete, []string{
"resource-delete",
"--doc", documentID,
"--type", "cover",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocResourceOutput(t, stdout).Data
if data["deleted"] != false {
t.Fatalf("deleted = %v, want false", data["deleted"])
}
if data["already_empty"] != true {
t.Fatalf("already_empty = %v, want true", data["already_empty"])
}
}
func TestDocResourceDeleteCoverClearsExistingCover(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-delete-app"))
documentID := "doxcnCoverDelete1"
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{"token": "cover_token_delete_123"}))
patchStub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/docx/v1/documents/" + documentID,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(patchStub)
err := mountAndRunDocs(t, DocResourceDelete, []string{
"resource-delete",
"--doc", documentID,
"--type", "cover",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := string(patchStub.CapturedBody)
if !strings.Contains(body, `"update_cover"`) || !strings.Contains(body, `"cover":null`) {
t.Fatalf("PATCH body = %s, want update_cover.cover=null", body)
}
data := decodeDocResourceOutput(t, stdout).Data
if data["deleted"] != true {
t.Fatalf("deleted = %v, want true", data["deleted"])
}
if data["already_empty"] != false {
t.Fatalf("already_empty = %v, want false", data["already_empty"])
}
}
func TestDocResourceUpdateCoverUploadsFileAndReturnsFullTokenOnlyOnStdout(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-update-app"))
documentID := "doxcnCoverUpdate1"
fileToken := "file_cover_uploaded_token_12345"
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
if err := os.WriteFile("cover.png", []byte("png-data"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": fileToken},
},
}
reg.Register(uploadStub)
patchStub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/docx/v1/documents/" + documentID,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(patchStub)
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"--doc", documentID,
"--type", "cover",
"--file", "cover.png",
"--offset-ratio-x", "0.2",
"--offset-ratio-y", "0.8",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v — stderr: %s", err, stderr.String())
}
if !bytes.Contains(uploadStub.CapturedBody, []byte("png-data")) {
t.Fatalf("upload body does not contain file bytes")
}
uploadBody := string(uploadStub.CapturedBody)
if !strings.Contains(uploadBody, `name="parent_type"`) || !strings.Contains(uploadBody, "docx_image") {
t.Fatalf("upload body missing docx_image parent type: %s", uploadBody)
}
if !strings.Contains(uploadBody, "drive_route_token") || !strings.Contains(uploadBody, documentID) {
t.Fatalf("upload body missing drive_route_token extra: %s", uploadBody)
}
patchBody := string(patchStub.CapturedBody)
for _, want := range []string{`"update_cover"`, `"token":"` + fileToken + `"`, `"offset_ratio_x":0.2`, `"offset_ratio_y":0.8`} {
if !strings.Contains(patchBody, want) {
t.Fatalf("PATCH body = %s, missing %s", patchBody, want)
}
}
if strings.Contains(stderr.String(), fileToken) {
t.Fatalf("stderr leaked full file_token: %s", stderr.String())
}
data := decodeDocResourceOutput(t, stdout).Data
if data["file_token"] != fileToken {
t.Fatalf("stdout file_token = %v, want %s", data["file_token"], fileToken)
}
cover := data["cover"].(map[string]interface{})
if cover["token"] != fileToken {
t.Fatalf("stdout cover.token = %v, want %s", cover["token"], fileToken)
}
}
func TestDocResourceUpdateCoverRejectsMultipleSources(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-validation-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"--doc", "doxcnCoverValidate1",
"--type", "cover",
"--file", "cover.png",
"--from-clipboard",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected mutual exclusion error, got nil")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--file", "--from-clipboard")
}
func TestDocResourceUpdateCoverRejectsMissingSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-required-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"--doc", "doxcnCoverValidateRequired1",
"--type", "cover",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected missing source error, got nil")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--file", "--from-clipboard", "--url")
}
func TestDocResourceUpdateCoverRejectsUnsafeURLSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-validation-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"--doc", "doxcnCoverURLValidate1",
"--type", "cover",
"--url", "https://127.0.0.1/cover.png",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected unsafe URL error, got nil")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
}
func TestDocCoverURLSyntaxValidation(t *testing.T) {
cases := []struct {
name string
raw string
ok bool
}{
{name: "https", raw: " https://example.com/cover.png ", ok: true},
{name: "http", raw: "http://example.com/cover.png"},
{name: "userinfo", raw: "https://user:pass@example.com/cover.png"},
{name: "empty host", raw: "https:///cover.png"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
u, err := parseDocCoverURLSyntax(tc.raw)
if tc.ok {
if err != nil {
t.Fatalf("parseDocCoverURLSyntax() error: %v", err)
}
if u.String() != "https://example.com/cover.png" {
t.Fatalf("URL = %q, want normalized https URL", u.String())
}
return
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
})
}
}
func TestDocResourceCoverDryRunsPlanAPIs(t *testing.T) {
downloadRT := docValidateRuntime(t, map[string]string{
"doc": "doxcnCoverDryRunDownload",
"output": "cover",
}, nil, nil)
download := decodeDocDryRun(t, DocResourceDownload.DryRun(context.Background(), downloadRT))
if len(download.API) != 2 {
t.Fatalf("download dry-run API count = %d, want 2", len(download.API))
}
if got := download.API[0].URL; got != "/open-apis/docx/v1/documents/doxcnCoverDryRunDownload" {
t.Fatalf("download metadata URL = %q", got)
}
if got := download.API[1].URL; got != "/open-apis/drive/v1/medias/%3Ccover.token%3E/download" {
t.Fatalf("download media URL = %q", got)
}
updateRT := docValidateRuntime(t, map[string]string{
"doc": "https://example.larksuite.com/wiki/wikcnCoverDryRunUpdate",
"url": "https://example.com/cover.png",
}, nil, nil)
update := decodeDocDryRun(t, DocResourceUpdate.DryRun(context.Background(), updateRT))
if len(update.API) != 3 {
t.Fatalf("update dry-run API count = %d, want 3", len(update.API))
}
if got := update.API[0].URL; got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("wiki resolve URL = %q", got)
}
if got := update.API[1].URL; got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("upload URL = %q", got)
}
if got := update.API[2].URL; got != "/open-apis/docx/v1/documents/%3Cresolved_docx_token%3E" {
t.Fatalf("patch URL = %q", got)
}
if got := update.API[1].Body["file"]; got != "https://example.com/cover.png" {
t.Fatalf("upload source = %#v", got)
}
deleteRT := docValidateRuntime(t, map[string]string{"doc": "doxcnCoverDryRunDelete"}, nil, nil)
deleteDry := decodeDocDryRun(t, DocResourceDelete.DryRun(context.Background(), deleteRT))
if len(deleteDry.API) != 2 {
t.Fatalf("delete dry-run API count = %d, want 2", len(deleteDry.API))
}
if got := deleteDry.API[1].URL; got != "/open-apis/docx/v1/documents/doxcnCoverDryRunDelete" {
t.Fatalf("delete patch URL = %q", got)
}
}
func TestDocResourceCoverDryRunReportsInvalidDoc(t *testing.T) {
rt := docValidateRuntime(t, map[string]string{"doc": "https://example.com/sheets/shtxxx"}, nil, nil)
dry := DocResourceDownload.DryRun(context.Background(), rt)
if got := dry.Format(); !strings.Contains(got, "error:") {
t.Fatalf("dry-run error output = %q, want error field", got)
}
}
func TestParseAndValidateDocCoverURLRejectsUnsafeIP(t *testing.T) {
_, err := parseAndValidateDocCoverURL(context.Background(), "https://127.0.0.1/cover.png")
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
}
func TestValidateDocCoverURLHost(t *testing.T) {
for _, host := range []string{"", "localhost", "service.localhost", "127.0.0.1"} {
t.Run(host, func(t *testing.T) {
assertValidationContract(t, validateDocCoverURLHost(context.Background(), host), errs.SubtypeInvalidArgument, "--url")
})
}
if err := validateDocCoverURLHost(context.Background(), "1.1.1.1"); err != nil {
t.Fatalf("validateDocCoverURLHost(public IP) error: %v", err)
}
}
func TestDocCoverIPSafetyBlocksSpecialRanges(t *testing.T) {
for _, rawIP := range []string{
"10.0.0.1",
"127.0.0.1",
"169.254.1.1",
"172.16.0.1",
"192.168.0.1",
"100.64.0.1",
"198.18.0.1",
"240.0.0.1",
} {
t.Run(rawIP, func(t *testing.T) {
if !isUnsafeDocCoverIP(net.ParseIP(rawIP)) {
t.Fatalf("%s was classified as safe", rawIP)
}
})
}
if isUnsafeDocCoverIP(net.ParseIP("1.1.1.1")) {
t.Fatal("public IPv4 address was classified as unsafe")
}
}
func TestDocCoverHTTPClientDoesNotUseProxy(t *testing.T) {
baseTransport := &http.Transport{Proxy: http.ProxyFromEnvironment}
baseClient := &http.Client{Transport: baseTransport}
client := newDocCoverHTTPClient(baseClient)
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("client transport = %T, want *http.Transport", client.Transport)
}
if transport.Proxy != nil {
t.Fatal("cover URL downloader must not inherit proxy settings")
}
if baseTransport.Proxy == nil {
t.Fatal("base transport proxy was mutated")
}
}
func TestDocCoverHTTPClientRedirectValidation(t *testing.T) {
client := newDocCoverHTTPClient(&http.Client{})
req, err := http.NewRequest(http.MethodGet, "https://1.1.1.1/cover.png", nil)
if err != nil {
t.Fatalf("NewRequest() error: %v", err)
}
if err := client.CheckRedirect(req, []*http.Request{{}, {}, {}}); err == nil {
t.Fatal("expected too many redirects error")
}
prev, err := http.NewRequest(http.MethodGet, "https://1.1.1.1/start", nil)
if err != nil {
t.Fatalf("NewRequest(prev) error: %v", err)
}
downgrade, err := http.NewRequest(http.MethodGet, "http://1.1.1.1/cover.png", nil)
if err != nil {
t.Fatalf("NewRequest(downgrade) error: %v", err)
}
if err := client.CheckRedirect(downgrade, []*http.Request{prev}); err == nil {
t.Fatal("expected https-to-http redirect error")
}
}
func TestDocCoverConnRemoteIPValidation(t *testing.T) {
if err := validateDocCoverConnRemoteIP(nil); err == nil {
t.Fatal("expected nil connection error")
}
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{}); err == nil {
t.Fatal("expected missing remote address error")
}
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: testAddr("not-ip")}); err == nil {
t.Fatal("expected invalid remote IP error")
}
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443}}); err == nil {
t.Fatal("expected local remote IP error")
}
}
func TestDocCoverURLFileName(t *testing.T) {
cases := []struct {
raw string
ext string
want string
}{
{raw: "https://example.com/images/cover", ext: ".png", want: "cover.png"},
{raw: "https://example.com/images/cover.jpeg", ext: ".png", want: "cover.jpeg"},
{raw: "https://example.com/", ext: ".webp", want: "cover.webp"},
{raw: "https://example.com/images/%2Fescaped", ext: ".gif", want: "escaped.gif"},
}
for _, tc := range cases {
t.Run(tc.want, func(t *testing.T) {
u, err := url.Parse(tc.raw)
if err != nil {
t.Fatalf("url.Parse(%q): %v", tc.raw, err)
}
if got := docCoverURLFileName(u, tc.ext); got != tc.want {
t.Fatalf("docCoverURLFileName() = %q, want %q", got, tc.want)
}
})
}
}
func TestDownloadDocCoverURLSuccess(t *testing.T) {
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("png-data"))
}))
content, fileName, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
if err != nil {
t.Fatalf("downloadDocCoverURL() error: %v", err)
}
if string(content) != "png-data" {
t.Fatalf("content = %q, want png-data", string(content))
}
if fileName != "cover.png" {
t.Fatalf("fileName = %q, want cover.png", fileName)
}
}
func TestDownloadDocCoverURLRejectsHTTPStatus(t *testing.T) {
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("unavailable"))
}))
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
if err == nil {
t.Fatal("expected HTTP status error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed problem", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Fatalf("problem category = %v, want %v", p.Category, errs.CategoryNetwork)
}
if p.Subtype != errs.SubtypeNetworkServer {
t.Fatalf("problem subtype = %v, want %v", p.Subtype, errs.SubtypeNetworkServer)
}
if p.Code != http.StatusServiceUnavailable {
t.Fatalf("problem code = %v, want %v", p.Code, http.StatusServiceUnavailable)
}
var networkErr *errs.NetworkError
if !errors.As(err, &networkErr) {
t.Fatalf("error = %T %v, want *errs.NetworkError", err, err)
}
if networkErr.Cause == nil {
t.Fatal("expected preserved underlying cause, got nil")
}
}
func TestDownloadDocCoverURLRejectsUnsupportedContentType(t *testing.T) {
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("not-image"))
}))
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
}
func TestDownloadDocCoverURLRejectsOversizeResponse(t *testing.T) {
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = io.CopyN(w, repeatByteReader('x'), docCoverURLMaxBytes+1)
}))
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
}
func TestDocCoverMetadataOutputAndOptionalFloats(t *testing.T) {
x := 0.25
y := 1.0
out := docCoverMetadata{
Token: "cover_token",
OffsetRatioX: &x,
OffsetRatioY: &y,
}.toOutput()
if out["token"] != "cover_token" || out["offset_ratio_x"] != x || out["offset_ratio_y"] != y {
t.Fatalf("cover output = %#v", out)
}
data := map[string]interface{}{
"float": float64(1.5),
"int": 2,
"int64": int64(3),
"text": "4",
}
if got, ok := getOptionalFloat(data, "float"); !ok || got != 1.5 {
t.Fatalf("float optional = %v/%v, want 1.5/true", got, ok)
}
if got, ok := getOptionalFloat(data, "int"); !ok || got != 2 {
t.Fatalf("int optional = %v/%v, want 2/true", got, ok)
}
if got, ok := getOptionalFloat(data, "int64"); !ok || got != 3 {
t.Fatalf("int64 optional = %v/%v, want 3/true", got, ok)
}
if _, ok := getOptionalFloat(data, "text"); ok {
t.Fatal("string optional unexpectedly parsed as float")
}
if _, ok := getOptionalFloat(nil, "missing"); ok {
t.Fatal("nil map optional unexpectedly returned a value")
}
}
func TestDocCoverDryRunSource(t *testing.T) {
cases := []struct {
name string
str map[string]string
bools map[string]bool
want string
}{
{name: "clipboard", bools: map[string]bool{"from-clipboard": true}, want: "<clipboard image>"},
{name: "url", str: map[string]string{"url": "https://example.com/cover.png"}, want: "https://example.com/cover.png"},
{name: "file", str: map[string]string{"file": "cover.png"}, want: "@cover.png"},
{name: "empty", want: "<cover image>"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := docValidateRuntime(t, tc.str, tc.bools, nil)
if got := docCoverDryRunSource(rt); got != tc.want {
t.Fatalf("docCoverDryRunSource() = %q, want %q", got, tc.want)
}
})
}
}
func TestDocShortcutsIncludeCoverResourceCommands(t *testing.T) {
got := map[string]bool{}
for _, shortcut := range Shortcuts() {
got[shortcut.Command] = true
}
for _, want := range []string{"resource-download", "resource-update", "resource-delete"} {
if !got[want] {
t.Fatalf("Shortcuts() missing %s", want)
}
}
}
func newDocCoverURLTestRuntime(t *testing.T, handler http.Handler) (*common.RuntimeContext, string) {
t.Helper()
server := httptest.NewTLSServer(handler)
t.Cleanup(server.Close)
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-download-app"))
targetAddr := server.Listener.Addr().String()
f.HttpClient = func() (*http.Client, error) {
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, network, targetAddr)
if err != nil {
return nil, err
}
return docCoverRemoteAddrConn{
Conn: conn,
addr: &net.TCPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443},
}, nil
},
},
}, nil
}
return &common.RuntimeContext{Factory: f}, "https://1.1.1.1/assets/cover"
}
type docCoverRemoteAddrConn struct {
net.Conn
addr net.Addr
}
func (c docCoverRemoteAddrConn) RemoteAddr() net.Addr {
return c.addr
}
type testAddr string
func (a testAddr) Network() string {
return "test"
}
func (a testAddr) String() string {
return string(a)
}
type repeatByteReader byte
func (r repeatByteReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = byte(r)
}
return len(p), nil
}
func docCoverMetadataStub(documentID string, cover map[string]interface{}) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + documentID,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"document": map[string]interface{}{
"cover": cover,
},
},
},
}
}
type docResourceOutput struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
func decodeDocResourceOutput(t *testing.T, stdout *bytes.Buffer) docResourceOutput {
t.Helper()
var out docResourceOutput
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("decode resource output: %v; output=%s", err, stdout.String())
}
return out
}

View File

@@ -19,7 +19,6 @@ func v2FetchFlags() []common.Flag {
return []common.Flag{
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
@@ -90,9 +89,6 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
if v := runtime.Int("revision-id"); v > 0 {
body["revision_id"] = v
}
if lang := resolveFetchLang(runtime); lang != "" {
body["lang"] = lang
}
detail := effectiveFetchDetail(runtime)
switch detail {
@@ -122,16 +118,6 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
return body
}
func resolveFetchLang(runtime *common.RuntimeContext) string {
if runtime.Changed("lang") {
return strings.TrimSpace(runtime.Str("lang"))
}
if runtime.Config == nil {
return ""
}
return strings.TrimSpace(string(runtime.Config.Lang))
}
// buildReadOption 拼装 read_option JSONfull/空模式返回 nil让服务端走默认全文路径。
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
mode := strings.TrimSpace(runtime.Str("scope"))

View File

@@ -10,7 +10,6 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
@@ -63,47 +62,6 @@ func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
}
}
func TestBuildFetchBodyIncludesExplicitLang(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
if err := runtime.Cmd.Flags().Set("lang", "en-US"); err != nil {
t.Fatalf("set lang: %v", err)
}
body := buildFetchBody(runtime)
if got := body["lang"]; got != "en-US" {
t.Fatalf("lang = %#v, want %q", got, "en-US")
}
}
func TestBuildFetchBodyUsesRuntimeConfigLang(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
runtime.Config = &core.CliConfig{Lang: "zh_cn"}
body := buildFetchBody(runtime)
if got := body["lang"]; got != "zh_cn" {
t.Fatalf("lang = %#v, want %q", got, "zh_cn")
}
}
func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
runtime.Config = &core.CliConfig{Lang: "zh_cn"}
if err := runtime.Cmd.Flags().Set("lang", ""); err != nil {
t.Fatalf("set lang: %v", err)
}
body := buildFetchBody(runtime)
if _, ok := body["lang"]; ok {
t.Fatalf("did not expect blank explicit lang in fetch body: %#v", body)
}
}
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
t.Parallel()
@@ -304,7 +262,6 @@ 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().String("lang", "", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")
@@ -324,7 +281,6 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("detail", "simple", "")
cmd.Flags().String("lang", "", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")

View File

@@ -60,9 +60,6 @@ func Shortcuts() []common.Shortcut {
DocMediaUpload,
DocMediaPreview,
DocMediaDownload,
DocResourceDownload,
DocResourceUpdate,
DocResourceDelete,
}
}

View File

@@ -34,7 +34,6 @@ var DriveExport = common.Shortcut{
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
{Name: "file-name", Desc: "preferred output filename (optional)"},
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
@@ -45,7 +44,6 @@ var DriveExport = common.Shortcut{
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -54,7 +52,6 @@ var DriveExport = common.Shortcut{
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
}
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
@@ -81,9 +78,6 @@ var DriveExport = common.Shortcut{
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
@@ -101,7 +95,6 @@ var DriveExport = common.Shortcut{
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
}
outputDir := runtime.Str("output-dir")
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))

View File

@@ -34,7 +34,6 @@ type driveExportSpec struct {
DocType string
FileExtension string
SubID string
OnlySchema bool
}
// driveExportTaskResultCommand prints the resume command shown when bounded
@@ -151,10 +150,6 @@ func validateDriveExportSpec(spec driveExportSpec) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
}
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
}
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
}
@@ -190,9 +185,6 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
if err != nil {

View File

@@ -51,15 +51,6 @@ func TestValidateDriveExportSpec(t *testing.T) {
name: "base bitable ok",
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base"},
},
{
name: "base bitable only schema ok",
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base", OnlySchema: true},
},
{
name: "only schema non base rejected",
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "xlsx", OnlySchema: true},
wantErr: "--only-schema is only used",
},
{
name: "slides pptx ok",
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pptx"},
@@ -621,7 +612,6 @@ func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
"--token", "bitable123",
"--doc-type", "bitable",
"--file-extension", "base",
"--only-schema",
"--as", "bot",
}, f, stdout)
if err != nil {
@@ -638,9 +628,6 @@ func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
if createBody["type"] != "bitable" {
t.Fatalf("export_tasks body type = %v, want %q", createBody["type"], "bitable")
}
if createBody["only_schema"] != true {
t.Fatalf("export_tasks body only_schema = %v, want true", createBody["only_schema"])
}
data, err := os.ReadFile(filepath.Join(tmpDir, "crm.base"))
if err != nil {

View File

@@ -25,8 +25,7 @@ var DriveImport = common.Shortcut{
"docs:document.media:upload",
"docs:document:import",
},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
@@ -62,7 +61,6 @@ var DriveImport = common.Shortcut{
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
@@ -89,9 +87,6 @@ var DriveImport = common.Shortcut{
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)

View File

@@ -257,46 +257,6 @@ func validateDriveImportSpec(spec driveImportSpec) error {
return nil
}
func appendDriveImportFolderTokenWikiCheckDryRun(dry *common.DryRunAPI, spec driveImportSpec) {
folderToken := strings.TrimSpace(spec.FolderToken)
if folderToken == "" {
return
}
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[0] Validate whether --folder-token is a wiki node").
Params(map[string]interface{}{"token": folderToken})
}
func rejectDriveImportWikiFolderToken(runtime *common.RuntimeContext, folderToken string) error {
folderToken = strings.TrimSpace(folderToken)
if folderToken == "" {
return nil
}
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": folderToken},
nil,
)
if err == nil {
node := common.GetMap(data, "node")
if len(node) == 0 {
return nil
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--folder-token only supports Drive folder tokens, but the provided token resolves to a wiki node",
).
WithParam("--folder-token").
WithHint("Pass a Drive folder token, or omit --folder-token to import into the Drive root folder. Wiki node tokens are not accepted as import mount folders.")
}
return nil
}
// driveImportStatus captures the backend fields needed to decide whether the
// import can be surfaced immediately or requires a follow-up poll.
type driveImportStatus struct {

View File

@@ -5,12 +5,10 @@ package drive
import (
"bytes"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -278,134 +276,6 @@ func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
}
}
func TestDriveImportRejectsWikiFolderToken(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"node_token": "wikcnImportTarget",
"obj_type": "docx",
"obj_token": "docxImportTarget",
"title": "Wiki Import Target",
},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("notes.md", []byte("# Hi"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "notes.md",
"--type", "docx",
"--folder-token", "wikcnImportTarget",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected wiki folder-token validation error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != "--folder-token" {
t.Fatalf("param = %q, want --folder-token", validationErr.Param)
}
wantMessage := "--folder-token only supports Drive folder tokens, but the provided token resolves to a wiki node"
if validationErr.Message != wantMessage {
t.Fatalf("message = %q, want %q", validationErr.Message, wantMessage)
}
for _, disallowed := range []string{"node_token=", "obj_type=", "Wiki Import Target"} {
if strings.Contains(validationErr.Message, disallowed) {
t.Fatalf("message = %q, must not contain %q", validationErr.Message, disallowed)
}
}
if !strings.Contains(validationErr.Hint, "Drive folder token") {
t.Fatalf("hint = %q, want Drive folder token guidance", validationErr.Hint)
}
}
func TestDriveImportContinuesWhenFolderTokenDoesNotResolveAsWiki(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 1310001,
"msg": "node not found",
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_import_media"},
},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_import_folder"},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_import_folder",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"type": "docx",
"job_status": 0,
"token": "docx_imported",
},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("notes.md", []byte("# Hi"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "notes.md",
"--type", "docx",
"--folder-token", "fldcnImportTarget",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["token"]; got != "docx_imported" {
t.Fatalf("token = %#v, want docx_imported", got)
}
body := decodeCapturedJSONBody(t, createStub)
point, _ := body["point"].(map[string]interface{})
if got := point["mount_key"]; got != "fldcnImportTarget" {
t.Fatalf("import mount_key = %#v, want fldcnImportTarget", got)
}
}
func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -114,20 +114,16 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 4 {
t.Fatalf("expected 4 API calls, got %d", len(got.API))
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
}
if got.API[0].Body != nil {
t.Fatalf("wiki probe should not have a request body, got %#v", got.API[0].Body)
}
uploadName, _ := got.API[1].Body["file_name"].(string)
uploadName, _ := got.API[0].Body["file_name"].(string)
if uploadName != "base-import.xlsx" {
t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx")
}
importName, _ := got.API[2].Body["file_name"].(string)
importName, _ := got.API[1].Body["file_name"].(string)
if importName != "base-import" {
t.Fatalf("import task file_name = %q, want %q", importName, "base-import")
}

View File

@@ -13,7 +13,6 @@ func Shortcuts() []common.Shortcut {
VCRecording,
VCMeetingJoin,
VCMeetingLeave,
VCMeetingListActive,
VCMeetingEvents,
}
}

View File

@@ -48,7 +48,7 @@ var VCMeetingEvents = common.Shortcut{
Description: "List bot meeting events by meeting ID",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{"user", "bot"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to query"},
@@ -156,9 +156,6 @@ func validateMeetingEventsMeetingID(meetingID string) error {
if meetingID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id is required").WithParam("--meeting-id")
}
if validMeetingNumber(meetingID) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id must be a long meeting_id, not a 9-digit meeting number; use +meeting-join or +meeting-list-active to get meeting_id").WithParam("--meeting-id")
}
value, err := strconv.ParseInt(meetingID, 10, 64)
if err != nil || value <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id must be a positive integer, got %q", meetingID).WithParam("--meeting-id")

View File

@@ -262,26 +262,6 @@ func TestMeetingEvents_Validation_InvalidMeetingID(t *testing.T) {
}
}
func TestMeetingEvents_Validation_RejectsMeetingNumber(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "732067044")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for 9-digit meeting number")
}
if !strings.Contains(err.Error(), "not a 9-digit meeting number") {
t.Fatalf("unexpected error: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--meeting-id" {
t.Errorf("Param = %q, want %q", ve.Param, "--meeting-id")
}
}
func TestMeetingEvents_Validation_InvalidTimeRange(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
@@ -838,7 +818,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
for _, shortcut := range got {
commands = append(commands, shortcut.Command)
}
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
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)
}

View File

@@ -28,7 +28,7 @@ var VCMeetingJoin = common.Shortcut{
Description: "Join a meeting by meeting number (bot join)",
Risk: "write",
Scopes: []string{"vc:meeting.bot.join:write"},
AuthTypes: []string{"user", "bot"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-number", Required: true, Desc: "meeting number to join"},

View File

@@ -20,7 +20,7 @@ var VCMeetingLeave = common.Shortcut{
Description: "Leave a meeting by meeting ID",
Risk: "write",
Scopes: []string{"vc:meeting.bot.join:write"},
AuthTypes: []string{"user", "bot"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to leave"},

View File

@@ -1,121 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const vcMeetingListActiveAPIPath = "/open-apis/vc/v1/bots/user_active_meeting"
// VCMeetingListActive lists meetings the current or target user is actively in.
var VCMeetingListActive = common.Shortcut{
Service: "vc",
Command: "+meeting-list-active",
Description: "List active meetings for the current identity or target user",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "user-id", Desc: "target user ID when using bot identity"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMeetingListActiveUserID(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params, err := buildMeetingListActiveParams(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dryRun := common.NewDryRunAPI().GET(vcMeetingListActiveAPIPath)
if len(params) > 0 {
dryRun.Params(params)
}
return dryRun
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params, err := buildMeetingListActiveParams(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(http.MethodGet, vcMeetingListActiveAPIPath, params, nil)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
meetings := common.GetSlice(data, "meetings")
runtime.OutFormat(data, &output.Meta{Count: len(meetings)}, func(w io.Writer) {
if len(meetings) == 0 {
fmt.Fprintln(w, "No active meetings.")
return
}
displayedMeetings := 0
for _, raw := range meetings {
meeting, _ := raw.(map[string]interface{})
if meeting == nil {
continue
}
if displayedMeetings > 0 {
fmt.Fprintln(w)
}
displayedMeetings++
title := common.GetString(meeting, "meeting_title")
if title == "" {
title = "Untitled meeting"
}
fmt.Fprintf(w, "%s\n", title)
if id := common.GetString(meeting, "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 displayedMeetings > 1 {
fmt.Fprintln(w)
fmt.Fprintln(w, "Multiple active meetings found. Ask the user to choose one meeting_id before calling +meeting-events.")
}
})
return nil
},
}
// validateMeetingListActiveUserID validates the target user only for bot identity.
func validateMeetingListActiveUserID(runtime *common.RuntimeContext) error {
if !runtime.IsBot() {
return nil
}
userID := strings.TrimSpace(runtime.Str("user-id"))
if userID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id is required when --as bot").WithParam("--user-id")
}
if _, err := common.ValidateUserIDTyped("--user-id", userID); err != nil {
return err
}
return nil
}
// buildMeetingListActiveParams builds the query params for active meeting lookup.
func buildMeetingListActiveParams(runtime *common.RuntimeContext) (map[string]interface{}, error) {
if err := validateMeetingListActiveUserID(runtime); err != nil {
return nil, err
}
params := map[string]interface{}{}
if runtime.IsBot() {
userID := strings.TrimSpace(runtime.Str("user-id"))
params["user_id"] = userID
}
return params, nil
}

View File

@@ -8,7 +8,6 @@ import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
@@ -16,7 +15,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -591,335 +589,6 @@ func TestMeetingLeave_Execute_APIError(t *testing.T) {
}
}
func TestMeetingListActive_DryRun_UserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--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/user_active_meeting") {
t.Errorf("dry-run should include API path, got: %s", out)
}
if strings.Contains(out, "user_id") {
t.Errorf("user identity should not send user_id by default, got: %s", out)
}
}
func TestMeetingListActive_ScopeMatchesEventReadPermission(t *testing.T) {
if len(VCMeetingListActive.Scopes) != 1 || VCMeetingListActive.Scopes[0] != "vc:meeting.meetingevent:read" {
t.Fatalf("scopes = %#v, want [vc:meeting.meetingevent:read]", VCMeetingListActive.Scopes)
}
}
func TestMeetingListActive_DryRun_UserIdentityIgnoresUserID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--dry-run", "--as", "user", "--user-id", "not-open-id",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(stdout.String(), "user_id") {
t.Errorf("user identity should not send user_id, got: %s", stdout.String())
}
}
func TestMeetingListActive_Execute_UserIdentityIgnoresInvalidUserID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
var gotUserID string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
OnMatch: func(req *http.Request) {
gotUserID = req.URL.Query().Get("user_id")
},
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"meetings": []interface{}{}},
},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--as", "user", "--user-id", "not-open-id", "--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotUserID != "" {
t.Fatalf("user identity should not send user_id, got %q", gotUserID)
}
}
func TestMeetingListActive_Validate_BotRequiresUserID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{"+meeting-list-active", "--as", "bot"}, f, nil)
if err == nil {
t.Fatal("expected error when --as bot omits --user-id")
}
assertMeetingListActiveUserIDValidationError(t, err)
}
func TestMeetingListActive_Validate_UserIDOpenIDFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--as", "bot", "--user-id", "300",
}, f, nil)
if err == nil {
t.Fatal("expected error for non-open_id user-id")
}
assertMeetingListActiveUserIDValidationError(t, err)
}
func TestMeetingListActive_Execute_BotPassesUserID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
var gotUserID string
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
OnMatch: func(req *http.Request) {
gotUserID = req.URL.Query().Get("user_id")
},
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"meetings": []interface{}{
map[string]interface{}{
"meeting_id": "9001",
"meeting_no": "123456789",
"meeting_title": "Standup",
},
},
},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--user-id", "ou_300",
"--format", "json", "--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotUserID != "ou_300" {
t.Fatalf("user_id query = %q, want ou_300", gotUserID)
}
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)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 1 {
t.Fatalf("meetings = %d, want 1 (envelope: %s)", len(meetings), stdout.String())
}
}
func TestMeetingListActive_DryRun_BotValidationErrorEnvelope(t *testing.T) {
cmd := &cobra.Command{Use: "+meeting-list-active"}
cmd.Flags().String("user-id", "", "")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
dry := VCMeetingListActive.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
raw, err := json.Marshal(dry)
if err != nil {
t.Fatalf("failed to marshal dry-run output: %v", err)
}
got := string(raw)
if !strings.Contains(got, "--user-id") {
t.Fatalf("dry-run error = %q, want user-id validation", got)
}
}
func TestMeetingListActive_DryRun_BotSendsUserID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--dry-run", "--as", "bot", "--user-id", "ou_300",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "user_id") || !strings.Contains(stdout.String(), "ou_300") {
t.Fatalf("dry-run should include user_id=ou_300, got: %s", stdout.String())
}
}
func TestMeetingListActive_Execute_ValidationError(t *testing.T) {
cmd := &cobra.Command{Use: "+meeting-list-active"}
cmd.Flags().String("user-id", "", "")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
err := VCMeetingListActive.Execute(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
assertMeetingListActiveUserIDValidationError(t, err)
}
func TestMeetingListActive_ExecutePretty_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "No active meetings.") {
t.Fatalf("pretty output = %q, want empty-state message", stdout.String())
}
}
func TestMeetingListActive_ExecutePretty_SingleMeetingNoSelectionPrompt(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"meetings": []interface{}{
map[string]interface{}{
"meeting_id": "9001",
"meeting_no": "123456789",
"meeting_title": "Standup",
},
},
},
},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"Standup", "Meeting ID: 9001", "Meeting No: 123456789"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q: %s", want, out)
}
}
if strings.Contains(out, "Multiple active meetings found") {
t.Fatalf("single meeting should not show selection prompt: %s", out)
}
}
func TestMeetingListActive_ExecutePretty_MultipleMeetings(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"meetings": []interface{}{
map[string]interface{}{
"meeting_id": "9001",
"meeting_no": "123456789",
"meeting_title": "Standup",
},
"ignored",
map[string]interface{}{
"meeting_id": "9002",
"meeting_no": "987654321",
"meeting_title": "Planning",
},
map[string]interface{}{
"meeting_id": "9003",
},
},
},
},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"Standup",
"Meeting ID: 9001",
"Meeting No: 123456789",
"Planning",
"Meeting ID: 9002",
"Meeting No: 987654321",
"Untitled meeting",
"Meeting ID: 9003",
"Multiple active meetings found. Ask the user to choose one meeting_id before calling +meeting-events.",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q: %s", want, out)
}
}
}
func TestMeetingListActive_Execute_APIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "json", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error")
}
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryAuthorization {
t.Fatalf("error problem = (%+v, %t), want authorization problem", p, ok)
} else if p.Subtype != errs.SubtypePermissionDenied {
t.Fatalf("error subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
} else if p.Code != 121005 {
t.Fatalf("error code = %d, want 121005", p.Code)
}
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
}
func assertMeetingListActiveUserIDValidationError(t *testing.T, err error) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--user-id" {
t.Errorf("Param = %q, want %q", ve.Param, "--user-id")
}
}
// ---------------------------------------------------------------------------
// Typed error lock assertions
// ---------------------------------------------------------------------------

View File

@@ -1,71 +1,46 @@
## Core Concepts
- **Message**: A single message in a chat, identified by `message_id` (om_xxx). Supports types: text, post, image, file, audio, video, sticker, interactive (card), share_chat, share_user, merge_forward, etc.
- **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.
- **Feed Shortcut**: A chat pinned to the current user's feed sidebar, identified by `feed_card_id` (an `oc_xxx` open_chat_id for CHAT type).
- **Feed Group**: A tag that groups feed cards in the feed list, identified by `feed_group_id` (ofg_xxx). Members are feed cards, each identified by `feed_id` + `feed_type`. Two types: `normal` (members managed explicitly) and `rule` (members auto-derived from rules).
## Resource Relationships
```
Chat (oc_xxx)
├── Message (om_xxx)
│ ├── Thread (reply thread)
│ ├── Reaction (emoji)
│ └── Resource (image / file / video / audio)
└── Member (user / bot)
```
- **Message** `message_id` (om_xxx) · **Chat** `chat_id` (oc_xxx, group or P2P) · **Thread** `thread_id` (om_xxx / omt_xxx).
- **Flag** — bookmark on a message/thread (two layers, see below).
- **Feed Shortcut** `feed_card_id` (oc_xxx) — a chat pinned to the user's feed sidebar.
- **Feed Group** `feed_group_id` (ofg_xxx) — a tag grouping feed cards (`feed_id`+`feed_type`); `normal` (explicit) / `rule` (auto-derived).
## Important Notes
### Identity and Token Mapping
### Identity (user vs bot)
- `--as user` means **user identity** and uses `user_access_token`. Calls run as the authorized end user, so permissions depend on both the app scopes and that user's own access to the target chat/message/resource.
- `--as bot` means **bot identity** and uses `tenant_access_token`. Calls run as the app bot, so behavior depends on the bot's membership, app visibility, availability range, and bot-specific scopes.
- If an IM API says it supports both `user` and `bot`, the token type changes who the operator is. The same API can succeed with one identity and fail with the other because owner/admin status, chat membership, tenant boundary, or app availability are checked against the current caller.
- `--as user` (`user_access_token`): runs as the authorized user; permission = app scopes + that user's own access to the target.
- `--as bot` (`tenant_access_token`): runs as the app bot; depends on bot's chat membership, app visibility range, bot scopes.
- When an API supports both, the token decides *who* operates — owner/admin, membership, tenant, visibility are checked against the caller, so the same API can pass on one identity and fail on the other.
### Sender Name Resolution with Bot Identity
### Sender name resolution
When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-list`, `+threads-messages-list`, `+messages-mget`), sender names may not be resolved (shown as open_id instead of display name). This happens when the bot cannot access the user's contact info.
As **bot**, the sender may show as `open_id` (bot visibility range doesn't cover it); `--as user` gives real names.
**Root cause**: The bot's app visibility settings do not include the message sender, so the contact API returns no name.
```bash
lark-cli im +chat-messages-list --chat-id oc_xxx --as bot # BAD: sender = open_id
lark-cli im +chat-messages-list --chat-id oc_xxx --as user # GOOD: sender = real name
```
**Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access.
### Default message enrichment
### Default message enrichment (reactions / update_time)
The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Opt-in resource auto-download (`--download-resources`)
`+chat-messages-list`, `+messages-mget`, and `+threads-messages-list` accept `--download-resources` (**off by default** — no `resources` block and no extra requests when omitted). When set, eligible message resources (image/file/audio/video/media + post-embedded; **stickers excluded**) are downloaded into `./lark-im-resources/` and each message gains a `resources` array of `{message_id, key, type, local_path, size_bytes}`. Downloads are deduped by `(message_id, file_key)`, run with bounded concurrency, and isolate single-resource failures (`error: true` + stderr warning). **Scope:** requires `im:message:readonly` (already declared by the listing commands — no extra scope); works under both user and bot identity. For one-off downloads use [`+messages-resources-download`](references/lark-im-messages-resources-download.md). Full contract: [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### 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.
The four message-pulling shortcuts auto-attach `reactions` (+ `update_time` for edited messages) — no separate `reactions.batch_query` (needs `im:message.reactions:read`); `--no-reactions` opts out. `+chat-messages-list` / `+messages-mget` / `+threads-messages-list` also accept `--download-resources` (opt-in, off by default) to fetch message binaries into `./lark-im-resources/`. Contract: [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### 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
Two layers (item_type auto-detected from chat mode — rarely set by hand):
- **Message-layer** `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark.
- **Feed-layer** `(ItemType{Thread|MsgThread}, FlagTypeFeed)` — thread bookmarked at feed level:
- **ItemTypeThread** (4) = a topic in a topic-style chat (an entry in the group's Thread tab).
- **ItemTypeMsgThread** (11) = a reply thread under a single message in a regular group.
### Feed Shortcut
Feed shortcuts add chats to the **current user's** feed sidebar. They are distinct from flags:
Pins a chat to the **current user's** feed sidebar. Limits: **CHAT-type only** (oc_xxx); **user-identity only**; **10 per call** for create/remove; list uses opaque `page_token`.
- **Flag** = bookmark on a message/thread, scoped to the user's bookmark list.
- **Feed shortcut** = entry in the user's feed sidebar (currently only chats).
## 不在本 skill 范围
Key limits:
- Only **CHAT-type** (`feed_card_id` is `oc_xxx`) is exposed via OpenAPI; doc/app/subscription shortcuts exist internally but are not yet whitelisted.
- All three operations (create/remove/list) are **user-identity only** — they sign with `user_access_token`.
- Batch size is **10 per call** for create/remove; list is a one-page wrapper with opaque `page_token` pagination.
- 邮件 → [`lark-mail`](../lark-mail/SKILL.md)|日程/会议 → [`lark-calendar`](../lark-calendar/SKILL.md)|会议回放/纪要 → [`lark-vc`](../lark-vc/SKILL.md)
- 文档评论 → [`lark-drive`](../lark-drive/SKILL.md)IM 事件订阅 → [`lark-event`](../lark-event/SKILL.md)|姓名解析 open_id → [`lark-contact`](../lark-contact/SKILL.md)
群禁言 / 管理员 / 角色 / 解散 / 转让 / 群设置 等群治理 lark-cli im 暂无命令:如实告知“暂不支持”、勿臆造,引导用户到飞书客户端群设置手动操作(高风险写操作,勿擅自走原生 API 代执行)。

View File

@@ -30,12 +30,8 @@ lark-cli schema {{service}}.<resource>.<method> # 调用 API 前必须先查
lark-cli {{service}} <resource> <method> [flags] # 调用 API
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
{{schema_note_block}}
{{resource_sections}}
## 权限表
| 方法 | 所需 scope |
|------|-----------|
{{permission_rows}}
{{permission_block}}
{{/actions}}

View File

@@ -5,7 +5,7 @@ description: "飞书云文档Docx / Wiki 文档v2 API读取和编辑
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help; lark-cli docs resource-download --help; lark-cli docs resource-update --help; lark-cli docs resource-delete --help"
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help"
---
# docs (v2)
@@ -45,8 +45,6 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
- 新增画板必须隔离到 SubAgent简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs resource-download/resource-update/resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
@@ -73,7 +71,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
| [`resource-download` / `resource-update` / `resource-delete`](references/lark-doc-resource-cover.md) | Download, update, or delete a Docx cover image resource with `--type cover` |
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
## 不在本 Skill 范围

View File

@@ -124,7 +124,6 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
- `<img>` / `<source>``url` 时,直接用该 URL 下载即可(普通 HTTP GET无需走 shortcut。
- 没有 `url`、或只想预览 → `docs +media-preview --token <token> --output ./preview_media`
- 明确下载,或目标是 `<whiteboard>`(画板只能走 shortcut`docs +media-download --token <token> --output ./downloaded_media`
- 文档封面图不是正文素材;下载/更新/删除封面图 → `docs resource-download/resource-update/resource-delete --type cover`
## 嵌入电子表格 / 多维表格
@@ -135,5 +134,4 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
- [lark-doc-create](lark-doc-create.md) — 创建文档
- [lark-doc-update](lark-doc-update.md) — 更新文档
- [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
- [lark-doc-resource-cover](lark-doc-resource-cover.md) — 读取、更新、删除文档封面图
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图

View File

@@ -1,70 +0,0 @@
# docs resource-*Docx 封面图资源)
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
Docx 封面图不是正文里的 `<img token="...">` 素材块。读取、更新、删除文档封面图时,使用 `docs resource-download/resource-update/resource-delete --type cover`,不要使用 `+media-insert``+media-download --token <cover.token>` 让用户手动拼步骤。
## 选择规则
- 用户要下载文档封面图:`docs resource-download --type cover`
- 用户要设置/替换文档封面图:`docs resource-update --type cover`
- 用户要删除文档封面图:`docs resource-delete --type cover`
- 用户要下载正文图片、附件、画板缩略图:继续使用 [`docs +media-download`](lark-doc-media-download.md)
## 命令
```bash
# 下载封面图。CLI 会先读取 document.cover.token再下载图片内容并保存到本地。
lark-cli docs resource-download --doc doxcnXXX --type cover --output ./cover
# 使用本地文件更新封面图。
lark-cli docs resource-update --doc doxcnXXX --type cover --file ./cover.png
# 使用剪切板图片更新封面图。
lark-cli docs resource-update --doc doxcnXXX --type cover --from-clipboard
# 使用 HTTPS URL 更新封面图。CLI 会先下载 URL 内容,再上传并写入 cover.token。
lark-cli docs resource-update --doc doxcnXXX --type cover --url "https://example.com/cover.png"
# 可选:设置封面图裁切偏移。
lark-cli docs resource-update --doc doxcnXXX --type cover --file ./cover.png --offset-ratio-x 0.2 --offset-ratio-y 0.8
# 删除封面图;当文档本来没有封面图时也成功返回。
lark-cli docs resource-delete --doc doxcnXXX --type cover
```
## 参数
| 命令 | 参数 | 必填 | 说明 |
|------|------|------|------|
| all | `--doc <id>` | 是 | 文档 ID、docx URL或可解析为 docx 的 wiki URL |
| all | `--type cover` | 否 | 当前只支持 `cover`;默认值也是 `cover` |
| download | `--output <path>` | 是 | 本地保存路径;不带扩展名会根据响应类型自动补全 |
| download | `--overwrite` | 否 | 覆盖已存在的输出文件 |
| update | `--file <path>` | 三选一 | 磁盘上的真实图片文件;大于 20MiB 自动使用分片上传 |
| update | `--from-clipboard` | 三选一 | 从系统剪切板读取图片 |
| update | `--url <https-url>` | 三选一 | 从 HTTPS URL 下载图片后上传 |
| update | `--offset-ratio-x <number>` | 否 | 视图相对原图中心的横向偏移比例:水平偏移 px / 原图宽度 px0 为居中,正数向右,负数向左 |
| update | `--offset-ratio-y <number>` | 否 | 视图相对原图中心的纵向偏移比例:垂直偏移 px / 原图高度 px0 为居中,正数向上,负数向下 |
## 输出契约
- `resource-download` 成功时 stdout JSON 的 `data` 包含 `document_id``type``saved_path``size_bytes``content_type``cover.token`。如果文档没有封面图,命令失败退出,错误包含 `document has no cover` 和脱敏 `document_id`,不会创建输出文件。
- `resource-update` 成功时 stdout JSON 的 `data` 包含完整 `file_token``cover.token`stderr 只打印脱敏 token。
- `resource-delete` 成功时 stdout JSON 的 `data.deleted` 表示本次是否真的发起删除,`data.already_empty` 表示删除前是否没有封面图。空封面图是幂等成功,不报错。
## URL 来源安全边界
`resource-update --url` 只用于下载公开 HTTPS 图片:
- 只允许 `https://`,拒绝 HTTP、空 host 和 URL userinfo。
- 拒绝解析到 private、loopback、link-local、multicast、unspecified 地址的 host。
- 最多跟随 3 次跳转,每次跳转都重新校验 URL。
- 响应 `Content-Type` 只允许 `image/png``image/jpeg``image/gif``image/webp`
- 响应体最大 20MiB。
## 参考
- [lark-doc-media-download](lark-doc-media-download.md) — 下载正文素材或画板缩略图
- [lark-doc-media-insert](lark-doc-media-insert.md) — 在正文插入图片/文件
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -76,14 +76,6 @@ lark-cli drive +export \
--file-extension base \
--output-dir ./exports
# 导出多维表格结构为 .base 快照(仅导出表结构,不导出记录数据)
lark-cli drive +export \
--token "<BITABLE_TOKEN>" \
--doc-type bitable \
--file-extension base \
--only-schema \
--output-dir ./exports
# 允许覆盖已存在文件
lark-cli drive +export \
--token "<DOCX_TOKEN>" \
@@ -100,7 +92,6 @@ lark-cli drive +export \
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` |
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` / `pptx` |
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
| `--only-schema` | 否 | 仅当 `--doc-type bitable --file-extension base` 时可用;只导出多维表格结构,不导出记录数据 |
| `--file-name` | 否 | 覆盖默认本地文件名;如未带扩展名,会按 `--file-extension` 自动补齐 |
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
| `--overwrite` | 否 | 覆盖已存在文件 |
@@ -109,7 +100,6 @@ lark-cli drive +export \
- `markdown` 只支持 `docx`
- `base` 只支持 `bitable`
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
- `pptx` 只支持 `slides`
- `slides` 支持导出为 `pptx` / `pdf`
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`

View File

@@ -1,7 +1,7 @@
---
name: lark-im
version: 1.0.0
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据时使用。"
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶、管理标签数据时使用。不负责收发邮件(→ lark-mail、日程与会议安排→ lark-calendar、会议回放与纪要→ lark-vc、IM 事件订阅(→ lark-event。"
metadata:
requires:
bins: ["lark-cli"]
@@ -12,104 +12,79 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## Core Concepts
- **Message**: A single message in a chat, identified by `message_id` (om_xxx). Supports types: text, post, image, file, audio, video, sticker, interactive (card), share_chat, share_user, merge_forward, etc.
- **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.
- **Feed Shortcut**: A chat pinned to the current user's feed sidebar, identified by `feed_card_id` (an `oc_xxx` open_chat_id for CHAT type).
- **Feed Group**: A tag that groups feed cards in the feed list, identified by `feed_group_id` (ofg_xxx). Members are feed cards, each identified by `feed_id` + `feed_type`. Two types: `normal` (members managed explicitly) and `rule` (members auto-derived from rules).
## Resource Relationships
```
Chat (oc_xxx)
├── Message (om_xxx)
│ ├── Thread (reply thread)
│ ├── Reaction (emoji)
│ └── Resource (image / file / video / audio)
└── Member (user / bot)
```
## Important Notes
### Identity and Token Mapping
- `--as user` means **user identity** and uses `user_access_token`. Calls run as the authorized end user, so permissions depend on both the app scopes and that user's own access to the target chat/message/resource.
- `--as bot` means **bot identity** and uses `tenant_access_token`. Calls run as the app bot, so behavior depends on the bot's membership, app visibility, availability range, and bot-specific scopes.
- If an IM API says it supports both `user` and `bot`, the token type changes who the operator is. The same API can succeed with one identity and fail with the other because owner/admin status, chat membership, tenant boundary, or app availability are checked against the current caller.
### Sender Name Resolution with Bot Identity
When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-list`, `+threads-messages-list`, `+messages-mget`), sender names may not be resolved (shown as open_id instead of display name). This happens when the bot cannot access the user's contact info.
**Root cause**: The bot's app visibility settings do not include the message sender, so the contact API returns no name.
**Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access.
### Default message enrichment (reactions / update_time)
The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Opt-in resource auto-download (`--download-resources`)
`+chat-messages-list`, `+messages-mget`, and `+threads-messages-list` accept `--download-resources` (**off by default** — no `resources` block and no extra requests when omitted). When set, eligible message resources (image/file/audio/video/media + post-embedded; **stickers excluded**) are downloaded into `./lark-im-resources/` and each message gains a `resources` array of `{message_id, key, type, local_path, size_bytes}`. Downloads are deduped by `(message_id, file_key)`, run with bounded concurrency, and isolate single-resource failures (`error: true` + stderr warning). **Scope:** requires `im:message:readonly` (already declared by the listing commands — no extra scope); works under both user and bot identity. For one-off downloads use [`+messages-resources-download`](references/lark-im-messages-resources-download.md). Full contract: [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### 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
### Feed Shortcut
Feed shortcuts add chats to the current user's feed sidebar. They are distinct from flags:
- **Flag** = bookmark on a message/thread, scoped to the user's bookmark list.
- **Feed shortcut** = entry in the user's feed sidebar (currently only chats).
Key limits:
- Only **CHAT-type** (`feed_card_id` is `oc_xxx`) is exposed via OpenAPI; doc/app/subscription shortcuts exist internally but are not yet whitelisted.
- All three operations (create/remove/list) are **user-identity only** — they sign with `user_access_token`.
- Batch size is **10 per call** for create/remove; list is a one-page wrapper with opaque `page_token` pagination.
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
| [`+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 --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
| [`+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 |
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; supports automatic chunked download for large files (8MB chunks), auto-detects file extension from Content-Type |
| [`+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; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode) |
| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer |
| [`+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 |
| [`+feed-shortcut-create`](references/lark-im-feed-shortcut-create.md) | Add chats to the user's feed shortcuts; user-only; oc_xxx chat IDs only; batch up to 10 per call; `--head`/`--tail` controls insertion order; partial failures return an `ok:false` ledger |
| [`+feed-shortcut-remove`](references/lark-im-feed-shortcut-remove.md) | Remove chats from the user's feed shortcuts; user-only; batch up to 10 per call; removing an absent shortcut is idempotent success; real per-item failures return an `ok:false` ledger |
| [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List one page of the user's feed shortcuts; user-only; omit `--page-token` for the first page; default output enriches CHAT entries under `detail`; pass `--no-detail` to skip the extra lookup and `im:chat:read` scope |
| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination |
| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination |
| [`+feed-group-query-item`](references/lark-im-feed-group-query-item.md) | Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id |
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat |
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description |
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs |
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies) |
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message |
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity |
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message |
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread |
| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message |
| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark |
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks |
| [`+feed-shortcut-create`](references/lark-im-feed-shortcut-create.md) | Add chats to the user's feed shortcuts |
| [`+feed-shortcut-remove`](references/lark-im-feed-shortcut-remove.md) | Remove chats from the user's feed shortcuts |
| [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List one page of the user's feed shortcuts |
| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags) |
| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag) |
| [`+feed-group-query-item`](references/lark-im-feed-group-query-item.md) | Look up specific feed cards in a feed group (tag) by ID |
## Core Concepts
- **Message** `message_id` (om_xxx) · **Chat** `chat_id` (oc_xxx, group or P2P) · **Thread** `thread_id` (om_xxx / omt_xxx).
- **Flag** — bookmark on a message/thread (two layers, see below).
- **Feed Shortcut** `feed_card_id` (oc_xxx) — a chat pinned to the user's feed sidebar.
- **Feed Group** `feed_group_id` (ofg_xxx) — a tag grouping feed cards (`feed_id`+`feed_type`); `normal` (explicit) / `rule` (auto-derived).
## Important Notes
### Identity (user vs bot)
- `--as user` (`user_access_token`): runs as the authorized user; permission = app scopes + that user's own access to the target.
- `--as bot` (`tenant_access_token`): runs as the app bot; depends on bot's chat membership, app visibility range, bot scopes.
- When an API supports both, the token decides *who* operates — owner/admin, membership, tenant, visibility are checked against the caller, so the same API can pass on one identity and fail on the other.
### Sender name resolution
As **bot**, the sender may show as `open_id` (bot visibility range doesn't cover it); `--as user` gives real names.
```bash
lark-cli im +chat-messages-list --chat-id oc_xxx --as bot # BAD: sender = open_id
lark-cli im +chat-messages-list --chat-id oc_xxx --as user # GOOD: sender = real name
```
### Default message enrichment
The four message-pulling shortcuts auto-attach `reactions` (+ `update_time` for edited messages) — no separate `reactions.batch_query` (needs `im:message.reactions:read`); `--no-reactions` opts out. `+chat-messages-list` / `+messages-mget` / `+threads-messages-list` also accept `--download-resources` (opt-in, off by default) to fetch message binaries into `./lark-im-resources/`. Contract: [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Flag Types
Two layers (item_type auto-detected from chat mode — rarely set by hand):
- **Message-layer** `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark.
- **Feed-layer** `(ItemType{Thread|MsgThread}, FlagTypeFeed)` — thread bookmarked at feed level:
- **ItemTypeThread** (4) = a topic in a topic-style chat (an entry in the group's Thread tab).
- **ItemTypeMsgThread** (11) = a reply thread under a single message in a regular group.
### Feed Shortcut
Pins a chat to the **current user's** feed sidebar. Limits: **CHAT-type only** (oc_xxx); **user-identity only**; **10 per call** for create/remove; list uses opaque `page_token`.
## 不在本 skill 范围
- 邮件 → [`lark-mail`](../lark-mail/SKILL.md)|日程/会议 → [`lark-calendar`](../lark-calendar/SKILL.md)|会议回放/纪要 → [`lark-vc`](../lark-vc/SKILL.md)
- 文档评论 → [`lark-drive`](../lark-drive/SKILL.md)IM 事件订阅 → [`lark-event`](../lark-event/SKILL.md)|姓名解析 open_id → [`lark-contact`](../lark-contact/SKILL.md)
群禁言 / 管理员 / 角色 / 解散 / 转让 / 群设置 等群治理 lark-cli im 暂无命令:如实告知“暂不支持”、勿臆造,引导用户到飞书客户端群设置手动操作(高风险写操作,勿擅自走原生 API 代执行)。
## API Resources
@@ -118,114 +93,47 @@ lark-cli schema im.<resource>.<method> # 调用 API 前必须先查看参数
lark-cli im <resource> <method> [flags] # 调用 API
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### chats
- `create` — 创建群。Identity: `bot` only (`tenant_access_token`).
- `get` — 获取群信息。Identity: supports `user` and `bot`; the caller must be in the target chat to get full details, and must belong to the same tenant for internal chats.
- `link` — 获取群分享链接。Identity: supports `user` and `bot`; the caller must be in the target chat, must be an owner or admin when chat sharing is restricted to owners/admins, and must belong to the same tenant for internal chats.
- `update` — 更新群信息。Identity: supports `user` and `bot`.
`create`(bot) · `get`(user/bot) · `link`(user/bot) · `update`(user/bot)
### 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.
- `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.
`bots`(user/bot) · `create`(user/bot) · `delete`(user/bot) · `get`(user/bot)
### chat.user_setting
- `batch_query` — 批量查询当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
- `batch_update` — 批量更新当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
`batch_query`(user) · `batch_update`(user)
### chat.managers
- `add_managers` — 指定群管理员。Identity: supports `user` and `bot`; only the group owner can add managers; max 10 managers per chat (20 for super-large chats), and at most 5 bots per request.
- `delete_managers` — 删除群管理员。Identity: supports `user` and `bot`; only the group owner can remove managers; max 50 users or 5 bots per request.
`add_managers`(user/bot) · `delete_managers`(user/bot)
### chat.moderation
- `get` — 获取群成员发言权限。Identity: supports `user` and `bot`; the caller must be in the target chat and belong to the same tenant.
- `update` — 更新群发言权限。Identity: supports `user` and `bot`; only the group owner (or creator bot with `im:chat:operate_as_owner`) can update; the caller must be in the chat.
`get`(user/bot) · `update`(user/bot)
### messages
- `delete` — 撤回消息。Identity: supports `user` and `bot`; for `bot` calls, the bot must be in the chat to revoke group messages; to revoke another user's group message, the bot must be the owner, an admin, or the creator; for user P2P recalls, the target user must be within the bot's availability.
- `forward` — 转发消息。Identity: supports `user` and `bot`.
- `merge_forward` — 合并转发消息。Identity: `bot` only (`tenant_access_token`).
- `read_users` — 查询消息已读信息。Identity: `bot` only (`tenant_access_token`); the bot must be in the chat, and can only query read status for messages it sent within the last 7 days.
- `urgent_app` — 发送应用内加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
- `urgent_phone` — 发送电话加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
- `urgent_sms` — 发送短信加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
`delete`(user/bot) · `forward`(user/bot) · `merge_forward`(bot) · `read_users`(bot) · `urgent_app`(bot) · `urgent_phone`(bot) · `urgent_sms`(bot)
### reactions
- `batch_query` — 批量获取消息表情。Identity: supports `user` and `bot`.[Must-read](references/lark-im-reactions.md)
- `create` — 添加消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
- `delete` — 删除消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message, and can only delete reactions added by itself.[Must-read](references/lark-im-reactions.md)
- `list` — 获取消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
`batch_query`(user/bot) · `create`(user/bot) · `delete`(user/bot) · `list`(user/bot)
### threads
- `forward` — 转发话题。Identity: supports `user` and `bot`.
`forward`(user/bot)
### images
- `create` — 上传图片。Identity: `bot` only (`tenant_access_token`).
`create`(bot)
### pins
- `create` — Pin 消息。Identity: supports `user` and `bot`.
- `delete` — 移除 Pin 消息。Identity: supports `user` and `bot`.
- `list` — 获取群内 Pin 消息。Identity: supports `user` and `bot`.
`create`(user/bot) · `delete`(user/bot) · `list`(user/bot)
### feed.groups
- `batch_add_item` — Batch add feed cards to a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- `batch_query` — Batch query feed groups. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- `batch_remove_item` — Batch remove feed cards from a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- `create` — Create a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- `delete` — Delete a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
- `update` — Update a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
`batch_add_item`(user) · `batch_query`(user) · `batch_remove_item`(user) · `create`(user) · `delete`(user) · `update`(user)
## 权限表
| 方法 | 所需 scope |
|------|-----------|
| `chats.create` | `im:chat:create` |
| `chats.get` | `im:chat:read` |
| `chats.link` | `im:chat:read` |
| `chats.update` | `im:chat:update` |
| `chat.members.bots` | `im:chat.members:read` |
| `chat.members.create` | `im:chat.members:write_only` |
| `chat.members.delete` | `im:chat.members:write_only` |
| `chat.members.get` | `im:chat.members:read` |
| `chat.user_setting.batch_query` | `im:chat.user_setting:read` |
| `chat.user_setting.batch_update` | `im:chat.user_setting:write` |
| `chat.managers.add_managers` | `im:chat.managers:write_only` |
| `chat.managers.delete_managers` | `im:chat.managers:write_only` |
| `chat.moderation.get` | `im:chat.moderation:read` |
| `chat.moderation.update` | `im:chat:moderation:write_only` |
| `messages.delete` | `im:message:recall` |
| `messages.forward` | `im:message` |
| `messages.merge_forward` | `im:message` |
| `messages.read_users` | `im:message:readonly` |
| `messages.urgent_app` | `im:message.urgent` |
| `messages.urgent_phone` | `im:message.urgent:phone` |
| `messages.urgent_sms` | `im:message.urgent:sms` |
| `reactions.batch_query` | `im:message.reactions:read` |
| `reactions.create` | `im:message.reactions:write_only` |
| `reactions.delete` | `im:message.reactions:write_only` |
| `reactions.list` | `im:message.reactions:read` |
| `threads.forward` | `im:message` |
| `images.create` | `im:resource` |
| `pins.create` | `im:message.pins:write_only` |
| `pins.delete` | `im:message.pins:write_only` |
| `pins.list` | `im:message.pins:read` |
| `feed.groups.batch_add_item` | `im:feed_group_v1:write` |
| `feed.groups.batch_query` | `im:feed_group_v1:read` |
| `feed.groups.batch_remove_item` | `im:feed_group_v1:write` |
| `feed.groups.create` | `im:feed_group_v1:write` |
| `feed.groups.delete` | `im:feed_group_v1:write` |
| `feed.groups.update` | `im:feed_group_v1:write` |

View File

@@ -1,162 +1,40 @@
# im +chat-create
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
Create a group chat. Supports both user identity (`--as user`) and bot identity (`--as bot`). You can specify the group name, description, members (users/bots), owner, chat type (private/public), and group mode. Set `--chat-mode topic` to create a topic chat.
Maps to `lark-cli im +chat-create`. **Run `lark-cli im +chat-create --help` for the authoritative flags (`--name` / `--description` / `--users` / `--bots` / `--owner` / `--type` / `--chat-mode` / `--set-bot-manager` / `--as`), limits, and enums.** This file covers only what `--help` cannot.
This skill maps to the shortcut: `lark-cli im +chat-create` (internally calls `POST /open-apis/im/v1/chats`).
Identity choice follows [Group Chat Identity Rules](lark-im-chat-identity.md). Scope: `--as bot` needs `im:chat:create`; `--as user` needs `im:chat:create_by_user`.
- `--as bot` requires the `im:chat:create` scope.
- `--as user` requires the `im:chat:create_by_user` scope.
## Gotchas
## Commands
- **`--chat-mode topic` ≠ "normal group in topic-message mode".** `topic` makes the *entire* group a 话题群. A normal group (`chat_mode=group`) with `group_message_type=thread` is a different thing — and this CLI intentionally does NOT surface `group_message_type`. When the user asks for a topic chat, pass `--chat-mode topic` explicitly; never rely on the default.
- **`--type public` defaults to `private`** — only pass `public` when the user explicitly asks for a discoverable group.
- **`--set-bot-manager` is silently a no-op without `--as bot`** (effective only when the creating bot is also a member).
```bash
# Create a private group (default)
lark-cli im +chat-create --name "My Group"
## The bot-invisible-members trap → create-then-add recipe
# Create a public group (name is required and must be at least 2 characters)
lark-cli im +chat-create --name "Public Group" --type public
A bot **cannot** invite users who are mutually invisible to it during creation — passing them in `--users` fails the *whole* request with **232043**. Do NOT pass arbitrary users in `--users` under `--as bot`. Instead:
# Create a topic chat
lark-cli im +chat-create --name "Topic Group" --chat-mode topic
# Specify the group owner
lark-cli im +chat-create --name "My Group" --owner ou_xxx
# Invite user members (comma-separated open_ids, up to 50)
lark-cli im +chat-create --name "My Group" --users "ou_aaa,ou_bbb"
# Invite bot members (comma-separated app IDs, up to 5)
lark-cli im +chat-create --name "My Group" --bots "cli_aaa,cli_bbb"
# Invite both users and bots
lark-cli im +chat-create --name "My Group" --users "ou_aaa" --bots "cli_aaa"
# Make the creating bot a group manager (bot identity only)
lark-cli im +chat-create --name "My Group" --set-bot-manager --as bot
# JSON output
lark-cli im +chat-create --name "My Group" --format json
# Create a group with bot identity
lark-cli im +chat-create --name "My Group" --users "ou_aaa" --as bot
# Create a group with user identity
lark-cli im +chat-create --name "My Group" --users "ou_aaa,ou_bbb" --as user
# Preview the request without creating anything
lark-cli im +chat-create --name "My Group" --dry-run
```
## Parameters
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--name <name>` | Required for public groups | Max 60 characters; at least 2 characters for public groups | Group name (`"(no subject)"` for private groups if omitted) |
| `--description <text>` | No | Max 100 characters | Group description |
| `--users <ids>` | No | Up to 50, format `ou_xxx` | Comma-separated user open_ids |
| `--bots <ids>` | No | Up to 5, format `cli_xxx` | Comma-separated bot app IDs |
| `--owner <open_id>` | No | Format `ou_xxx` | Owner open_id (defaults to the bot when using `--as bot`, or the authorized user when using `--as user`) |
| `--type <type>` | No | `private` (default) or `public` | Group type. Default to `private`; pass `public` only when the user explicitly asks for a discoverable/public group. |
| `--chat-mode <mode>` | No | `group` (default) or `topic` | Group mode; `topic` creates a topic chat (not the same as `group_message_type=thread`). When the user asks for a topic chat, pass `topic` explicitly — do not rely on the default. |
| `--set-bot-manager` | No | - | Set the creating bot as a group manager (only effective with `--as bot`) |
| `--format json` | No | - | Output as JSON |
| `--as <identity>` | No | `bot` or `user` | Identity type |
| `--dry-run` | No | - | Preview the request without executing it |
> **`--chat-mode topic` vs "normal group with topic-message mode"**: `--chat-mode topic` here creates a 话题群 — the entire group is a topic chat. This is different from "normal group (`chat_mode=group`) + topic-message mode (`group_message_type=thread`)". This CLI exposes only `chat_mode`; `group_message_type` is intentionally not surfaced.
## AI Usage Guidance
### When using `--as bot`
Bot may fail to invite users who are mutually invisible to it during group creation (error 232043). To avoid this, use the **two-step flow** below instead of passing other users' open_ids in `--users`.
1. **Get the current user's open_id:** Run `lark-cli contact +search-user --query "<name or email>"` to retrieve it.
2. **Create the group — by default include the current user:**
1. Resolve the **current user's** open_id (`lark-cli contact +search-user --query "<name|email>"`).
2. Create the group with the bot, including **only the current user** (omit `--users` entirely only if the user explicitly says "bot-only group" / "do not add me"):
```bash
lark-cli im +chat-create --name "<group name>" \
--users "<current user open_id>" --as bot
lark-cli im +chat-create --name "<name>" --users "<current user open_id>" --as bot
```
**Default behavior:** Always add the current user to the group, unless the user explicitly says "do not add me" or "bot-only group" — only then omit `--users`.
3. **Add other members via user identity** (requires the current user to be in the group):
3. Add the remaining members with **user identity** (requires the current user to be in the group), tolerating unreachable IDs via `succeed_type=1`:
```bash
lark-cli im chat.members create \
--params '{"chat_id":"<chat_id from step 2>","member_id_type":"open_id","succeed_type":1}' \
--data '{"id_list":["ou_aaa","ou_bbb"]}' \
--as user
--data '{"id_list":["ou_aaa","ou_bbb"]}' --as user
```
`succeed_type=1` ensures reachable users are added successfully; unreachable ones are returned in `invalid_id_list` instead of failing the whole request.
4. **Check `invalid_id_list`** in the response; report any members that could not be added.
4. **Check `invalid_id_list`** in the response. If non-empty, report to the user which members could not be added.
Under `--as user` there is no visibility limit, so create + invite happens in one call; the authorized user is automatically creator and member.
### When using `--as user`
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
User identity does not have the bot visibility limitation, so you can create the group and invite members in one step:
```bash
lark-cli im +chat-create --name "<group name>" --users "ou_aaa,ou_bbb" --as user
```
The authorized user is automatically the group creator and member.
## Output Fields
| Field | Description |
|------|------|
| `chat_id` | The new group's ID (`oc_xxx` format) |
| `name` | Group name |
| `chat_type` | Group type (`private` / `public`) |
| `owner_id` | Owner ID (may be empty when a bot creates the group and `--owner` is not specified) |
| `external` | Whether the group is external |
| `share_link` | Group share link (omitted if retrieval fails) |
## Usage Scenarios
### Scenario 1: Create a group and specify the owner
```bash
lark-cli im +chat-create --name "Project Discussion Group" --owner ou_xxx
```
### Scenario 2: Create a group and invite users and a bot
```bash
lark-cli im +chat-create --name "Project Discussion Group" \
--owner ou_xxx \
--users "ou_aaa,ou_bbb" \
--bots "cli_aaa"
```
### Scenario 3: Create a group and send a welcome message
```bash
CHAT_ID=$(lark-cli im +chat-create --name "New Group" --format json | jq -r '.data.chat_id')
lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Welcome, everyone!"
```
## Common Errors and Troubleshooting
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| Permission denied (99991672) | The app does not have `im:chat:create` (bot) or `im:chat:create_by_user` (user) permission enabled | Enable the required permission for the app in the Open Platform console |
| `--name is required for public groups and must be at least 2 characters` | A public group was created without a name or with a name shorter than 2 characters | Provide a name with at least 2 characters |
| `--name exceeds the maximum of 60 characters` | The group name is too long | Shorten the name to 60 characters or fewer |
| `--description exceeds the maximum of 100 characters` | The group description is too long | Shorten the description to 100 characters or fewer |
| `--users exceeds the maximum of 50` | Too many user members were provided | Split the operation into batches and add more members later |
| `--bots exceeds the maximum of 5` | Too many bot members were provided | Invite at most 5 bots at once |
| `invalid user id: expected open_id (ou_xxx)` | Invalid user ID format | Use the `ou_xxx` format for users |
| `invalid bot id: expected app ID (cli_xxx)` | Invalid bot ID format | Use the `cli_xxx` format for bots |
| `invalid --owner: expected open_id (ou_xxx)` | Invalid owner ID format | Use the `ou_xxx` format for the owner |
| `bot is invisible to user` (232043) | The bot and target users are mutually invisible | Follow the two-step flow in AI Usage Guidance above — do not pass other users in `--users` during creation |
## References
- [lark-im](../SKILL.md) - all IM commands
- [lark-shared](../../lark-shared/SKILL.md) - authentication and global parameters
- **Output fields**: `chat_id` (oc_xxx) · `name` · `chat_type` (`private` / `public`) · `owner_id` (**may be empty** when a bot creates the group and `--owner` is unset) · `external` · `share_link` (**omitted if retrieval fails**).

View File

@@ -1,55 +1,26 @@
# Group Chat Identity Rules
> Warning: The most common source of failure in group operations is choosing the wrong identity. Confirm the identity before performing the action.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
Group-chat operations support both `--as user` (UAT user identity) and `--as bot` (TAT bot identity). Choosing the correct identity is critical for success.
Concept doc (no direct command). These cross-cutting identity/ownership rules apply to [`+chat-create`](lark-im-chat-create.md), [`+chat-update`](lark-im-chat-update.md), and the member-management flow (`im chat.members ...`). **The most common cause of group-operation failure is choosing the wrong identity** — decide it before acting.
## Basic Principles
## Gotchas
- **If the user explicitly specifies an identity:** use exactly what the user requested (`--as user` or `--as bot`) without guessing.
- **If the user does not specify an identity:** infer the correct identity from context instead of relying on the default.
- **If the user names an identity, use it verbatim** (`--as user` / `--as bot`) — do not second-guess. Only infer when the user is silent; never just take the default.
- **Adding members → prefer `--as user`.** Bot visibility is limited and fails when the target is mutually invisible to the bot (**232024** for member-add, **232043** during create). See the create-then-add recipe in [`+chat-create`](lark-im-chat-create.md); do not retry the bot blindly.
- **Owner-level actions need owner/admin identity** (rename/permissions: **232016 / 232002 / 232017** if under-privileged; **232011** if not in the group).
## Identity Selection by Operation
## Inferring the owner (when an owner-level action is needed and the owner is unknown)
| Operation | Recommended Identity | Why |
|------|---------|-----------------------------------|
| Create group (`+chat-create`) | Depends on the scenario | Infer from context |
| Add members (member-management flow) | `--as user` | Bot visibility is limited and often fails when the target user is mutually invisible to the bot (232024) |
| Update group (`+chat-update`) | Owner identity | Permission changes require owner/admin privileges; owner transfer requires owner identity |
1. Bot created the group, `--owner` **unset** → owner is the bot (`--as bot`).
2. Bot created the group, `--owner ou_xxx` **set** → owner is that user (`--as user`).
3. User created the group, `--owner` **unset** → owner is the current user (`--as user`).
4. Still unclear → **ask** before any owner-level change; don't guess.
## Inferring the Owner
## When the owner is a third party (neither current user nor bot)
When an owner-level action is needed and the owner is unknown, infer in this order:
The current identity has no owner privileges. Then:
1. A bot created the group and `--owner` was **not** specified -> the owner is the bot (`--as bot`)
2. A bot created the group and `--owner ou_xxx` **was** specified -> the owner is that user (`--as user`)
3. A user created the group and `--owner` was **not** specified -> the owner is the current user (`--as user`)
4. Still unclear -> ask the user to confirm who owns the group before making owner-level changes
### When the Owner Is Neither the Current User Nor the Bot
If the query shows that the owner is a third-party user (`owner_id` is neither the currently authorized user nor the bot), the current identity does not have owner privileges. In that case:
- **Permission/setting changes:** if the bot is an admin of the group, `--as bot` can still perform admin-level operations such as renaming the group or changing permissions.
- **Owner-only actions such as owner transfer:** require the actual owner to complete UAT authorization via `lark-cli auth login`, then perform the action as that owner.
- Explain the limitation clearly to the user instead of retrying blindly.
## Common Pitfalls
### Inviting Members During Group Creation
If a bot creates a group and `--users` includes users who are mutually invisible to the bot, the entire request fails with 232043. Use two steps instead:
1. Create the group with the bot first, excluding invisible users: `lark-cli im +chat-create --name "Group Name"`
2. Add users later with a user-identity member-management flow
### Insufficient Privileges
- **232016 / 232002 / 232017:** the current identity is not the owner or an admin -> switch to the owner identity
- **232011:** the current user is not in the group -> use a group-member identity, or join the group first
- **232024:** the bot and the target user are mutually invisible -> switch to `--as user`
## References
- [lark-im](../SKILL.md) - all IM commands
- [lark-shared](../../lark-shared/SKILL.md) - authentication and global parameters
- **Rename / permission / setting changes:** if the bot is a group **admin**, `--as bot` can still perform these admin-level operations.
- **Owner-only actions (e.g. owner transfer):** the actual owner must complete UAT auth via `lark-cli auth login` first, then act as that owner. There is no bot workaround.
- Explain the limitation to the user instead of retrying blindly.

View File

@@ -1,166 +1,30 @@
# im +chat-list
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
List chats the current user (or bot, with `--as bot`) is a member of. **Not a search API — there is no `--query` parameter; the call always returns the full member list, paginated.** For keyword-based lookup (e.g. find a group by name or by member), use [`+chat-search`](lark-im-chat-search.md) instead.
Maps to `lark-cli im +chat-list`. **Run `lark-cli im +chat-list --help` for the authoritative flags (`--types` / `--sort` / `--page-size` / `--page-token` / `--exclude-muted` / `--user-id-type` / `--as`), enums, and defaults.** This file covers only what `--help` cannot.
**Defaults to groups only**; pass `--types=p2p,group` (or `--types p2p --types group`) to also include p2p single chats (user identity only — see ["Bot identity and p2p"](#bot-identity-and-p2p)). Supports pagination, sort order, and (user identity only) muted-chat filtering.
## Gotchas
This skill maps to the shortcut: `lark-cli im +chat-list` (internally calls `GET /open-apis/im/v1/chats`).
- **Not a search API — there is no `--query`.** It always returns the full member list, paginated. For keyword/name/member lookup use [`+chat-search`](lark-im-chat-search.md); do NOT loop `+chat-list` to find a named group.
- **Defaults to groups only.** p2p single chats appear only when you pass `--types` to include `p2p`, and **only under `--as user`** (see below).
- **`--exclude-muted` is user-only.** Under `--as bot` the mute API is UAT-only, so the filter is silently skipped and all chats come back unfiltered — don't trust it under bot identity.
## Commands
### Bot identity + p2p (silent-downgrade trap)
```bash
# List the user's chats (default sort: create_time, ascending)
lark-cli im +chat-list
`tenant_access_token` cannot enumerate p2p chats (user-privacy protection). Under `--as bot`:
# Sort by recent activity (most recently active first)
lark-cli im +chat-list --sort active_time
- `--types=p2p` alone → **rejected at validation** with an actionable error; no request sent.
- `--types=p2p,group` → CLI **silently strips `p2p`** and sends `group` only. The strip surfaces two ways (so neither humans nor agents miss it): a `warning: bot_strip_p2p: ...` line on **stderr**, and a structured entry in the top-level **`notices`** array of the stdout JSON (`{"code":"bot_strip_p2p","message":"..."}`). DryRun emits the same stderr warning.
# Limit page size
lark-cli im +chat-list --page-size 50
To include p2p, switch to `--as user --types=p2p,group`.
# Pagination
lark-cli im +chat-list --page-token "xxx"
### `--exclude-muted` output envelope
# Drop muted chats (user identity only)
lark-cli im +chat-list --exclude-muted
When set, the JSON gains a top-level `filter` sub-object (absent otherwise, so existing consumers are unaffected). Invariant **`fetched_count == returned_count + filtered_count`** always holds. Filtering is **per page, client-side** (the page's chat_ids are batched through the mute-status API after the list call), so a filtered page can return fewer than `--page-size` rows — keep paginating via `page_token`. `filter` and `notices` are separate top-level keys and never collide.
# JSON output
lark-cli im +chat-list --format json
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
# Preview the request without executing it
lark-cli im +chat-list --dry-run
# Include p2p single chats (user identity only) — comma form
lark-cli im +chat-list --as user --types p2p,group
# Same, using repeat flag instead of CSV
lark-cli im +chat-list --as user --types p2p --types group
# Only p2p single chats (user identity only)
lark-cli im +chat-list --as user --types p2p
```
## Parameters
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--user-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response |
| `--types <strings>` | No | `group`, `p2p` (comma-separated or repeated) | Chat types to include. Omitted = groups only (backward compatible). `p2p` requires user identity (`--as user`); under `--as bot`, `--types=p2p` alone is rejected and `--types=p2p,group` is silently downgraded to `group` |
| `--sort <field>` | No | `create_time` (default, ascending), `active_time` (descending) | Result ordering |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive; see "Filtering muted chats" below |
| `--format json` | No | - | Output as JSON |
| `--dry-run` | No | - | Preview the request without executing it |
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
## Output Fields
| Field | Description |
|------|------|
| `chat_id` | Chat ID (`oc_xxx` format) |
| `name` | Chat name |
| `description` | Chat description |
| `owner_id` | Owner ID (type controlled by `--user-id-type`) |
| `external` | Whether the chat is external |
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
| `chat_mode` | Chat mode discriminator: `group` (regular) / `topic` (topic group) / `p2p` (single chat) |
| `p2p_target_type` | Peer type, e.g., `user` |
| `p2p_target_id` | Peer ID (type controlled by `--user-id-type`) |
## Including p2p single chats
Default behavior lists groups only — same as before this feature. To include p2p, pass `--types`:
| User intent | Call | Identity |
|---|---|---|
| "list my groups" / 我的群 / 我加入了哪些群 | (default, omit `--types`) | user or bot |
| "list my p2p chats" / 我的单聊 / 我跟谁有 1v1 | `--types p2p` | **user only** |
| "all my chats" / 全部聊天 / 所有会话 (ambiguous) | `--types p2p,group` | **user only** |
For p2p rows in the response: `name` is the peer's display name, `owner_id` follows group semantics, `chat_mode = "p2p"`, and `p2p_target_type` / `p2p_target_id` identify the peer.
## Bot identity and p2p
`tenant_access_token` cannot list p2p chats — to protect user privacy, bot identity is not permitted to enumerate p2p single chats. Behavior under `--as bot`:
- `--as bot --types=p2p` → rejected at validation time with an actionable error; no request is sent.
- `--as bot --types=p2p,group` → CLI strips `p2p` and sends `types=group`. Request proceeds; only groups are returned. The strip is a **request-level adjustment**, surfaced two ways so neither humans nor agents miss it:
- **stderr**: `warning: bot_strip_p2p: To protect user privacy, bot identity cannot list p2p chats; --types=p2p,group was sent as types=group. Use --as user to include p2p.` (matches the `warning: <code>: <message>` convention in `shortcuts/common/runner.go`)
- **stdout JSON**: a top-level `notices` array gains a structured entry:
```json
{
"chats": [...],
"notices": [
{ "code": "bot_strip_p2p", "message": "To protect user privacy, bot identity cannot list p2p chats; …" }
]
}
```
- The `filter` slot stays scoped to `--exclude-muted`; `notices` is a separate top-level key, so the two never collide and no priority is needed when both fire.
- DryRun emits the same stderr warning so a previewed request truthfully reflects what Execute will send (parity with `shortcuts/drive/drive_search.go`).
- `--as bot --types=group` → accepted, returns groups normally.
- `--as bot` (no `--types`) → unchanged, returns groups.
To include p2p single chats, switch to user identity: `--as user --types=p2p,group`.
## Filtering muted chats
`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the list call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped.
When the flag is set, the JSON envelope gains a `filter` sub-object (absent otherwise, so existing consumers are unaffected); `fetched_count == returned_count + filtered_count` always holds:
```json
{
"chats": [...],
"filter": {
"applied": "exclude_muted",
"fetched_count": 20,
"returned_count": 17,
"filtered_count": 3,
"hint": "Filtered out 3 muted chat(s) on this page (17 remaining); use --page-token to fetch more."
}
}
```
## Usage Scenarios
### Scenario 1: List my recent chats
```bash
lark-cli im +chat-list --sort active_time --page-size 10
```
### Scenario 2: List my non-muted chats sorted by activity
```bash
lark-cli im +chat-list --sort active_time --exclude-muted
```
### Scenario 3: Iterate all my chats programmatically
```bash
TOKEN=""
while :; do
RESP=$(lark-cli im +chat-list --page-size 100 --page-token "$TOKEN" --format json)
echo "$RESP" | jq -r '.data.chats[].chat_id'
HAS_MORE=$(echo "$RESP" | jq -r '.data.has_more')
[ "$HAS_MORE" = "true" ] || break
TOKEN=$(echo "$RESP" | jq -r '.data.page_token')
done
```
## Common Errors and Troubleshooting
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
| `--exclude-muted` returns all chats unfiltered and `hint` says "no effect under bot identity" | Running under `--as bot` (mute API is UAT-only) | Switch to `--as user` for mute filtering |
| `--types=p2p (single chats) is only supported with user identity` | `--as bot` + `--types=p2p` (single-value only; mixed `--types=p2p,group` is downgraded to `group` and surfaces a `bot_strip_p2p` notice via stderr + `outData["notices"]` — see "Bot identity and p2p") | Use `--as user`, or include `group` in `--types` (the bot proceeds with `group` only and emits the `bot_strip_p2p` notice) |
> Full error message of the row above: `--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`
- **Output fields**: `chat_id` (oc_xxx) · `name` · `description` · `owner_id` (type per `--user-id-type`) · `external` · `chat_status` (`normal` / `dissolved` / `dissolved_save`) · `chat_mode` (`group` / `topic` / `p2p`) · `p2p_target_type` (e.g. `user`) · `p2p_target_id`. For p2p rows, `name` is the peer's display name and `p2p_target_*` identify the peer.
- **`filter` envelope shape**: `{"applied":"exclude_muted","fetched_count","returned_count","filtered_count","hint"}`.
- **Pagination fields** (`--format json`): `has_more` / `page_token` live under `.data`.

View File

@@ -1,157 +1,34 @@
# im +chat-messages-list
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
Fetch the message list for a conversation. Supports both group chats and direct messages.
Maps to `lark-cli im +chat-messages-list`. **Run `lark-cli im +chat-messages-list --help` for the authoritative flags (`--chat-id` / `--user-id` / `--start` / `--end` / `--order` / `--page-size` / `--page-token` / `--no-reactions` / `--download-resources` / `--as`), enums, time format (ISO 8601 or date-only), and the `--chat-id`/`--user-id` mutual-exclusion.** This file covers only what `--help` cannot.
By default the response carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Thread replies expanded via auto-`thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. Pass `--download-resources` to additionally download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block — off by default. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
Supports both `--as user` (default) and `--as bot`. Auto-resolves the p2p `chat_id`, auto-expands `thread_replies`, and enriches with reactions / `update_time` per [message enrichment](lark-im-message-enrichment.md).
This skill maps to the shortcut: `lark-cli im +chat-messages-list` (internally calls `GET /open-apis/im/v1/messages`, and automatically resolves the p2p chat_id when needed).
## Gotchas
## Commands
- **`--user-id` (DM by open_id) is user-identity only — and the constraint is silent until you hit it.** The p2p-resolution endpoint requires user identity; with `--as bot` it errors. For bot identity, look up the p2p `chat_id` yourself and pass `--chat-id`.
- **`P2P chat not found for this user`** means no DM exists *for the current identity* with that user — not a bad ID. Confirm the DM relationship under the identity you're calling as.
- **Resolve a chat name → `chat_id` via [`+chat-search`](lark-im-chat-search.md) first**, then pass `--chat-id`. **Do NOT use `im chats search` or `+chat-list`** — those are not search APIs and won't locate the target.
- **`--order` defaults to `desc`** (newest first); pass `--order asc` for chronological reading. (Note: the flag is `--order`, not `--sort`.) It is the **only** sort axis — messages are always ordered by creation time. There is no field sort: `--sort sender` (or any field) is rejected. If asked to group/sort by sender, fetch with `--order` and aggregate client-side, and say so (local post-processing, not a CLI/API sort).
- **Image content is a placeholder, not bytes**: images render as `[Image: img_xxx]`; files/audio/video carry resource keys. Nothing downloads unless you pass `--download-resources` (writes to `./lark-im-resources/`) or use [`im +messages-resources-download`](lark-im-messages-resources-download.md).
## Thread expansion (`thread_id`)
A message carrying `thread_id` (`omt_xxx`) has thread replies. Auto-expansion attaches them as `thread_replies` (see enrichment doc); to drive the thread yourself use [`im +threads-messages-list --thread <id>`](lark-im-threads-messages-list.md) — `--order desc --page-size 10` for recent replies, `--order asc --page-size 50` (then paginate) for the full discussion, skip it for an overview.
## Bot identity + named-group history (cross-command recipe)
When the user says "以 bot 身份 / use application identity" and wants historical messages for a *named* group, use bot identity for **both** steps:
```bash
# Get group chat messages (json output by default)
lark-cli im +chat-messages-list --chat-id oc_xxx
# Get direct messages with a user (pass open_id and resolve p2p chat_id automatically)
lark-cli im +chat-messages-list --user-id ou_xxx
# Specify a time range (ISO 8601)
lark-cli im +chat-messages-list --chat-id oc_xxx --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00"
# Specify a time range (date only)
lark-cli im +chat-messages-list --chat-id oc_xxx --start 2026-03-10 --end 2026-03-11
# Control sort order and page size (max 50)
lark-cli im +chat-messages-list --chat-id oc_xxx --order asc --page-size 20
# Pagination
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token "xxx"
# JSON output
lark-cli im +chat-messages-list --chat-id oc_xxx --format json
lark-cli im +chat-search --as bot --query "<chat name keyword>" --format json
lark-cli im +chat-messages-list --as bot --chat-id <chat_id> --page-size 50 --format json
```
## Parameters
**Do NOT reach for `im +messages-search --as bot`** — that command is user-only. Continue with `--page-token` while `has_more=true`.
| Parameter | Required | Description |
|------|------|------|
| `--chat-id <id>` | One of two | Specify the conversation by its chat_id directly (e.g., group chat `oc_xxx`) |
| `--user-id <id>` | One of two | Specify a DM conversation by the other user's open_id (`ou_xxx`); p2p chat_id is resolved automatically. Requires user identity (`--as user`); not supported with bot identity |
| `--start <time>` | No | Start time (ISO 8601 or date only) |
| `--end <time>` | No | End time (ISO 8601 or date only) |
| `--order <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
| `--page-size <n>` | No | Page size (default 50, max 50) |
| `--page-token <token>` | No | Pagination token |
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default; no extra requests when omitted |
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
> Rule: `--chat-id` and `--user-id` are mutually exclusive. You must provide exactly one of them.
> **CAUTION:** `--order` is the only sort axis — messages are always ordered by creation time, `asc` or `desc`. There is no field axis: the command cannot sort by sender or any other field, so do **not** attempt `--sort sender` or similar (it is rejected). If the user asks to group or sort by sender, fetch with `--order` and aggregate client-side, and tell them this is local post-processing, not a CLI/API sort capability.
## Resource Rendering
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `[Image: img_xxx]`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
Two ways to get the binaries:
- **In one pass:** add `--download-resources` to this command — every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) is downloaded into `./lark-im-resources/` and a `resources` block (`{message_id, key, type, local_path, size_bytes}`) is attached to each message. See [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in).
- **One at a time:** use [lark-im-messages-resources-download](lark-im-messages-resources-download.md).
| Resource Type | Marker in Content | Behavior |
|---------|-------------|------|
| Image | `[Image: img_xxx]` | `--download-resources`, or manually `im +messages-resources-download --type image` |
| File | `<file key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Audio | `<audio key="file_xxx" duration="Xs"/>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Video | `<video key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Sticker | `[Sticker]` | Not downloadable (Feishu does not support fetching sticker resources) |
## Thread Expansion (`thread_id`)
In JSON output, a message may contain a `thread_id` (`omt_xxx`) field, which means the message has replies in a thread. Use [`im +threads-messages-list`](lark-im-threads-messages-list.md) to inspect replies in that thread:
```bash
lark-cli im +threads-messages-list --thread omt_xxx
```
| Scenario | Recommendation |
|------|------|
| You need context | Call `im +threads-messages-list --order desc --page-size 10` for the discovered thread_id to inspect recent replies |
| The user asks for the "full discussion" | Use `im +threads-messages-list --order asc --page-size 50`, then paginate if needed |
| You only need an overview | Skip thread expansion |
## Output Fields
| Field | Description |
|------|------|
| `messages` | Message array |
| `total` | Number of messages in the current page |
| `has_more` | Whether additional pages are available |
| `page_token` | Pagination token for the next page |
Each message contains:
| Field | Description |
|------|------|
| `message_id` | Message ID |
| `msg_type` | Message type: `text`, `image`, `file`, `interactive`, `post`, `audio`, `video`, `system`, etc. |
| `create_time` | Creation time |
| `sender` | Sender information (includes `name` for user senders) |
| `content` | Message content |
| `deleted` | Whether the message has been recalled (always present, `true` = recalled) |
| `updated` | Whether the message has been edited after sending |
| `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions |
| `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist |
## Pagination (`has_more` / `page_token`)
`im +chat-messages-list` returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue:
```bash
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token <PAGE_TOKEN>
```
You can also fall back to the generic API:
```bash
lark-cli api GET /open-apis/im/v1/messages \
--params 'container_id_type=chat&container_id=oc_xxx&page_size=50&page_token=<PAGE_TOKEN>'
```
## Common Errors and Troubleshooting
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| `specify --chat-id <chat_id> or --user-id <open_id>` | Neither `--chat-id` nor `--user-id` was provided | You must provide exactly one |
| `--chat-id and --user-id cannot be specified together` | Both parameters were provided | Use only one |
| `--user-id requires user identity (--as user); use --chat-id when calling with bot identity` | `--user-id` was used with bot identity | The p2p resolution endpoint requires user identity. Either pass `--as user` or look up the p2p `chat_id` separately and pass it via `--chat-id` |
| `P2P chat not found for this user` | `--user-id` was used but no p2p chat exists for the current identity and that user | Confirm the target direct-message relationship exists for the current identity |
| `--start: invalid time format` | Invalid time format | Use ISO 8601 or date-only format such as `2026-03-10` |
| Permission denied | Message read permissions are missing | Ensure the app has `im:message:readonly` and `im:chat:read` enabled |
## AI Usage Guidance
1. **Resolving chat_id from a chat name:** When the user refers to a chat by name and you don't have the `chat_id`, use [`+chat-search`](lark-im-chat-search.md) first:
```bash
# Find chat_id by name, then list messages
lark-cli im +chat-search --query "<chat name keyword>" --format json
lark-cli im +chat-messages-list --chat-id <chat_id>
```
**Do not use `im chats search` or `+chat-list` — always use the `+chat-search` shortcut.**
2. **Prefer `--chat-id` when available:** if the chat_id is already known, use it directly to avoid extra API calls.
3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually. This requires user identity (`--as user`); with bot identity, resolve the p2p `chat_id` yourself and pass it via `--chat-id`.
4. **For time ranges:** both ISO 8601 and date-only inputs are supported. Date-only is usually simpler.
5. **For full content:** table output truncates content. Use `--format json` when you need the complete message body.
6. **For sender info:** the command already resolves sender names, so you do not need a separate lookup.
7. **Application/bot identity + named group history:** If the user says "使用应用身份/以 bot 身份" and asks to list or read historical messages for a named group, use bot identity for both steps:
```bash
lark-cli im +chat-search --as bot --query "<chat name keyword>" --format json
lark-cli im +chat-messages-list --as bot --chat-id <chat_id> --page-size 50 --format json
```
Do not use `im +messages-search --as bot`; `+messages-search` is user-only. Continue with `--page-token` if `has_more=true`.
## References
- [lark-im](../SKILL.md) - all IM commands
- [lark-shared](../../lark-shared/SKILL.md) - authentication and global parameters
- **Per-message fields** (JSON): `deleted` (always present; `true` = recalled) · `updated` (edited after send) · `mentions` `[{id,key,name}]` **present only when @mentions exist** · `thread_id` (omt_xxx) **present only when replies exist**. Page envelope carries `has_more` / `page_token`.

View File

@@ -1,142 +1,29 @@
# im +chat-search
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
Search the list of group chats visible to a user or bot, including chats the user or bot belongs to and public chats visible to them. Supports keyword matching on chat names and member names, including pinyin and prefix fuzzy search.
Maps to `lark-cli im +chat-search`. **Run `lark-cli im +chat-search --help` for the authoritative flag list (`--query` / `--member-ids` / `--search-types` / `--chat-modes` / `--sort` / `--page-size` / `--page-token` / `--is-manager` / `--exclude-muted` / `--disable-search-by-user`), limits, and enums.** This file covers only what `--help` cannot.
This skill maps to the shortcut: `lark-cli im +chat-search` (internally calls `POST /open-apis/im/v2/chats/search`).
## Gotchas
## Commands
- **Visibility-scoped, not global**: only finds chats visible to the current identity (joined chats + visible public chats). A chat the caller can't see won't appear.
- **At least one of `--query` / `--member-ids`** must be given (either alone, or combined).
- `--query` containing `-` is auto-wrapped in quotes.
- **On empty results, do NOT fall back to `+chat-list` / `GET /chats`** — list is not a search API (returns all chats unfiltered, won't locate the target). Refine the keyword or check visibility under the current identity instead.
- Supports `--as user` (default) and `--as bot` (bot needs bot capability enabled).
- **`--sort` is always descending** (`create_time` / `update_time` / `member_count`, high→low). There is no ascending option. If the user asks for "fewest first / ascending / 从少到多", tell them the search API doesn't support ascending and re-sort the fetched page client-side — do **NOT** invent `member_count_asc` or pass `asc` (rejected).
```bash
# Search chats by keyword
lark-cli im +chat-search --query "project"
## `--exclude-muted` (user identity only)
# Restrict by search types
lark-cli im +chat-search --query "project" --search-types "private,public_joined"
Drops do-not-disturb chats; under `--as bot` the filter is silently inactive (mute is per-user / UAT-only). When set, the JSON envelope gains a `filter` sub-object (absent otherwise, so existing consumers are unaffected); the invariant **`fetched_count == returned_count + filtered_count`** always holds. Only confirmed-muted chats count toward `filtered_count`; non-member public groups are retained and surfaced in `hint`. For strict member-only results, combine with `--search-types "private,public_joined,external"`.
# Filter by chat mode (group = regular group, topic = topic/thread group)
lark-cli im +chat-search --query "project" --chat-modes "topic"
## Error → remediation
# Filter by member open_ids (with keyword)
lark-cli im +chat-search --query "project" --member-ids "ou_xxx,ou_yyy"
- `99991679` (`--as user`): UAT not authorized for `im:chat:read``lark-cli auth login --scope "im:chat:read"`.
- `99991672` (`--as bot`): app lacks `im:chat:read` TAT → enable in the Open Platform console.
- `232025`: bot capability not activated → enable in the console.
# Search by member open_ids only
lark-cli im +chat-search --member-ids "ou_xxx,ou_yyy"
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
# Only show chats you created or manage
lark-cli im +chat-search --query "project" --is-manager
# Set page size
lark-cli im +chat-search --query "project" --page-size 10
# Pagination
lark-cli im +chat-search --query "project" --page-token "xxx"
# JSON output
lark-cli im +chat-search --query "project" --format json
# Preview the request without executing it
lark-cli im +chat-search --query "project" --dry-run
```
## Parameters
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--query <keyword>` | No (at least one of `--query` / `--member-ids` required) | Max 64 characters | Search keyword. Supports matching localized chat names, member names, multilingual search, pinyin, and prefix fuzzy search. If the query contains `-`, it is automatically wrapped in quotes |
| `--search-types <types>` | No | Comma-separated: `private`, `external`, `public_joined`, `public_not_joined` | Restrict the visible chat types returned by search |
| `--chat-modes <modes>` | No | Comma-separated: `group`, `topic` | Filter by chat mode (server-side): `group` = regular group, `topic` = topic/thread group |
| `--member-ids <ids>` | No (at least one of `--query` / `--member-ids` required) | Up to 50, format `ou_xxx` | Filter by member open_ids; can be used alone or combined with `--query` |
| `--is-manager` | No | - | Only show chats you created or manage |
| `--disable-search-by-user` | No | - | Disable member-name-based matching and search by group name only |
| `--sort <field>` | No | `create_time`, `update_time`, `member_count` | Sort field (always descending) |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below |
| `--format json` | No | - | Output as JSON |
| `--dry-run` | No | - | Preview the request without executing it |
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
> **CAUTION:** `--sort` is **always descending** — the search API only ranks the chosen field high-to-low (e.g. `member_count` = most members first). There is no ascending option. If the user asks for "fewest first / ascending / 从少到多", tell them the search API does not support ascending order; any low-to-high view requires re-sorting the fetched page client-side and is not an upstream sort. Do **not** invent values like `member_count_asc` or pass `asc` (they are rejected).
## Output Fields
| Field | Description |
|------|------|
| `chat_id` | Chat ID (`oc_xxx` format) |
| `name` | Chat name |
| `description` | Chat description |
| `owner_id` | Owner ID |
| `external` | Whether the chat is external |
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
## Filtering muted chats
`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the search call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped.
When the flag is set, the JSON envelope gains a `filter` sub-object (absent otherwise, so existing consumers are unaffected); `fetched_count == returned_count + filtered_count` always holds:
```json
{
"chats": [...],
"filter": {
"applied": "exclude_muted",
"fetched_count": 20,
"returned_count": 19,
"filtered_count": 1,
"hint": "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); use --page-token to fetch more."
}
}
```
Note: only confirmed-muted chats count toward `filtered_count`; non-member public groups are retained and surfaced in `hint`. For strict member-only results, combine with `--search-types "private,public_joined,external"`.
## Usage Scenarios
### Scenario 1: Search chats that contain a keyword
```bash
lark-cli im +chat-search --query "design review"
```
### Scenario 2: Search a chat and list recent messages
```bash
CHAT_ID=$(lark-cli im +chat-search --query "project" --format json | jq -r '.data.chats[0].chat_id')
lark-cli im +chat-messages-list --chat-id "$CHAT_ID"
```
### Scenario 3: Search a chat and send a message
```bash
CHAT_ID=$(lark-cli im +chat-search --query "daily report" --format json | jq -r '.data.chats[0].chat_id')
lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Today's progress update"
```
## Common Errors and Troubleshooting
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| `--query and --member-ids cannot both be empty` | Both were omitted | Provide at least `--query` or `--member-ids` |
| Empty results | No visible chats matched the keyword or filters | Relax the keyword or filters and try again |
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
## AI Usage Guidance
When the user asks to search chats, follow these rules:
1. **At least one filter required:** `--query` and `--member-ids` cannot both be empty. Either alone or combined together are valid.
2. **Search scope is limited:** only chats visible to the current user or bot can be found (joined chats plus public chats). This is not a global search over all chats.
3. **Control result volume:** the result set may be large. Use `--page-size` deliberately.
4. **Suggest follow-up actions:** after finding a chat, common next steps include listing recent messages (`im +chat-messages-list`) or sending a message (`im +messages-send`).
5. **NEVER fall back to chats list:** If `+chat-search` returns empty results, do NOT attempt to use `+chat-list` or `GET /open-apis/im/v1/chats` as a fallback. The list API is not a search API — it returns all chats without keyword filtering and will not help locate the target chat. Instead, ask the user to refine the keyword or check whether the chat is visible to the current identity.
## References
- [lark-im](../SKILL.md) - all IM commands
- [lark-shared](../../lark-shared/SKILL.md) - authentication and global parameters
- **Output fields**: `chat_id` (oc_xxx) · `name` · `description` · `owner_id` · `external` · `chat_status` (`normal` / `dissolved` / `dissolved_save`).
- `--member-ids`: up to 50, format `ou_xxx` (`--help` only says "comma-separated").

View File

@@ -1,84 +1,11 @@
# im +chat-update
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
Update a group's name or description. Supports both **TAT (bot)** and **UAT (user)** identity.
Maps to `lark-cli im +chat-update`. **Run `lark-cli im +chat-update --help` for the authoritative flags (`--chat-id` / `--name` / `--description` / `--as`), limits, and the "at least one field" rule.** This file covers only what `--help` cannot.
This skill maps to the shortcut: `lark-cli im +chat-update` (internally calls `PUT /open-apis/im/v1/chats/:chat_id`).
## Gotchas
## Commands
```bash
# Update the group name
lark-cli im +chat-update --chat-id oc_xxx --name "New Group Name"
# Update the group description
lark-cli im +chat-update --chat-id oc_xxx --description "Updated group description"
# Update multiple fields at once
lark-cli im +chat-update --chat-id oc_xxx \
--name "Q2 Project Team" \
--description "Owns Q2 goal tracking"
# Preview the request without executing it
lark-cli im +chat-update --chat-id oc_xxx --name "Test" --dry-run
```
## Parameters
### Required
| Parameter | Description |
|------|------|
| `--chat-id <oc_xxx>` | Group ID |
### Optional Fields
| Parameter | Limits | Description |
|------|------|------|
| `--name <name>` | Max 60 characters | Group name |
| `--description <text>` | Max 100 characters | Group description |
### Global Parameters
| Parameter | Description |
|------|------|
| `--format json` | Output as JSON (default) |
| `--dry-run` | Preview the request without executing it |
## Usage Scenarios
### Scenario 1: Rename a group and update its description
```bash
lark-cli im +chat-update --chat-id oc_xxx \
--name "Q2 Project Team" \
--description "Owns Q2 goal tracking"
```
## Common Errors and Troubleshooting
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| `invalid --chat-id: expected chat ID (oc_xxx)` | Invalid chat_id format | Use a valid `oc_xxx` chat ID |
| `--name exceeds the maximum of 60 characters` | Group name too long | Shorten the name to 60 characters or fewer |
| `--description exceeds the maximum of 100 characters` | Group description too long | Shorten the description to 100 characters or fewer |
| `at least one field must be specified to update` | No update field was provided | Specify at least one field to update |
| Permission denied (99991679) | Missing `im:chat:update` permission | Run `lark-cli auth login --scope "im:chat:update"` |
| Non-owner/admin cannot update (232016/232002/232017) | Current identity is not the owner/admin | Try switching identity with `--as bot` or `--as user` |
| Not in the group (232011) | The current user is not a member of the group | Use a member identity (`--as bot`) or join the group first |
## AI Usage Guidance
### Identity Selection
`+chat-update` supports both user and bot identity (`--as user` / `--as bot`).
Infer the group owner from context whenever possible (for example, if a bot just created the group, the owner is the bot) and use the matching identity directly. If ownership is unclear, query the group first and confirm `owner_id`.
Identity choice should follow [Group Chat Identity Rules](lark-im-chat-identity.md): if the user explicitly specifies an identity, use it directly; otherwise infer the owner identity from context.
## References
- [lark-im](../SKILL.md) - all IM commands
- [lark-shared](../../lark-shared/SKILL.md) - authentication and global parameters
- **Updating requires owner/admin privileges.** A non-owner/non-admin identity fails with **232016 / 232002 / 232017**; an identity that isn't even in the group fails with **232011**. `--help` won't tell you this — pick the identity *before* you call.
- **Infer the owner identity from context** rather than the default (per [Group Chat Identity Rules](lark-im-chat-identity.md)): if the user names an identity, use it; if a bot just created the group, the owner is the bot; otherwise query the group first and confirm `owner_id` before choosing `--as user` / `--as bot`.
- **A bot that is an admin (not owner) can still rename / change settings** under `--as bot` — owner-only actions (e.g. owner transfer) are not exposed here and need the real owner's UAT auth.

View File

@@ -1,68 +1,25 @@
# +feed-group-list-item
> Shortcut for `lark-cli im +feed-group-list-item`. List the feed cards inside one feed group (tag), enriched with a readable `chat_name`.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
`+feed-group-list-item` is the only CLI surface for the `feed.groups.list_item` read API — there is no raw `feed.groups list_item` command. It resolves a human-readable `chat_name` for every feed card it returns: a v1 feed card's `feed_id` is always a chat ID (`oc_xxx`), so the shortcut issues a follow-up `POST /open-apis/im/v1/chats/batch_query` and injects `chat_name` into each entry of both `items[]` and `deleted_items[]`.
Maps to `lark-cli im +feed-group-list-item`. **Run `lark-cli im +feed-group-list-item --help` for the authoritative flags (`--feed-group-id` / `--page-size` / `--page-token` / `--page-all` / `--page-limit` / `--start-time` / `--end-time` / `--as`), the page-size range, and the time format.** This file covers only what `--help` cannot.
## Identity
**User identity only** (`--as user`); bot/tenant tokens are rejected. This is the **only** CLI surface for `feed.groups.list_item` — there is no raw command.
User-only. Run with `--as user`.
## Gotchas
## Scopes
- **`chat_name` enrichment is unconditional → needs a second scope.** A v1 feed card's `feed_id` is always a chat ID (`oc_xxx`), so the shortcut always issues a follow-up `chats/batch_query` and injects `chat_name` into each entry of **both** `items[]` and `deleted_items[]`. There is no single-scope, un-enriched path — so this needs `im:chat:read` **in addition to** `im:feed_group_v1:read` (vs. `+feed-group-list`, which needs only the read scope).
- **Unresolvable cards silently omit `chat_name`** — a soft-deleted chat or one you can't see just lacks the field; the command still exits 0. Do not treat a missing `chat_name` as an error. **p2p (direct) cards also omit it** (the server returns an empty `name`; clients show the partner's display name instead) — if you need a label, fetch the chat via `chats/batch_query`, read `p2p_target_id`, and resolve it with a contact lookup.
- **Dual-list response.** Like `+feed-group-list`, results split into `items[]` (live) and `deleted_items[]` (soft-deleted); `--page-all` merges both. Incremental-sync consumers must read both.
- **`--page-token` wins over `--page-all`** when both are set — you get exactly that one page.
Because chat-name resolution always runs, this shortcut needs **two** user scopes unconditionally:
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
- `im:feed_group_v1:read` — to read the items
- `im:chat:read` — to resolve names
`chat_name` resolution always runs, so there is no single-scope, un-enriched path. For the other raw `feed.groups.*` methods, see [lark-im-feed-groups.md](lark-im-feed-groups.md).
## Usage
```bash
# First page, enriched with chat names
lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx
# Auto-paginate through everything within a time window
lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx \
--page-all --start-time 1767196800000 --end-time 1767200000000
```
## Flags
| Flag | Required | Description |
|---|---|---|
| `--feed-group-id` | Yes | Feed group ID (`ofg_xxx`); path parameter |
| `--page-size` | No | Records per page, 150 (default 50) |
| `--page-token` | No | Continuation token for a specific page |
| `--page-all` | No | Auto-paginate and merge all pages |
| `--page-limit` | No | Max pages when `--page-all` is set, 11000 (default 20) |
| `--start-time` | No | Update-time window start (Unix milliseconds as a decimal string) |
| `--end-time` | No | Update-time window end (Unix milliseconds as a decimal string) |
When `--page-token` is set explicitly, it wins over `--page-all` (you get exactly that page).
## Output
JSON keeps the raw envelope and adds `chat_name` to each resolvable item:
```json
{
"items": [
{ "feed_id": "oc_abc", "feed_type": "chat", "update_time": "1767196800000", "chat_name": "Release Team" }
],
"deleted_items": [
{ "feed_id": "oc_def", "feed_type": "chat", "update_time": "1767196800000", "chat_name": "Old Channel" }
],
"page_token": "",
"has_more": false
}
```
A feed card whose chat cannot be resolved (soft-deleted or no permission) simply omits `chat_name` — the command still exits 0. p2p (direct) chats also omit `chat_name`: the server returns an empty `name` for them (the client UI shows the partner's display name instead); if a label is needed, fetch the chat via `chats/batch_query`, read `p2p_target_id`, and resolve it with a contact lookup.
- **Required scopes**: `im:feed_group_v1:read` **plus** `im:chat:read` (always, because enrichment always runs).
- **Output fields** (raw envelope): `items[]` / `deleted_items[]`, each `{feed_id (oc_xxx), feed_type (chat), update_time, chat_name (when resolvable)}` · `page_token` · `has_more`.
## See also
- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` APIs, enums, and rule guidance
- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` write APIs, enums, and rule guidance
- [lark-im-feed-group-list.md](lark-im-feed-group-list.md) — list your feed groups
- [lark-im-feed-group-query-item.md](lark-im-feed-group-query-item.md) — look up specific feed cards by ID

View File

@@ -1,65 +1,24 @@
# +feed-group-list
> Shortcut for `lark-cli im +feed-group-list`. List the caller's feed groups (tags) with auto-pagination that correctly merges both the live and soft-deleted lists.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
`+feed-group-list` is the only CLI surface for listing feed groups — there is no raw `feed.groups list` command. The list response carries two parallel arrays — `groups` (live) and `deleted_groups` (soft-deleted). The shortcut paginates this dual-list response correctly: its `--page-all` merges **both** arrays across pages (a naive single-array pager would silently drop one list's later pages). It adds no enrichment.
Maps to `lark-cli im +feed-group-list`. **Run `lark-cli im +feed-group-list --help` for the authoritative flags (`--page-size` / `--page-token` / `--page-all` / `--page-limit` / `--start-time` / `--end-time` / `--as`), the page-size range, and the time format.** This file covers only what `--help` cannot.
## Identity
**User identity only** (`--as user`); bot/tenant tokens are rejected by the server. This is the **only** CLI surface for listing feed groups — there is no raw `feed.groups list` command.
User-only. Run with `--as user`.
## Gotchas
## Scopes
- **Dual-list response, merged by `--page-all`.** The response carries two parallel arrays — `groups` (live) and `deleted_groups` (soft-deleted). `--page-all` merges **both** across pages; a naive single-array pager would silently drop one list's later pages. Incremental-sync consumers must read both arrays. Adds no enrichment.
- **`--page-token` wins over `--page-all`** when both are set — you get exactly that one page, not a full sweep.
- **Never infer completeness from counts.** `--page-size` caps `groups` + `deleted_groups` *combined*, so a page may hold fewer live groups than the size suggests, and per-page counts can be smaller still when entries are filtered. Pagination is governed solely by `has_more`.
- `im:feed_group_v1:read`
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
## Usage
```bash
# First page
lark-cli im +feed-group-list --as user
# Auto-paginate through all your feed groups (both live and deleted)
lark-cli im +feed-group-list --as user --page-all
# Within an update-time window
lark-cli im +feed-group-list --as user --page-all \
--start-time 1767196800000 --end-time 1767200000000
```
## Flags
| Flag | Required | Description |
|---|---|---|
| `--page-size` | No | Records per page, 150 (default 50). Caps the combined `groups` + `deleted_groups` count, so a page may hold fewer live groups than the size suggests |
| `--page-token` | No | Continuation token for a specific page |
| `--page-all` | No | Auto-paginate and merge all pages (both lists) |
| `--page-limit` | No | Max pages when `--page-all` is set, 11000 (default 20) |
| `--start-time` | No | Update-time window start (Unix milliseconds as a decimal string) |
| `--end-time` | No | Update-time window end (Unix milliseconds as a decimal string) |
When `--page-token` is set explicitly, it wins over `--page-all` (you get exactly that page).
## Output
JSON keeps the raw envelope; with `--page-all` both lists are returned fully merged:
```json
{
"groups": [
{ "group_id": "ofg_xxx", "type": "normal", "name": "Releases", "rules": { "rules": [] } }
],
"deleted_groups": [
{ "group_id": "ofg_yyy", "type": "rule", "name": "Old", "rules": { "rules": [] } }
],
"page_token": "",
"has_more": false
}
```
> `page_size` counts live and deleted groups together, and the per-page count can be smaller still when entries are filtered — so never infer completeness from counts. Pagination is governed solely by `has_more`.
- **Required scope**: `im:feed_group_v1:read`.
- **Output fields** (raw envelope): `groups[]` / `deleted_groups[]`, each `{group_id (ofg_xxx), type (normal|rule), name, rules{rules[]}}` · `page_token` · `has_more`. The `rules` shape is documented in [lark-im-feed-groups.md](lark-im-feed-groups.md).
## See also
- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` APIs, enums, and rule guidance
- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` write APIs, enums, and rule guidance
- [lark-im-feed-group-list-item.md](lark-im-feed-group-list-item.md) — list the feed cards inside one group
- [lark-im-feed-group-query-item.md](lark-im-feed-group-query-item.md) — look up specific feed cards by ID

View File

@@ -1,44 +1,24 @@
# +feed-group-query-item
> Shortcut for `lark-cli im +feed-group-query-item`. Look up specific feed cards inside one feed group (tag) by ID, enriched with a readable `chat_name`.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
`+feed-group-query-item` is the only CLI surface for the `feed.groups.batch_query_item` read API — there is no raw `feed.groups batch_query_item` command. It resolves a human-readable `chat_name` for every feed card it returns: a v1 feed card's `feed_id` is always a chat ID (`oc_xxx`), so the shortcut issues a follow-up `POST /open-apis/im/v1/chats/batch_query` and injects `chat_name` into each entry of both `items[]` and `deleted_items[]`.
Maps to `lark-cli im +feed-group-query-item`. **Run `lark-cli im +feed-group-query-item --help` for the authoritative flags (`--feed-group-id` / `--feed-id` / `--as`); `--feed-id` is a comma-separated list of chat IDs and `feed_type` is fixed to `chat`.** This file covers only what `--help` cannot.
## Identity
**User identity only** (`--as user`); bot/tenant tokens are rejected. This is the **only** CLI surface for `feed.groups.batch_query_item` — there is no raw command.
User-only. Run with `--as user`.
## Gotchas
## Scopes
- **Lightweight ID lookup — prefer it over the list methods when you already hold the IDs.** Use this when you have the `feed_id`s (the `oc_xxx` you passed to `batch_add_item`); reserve `+feed-group-list-item` (paginated, heavier) for discovering IDs you don't have. **No pagination** for this method.
- **`chat_name` enrichment is unconditional → needs a second scope.** Resolves `chat_name` for each card exactly as `+feed-group-list-item` does (follow-up `chats/batch_query`, injected into both `items[]` and `deleted_items[]`). So this needs `im:chat:read` **in addition to** `im:feed_group_v1:read`; there is no un-enriched path.
- **Unresolvable cards silently omit `chat_name`** — soft-deleted or no-permission chats just lack the field; the command still exits 0. **p2p (direct) cards also omit it** (server returns an empty `name`); to label one, fetch the chat via `chats/batch_query`, read `p2p_target_id`, and resolve it with a contact lookup.
Because chat-name resolution always runs, this shortcut needs **two** user scopes unconditionally:
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
- `im:feed_group_v1:read` — to read the items
- `im:chat:read` — to resolve names
`chat_name` resolution always runs, so there is no single-scope, un-enriched path. For the other raw `feed.groups.*` methods, see [lark-im-feed-groups.md](lark-im-feed-groups.md).
## Usage
```bash
lark-cli im +feed-group-query-item --as user \
--feed-group-id ofg_xxx --feed-id oc_a,oc_b
```
## Flags
| Flag | Required | Description |
|---|---|---|
| `--feed-group-id` | Yes | Feed group ID (`ofg_xxx`); path parameter |
| `--feed-id` | Yes | Comma-separated chat IDs (`oc_xxx`); `feed_type` is fixed to `chat` |
## Output
The command sends `{"items":[{"feed_id":"oc_a","feed_type":"chat"},{"feed_id":"oc_b","feed_type":"chat"}]}`, then enriches the response (`items[]` and `deleted_items[]`) with `chat_name` exactly as `+feed-group-list-item` does. There is no pagination for this method.
A feed card whose chat cannot be resolved (soft-deleted or no permission) simply omits `chat_name` — the command still exits 0. p2p (direct) chats also omit `chat_name`: the server returns an empty `name` for them (the client UI shows the partner's display name instead); if a label is needed, fetch the chat via `chats/batch_query`, read `p2p_target_id`, and resolve it with a contact lookup.
- **Required scopes**: `im:feed_group_v1:read` **plus** `im:chat:read` (always).
- **Output fields** (raw envelope): `items[]` (live) / `deleted_items[]` (soft-deleted), each `{feed_id (oc_xxx), feed_type (chat), update_time, chat_name (when resolvable)}`.
## See also
- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` APIs, enums, and rule guidance
- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` write APIs, enums, and rule guidance
- [lark-im-feed-group-list.md](lark-im-feed-group-list.md) — list your feed groups
- [lark-im-feed-group-list-item.md](lark-im-feed-group-list-item.md) — list all feed cards in a group (paginated)

View File

@@ -2,70 +2,46 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
This reference is the shared annotation target for the IM feed-group (tag) APIs: it documents what each method does, the `--params` / `--data` request and response shapes, and the enum surface used in payloads. The full method list is in [Command Overview](#command-overview) below.
This reference covers the IM feed-group (tag) APIs. **There is no resolvable `--help` or `schema` for the six raw `feed.groups.*` write methods** (see [Schema gap](#schema-gap)), so the request/response shapes, enums, and rule guidance below are the only source of truth and are kept in full. The three read methods are exposed only as typed `+` shortcuts — for those, see the sibling references and their `--help` rather than this file.
> **Important:** The six raw commands (`create`, `update`, `delete`, `batch_query`, `batch_add_item`, `batch_remove_item`) take structured input through `--params '<json>'` and `--data '<json>'` rather than typed flags. The three read methods (`list`, `list_item`, `batch_query_item`) are exposed only as typed `+` shortcut wrappers — see [Shortcuts](#shortcuts). All methods are user-only; see [Common Notes](#common-notes).
> **All `feed.groups.*` methods are user-only** — they require `user_access_token`. Run with `--as user`; bot/tenant tokens are rejected.
> **Picking a read method:** `batch_query` / `+feed-group-query-item` are lightweight ID lookups; `+feed-group-list` / `+feed-group-list-item` paginate the whole set and are much heavier. When you already hold the IDs (`group_id` from `create`, the `feed_id`s you passed to `batch_add_item`), prefer the lightweight lookup. Reserve the list methods for when you actually need to discover IDs you don't have.
## Schema gap
## Command Overview
> Report this when you hit it: `lark-cli schema im.feed.groups.<method>` does **not** resolve — there is no `feed` service and no `im.feed*` resource in the schema registry (`lark-cli schema im` lists only `chat.members, chats, images, messages, pins, reactions, threads`). Do **not** send users to `schema` for feed-group methods. Until the CLI registers these methods, this reference is the authority for the six raw methods, and the three shortcuts' `--help` is the authority for the read methods.
| Method | Purpose |
|---|---|
| `feed.groups.create` | Create a new feed group (tag) |
| `feed.groups.update` | Update a feed group's name and/or rules |
| `feed.groups.delete` | Delete one feed group |
| `feed.groups.batch_query` | Look up feed groups by ID list |
| `feed.groups.list` | List the caller's feed groups with optional time-range filter — **CLI: only via `+feed-group-list` shortcut** |
| `feed.groups.batch_add_item` | Add feed cards (chats) into a feed group |
| `feed.groups.batch_remove_item` | Remove feed cards from a feed group |
| `feed.groups.batch_query_item` | Look up feed cards inside a group by ID list — **CLI: only via `+feed-group-query-item` shortcut** |
| `feed.groups.list_item` | List feed cards inside one feed group — **CLI: only via `+feed-group-list-item` shortcut** |
## Routing: pick the right method
> HTTP method and path are not duplicated here. For the six raw methods, inspect them with `lark-cli schema im.feed.groups.<method>` when needed; the three shortcut-only read methods (`list`, `list_item`, `batch_query_item`) use typed flags (see their `--help`).
- **Read paths are shortcut-only** — `list`, `list_item`, `batch_query_item` have **no** raw command. Use the typed `+` shortcuts:
- [`+feed-group-list`](lark-im-feed-group-list.md) — list your feed groups (`--page-all` merges live + soft-deleted). No enrichment. Scope: `im:feed_group_v1:read`.
- [`+feed-group-list-item`](lark-im-feed-group-list-item.md) — list feed cards in a group, enriched with `chat_name`. Scopes: `im:feed_group_v1:read` + `im:chat:read`.
- [`+feed-group-query-item`](lark-im-feed-group-query-item.md) — look up feed cards by ID, enriched with `chat_name`. Scopes: `im:feed_group_v1:read` + `im:chat:read`.
- **Lightweight lookup vs. heavy list**: when you already hold the IDs (`group_id` from `create`, the `feed_id`s you passed to `batch_add_item`), prefer the lightweight ID lookups (`batch_query` / `+feed-group-query-item`) over the paginated list methods (`+feed-group-list` / `+feed-group-list-item`), which are much heavier. Reserve the list methods for discovering IDs you don't have.
- **`type=normal` vs `type=rule`**: a `normal` group's membership is managed explicitly via `batch_add_item` / `batch_remove_item`; a `rule` group auto-populates from `feed_group_creator.rules`. See [rule guidance](#choosing-a-group-shape).
## Shortcuts
## Choosing a group shape
Three typed `+` shortcuts cover the feed-group read paths. All are user-only.
| Shortcut | Purpose | Notes |
|---|---|---|
| [`+feed-group-list`](lark-im-feed-group-list.md) | List your feed groups | Its `--page-all` correctly merges the live and soft-deleted lists. No enrichment |
| [`+feed-group-list-item`](lark-im-feed-group-list-item.md) | List the feed cards inside a group | Enriches each card with `chat_name` |
| [`+feed-group-query-item`](lark-im-feed-group-query-item.md) | Look up feed cards in a group by ID | Enriches each card with `chat_name` |
The two `*-item` shortcuts resolve `chat_name` via a follow-up `chats/batch_query`, so they need `im:chat:read` in addition to `im:feed_group_v1:read`; `+feed-group-list` needs only `im:feed_group_v1:read`. All three are the **only** CLI surface for their methods — `list`, `list_item`, and `batch_query_item` have no raw command; full flags and response shapes live in the shortcut docs linked above.
## Common Notes
- `feed_group_id` is the feed-group identifier returned by `create`, typically formatted as `ofg_xxx`. It is an opaque string — the group's stable ID.
- `feed_id` is the identifier of one feed card inside a group. In v1 only the `chat` feed card type is supported (see `feed_card_type` below), so `feed_id` is currently a chat ID such as `oc_xxx`.
- All `feed.groups.*` methods require `user_access_token`. Run with `--as user`; bot/tenant tokens are rejected.
- Read APIs (`batch_query`, `list`, `batch_query_item`, `list_item`) return **two parallel lists**: a live list (`groups[]` or `items[]`) and a soft-deleted list (`deleted_groups[]` or `deleted_items[]`). Consumers tracking incremental sync should consume both.
- Time-range fields (`start_time`, `end_time`, `update_time`) are Unix timestamps **in milliseconds**, encoded as decimal strings (e.g. `1767196800000`).
- Rule-based feed groups (`type=rule`) auto-populate from the rules declared in `feed_group_creator.rules`. Normal feed groups (`type=normal`) are managed explicitly via `batch_add_item` / `batch_remove_item`.
> **Choose the simplest group that fits** — it keeps `create` / `update` fast and predictable. Apply these in order:
> **Choose the simplest group that fits** — it keeps `create` / `update` fast and predictable. Apply in order:
> 1. **Prefer `type=normal`.** When the target chats are known up front, set membership explicitly with `batch_add_item` / `batch_remove_item`. Use `type=rule` only when membership must be derived automatically.
> 2. **Keep the rule set smallest.** Use the fewest `rules[]` and `condition_items[]` that express the intent (one condition is ideal). This outranks the style rules below — never split a rule or add conditions just to satisfy them (e.g. one `match_any` rule beats two single-condition rules for "A or B").
> 2. **Keep the rule set smallest.** Use the fewest `rules[]` and `condition_items[]` that express the intent (one condition is ideal). This outranks the precision rule below — never split a rule or add conditions just to satisfy it (e.g. one `match_any` rule beats two single-condition rules for "A or B").
> 3. **Within that, make each condition precise.** Prefer positive, specific conditions (`is`, or `contain` with a distinctive keyword) over exclusion (`is_not`, `not_contain`) or broad keywords, which capture more than intended. For a multi-condition rule, prefer `match_all` (narrower) over `match_any` (wider).
## Inspect Schema
## Identity / ID conventions (shared)
```bash
lark-cli schema im.feed.groups
lark-cli schema im.feed.groups.create --format pretty
lark-cli schema im.feed.groups.batch_add_item --format pretty
```
- `feed_group_id` — the feed-group identifier returned by `create`, formatted as `ofg_xxx`.
- `feed_id` — the identifier of one feed card inside a group. In v1 only the `chat` feed card type is supported, so `feed_id` is currently a chat ID such as `oc_xxx`.
- **Read APIs return two parallel lists** — a live list (`groups[]` or `items[]`) and a soft-deleted list (`deleted_groups[]` or `deleted_items[]`). Consumers tracking incremental sync must consume both.
> `list`, `list_item`, and `batch_query_item` have no raw method schema (they are shortcut-only). Inspect their flags with `lark-cli im +feed-group-list --help` / `+feed-group-list-item --help` / `+feed-group-query-item --help` instead.
---
# HELP-GAP — raw `feed.groups.*` write methods (no `--help`/schema; keep until CLI adds it)
> Everything from here down documents the six raw methods that take `--params '<json>'` / `--data '<json>'`. None of it is expressible via `--help` or `schema` today (the methods are unregistered — see [Schema gap](#schema-gap)). Once the CLI registers these methods, replace this section with a pointer to schema.
## create
Create a new feed group. Returns the new `group_id` on success.
> **Prefer `type=normal`.** Use `type=rule` only when membership must be derived automatically, and keep the rule set small and precise — see the guidance under [Common Notes](#common-notes).
```bash
# Normal (empty) group
lark-cli im feed.groups create --as user \
@@ -95,35 +71,15 @@ lark-cli im feed.groups create --as user \
}'
```
### Request
#### `--params`
| Parameter | Required | Description |
|---|---|---|
| `user_id_type` | No | ID type used when the request body contains `user_id` references inside rules. One of `open_id`, `union_id`, `user_id` |
#### `--data`
| Field | Required | Description |
|---|---|---|
| `feed_group_creator.type` | Yes | `normal` (empty group) or `rule` (auto-populated by rules) |
| `feed_group_creator.name` | Yes | Display name, e.g. `"标签名称测试"` |
| `feed_group_creator.rules` | No | Rule object (required when `type=rule`). See `feed_group_rules` section below |
### Response
```json
{
"group_id": "ofg_xxx"
}
```
- `--params`: `user_id_type` (optional) — `open_id` | `union_id` | `user_id`; used when the body contains `user_id` references inside rules.
- `--data`: `feed_group_creator.type` (required: `normal` | `rule`) · `feed_group_creator.name` (required) · `feed_group_creator.rules` (required when `type=rule`; see [feed_group_rules](#feed_group_rules)).
- Response: `{"group_id":"ofg_xxx"}`.
## update
Update a feed group's name and/or rules. The `update_fields` array tells the server which fields are being updated.
Update a feed group's name and/or rules. The `update_fields` array tells the server which fields to apply.
> **Scope each update to what actually changed.** If you only need to rename, pass `update_fields:[1]` so the rules are left untouched. When you do change rules, the same guidance under [Common Notes](#common-notes) applies to the resulting set.
> **Scope each update to what actually changed.** To rename only, pass `update_fields:[1]` so rules are left untouched. When you do change rules, the [group-shape guidance](#choosing-a-group-shape) applies to the resulting set.
```bash
# Rename only
@@ -131,60 +87,29 @@ lark-cli im feed.groups update --as user \
--params '{"feed_group_id":"ofg_xxx"}' \
--data '{"feed_group_updater":{"name":"测试标签名称","update_fields":[1]}}'
# Replace rules only (rules array uses the feed_group_rules shape — see that section)
# Replace rules only
lark-cli im feed.groups update --as user \
--params '{"feed_group_id":"ofg_xxx"}' \
--data '{
"feed_group_updater":{
"rules":{"rules":[]},
"update_fields":[2]
}
}'
--data '{"feed_group_updater":{"rules":{"rules":[]},"update_fields":[2]}}'
```
### Request
- `--params`: `feed_group_id` (required, path) · `user_id_type` (optional, for `user_id` fields inside `rules`).
- `--data`: `feed_group_updater.name` (optional) · `feed_group_updater.rules` (optional; same shape as `create`) · `feed_group_updater.update_fields` (optional integer markers: `1`=name, `2`=rules — server applies only the listed fields).
- Response: empty body on success — inspect the CLI exit code for status.
#### `--params`
| Parameter | Required | Description |
|---|---|---|
| `feed_group_id` | Yes | Path parameter — the feed group to update |
| `user_id_type` | No | ID type for any `user_id` fields inside `rules` |
#### `--data`
| Field | Required | Description |
|---|---|---|
| `feed_group_updater.name` | No | New display name |
| `feed_group_updater.rules` | No | Replacement rule object. Same structure as `create.feed_group_creator.rules` |
| `feed_group_updater.update_fields` | No | Array of integer update markers: `1` = name, `2` = rules. Server applies only the listed fields |
### Response
Empty body on success. Inspect the CLI exit code for status.
> **`update_fields` takes integers, not strings.** The server rejects the lowercase string forms (`"name"`, `"rules"`) with `9499 Invalid parameter value`. Use `[1]` / `[2]`. Omit the array (or pass `[]`) to make no field updates.
## delete
Delete one feed group.
```bash
lark-cli im feed.groups delete --as user \
--params '{"feed_group_id":"ofg_xxx"}'
lark-cli im feed.groups delete --as user --params '{"feed_group_id":"ofg_xxx"}'
```
### Request
| Parameter | Required | Description |
|---|---|---|
| `feed_group_id` | Yes | Path parameter — the feed group to delete |
### Response
Empty body on success.
- `--params`: `feed_group_id` (required, path). Response: empty body on success.
## batch_query
Look up feed groups by an explicit list of IDs. Returns both live and soft-deleted matches.
Look up feed groups by an explicit ID list. Returns both live and soft-deleted matches.
```bash
lark-cli im feed.groups batch_query --as user \
@@ -192,57 +117,9 @@ lark-cli im feed.groups batch_query --as user \
--data '{"group_ids":["ofg_xxx","ofg_yyy"]}'
```
### Request
#### `--params`
| Parameter | Required | Description |
|---|---|---|
| `user_id_type` | No | ID type used when the response includes `user_id` references inside `groups[].rules` |
#### `--data`
| Field | Required | Description |
|---|---|---|
| `group_ids` | Yes | Array of feed group IDs to look up |
### Response
```json
{
"groups": [
{
"group_id": "ofg_xxx",
"type": "normal",
"name": "test",
"rules": { "rules": [] }
}
],
"deleted_groups": [
{
"group_id": "ofg_yyy",
"type": "rule",
"name": "test",
"rules": { "rules": [] }
}
]
}
```
Each `rules.rules[]` element follows the `feed_group_rules` shape — see that section for the full structure.
### Top-Level Fields
| Field | Type | Meaning |
|---|---|---|
| `groups` | `array<object>` | Live feed groups for the requested IDs |
| `deleted_groups` | `array<object>` | Soft-deleted matches, returned for incremental-sync clients |
Each element carries `group_id`, `type`, `name`, and (when defined) `rules`.
## list
Shortcut-only: [`+feed-group-list`](lark-im-feed-group-list.md). Lists the caller's feed groups, optionally filtered by an update-time window. Its `--page-all` correctly merges the live (`groups`) and soft-deleted (`deleted_groups`) lists across pages. There is no raw command — flags and response shape are in the linked shortcut doc.
- `--params`: `user_id_type` (optional) — interprets `user_id` references inside `groups[].rules`.
- `--data`: `group_ids` (required array).
- Response: `{"groups":[...],"deleted_groups":[...]}`; each element carries `group_id`, `type`, `name`, and (when defined) `rules` (the [feed_group_rules](#feed_group_rules) shape). `deleted_groups[]` is the soft-deleted list returned for incremental-sync clients.
## batch_add_item
@@ -251,159 +128,41 @@ Add feed cards (chats) into one feed group. Partial failures are reported in `fa
```bash
lark-cli im feed.groups batch_add_item --as user \
--params '{"feed_group_id":"ofg_xxx"}' \
--data '{
"items":[
{"feed_id":"oc_xxx","feed_type":"chat"},
{"feed_id":"oc_yyy","feed_type":"chat"}
]
}'
--data '{"items":[{"feed_id":"oc_xxx","feed_type":"chat"},{"feed_id":"oc_yyy","feed_type":"chat"}]}'
```
### Request
- `--params`: `feed_group_id` (required, path).
- `--data`: `items[]` (required array). Each item: `feed_id` (the chat ID, e.g. `oc_xxx`) and `feed_type` (`"chat"` only).
- Response: `{"failed_items":[{"item":{...},"error_code":<int>,"error_message":"..."}]}`. **`failed_items` absent or empty means all succeeded** — check it for partial failure; it lists the original `{feed_id, feed_type}` plus a numeric `error_code` and human-readable `error_message`.
| Source | Field | Required | Description |
|---|---|---|---|
| `--params` | `feed_group_id` | Yes | Path parameter — the target feed group |
| `--data` | `items[]` | Yes | Array of feed cards to add |
| `--data` | `items[].feed_id` | No | The chat ID to add (e.g. `oc_xxx`) |
| `--data` | `items[].feed_type` | Yes (`"chat"` only) | Wire-typed as an open string. v1 OAPI service accepts only `chat`; anything else is rejected at runtime. See the Enums section. |
> Note: `items[].feed_id` is not marked as required in the API schema, but every element of `items` must set it — a missing field yields an unusable entry. Always pass `{"feed_id": "oc_xxx", "feed_type": "chat"}` per item.
### Response
```json
{
"failed_items": [
{
"item": { "feed_id": "oc_xxx", "feed_type": "chat" },
"error_code": 240001,
"error_message": "feed_id is invalid"
}
]
}
```
| Field | Type | Meaning |
|---|---|---|
| `failed_items` | `array<object>` | Items that failed; absent or empty means all succeeded |
| `failed_items[].item` | `object` | The original `{feed_id, feed_type}` element |
| `failed_items[].error_code` | `integer` | Numeric error code |
| `failed_items[].error_message` | `string` | Human-readable failure reason |
> **`items[].feed_id` is a trap.** Although the meta marks it `Required: No`, every element of `items` must set it — a missing `feed_id` yields an unusable entry. Always pass `{"feed_id":"oc_xxx","feed_type":"chat"}` per item.
## batch_remove_item
Remove feed cards from one feed group. Same request and response shape as `batch_add_item`.
Remove feed cards from one feed group. **Same request and response shape as `batch_add_item`** — same `feed_group_id` path param, same `items[]` body, same `failed_items[]` response, and the same `feed_id`-required-in-practice caveat.
```bash
lark-cli im feed.groups batch_remove_item --as user \
--params '{"feed_group_id":"ofg_xxx"}' \
--data '{
"items":[
{"feed_id":"oc_xxx","feed_type":"chat"}
]
}'
--data '{"items":[{"feed_id":"oc_xxx","feed_type":"chat"}]}'
```
### Request
## Enums (raw methods)
| Source | Field | Required | Description |
|---|---|---|---|
| `--params` | `feed_group_id` | Yes | Path parameter — the target feed group |
| `--data` | `items[]` | Yes | Array of feed cards to remove |
| `--data` | `items[].feed_id` | No | The chat ID to remove |
| `--data` | `items[].feed_type` | Yes (`"chat"` only) | Wire-typed as an open string. v1 OAPI service accepts only `chat`; anything else is rejected at runtime. See the Enums section. |
Sourced from the internal datasync IDL (`lark.im.datasync.open.thrift`). Values listed are exhaustive.
> Note: same caveat as `batch_add_item` — `items[].feed_id` is optional per the API schema but must be present in practice.
### Response
Identical shape to `batch_add_item``failed_items[]` lists rows that did not remove cleanly.
## batch_query_item
Shortcut-only: [`+feed-group-query-item`](lark-im-feed-group-query-item.md). Looks up feed cards in a group by an explicit ID list and enriches each with `chat_name`. There is no raw command — flags and response shape are in the linked shortcut doc.
## list_item
Shortcut-only: [`+feed-group-list-item`](lark-im-feed-group-list-item.md). Lists the feed cards inside a group (paginated, `--page-all` supported) and enriches each with `chat_name`. There is no raw command — flags and response shape are in the linked shortcut doc.
## Enums
All enum values listed here are exhaustive.
### `feed_group_type`
Used in `feed_group_creator.type` and the response `groups[].type`.
- `normal` — empty group; members managed explicitly via `batch_add_item` / `batch_remove_item`.
- `rule` — auto-populated; `feed_group_creator.rules` must be supplied.
### `feed_card_type`
Used in `items[].feed_type` everywhere a feed card appears. Wire type is an open string.
- `chat` — the only value the v1 OAPI service accepts. `feed_id` is therefore a chat ID such as `oc_xxx`.
The CLI does not pre-validate this field — passing anything other than `chat` reaches the server and is rejected at runtime. Treat `chat` as effectively required.
### `feed_group_rule_action`
Used inside `feed_group_rules.rules[].action`.
- `add` — when the condition matches, add the matching feed into this group.
- `remove` — when the condition matches, remove the matching feed from this group.
### `feed_group_rule_cond_match_type`
Used inside `feed_group_rules.rules[].condition.match_type`.
- `match_all` — every condition item must match.
- `match_any` — at least one condition item must match.
### `feed_group_rule_cond_item_type`
Used inside `feed_group_rules.rules[].condition.condition_items[].type`. Determines which sibling field of the item is consulted.
- `keyword` — match against a keyword; consult the `keyword` field.
- `chatter` — match against a user; consult the `user_id` field (interpreted per the request's `user_id_type`).
- `chat_type` — match against a chat type; consult the `chat_type` field.
### `feed_group_rule_cond_item_operator`
Used inside `feed_group_rules.rules[].condition.condition_items[].operator`. Typically paired with the relevant `type`:
- `contain` — substring match; typically paired with `keyword`.
- `not_contain` — substring non-match; typically paired with `keyword`.
- `is` — equality; typically paired with `chatter` or `chat_type`.
- `is_not` — non-equality; typically paired with `chatter` or `chat_type`.
### `feed_group_rule_cond_item_chat_type`
Used inside `feed_group_rules.rules[].condition.condition_items[].chat_type` when `type=chat_type`.
- `p2p`
- `group`
- `thread_group`
- `helpdesk`
- `bot`
- `mute`
- `flag`
- `cross_tenant`
- `any`
### `update_fields`
Used inside `feed_group_updater.update_fields`. Multiple values may be listed.
- `1` — update name only.
- `2` — update rules only.
Wire form: integers (`1` = name, `2` = rules). The server rejects the lowercase string forms (`"name"`, `"rules"`) with `9499 Invalid parameter value`. Omit the array (or pass an empty array) to make no field updates.
- **`feed_group_type`** (`feed_group_creator.type`, response `groups[].type`): `normal` (empty; managed via `batch_add_item`/`batch_remove_item`) · `rule` (auto-populated; requires `feed_group_creator.rules`).
- **`feed_card_type`** (`items[].feed_type`; wire alias `FeedCardTypeV1`): `chat` is the **only** value the v1 OAPI service accepts, so `feed_id` is a chat ID (`oc_xxx`). **The CLI does not pre-validate this** — anything other than `chat` reaches the server and is rejected at runtime. Treat `chat` as effectively required.
- **`feed_group_rule_action`** (`rules[].action`): `add` · `remove`.
- **`feed_group_rule_cond_match_type`** (`rules[].condition.match_type`): `match_all` (every item must match) · `match_any` (at least one).
- **`feed_group_rule_cond_item_type`** (`condition_items[].type` — determines which sibling field is consulted): `keyword` (→ `keyword` field) · `chatter` (→ `user_id` field, interpreted per `user_id_type`) · `chat_type` (→ `chat_type` field).
- **`feed_group_rule_cond_item_operator`** (`condition_items[].operator`): `contain` / `not_contain` (substring, with `keyword`) · `is` / `is_not` (equality, with `chatter` or `chat_type`).
- **`feed_group_rule_cond_item_chat_type`** (`condition_items[].chat_type` when `type=chat_type`): `p2p` · `group` · `thread_group` · `helpdesk` · `bot` · `mute` · `flag` · `cross_tenant` · `any`.
- **`update_fields`** (`feed_group_updater.update_fields`): integers `1` = name, `2` = rules (multiple may be listed). See the string-vs-integer trap under [update](#update).
## feed_group_rules
The same nested object is used in `feed_group_creator.rules` (create), `feed_group_updater.rules` (update), and in read responses under `groups[].rules`. Shape:
The same nested object is used in `feed_group_creator.rules` (create), `feed_group_updater.rules` (update), and read responses under `groups[].rules`:
```json
{
@@ -424,25 +183,15 @@ The same nested object is used in `feed_group_creator.rules` (create), `feed_gro
Per-`type` required-field legend:
- `type=keyword``keyword` is required; `user_id` and `chat_type` are ignored.
- `type=chatter``user_id` is required; the request's `user_id_type` query parameter tells the server how to interpret it.
- `type=chat_type``chat_type` is required.
- `type=keyword``keyword` required; `user_id` and `chat_type` ignored.
- `type=chatter``user_id` required; the request's `user_id_type` query parameter tells the server how to interpret it.
- `type=chat_type``chat_type` required.
## Permissions
## Permissions (raw write methods)
| Method | Scope |
|---|---|
| `feed.groups.create` | `im:feed_group_v1:write` |
| `feed.groups.update` | `im:feed_group_v1:write` |
| `feed.groups.delete` | `im:feed_group_v1:write` |
| `feed.groups.batch_query` | `im:feed_group_v1:read` |
| `feed.groups.batch_add_item` | `im:feed_group_v1:write` |
| `feed.groups.batch_remove_item` | `im:feed_group_v1:write` |
The three read methods are shortcut-only:
- [`+feed-group-list`](lark-im-feed-group-list.md) — `im:feed_group_v1:read`
- [`+feed-group-list-item`](lark-im-feed-group-list-item.md) / [`+feed-group-query-item`](lark-im-feed-group-query-item.md) — `im:feed_group_v1:read` **plus** `im:chat:read` (they always resolve `chat_name`)
- `create` / `update` / `delete` / `batch_add_item` / `batch_remove_item``im:feed_group_v1:write`.
- `batch_query``im:feed_group_v1:read`.
- Read shortcuts: scopes listed under [Routing](#routing-pick-the-right-method) and in each shortcut doc.
If a required scope is missing, the CLI surfaces a hint such as `lark-cli auth login --scope "im:feed_group_v1:write"`.

View File

@@ -1,97 +1,18 @@
# im +feed-shortcut-create
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
This skill maps to shortcut: `lark-cli im +feed-shortcut-create`. Underlying API: `POST /open-apis/im/v2/feed_shortcuts`.
Maps to `lark-cli im +feed-shortcut-create`. **Run `lark-cli im +feed-shortcut-create --help` for the authoritative flags (`--chat-id` / `--head` / `--tail` / `--as` / `--dry-run` / `--format` / `-q`), the 10-ID batch limit, and `--head`/`--tail` mutual exclusion.** This file covers only what `--help` cannot.
## What it does
## Gotchas
Adds one or more chats to the **current user's** feed shortcuts — equivalent to right-clicking a chat in the Feishu client and pinning it to the feed sidebar.
- **Only CHAT-type shortcuts are supported** (`--chat-id` must be an `oc_xxx` open_chat_id). If you only know a group name, resolve its `oc_xxx` first with `im +chat-search`.
- **Re-adding an existing shortcut is idempotent** — the server treats it as a no-op rather than an error; `ok:true`, `failure_count=0`.
- **Partial failure exits non-zero**: any non-empty `failed_shortcuts` sets `ok:false` on stdout and exits `1`. The full batch ledger (`total`, `success_count`, `failure_count`, `succeeded_shortcuts`, `failed_shortcuts`) remains machine-readable on stdout even on partial failure — check exit code AND `ok` AND `failure_count` in scripts.
- **`failed_shortcuts[].reason_label`** is a CLI-added human-readable label alongside the numeric `reason`. The server only returns the number; the CLI enriches it. Reason codes: `1=no_permission`, `2=invalid_item`, `3=has_pending_delete`, `4=type_not_support`, `5=internal_error`.
- **User identity only** (`--as user`). The CLI rejects `--as bot` locally; the server also rejects it.
- Only **CHAT-type** shortcuts are exposed by the OpenAPI gateway right now (`feed_card_id` must be an `oc_xxx` open_chat_id).
- Batch up to **10 chat IDs per call**; pass more by issuing multiple calls.
- Currently only supports **user identity** (`--as user`); bot identity is not allowed by the server.
- If you only know a group name, resolve its `oc_xxx` first with `im +chat-search` or `im +chat-list`.
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
## Commands
```bash
# Add a single chat as a feed shortcut (defaults to head/top insertion)
lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx
# Add multiple chats; comma-separated or repeated flag both work
lark-cli im +feed-shortcut-create --as user --chat-id oc_a,oc_b,oc_c
lark-cli im +feed-shortcut-create --as user --chat-id oc_a --chat-id oc_b
# Append at the bottom of the shortcut list instead of the top
lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx --tail
# Preview the request without sending
lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx --dry-run
```
## Parameters
| Parameter | Default | Description |
|------|------|------|
| `--chat-id <oc_xxx>` | required | open_chat_id to add as a feed shortcut; repeatable or comma-separated; **max 10 per call** |
| `--head` | true (implied) | Insert at the top of the shortcut list; mutually exclusive with `--tail` |
| `--tail` | false | Append at the bottom of the shortcut list |
| `--as user` | required | Server only accepts user_access_token for this API |
## Response
The response is a batch ledger. A full success exits `0` with `ok:true`. Any non-empty `failed_shortcuts` is a partial failure: the process exits non-zero (currently exit `1`), stdout carries `ok:false`, and the full ledger remains machine-readable:
| Field | Meaning |
|------|------|
| `total` | Number of requested shortcuts |
| `success_count` | Number of requested shortcuts not reported in `failed_shortcuts` |
| `failure_count` | Number of requested shortcuts reported as failed; `failed_shortcuts` preserves the raw server failure list |
| `succeeded_shortcuts` | Requested shortcut entries that succeeded |
| `failed_shortcuts` | Per-item failures returned by the server, enriched with `reason_label` |
The shortcut adds a `reason_label` field next to each numeric `reason`:
| `reason` | `reason_label` | Meaning |
|---:|------|------|
| 1 | `no_permission` | User has no permission on the feed card |
| 2 | `invalid_item` | `feed_card_id` is invalid or type doesn't match |
| 3 | `has_pending_delete` | The chat is being deleted |
| 4 | `type_not_support` | Type is not whitelisted (only CHAT is open now) |
| 5 | `internal_error` | Server internal error |
Example:
```json
{
"ok": false,
"data": {
"total": 2,
"success_count": 1,
"failure_count": 1,
"succeeded_shortcuts": [
{ "feed_card_id": "oc_good", "type": 1 }
],
"failed_shortcuts": [
{
"shortcut": { "feed_card_id": "oc_bad", "type": 1 },
"reason": 2,
"reason_label": "invalid_item"
}
]
}
}
```
## Permissions
- Required scope: `im:feed.shortcut:write`
- Only available with user identity (`--as user`). The CLI will reject `--as bot` for this shortcut.
## Note
- The shortcut list is **per user**: the call adds shortcuts for the currently authenticated user only.
- Adding the same chat twice is **idempotent at the user level** (re-adding an existing shortcut is a no-op rather than an error).
- Scripts should check the process exit code, top-level `ok`, and ledger counts. Partial failures intentionally keep machine-readable success and failure details on stdout.
- To inspect the current shortcut list, use [`+feed-shortcut-list`](lark-im-feed-shortcut-list.md). To remove a shortcut, use [`+feed-shortcut-remove`](lark-im-feed-shortcut-remove.md).
- **Required scope**: `im:feed.shortcut:write`.
- **Response ledger fields**: `total` · `success_count` · `failure_count` · `succeeded_shortcuts[]{feed_card_id, type}` · `failed_shortcuts[]{shortcut{feed_card_id, type}, reason, reason_label}`.

View File

@@ -1,103 +1,20 @@
# im +feed-shortcut-list
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
This skill maps to shortcut: `lark-cli im +feed-shortcut-list`. Underlying API: `GET /open-apis/im/v2/feed_shortcuts`.
Maps to `lark-cli im +feed-shortcut-list`. **Run `lark-cli im +feed-shortcut-list --help` for the authoritative flags (`--page-token` / `--no-detail` / `--as` / `--dry-run` / `--format` / `-q`), and pagination restart behavior.** This file covers only what `--help` cannot.
## What it does
## Gotchas
Lists **one page** of the **current user's** feed shortcuts.
- **Only CHAT-type shortcuts are returned** — other shortcut types exist in the API IDL but are not whitelisted by the server today. Do not assume `type` will ever be non-1.
- **No built-in auto-pagination.** Drive the loop yourself: read `data.page_token` and pass it back until `has_more=false`. The shortcut intentionally stays one-page-at-a-time so callers decide what to do when a token is rejected.
- **Detail enrichment calls `im.chats.batch_query` in batches of 50**, requires `im:chat:read`, and attaches the full chat object under `detail`. Pass `--no-detail` to avoid the extra scope and network call when only `feed_card_id` values are needed.
- **P2P chats return an empty `name`** — the Feishu client renders the partner's display name client-side. Use `p2p_target_id` to resolve the partner via `+contact-search` if a display title is needed.
- **Enrichment failure is silent on stdout**: if `im:chat:read` is missing or the batch_query errors, the list still returns successfully; a warning goes to stderr and the data payload gains a `_notice` field (`"detail enrichment skipped: ..."`). Affected entries simply lack the `detail` field. Check `_notice` to distinguish "enrichment skipped" from "nothing to enrich."
- **`detail` shape is dispatched per `type`** — switch on `type` before parsing `detail`; future shortcut types may attach a different object shape.
- Only **CHAT-type** shortcuts are exposed via OpenAPI today (others in the IDL are not yet whitelisted).
- The shortcut is a **thin one-page wrapper** — there is no built-in auto-pagination. Callers drive their own loop when they actually need to paginate.
- Server-side page size is controlled by the service; in normal use one page usually covers the list.
- Pagination tokens are opaque. If a token is rejected because the shortcut list changed, restart by omitting `--page-token`.
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
## Commands
```bash
# First page (the only call most users ever need — --page-token omitted)
lark-cli im +feed-shortcut-list --as user
# Continue from the previous response's page_token
lark-cli im +feed-shortcut-list --as user --page-token <token-from-previous-response>
# Skip detail enrichment when only IDs are needed; avoids the extra im:chat:read lookup
lark-cli im +feed-shortcut-list --as user --no-detail -q '.data.shortcuts[].feed_card_id'
```
> If you need to walk every page, write the loop yourself: read `data.page_token` from each response and pass it back in until `has_more=false`. The shortcut intentionally does not auto-walk because page-token errors require the caller to decide whether to restart from the first page.
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--page-token <token>` | no | Opaque pagination token from the previous response. **Omit it for the first page.** |
| `--no-detail` | no (default `false`) | Skip fetching each entry's full info object. By default enrichment is enabled: CHAT-type entries call `im.chats.batch_query`, need `im:chat:read`, and attach the object under the `detail` field. Pass `--no-detail` to skip the extra call and scope. |
| `--as user` | yes | Server only accepts user_access_token for this API |
## Response Structure
| Field | Type | Description |
|------|------|------|
| `shortcuts` | array | Feed shortcut entries; each has `feed_card_id` (oc_xxx) and `type` (1=CHAT). By default (without `--no-detail`), each entry also has a `detail` field with the full per-type info object. |
| `has_more` | boolean | Whether more pages exist |
| `page_token` | string | Opaque token to pass to the next call when continuing pagination |
Example (with detail enrichment, CHAT type):
```json
{
"data": {
"shortcuts": [
{
"feed_card_id": "oc_092f0100fe59c35995727db1039777a8",
"type": 1,
"detail": {
"chat_id": "oc_092f0100fe59c35995727db1039777a8",
"chat_mode": "group",
"name": "Engineering",
"avatar": "https://...",
"description": "",
"external": false,
"owner_id": "ou_xxx",
"owner_id_type": "open_id",
"tenant_key": "..."
}
},
{
"feed_card_id": "oc_c82061d126a06635aa3569587b134bb1",
"type": 1,
"detail": {
"chat_id": "oc_c82061d126a06635aa3569587b134bb1",
"chat_mode": "p2p",
"name": "",
"p2p_target_id": "ou_xxx",
"p2p_target_type": "user",
"avatar": "",
"description": "",
"external": false,
"tenant_key": "..."
}
}
],
"has_more": false,
"page_token": "v1.example-opaque-token"
}
}
```
## Detail Enrichment
The `detail` payload is dispatched **per `type`**. Today only CHAT is wired in; future shortcut types can attach different object shapes. Callers should `switch` on `type` before parsing `detail`. For CHAT (`type=1`):
- **Source**: `POST /open-apis/im/v1/chats/batch_query` (50 ids per call, server limit).
- **Payload**: the **full chat object** is passed through verbatim — `chat_id`, `chat_mode` (`group` / `p2p` / `topic`), `name`, `avatar`, `description`, `external`, `tenant_key`, plus type-specific fields (`owner_id*` for groups, `p2p_target_*` for p2p).
- **P2P chats** return an empty `name` because the Feishu client renders the partner's display name there. The rest of the object (especially `p2p_target_id`) still flows through, so callers can resolve the partner via `+contact-search` if a display title is needed.
- **Lookup failure** (missing scope, network error) → the list still returns successfully; a warning is printed to stderr, the data payload carries a `_notice` field (`"detail enrichment skipped: ..."`), and affected entries simply lack the `detail` field. Check `_notice` to tell "enrichment skipped" from "nothing to enrich".
## Permissions
- Required scope: `im:feed.shortcut:read`
- Conditional scope (default detail path only): `im:chat:read`; pass `--no-detail` to avoid this extra scope and lookup.
- Only available with user identity (`--as user`).
- **Output fields**: `shortcuts[].feed_card_id` (oc_xxx) · `shortcuts[].type` (1=CHAT) · `shortcuts[].detail` (full chat object; absent when `--no-detail` or enrichment fails) · `has_more` · `page_token`.
- **`detail` for CHAT**: `chat_id` · `chat_mode` (`group`/`p2p`/`topic`) · `name` · `avatar` · `description` · `external` · `tenant_key`; groups add `owner_id`/`owner_id_type`; p2p adds `p2p_target_id`/`p2p_target_type`.
- **Required scopes**: `im:feed.shortcut:read` always; `im:chat:read` conditionally (default detail path only).

View File

@@ -1,48 +1,16 @@
# im +feed-shortcut-remove
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
This skill maps to shortcut: `lark-cli im +feed-shortcut-remove`. Underlying API: `POST /open-apis/im/v2/feed_shortcuts/remove`.
Maps to `lark-cli im +feed-shortcut-remove`. **Run `lark-cli im +feed-shortcut-remove --help` for the authoritative flags (`--chat-id` / `--as` / `--dry-run` / `--format` / `-q`) and the 10-ID batch limit.** This file covers only what `--help` cannot.
## What it does
## Gotchas
Removes one or more chats from the **current user's** feed shortcuts.
- **Removing a chat that is not in the shortcut list is idempotent** — the server returns `ok:true`, `failure_count=0`, no `failed_shortcuts` entry. This means you cannot distinguish "removed" from "was not present."
- **Partial failure exits non-zero**: any non-empty `failed_shortcuts` sets `ok:false` on stdout and exits `1`. The full batch ledger remains on stdout — check exit code AND `ok` in scripts. See [`+feed-shortcut-create`](lark-im-feed-shortcut-create.md) for the shared ledger field definitions and `reason_label` codes.
- **User identity only** (`--as user`). The server does not accept bot identity.
- **To inspect the list before removing**, run `+feed-shortcut-list --no-detail` (avoids the extra `im:chat:read` call when only `feed_card_id` values are needed).
- Only **CHAT-type** shortcuts are supported (`feed_card_id` must be an `oc_xxx`).
- Batch up to **10 chat IDs per call**.
- Currently only supports **user identity** (`--as user`).
- Removing a chat that is not currently in the shortcut list is idempotent success: the call returns `ok:true`, `failure_count=0`, and no `failed_shortcuts` entry for that chat.
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
## Commands
```bash
# Remove a single feed shortcut
lark-cli im +feed-shortcut-remove --as user --chat-id oc_xxx
# Remove multiple feed shortcuts in one call
lark-cli im +feed-shortcut-remove --as user --chat-id oc_a,oc_b
lark-cli im +feed-shortcut-remove --as user --chat-id oc_a --chat-id oc_b
# Preview the request
lark-cli im +feed-shortcut-remove --as user --chat-id oc_xxx --dry-run
```
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--chat-id <oc_xxx>` | yes | open_chat_id to remove from feed shortcuts; repeatable or comma-separated; max 10 per call |
| `--as user` | yes | Server only accepts user_access_token for this API |
## Response
The response uses the same batch ledger as [`+feed-shortcut-create`](lark-im-feed-shortcut-create.md#response): `total`, `success_count`, `failure_count`, `succeeded_shortcuts`, and `failed_shortcuts`. A non-empty `failed_shortcuts` is a partial failure: stdout carries `ok:false` with the full ledger and the process exits non-zero (currently exit `1`).
## Permissions
- Required scope: `im:feed.shortcut:write`
- Only available with user identity (`--as user`).
## Note
- To see what is currently in the shortcut list before removing, run [`+feed-shortcut-list`](lark-im-feed-shortcut-list.md). Use `--no-detail` when you only need the `feed_card_id` values.
- **Required scope**: `im:feed.shortcut:write`.

View File

@@ -2,66 +2,13 @@
> **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`.
Maps to `lark-cli im +flag-cancel`. **Run `lark-cli im +flag-cancel --help` for the authoritative flags (`--message-id` / `--flag-type` / `--item-type`), defaults, and enums.** This file covers only what `--help` cannot.
## Double-Cancel Behavior (Important)
## Gotchas
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 best-effort double-cancel**: the message-layer flag is always removed; the feed-layer flag is also removed when the chat type can be determined (otherwise a warning is printed on stderr and the feed layer is skipped). 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, best-effort double-cancel of 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'
```
- **Default is double-cancel (both layers)**: omitting `--flag-type` best-effort cancels both the message-layer flag `(default, message)` and the feed-layer flag `(thread, feed)` or `(msg_thread, feed)`. The server treats cancel of a non-existent flag idempotently — no error — so double-cancel is safe even when only one layer has a flag.
- **Feed-layer `item_type` is determined by `chat_mode`**: `topic` chat → `item_type=thread`; regular `group` chat → `item_type=msg_thread`. The double-cancel path auto-detects this (requires `im:chat:read`). **Best-effort, not all-or-nothing**: the message layer is always removed; the feed layer is removed only when the chat type can be determined — otherwise a warning is printed on stderr and the feed layer is silently skipped (the command still exits 0). When calling single-layer feed cancel with `--flag-type feed`, you must supply `--item-type` explicitly.
- **Idempotent**: repeated cancel calls on an already-unflagged message succeed silently.
- **Do NOT call `+flag-list` for verification**: a success response means the flag was removed. Paginating `+flag-list` to confirm is expensive and unnecessary.
- **Finding a message ID**: use `+messages-search --query "<keywords>"` to locate the message and extract `message_id`. Do NOT use `+flag-list` — it requires full pagination and won't reliably locate the message.
- **User identity only** — `--as bot` is not supported.

View File

@@ -2,66 +2,13 @@
> **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`.
Maps to `lark-cli im +flag-create`. **Run `lark-cli im +flag-create --help` for the authoritative flags (`--message-id` / `--flag-type` / `--item-type`), defaults, and enums.** This file covers only what `--help` cannot.
## Default Behavior
## Gotchas
- **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'
```
- **Message-layer vs feed-layer are distinct**: default (`--flag-type message`) creates a `(default, message)` flag visible in message history. `--flag-type feed` creates a feed-layer flag visible in the Feed tab. A message can carry both simultaneously — they are independent.
- **Feed-layer auto-detection requires extra scopes**: `--flag-type feed` without `--item-type` calls the chat API to determine `chat_mode`; this needs `im:message.group_msg:get_as_user` / `im:message.p2p_msg:get_as_user` / `im:chat:read`. If you already know the chat type, pass `--item-type` explicitly to skip the lookup.
- **Only three valid `(item_type, flag_type)` pairs**: `(default, message)`, `(thread, feed)`, `(msg_thread, feed)`. Any other combination is rejected by the server.
- **Do NOT call `+flag-list` for verification**: a success response means the flag was created. Full pagination of `+flag-list` to confirm is expensive and unnecessary.
- **Finding a message ID**: use `+messages-search --query "<keywords>"` to locate the message and extract `message_id`. Do NOT use `+flag-list` — it requires full pagination and won't reliably locate the message.
- **User identity only** — `--as bot` is not supported.

View File

@@ -2,99 +2,16 @@
> **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`.
Maps to `lark-cli im +flag-list`. **Run `lark-cli im +flag-list --help` for the authoritative flags (`--page-size` / `--page-token` / `--page-all` / `--page-limit` / `--enrich-feed-thread`), limits, and enums.** This file covers only what `--help` cannot.
## Sorting Rules (Important)
## Gotchas
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.
- **Sort order is ascending (oldest first)**: the API returns `flag_items` sorted by `update_time` ascending. When `has_more=true`, the first page's items are the oldest, not the newest. To get the latest flag, paginate all pages (`--page-all`) and take the last item: `-q '.data.flag_items[-1]'`.
- **`delete_flag_items` are NOT enriched**: message content is fetched only for active flags (`flag_items`). To get message content for a canceled flag, query separately via `+messages-mget --message-ids <item_id>`.
- **`(thread, feed)` / `(msg_thread, feed)` entries are enriched automatically**: the shortcut calls `messages/mget` for feed-type thread entries and writes the result into each entry's `message` field. Disable with `--enrich-feed-thread=false` to avoid the extra scope requirement.
- **User identity only** — `--as bot` is not supported.
Recommended: use `--page-all` for auto-pagination to get the complete list, then use `-q '.data.flag_items[-1]'` to get the latest item.
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
## 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`
- **Output fields**: `flag_items` (active flags, ascending `update_time`) · `delete_flag_items` (canceled flags, ascending `update_time`, unenriched) · `messages` (server-inlined content for `(default, message)` flags) · `has_more` · `page_token`.
- **Enrichment write target**: for `(thread, feed)` / `(msg_thread, feed)` entries, enriched message content is written to the entry's `message` field (not a top-level `messages` array).

View File

@@ -1,54 +1,32 @@
# im default message enrichment (reactions / update_time)
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
This is the single source of truth for the automatic message-enrichment contract shared by the four message-pulling shortcuts — [`+messages-mget`](lark-im-messages-mget.md), [`+chat-messages-list`](lark-im-chat-messages-list.md), [`+messages-search`](lark-im-messages-search.md), [`+threads-messages-list`](lark-im-threads-messages-list.md). They automatically attach `reactions` and `update_time` to each returned message, so callers do **not** need to invoke the raw [`im.reactions.batch_query`](lark-im-reactions.md) API separately.
Cross-cutting behavior shared by the message-pulling shortcuts. `--help` documents only the `--no-reactions` opt-out; the field semantics, caps, and failure contract below are not in any `--help` or schema.
- **`reactions`** — populated from `im.reactions.batch_query` as `{counts, details}`. The field is only attached when the server actually returns data; messages with no reactions omit it. Replies inside `thread_replies` are enriched alongside their parent (collected into the same id set), so outer and inner messages follow identical semantics. The id set is split into batches of <= 20 (server-side cap) and the batches are dispatched with bounded concurrency (up to 4 in flight), so high-N pulls — e.g. page 50 + ~500 expanded thread replies = 550 ids → ⌈550 / 20⌉ = **28 batches** — finish in a few round-trips instead of serializing into tens of seconds.
- **`update_time`** — emitted only when `updated == true` (message was actually edited). The server echoes `update_time == create_time` for unedited messages too, but the CLI gates that output away so consumers don't misread every message as "edited".
- **Opt-out** — each shortcut accepts `--no-reactions` to skip the extra round-trip when the caller only needs message bodies.
**Relied on by:** [`+messages-mget`](lark-im-messages-mget.md) · [`+chat-messages-list`](lark-im-chat-messages-list.md) · [`+messages-search`](lark-im-messages-search.md) · [`+threads-messages-list`](lark-im-threads-messages-list.md). All four attach `reactions` + `update_time` so callers do **not** need the raw [`im.reactions.batch_query`](lark-im-reactions.md) API. **Only `+messages-mget` and `+chat-messages-list` additionally auto-expand `thread_replies`**; `+messages-search` and `+threads-messages-list` do reactions/`update_time` only.
## Thread replies expansion
## Gotchas
`+messages-mget` and `+chat-messages-list` also auto-expand thread replies: any returned message that carries a `thread_id` triggers a fetch of that thread's replies, which are attached as a `thread_replies` array on the host. Fetches across distinct threads run with bounded concurrency (up to 4 in flight). Two caps gate the result:
- **`perThread` (default 50)** — max replies fetched for any single thread.
- **`totalLimit` (default 500)** — max cumulative replies across all threads on the page.
`totalLimit` is enforced **post-fetch against actual returned reply counts**, not against the planned per-thread ceiling — so a chat with many short threads (e.g. 12 threads × 3 actual replies = 36 ≪ 500) attaches every thread, even though the planned sum (12 × 50 = 600) would exceed the budget. When a thread's actual replies push the running total across `totalLimit`, that thread is truncated to fit the remaining budget and its host is flagged with `thread_has_more: true` so consumers know the server has more.
On per-thread fetch failure the host gets `thread_replies_error: true` (mirrors the reactions data contract); budget-truncated or budget-skipped threads do NOT carry that flag.
- **Missing field ≠ fetch failure.** `reactions` is attached only when the server returns data — a message with no reactions *omits* the field (not `{}`, not `null`). To decide "has the user already reacted?", branch on **presence of `reactions` + its `counts` contents**, never on `null`. Fetch failure is signaled separately on stderr (`warning: reactions_batch_query_failed` for a whole batch; `warning: reactions_partial_failed: N message(s) failed` for some IDs).
- **`update_time` is gated, not raw.** It's emitted only when `updated == true`. The server echoes `update_time == create_time` for unedited messages, but the CLI suppresses that so you don't misread every message as edited.
- **Requires `im:message.reactions:read`.** Declared in each shortcut's scopes, so the pre-flight surfaces `missing_scope` before sending. Bots registered before this scope existed need an incremental authorization in the developer console (a user re-login picking up the scope is enough for user identity).
- **High-N pulls are batched, not serialized.** Reaction lookups split into batches of ≤20 ids (server cap) dispatched with bounded concurrency (≤4 in flight), so e.g. 550 ids → 28 batches finish in a few round-trips, not tens of seconds. Don't add your own throttling/looping on top.
## Resource auto-download (`--download-resources`, opt-in)
`+chat-messages-list`, `+messages-mget`, and `+threads-messages-list` accept an **opt-in** `--download-resources` flag. It is **off by default** when omitted, output and the request count are identical to before (no `resources` block, no extra round-trips).
`+chat-messages-list` / `+messages-mget` / `+threads-messages-list` accept `--download-resources` (**off by default** — omitted = no `resources` block and zero extra requests). When set, eligible resources (`image`/`file`/`audio`/`video`/`media` + post-embedded; **stickers excluded** — Feishu can't fetch them) download into `./lark-im-resources/`, and each message gains a `resources` array of `{message_id, key, type, local_path, size_bytes}`.
When enabled:
- **`merge_forward` trap**: for a resource inside a forwarded message, `message_id` is the **top-level container** id, not the sub-item's — the download endpoint rejects sub-item ids with `234003 File not in msg`. Thread replies each get their own block.
- **Fail-silent isolation**: one resource that fails to download is flagged `"error": true` + a single stderr `warning: resource_download_failed: …`; the message and other resources are unaffected (exit 0).
- Deduped by `(message_id, file_key)`, bounded concurrency (≤3), output confined to `./lark-im-resources/` (path-separator / `..` / absolute `file_key` rejected).
- **No extra scope**: uses `im:message:readonly`, already declared by the listing commands; works under user and bot identity. For one-off fetches use [`+messages-resources-download`](lark-im-messages-resources-download.md).
- Each message that carries downloadable resources gets a `resources` array. Eligible types: `image`, `file`, `audio`, `video`, `media`, and post-embedded `img` / `media`. **Stickers are excluded** (Feishu does not support fetching sticker resources).
- Each ref is `{message_id, key, type, local_path, size_bytes}``type` is `image` or `file`; `message_id` is the id used to fetch the resource. For a standalone message that is its own id; for a resource inside a **merge_forward** it is the **top-level container** `message_id`, not the sub-item's own id (the download endpoint rejects sub-item ids with `234003 File not in msg` and can only fetch a forwarded resource through the container). Thread replies each get their own block.
- Files download into `./lark-im-resources/` under the current working directory. Each distinct `(message_id, file_key)` is downloaded once (deduped) with bounded concurrency (up to 3 in flight).
- **Fail-silent isolation**: a single resource that fails to download is flagged `"error": true` with one stderr line (`warning: resource_download_failed: <message_id>/<key>: ...`); the main message and the other resources are unaffected.
- Output paths are confined to `./lark-im-resources/` by the same guards as [`+messages-resources-download`](lark-im-messages-resources-download.md) (abnormal `file_key` with path separators / `..` / absolute paths is rejected).
- **Scope**: the download uses `GET /open-apis/im/v1/messages/:message_id/resources/:file_key`, which requires `im:message:readonly` — already declared in each listing command's `Scopes`, so `--download-resources` needs **no extra scope** beyond what's required to read the messages (user identity also needs `im:message.group_msg:get_as_user` / `im:message.p2p_msg:get_as_user`; bot identity needs `im:message.group_msg` / `im:message.p2p_msg:readonly`, all already declared). Works under both user and bot identity. If a bot was registered before `im:message:readonly` was granted, a single resource will fail-silently (`error: true` + stderr warning) rather than aborting the pull.
## Thread-replies expansion caps (mget / chat-messages-list only)
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. `[Image: img_xxx]`, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
Any returned message carrying a `thread_id` triggers a fetch of that thread's replies, attached as a `thread_replies` array on the host (distinct threads fetched with ≤4 concurrent). Two caps gate it:
## Scope requirement
- **`perThread` (default 50)** — max replies per single thread.
- **`totalLimit` (default 500)** — max cumulative replies across all threads on the page.
The default enrichment requires `im:message.reactions:read`, already declared in each shortcut's `UserScopes` / `BotScopes` (or `Scopes` for the user-only search command), so the framework's pre-flight check surfaces a `missing_scope` error before the request is sent. Bots that were registered before this scope was added need an incremental authorization in the Feishu developer console; users can run:
```bash
lark-cli auth login --scope "im:message.reactions:read"
```
## Data contract — missing field ≠ fetch failure
| Situation | Output |
|---|---|
| Message has no reactions | `reactions` field is omitted (not `{}`, not an empty list) |
| Message was never edited | `update_time` field is omitted |
| Whole batch failed | Messages in that batch carry no `reactions`; one line on stderr: `warning: reactions_batch_query_failed: ...` |
| Some message IDs failed | Failed IDs go to stderr: `warning: reactions_partial_failed: N message(s) failed (...)` |
When deciding "has the user already reacted?", branch on the **presence of the `reactions` field plus its `counts` contents**, not on whether a value is `null` — the field's absence means "no data attached" (which usually means "no reactions exist"), not "fetch failed".
**`totalLimit` is enforced post-fetch against *actual* returned counts, not the planned per-thread ceiling.** So 12 threads × 3 real replies = 36 all attach, even though the planned sum (12 × 50 = 600) would blow the budget. When a thread's real replies push the running total past `totalLimit`, that thread is truncated to fit and its host is flagged **`thread_has_more: true`**. A per-thread fetch *failure* instead flags the host **`thread_replies_error: true`** — budget-truncated/skipped threads do NOT carry that flag (so the two cases are distinguishable).

View File

@@ -1,99 +1,18 @@
# im +messages-mget
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
Fetch message details in batch. Given a list of message IDs, this returns the full content for multiple messages in one call and automatically resolves sender names.
Maps to `lark-cli im +messages-mget`. **Run `lark-cli im +messages-mget --help` for the authoritative flags (`--message-ids` / `--no-reactions` / `--download-resources` / `--as` / `--format`), the `om_xxx` ID format, and the max-50 batch cap.** This file covers only what `--help` cannot.
By default the response also carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Replies inside `thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. Pass `--download-resources` to additionally download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block — off by default, no extra requests when omitted. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
Supports both `--as user` (default) and `--as bot`. Auto-resolves sender names and auto-expands `thread_replies`, both with reactions / `update_time` per [message enrichment](lark-im-message-enrichment.md).
> **Supports both `--as user` (default) and `--as bot`.**
## Gotchas
This skill maps to the shortcut: `lark-cli im +messages-mget` (internally calls `GET /open-apis/im/v1/messages/mget`).
- **Batch, don't loop**: one call with comma-separated IDs is one request (up to 50); calling per-ID wastes round-trips.
- **Image content is a placeholder, not bytes**: image messages render as `[Image: img_xxx]`. Use `--download-resources` to write them to `./lark-im-resources/`, or [`im +messages-resources-download`](lark-im-messages-resources-download.md) for a single resource.
- **Use `--format json` for full bodies** — non-JSON formats truncate `content`.
- Permission denied → needs `im:message:readonly` + `contact:user.base:readonly` (sender-name resolution requires the contact scope, which the message scope alone won't satisfy).
## Commands
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
```bash
# Fetch a single message
lark-cli im +messages-mget --message-ids om_xxx
# Fetch multiple messages in batch (comma-separated)
lark-cli im +messages-mget --message-ids "om_aaa,om_bbb,om_ccc"
# JSON output
lark-cli im +messages-mget --message-ids "om_aaa,om_bbb" --format json
# Preview the request without executing it
lark-cli im +messages-mget --message-ids "om_aaa" --dry-run
```
## Parameters
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--message-ids <ids>` | Yes | At least one, max 50, `om_xxx` format, comma-separated | Message ID list |
| `--no-reactions` | No | — | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | — | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |
## Output Fields
| Field | Description |
|------|------|
| `messages` | Message array |
| `total` | Number of messages returned |
Each message contains:
| Field | Description |
|------|------|
| `message_id` | Message ID |
| `msg_type` | Message type (`text`, `image`, `file`, etc.) |
| `create_time` | Creation time |
| `sender` | Sender information (includes `name`) |
| `content` | Message content |
## Usage Scenarios
### Scenario 1: Fetch the full content of a specific message
```bash
lark-cli im +messages-mget --message-ids om_xxx --format json
```
### Scenario 2: Fetch multiple messages in one batch
```bash
lark-cli im +messages-mget --message-ids "om_aaa,om_bbb,om_ccc"
```
### Scenario 3: Use together with the message list command
First get message IDs via `+chat-messages-list`, then fetch full content via `+messages-mget`:
```bash
# Get the message list
lark-cli im +chat-messages-list --chat-id oc_xxx --format json
# Fetch specific message details
lark-cli im +messages-mget --message-ids "om_aaa,om_bbb"
```
## Common Errors and Troubleshooting
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| `--message-ids requires at least one message ID` | No message ID was provided | Provide at least one message ID |
| `invalid message ID: must start with om_` | Invalid message ID format | Message IDs must start with `om_` |
| Permission denied | Message read permission is missing | Ensure the app has `im:message:readonly` and `contact:user.base:readonly` enabled |
| Empty result | Message IDs do not exist or are not accessible | Verify the IDs and access permissions |
## AI Usage Guidance
1. **Use JSON for full content:** table output truncates content. Use `--format json` when the full body matters.
2. **Sender names are already enriched:** the command resolves sender names automatically, so no extra lookup is required.
3. **Images are rendered as placeholders:** image messages appear as placeholders such as `[Image: img_xxx]`. Use `+messages-resources-download` when you need the binary resource.
4. **Batching is more efficient:** fetching multiple IDs in one request is better than calling the API repeatedly.
## References
- [lark-im](../SKILL.md) - all IM commands
- [lark-shared](../../lark-shared/SKILL.md) - authentication and global parameters
- **Return shape**: `{messages:[...], total:N}`; each message has `message_id` / `msg_type` / `create_time` / `sender` (incl. resolved `name`) / `content`. Enrichment-added fields are documented in [message enrichment](lark-im-message-enrichment.md).

View File

@@ -1,263 +1,14 @@
# im +messages-reply
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
Reply to a specific message. Supports both user identity (`--as user`) and bot identity (`--as bot`). Also supports thread replies.
Maps to `lark-cli im +messages-reply`. **Run `lark-cli im +messages-reply --help` for the authoritative flags (`--message-id` / `--text` / `--markdown` / `--content` / `--msg-type` / `--image` / `--file` / `--video` / `--video-cover` / `--audio` / `--reply-in-thread` / `--idempotency-key` / `--as`), content/media flags, mutual-exclusion rules, msg-type inference, `--video`/`--video-cover` pairing, and the cwd-relative media path rules.** This file covers only what `--help` cannot.
This skill maps to the shortcut: `lark-cli im +messages-reply` (internally calls `POST /open-apis/im/v1/messages/:message_id/reply`).
Safety: replies are visible to others — confirm the target message, content, and identity before sending (see lark-shared risk policy). `--as bot` requires the app to already be in the target chat.
## Safety Constraints
**Shared with [`im +messages-send`](lark-im-messages-send.md)**: picking the content flag (`--markdown` vs `--text` vs `--content`), `--markdown` boundary rules, the `images.create` pre-upload step for local images in Markdown, `$'...'` for exact formatting, `@mention` syntax, the `--content` JSON shape per `msg_type`, the return value, and the `--idempotency-key` 1-hour window all behave identically — see that reference, do not re-derive here.
Replies sent by this tool are visible to other people. Before calling it, you **must** confirm with the user:
## Gotchas
1. Which message to reply to
2. The reply content
3. Which identity to use (user or bot)
**Do not** send a reply without explicit user approval.
When using `--as bot`, the reply is sent in the app's name, so make sure the app has already been added to the target chat.
When using `--as user`, the reply is sent as the authorized end user and requires the `im:message.send_as_user` and `im:message` scopes.
## Choose The Right Content Flag
### Default Selection Rule For Agents
- Prefer `--markdown` for headings, lists, links, summaries, investigation notes, or Markdown-looking content.
- Use `--text` for exact plain text: logs, code, indentation-sensitive text, or literal Markdown.
- Use `--content` for exact `post` JSON, titles, multiple locales, cards, or unsupported structures.
| Need | Recommended flag | Why |
|------|------|------|
| Reply with headings, lists, links, summaries, or investigation notes | `--markdown` | Best default for lightweight formatting; converted to Feishu `post` JSON |
| Reply with plain text exactly as written | `--text` | Preserves literal text; no Markdown conversion |
| Precisely control the reply payload | `--content` | You provide the exact JSON |
| Reply with media | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads URLs, or cwd-relative local files automatically |
### `--text` vs `--markdown`
- Use `--markdown` for lightweight formatted replies.
- Use `--text` for exact plain text, especially logs, code, indentation, or literal Markdown characters.
- Use `--content` when you need exact `post` JSON, a card, a title, multiple locales, or any structure that `--markdown` cannot express reliably.
## What `--markdown` Really Does
`--markdown` accepts Markdown-like input and converts it to the Feishu `post` payload required by the reply API.
The shortcut:
1. Forces `msg_type=post`
2. Resolves remote Markdown images like `![x](https://...)`
3. Normalizes the Markdown for Feishu post rendering
4. Wraps the final content as:
```json
{"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
```
This makes `--markdown` the simplest path for lightweight formatted replies.
### Markdown Boundaries
- It does **not** promise full CommonMark / GitHub Flavored Markdown support.
- It always becomes a `post` payload with a single `zh_cn` locale.
- It does **not** let you set a `post` title.
- Headings are rewritten:
- `# Title` becomes `#### Title`
- `##` to `######` are normalized to `#####` when the content contains H1-H3
- Consecutive headings are separated with blank lines after heading normalization.
- Block spacing and line breaks may be normalized during conversion.
- Code blocks are preserved as code blocks.
- Excess blank lines are compressed.
- Already-uploaded `img_xxx` image keys are the most reliable Markdown image input.
- Local paths (e.g. `![x](./a.png)`) are **not** supported directly in `--markdown` and will not be auto-uploaded.
- Remote URLs (`https://...`) will be auto-downloaded and uploaded at runtime; if the download or upload fails, the image is removed with a warning.
If you need a title, multiple locales, cards, unsupported rich structures, or byte-for-byte post JSON control, use `--msg-type post --content ...`.
### Image Constraint for `--markdown`
When using `--markdown` with images, prefer pre-uploading via `images.create` and referencing `![alt](img_xxx)` for predictable results. Remote URLs may work but are not guaranteed.
**Steps:**
```bash
# 1. Upload image to get image_key
lark-cli im images create --data '{"image_type":"message"}' --file ./diagram.png
# Returns: {"image_key":"img_v3_xxxx"}
# 2. Use image_key in --markdown reply
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Result\n\n![diagram](img_v3_xxxx)\n\nSee above for details.'
```
## Preserving Formatting
If the reply contains multiple lines, code blocks, indentation, tabs, or a lot of escaping, prefer `$'...'` for either `--markdown` or `--text`.
### When formatting must be preserved
Use `--text` plus `$'...'`:
```bash
lark-cli im +messages-reply --message-id om_xxx --text $'Received\nI will check this today.\nOwner: alice'
```
```bash
lark-cli im +messages-reply --message-id om_xxx --text $'```sql\nselect * from jobs;\n```'
```
This keeps the reply as plain text instead of converting it to a `post`.
## Commands
```bash
# Reply with a formatted update
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Reply\n\n- item 1\n- item 2'
# Reply with a plain one-line message
lark-cli im +messages-reply --message-id om_xxx --text "Received"
# Equivalent manual JSON
lark-cli im +messages-reply --message-id om_xxx --content '{"text":"Received"}'
# Reply as a bot
lark-cli im +messages-reply --message-id om_xxx --text "bot reply" --as bot
# Reply with preserved multi-line text
lark-cli im +messages-reply --message-id om_xxx --text $'Line 1\nLine 2\n indented line'
# Reply inside the thread (message appears in the target thread)
lark-cli im +messages-reply --message-id om_xxx --text "Let's discuss this" --reply-in-thread
# Reply with Markdown containing an image (must pre-upload via images.create)
lark-cli im images create --data '{"image_type":"message"}' --file ./screenshot.png
# Use the returned image_key
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Screenshot\n\n![screenshot](img_v3_xxxx)\n\nConfirmed.'
# If you need exact post structure, send JSON directly
lark-cli im +messages-reply --message-id om_xxx --msg-type post --content '{"zh_cn":{"title":"Reply","content":[[{"tag":"text","text":"Detailed content"}]]}}'
# Reply with a local image (uploaded automatically before sending)
lark-cli im +messages-reply --message-id om_xxx --image ./photo.png
# Reply with a local file (uploaded automatically before sending)
lark-cli im +messages-reply --message-id om_xxx --file ./report.pdf
# Reply with a local video (--video-cover is required as the video cover)
lark-cli im +messages-reply --message-id om_xxx --video ./demo.mp4 --video-cover ./cover.png
# With an idempotency key
lark-cli im +messages-reply --message-id om_xxx --text "Received" --idempotency-key my-unique-id
# Preview the request without executing it
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' --dry-run
```
## Media Input Rules
- Media flags accept an existing key (`img_xxx` / `file_xxx`), an `http://` or `https://` URL, or a local file path.
- Local paths must be relative to the current working directory and stay within it after resolving `..` and symlinks.
- Absolute paths such as `/tmp/photo.png` are rejected. Run the command from the file's directory and pass `./photo.png`, or copy the file into the current directory first.
## Parameters
| Parameter | Required | Description |
|------|------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--message-id <id>` | Yes | ID of the message being replied to (`om_xxx`) |
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
| `--content <json>` | One content option | Exact reply content as JSON. The JSON must match the effective `--msg-type` |
| `--text <string>` | One content option | Plain text reply. Use when exact text and formatting preservation matter |
| `--markdown <string>` | One content option | Best default for lightweight formatted replies such as headings, lists, links, summaries, and investigation notes. Internally converted to `post` JSON with Feishu-specific normalization |
| `--image <path\|url\|key>` | One content option | Cwd-relative local image path, URL, or `image_key` (`img_xxx`) |
| `--file <path\|url\|key>` | One content option | Cwd-relative local file path, URL, or `file_key` (`file_xxx`) |
| `--video <path\|url\|key>` | One content option | Cwd-relative local video path, URL, or `file_key` (`file_xxx`); **must be used together with `--video-cover`** |
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`) |
| `--audio <path\|url\|key>` | One content option | Cwd-relative local audio path, URL, or `file_key` (`file_xxx`) |
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
| `--dry-run` | No | Print the request only, do not execute it |
> **Mutual exclusivity rule:** `--text`, `--markdown`, `--content`, and `--image`/`--file`/`--video`/`--audio` cannot be used together. Media flags are also mutually exclusive with each other.
>
> **Video cover rule:** `--video` **must** be accompanied by `--video-cover`. Omitting `--video-cover` when using `--video` will fail validation. `--video-cover` cannot be used without `--video`.
## Common Mistakes
- Choosing `--text` for headings, lists, links, summaries, or investigation notes. Use `--markdown`.
- Choosing `--markdown` when you actually need exact plain text. If exact line breaks, spacing, logs, code, or literal Markdown characters matter, use `--text`, usually with `$'...'`.
- Assuming `--markdown` supports every Markdown feature. It is converted into a Feishu `post` payload and normalized first.
- Putting local image paths inside Markdown like `![x](./a.png)`. `--markdown` does not auto-upload those paths.
- **Using local file paths inside Markdown image syntax** (e.g. `![x](./a.png)`) with `--markdown`. Local paths are not auto-uploaded and will not render as an image. Pre-upload via `images.create` to get an `image_key` instead.
- Using `--content` without making the JSON match the effective `--msg-type`.
- Explicitly setting `--msg-type` to something that conflicts with `--text`, `--markdown`, or media flags.
- Mixing `--text`, `--markdown`, or `--content` with media flags in one command.
## Return Value
```json
{
"message_id": "om_xxx",
"chat_id": "oc_xxx",
"create_time": "1234567890"
}
```
## Usage Scenarios
### Scenario 1: Reply in the main chat stream
```bash
lark-cli im +messages-reply --message-id om_xxx --text "OK, I will handle it"
```
The reply appears in the main chat stream and references the target message.
### Scenario 2: Reply inside a thread
```bash
lark-cli im +messages-reply --message-id om_xxx --text "Let me take a look at this" --reply-in-thread
```
The reply appears in the target message's thread and does not show up in the main chat stream.
## @Mention Format
The `<at>` syntax differs by message type. The shortcut only normalizes mentions for `text` and `post`; `interactive` card content is passed through verbatim, so cards must use the card-native syntax below.
### `text`
- `<at user_id="ou_xxx">name</at>` — the inner text is the mentioned user's display name and is optional (`<at user_id="ou_xxx"></at>` also works)
- @all: `<at user_id="all"></at>`
### `post`
- Inside a `text` or `md` element, the same inline form as `text` works: `<at user_id="ou_xxx">name</at>`
- Or use a dedicated `at` element node: `{"tag":"at","user_id":"ou_xxx"}` (use `"all"` to mention everyone)
### `interactive` (card)
Card content is **not** normalized — use the card-native `<at>` syntax inside a `lark_md` / `markdown` element:
- single user by open_id: `<at id=ou_xxx></at>`
- multiple users: `<at ids=ou_xxx1,ou_xxx2></at>`
- by email: `<at email=user@example.com></at>`
## Notes
- `--message-id` must be a valid message ID in `om_xxx` format
- `--content` must be valid JSON
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
- `--reply-in-thread` adds `reply_in_thread=true` to the API request
- `--reply-in-thread` is mainly meaningful in chats that support thread replies
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support existing keys, URLs, and cwd-relative local file paths; the shortcut uploads local paths and URLs first, then sends the reply; both the upload and send steps use the same identity (UAT when `--as user`, TAT when `--as bot`)
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
- `--markdown` always sends `msg_type=post`
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
- When using `--video`, `--video-cover` is required as the video cover
- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
- Failures return error codes and messages
- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the reply is sent as the authorized end user
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
- When using `--markdown` with images, pre-uploading via `images.create` to obtain an `image_key` is recommended for reliability; remote URLs may be auto-resolved at runtime, but if download/upload fails the image is removed with a warning; local paths are not supported
- **`--reply-in-thread` posts into the target message's thread** (thread stream), not the main chat stream; without it the reply appears inline in the main stream referencing the target. Only meaningful in chats that support thread replies — in a non-thread chat the flag is a no-op.
- **`--message-id` is the message being replied to** (`om_xxx`), not a chat id. To reply by named group, resolve the message first via `im +chat-search``im +chat-messages-list`, then take its `message_id`.

View File

@@ -1,94 +1,16 @@
# im +messages-resources-download
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
Download image or file resources from a message. Supports **automatic chunked download for large files** using HTTP Range requests. Resources are identified by the combination of `message_id` + `file_key`, both of which come directly from message content returned by `im +chat-messages-list`.
Maps to `lark-cli im +messages-resources-download`. **Run `lark-cli im +messages-resources-download --help` for the authoritative flags (`--message-id` / `--file-key` / `--type` / `--output` / `--as`), the `image`/`file` type enum, and the `--output` behavior (relative-only / no `..`, Content-Disposition filename fallback, extension inference).** This file covers only what `--help` cannot.
> **Note:** read-only message commands render resource keys in message content, but they do not download binaries automatically. Use this command whenever you need to fetch the actual image/file bytes or save them to a specific path.
## Gotchas
This skill maps to the shortcut: `lark-cli im +messages-resources-download` (internally calls `GET /open-apis/im/v1/messages/{message_id}/resources/{file_key}`).
- **A resource is identified by `message_id` + `file_key` together** — the `file_key` must come from *that* message's own content (returned by `im +chat-messages-list`). A `file_key` from a different message fails to download even if both look valid. Read-only message commands render the key in content but never fetch the bytes; this command is the only way to get them.
- **`--type` must match the marker, not the original media kind**: `img_xxx``--type image`; `file_xxx``--type file`. Audio and video both surface as `file_xxx`, so they download with `--type file` (not `audio`/`video`).
- **`234002` / `14005` are permission/state errors, NOT missing scope** — no access to the chat, or the file was deleted. Do not retry and do not re-run `auth login`; return the error to the user. (`im:message:readonly` not authorized is a *different* failure → `auth login --scope "im:message:readonly"`.)
- **Large files auto-chunk via HTTP Range** (128 KB size-probe, then sequential 8 MB chunks, up to 2 retries w/ backoff, post-download size-integrity check). A "file size mismatch" or "Content-Range" error is transient network instability — just retry the command.
## Commands
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
```bash
# Download an image (save to the current directory)
lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_xxx --type image
# Download a file
lark-cli im +messages-resources-download --message-id om_xxx --file-key file_v3_xxx --type file
# Specify the output path
lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_xxx --type image --output ./photo.png
# Download as a bot
lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_xxx --type image --as bot
# Preview the request without executing it
lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_xxx --type image --dry-run
```
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--message-id <id>` | Yes | Message ID (`om_xxx` format) |
| `--file-key <key>` | Yes | Resource key (`img_xxx` or `file_xxx`) |
| `--type <type>` | Yes | Resource type: `image` or `file` |
| `--output <path>` | No | Output path (relative paths only; `..` traversal is not allowed). When omitted, the server's original filename from `Content-Disposition` is used if available; otherwise defaults to `file_key`. File extension is automatically inferred from `Content-Disposition` or `Content-Type` if not provided |
| `--as <identity>` | No | Identity type: `user` (default) or `bot` |
| `--dry-run` | No | Print the request only, do not execute it |
## Large File Download (Auto Chunking)
When downloading large files, the command automatically uses **HTTP Range requests** for reliable chunked downloading:
| Behavior | Details |
|----------|---------|
| Probe chunk | First 128 KB to detect file size and Content-Type |
| Chunk size | 8 MB per subsequent request |
| Workers | Single-threaded sequential download (ensures reliability) |
| Retries | Up to 2 retries for transient request failures, with exponential backoff |
**Benefits:**
- Reduces the impact of transient request failures during large downloads
- Preserves the server's original filename via `Content-Disposition` (supports RFC 5987 UTF-8 encoding); falls back to `Content-Type`-based extension inference
- Validates file size integrity after download completion
## `file_key` Sources
Different resource markers in message content correspond to different `file_key` and `type` values:
| Message Type | Marker in Content | `file_key` Format | `--type` |
|---------|-------------|---------------|--------|
| Image | `img_xxx` | `img_xxx` | `image` |
| File | `file_xxx` | `file_xxx` | `file` |
| Audio | `file_xxx` | `file_xxx` | `file` |
| Video | `file_xxx` | `file_xxx` | `file` |
## Usage Scenario
### Scenario: Extract and download an image from a message
```bash
# Step 1: Fetch messages and find one containing an image
lark-cli im +chat-messages-list --chat-id oc_xxx
# In the response you see: { "msg_type": "image", "content": "{\"image_key\":\"img_v3_xxx\"}" }
# Step 2: Download the image
lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_xxx --type image
```
## Common Errors and Troubleshooting
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| Download failed | `file_key` does not match the `message_id` | Make sure the `file_key` came from that message's content |
| Hit error code 234002 or 14005 | No permission, **not** missing API scope | no access to this chat or file was deleted — do not retry, return the error to the user |
| Permission denied | `im:message:readonly` is not authorized | Run `auth login --scope "im:message:readonly"` |
| File size mismatch | Chunked download integrity check failed | Network instability during download; retry the command |
| Content-Range error | Server returned invalid range header | Transient API issue; retry the command |
## References
- [lark-im](../SKILL.md) - all message-related commands
- [lark-shared](../../lark-shared/SKILL.md) - authentication and global parameters
- The chunked-download internals above (probe size, 8 MB chunk, sequential single-worker, 2 retries, integrity check) are runtime behavior not surfaced by `--help`.

View File

@@ -1,234 +1,32 @@
# im +messages-search
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
Search Feishu messages across conversations. This shortcut automatically performs a multi-step workflow: search for message IDs, batch fetch message details, then enrich the results with chat context.
Maps to `lark-cli im +messages-search`. **Run `lark-cli im +messages-search --help` for the authoritative flags (`--query` / `--chat-id` / `--sender` / `--include-attachment-type` / `--chat-type` / `--sender-type` / `--exclude-sender-type` / `--is-at-me` / `--at-chatter-ids` / `--start` / `--end` / `--page-size` / `--page-all` / `--page-limit`), enums, time format (ISO 8601 + offset), and `--no-reactions`.** This file covers only what `--help` cannot.
By default each result message also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. With `--page-all`, every page is enriched; pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
**User identity only** (`--as user`); bot not supported. Auto-orchestrates: search → batch mget → batch chat-context enrich (plus reactions / update_time per [message enrichment](lark-im-message-enrichment.md)).
> **User identity only** (`--as user`). Bot identity is not supported.
## Gotchas
This skill maps to the shortcut: `lark-cli im +messages-search` (internally calls `POST /open-apis/im/v1/messages/search` + batched `GET /open-apis/im/v1/messages/mget`, then batch-fetches chat context).
- **Identity is user-only — do NOT try `--as bot`.** For bot identity with a named group + history intent, resolve via `im +chat-search --as bot`, then `im +chat-messages-list --as bot --chat-id <id>`.
- **Resolving a chat by name**: use `im +chat-search` first to get `chat_id`, then pass `--chat-id`. **Do not use `im chats search` or `+chat-list`.**
- `--at-chatter-ids` results also include messages that `@all`.
- **Resources not downloaded**: images render as `[Image: img_xxx]` placeholders; use `im +messages-resources-download` for the bytes.
## Commands
## Query construction (high-signal vs over-constrained)
```bash
# Search by keyword
lark-cli im +messages-search --query "project progress"
- Use `--query` only for **real message keywords**. For activity review ("最近一周和哪些 Bot 交互"、"整理和某人的聊天记录"), keep `--query ""` and rely on `--sender` / `--sender-type` / `--chat-id` / `--start` / `--end`. Do NOT put instruction words ("看看"、"总结"、"聊天记录") into `--query` — they over-constrain and hide the relevant messages.
- **Relative time**: compute start/end from the current day at execution time; never copy date literals from this file ("最近一周" = compute the actual range, don't hardcode).
- For activity summaries, retain each item's `message_id` / sender / chat / create_time as recall targets; don't rely on a high-level keyword match alone.
# Restrict search to a specific group chat
lark-cli im +messages-search --query "weekly report" --chat-id oc_xxx
## Work summary / report generation
# Filter by sender (comma-separated)
lark-cli im +messages-search --query "requirement" --sender ou_xxx,ou_yyy
- Narrow first (`--chat-id` / `--sender` / `--start` / `--end`), then **paginate exhaustively** — one page (2050) is rarely enough.
- Default to `--page-all --format json` (JSON carries `has_more` / `page_token`); use `--page-limit <n>` to bound the run; resume via `--page-token` if a bounded run still returns `has_more=true`.
- **Accumulate all pages, then summarize** — don't summarize after page 1. Group by topic/thread, not chronology.
- If no time range is given, default to the current week (Mon→today), or ask.
# Filter by attachment type
lark-cli im +messages-search --query "report" --include-attachment-type file
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
# Filter by chat type (group / p2p)
lark-cli im +messages-search --query "progress" --chat-type group
# Filter by sender type (user / bot)
lark-cli im +messages-search --query "reminder" --sender-type bot
# Exclude bot senders
lark-cli im +messages-search --query "reminder" --exclude-sender-type bot
# Only messages that @me
lark-cli im +messages-search --query "announcement" --is-at-me
# Only messages that @mention specific users (results also include messages that @all)
lark-cli im +messages-search --query "release" --at-chatter-ids ou_xxx,ou_yyy
# Combined filters + time range
lark-cli im +messages-search --query "meeting" --sender ou_xxx --chat-type group --start "2026-03-13T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00"
# Specific time range (ISO 8601)
lark-cli im +messages-search --query "release" --start "2026-03-01T00:00:00+08:00" --end "2026-03-10T00:00:00+08:00"
# Output format options
lark-cli im +messages-search --query "test" --format pretty
lark-cli im +messages-search --query "test" --format table
lark-cli im +messages-search --query "test" --format csv
# Pagination
lark-cli im +messages-search --query "test" --page-token <PAGE_TOKEN>
# Auto-pagination across multiple pages
lark-cli im +messages-search --query "test" --page-all --format json
# Auto-pagination with an explicit page cap
lark-cli im +messages-search --query "test" --page-limit 5 --format json
# Preview the request without executing it
lark-cli im +messages-search --query "test" --dry-run
```
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--query <text>` | No | Search keyword (may be empty when used with other filters) |
| `--chat-id <id>` | No | Restrict to chat IDs, comma-separated (`oc_xxx,oc_yyy`) |
| `--sender <ids>` | No | Sender open_ids, comma-separated (`ou_xxx`) |
| `--include-attachment-type <type>` | No | Attachment filter: `file` / `image` / `video` / `link` |
| `--chat-type <type>` | No | Chat type: `group` / `p2p` |
| `--sender-type <type>` | No | Sender type: `user` / `bot` |
| `--exclude-sender-type <type>` | No | Exclude messages from `user` or `bot` senders |
| `--is-at-me` | No | Only return messages that mention `@me` |
| `--at-chatter-ids <ids>` | No | Filter by @mentioned user open_ids, comma-separated (`ou_xxx,ou_yyy`). Matched results also include messages that `@all` |
| `--start <time>` | No | Start time with local timezone offset required (e.g. `2026-03-24T00:00:00+08:00`) |
| `--end <time>` | No | End time with local timezone offset required (e.g. `2026-03-25T23:59:59+08:00`) |
| `--page-size <n>` | No | Page size (default 20, range 1-50) |
| `--page-token <token>` | No | Pagination token for the next page |
| `--page-all` | No | Automatically paginate through all result pages (up to 40 pages) |
| `--page-limit <n>` | No | Max pages to fetch when auto-pagination is enabled (default 20, max 40). Setting it explicitly also enables auto-pagination |
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
| `--as <identity>` | No | Identity type (defaults to and only supports `user`) |
| `--dry-run` | No | Print the request only, do not execute it |
## Core Constraints
### 1. Provide at least one filter whenever possible
All parameters are optional, but you should usually provide at least one filter (`--query`, `--sender`, `--chat-id`, etc.). Otherwise the search scope may be too broad and return low-signal results.
### 2. Two-step orchestration is automatic
The shortcut automatically performs:
1. The **search API** returns matching `message_id` values
2. The **mget API** fetches full message content for those message IDs in batch
3. Chat context lookup is fetched in batch and attached to each message
The user does not need to manage the orchestration manually. When search results span multiple pages, the shortcut can also paginate automatically with `--page-all` or `--page-limit`.
### 3. Conversation context is enriched automatically
In JSON output, each message automatically includes conversation context:
| Field | Description |
|------|------|
| `chat_type` | Conversation type: `p2p` / `group` |
| `chat_name` | Group name (for groups) or the other participant's name (for p2p chats) |
| `chat_partner` | For p2p only: the other participant's `open_id` and `name` |
In pretty output, the `chat` column shows the chat name for groups, or `"p2p"` for direct messages.
Each message in JSON output contains:
| Field | Description |
|------|------|
| `message_id` | Message ID |
| `msg_type` | Message type: `text`, `image`, `file`, `interactive`, `post`, `audio`, `video`, `system`, etc. |
| `create_time` | Creation time |
| `sender` | Sender information (includes `name` for user senders) |
| `content` | Message content |
| `chat_id` | ID of the conversation the message belongs to |
| `deleted` | Whether the message has been recalled (`true` = recalled) |
| `updated` | Whether the message has been edited after sending |
| `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions |
| `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist |
### 4. Pagination behavior
- Default behavior is still **single-page**.
- `--page-token` is the manual continuation mechanism when you already have a token from a previous response.
- `--page-all` enables auto-pagination and uses a default cap of **40 pages**.
- `--page-limit <n>` enables auto-pagination with an explicit cap. If you pass `--page-limit` without `--page-all`, auto-pagination is still enabled.
- When auto-pagination stops because of the configured page cap, the response still includes the last `has_more` / `page_token` so you can continue manually.
### 5. Search results contain follow-up clues
In JSON output, each message includes `chat_id` and `thread_id` (when present). Use them with other shortcuts for deeper inspection:
```bash
# View the full message stream for the conversation that contains the search result
lark-cli im +chat-messages-list --chat-id <chat_id>
# View replies in the thread that contains the search result
lark-cli im +threads-messages-list --thread <thread_id>
```
## Resource Rendering
Search results reuse the same content formatter as other read commands. Image messages are rendered as placeholders such as `[Image: img_xxx]`; resource binaries are **not** downloaded automatically.
Use `im +messages-resources-download` if you need to fetch the underlying image or file bytes from a specific message.
## AI Usage Guidance
### Query boundary for activity review
Use `--query` only for real message keywords. If the user asks for activity review such as "最近一周我和哪些 Bot 有过交互" or "整理我和某人的聊天记录", and the useful constraints are sender type, chat, person, or time range, keep `--query ""` and rely on those filters. Do not put generic instruction words such as "看看", "总结", "交互内容", or "聊天记录" into `--query`; those words often over-constrain message search and hide the relevant messages.
This guidance applies only when using user identity. `im +messages-search` is user-only; if the user explicitly asks for application/bot identity, do not try `--as bot`. For bot identity with a named group and history/listing intent, resolve the group with `im +chat-search --as bot`, then list messages with `im +chat-messages-list --as bot --chat-id <chat_id>`.
```bash
# Review recent bot interactions without forcing a keyword
lark-cli im +messages-search --query "" --sender-type bot --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --page-all --format json
```
Replace the time placeholders at execution time. For example, "最近一周" means computing the start date and end date from the current day before running the command; do not copy date literals from this reference into answers for relative requests.
For activity summaries, validate evidence by message IDs and chat context. The final answer should cite or retain the `message_id`, sender, chat, and create time for each important item. If the row's source data contains concrete `om_...` message IDs or `ou_...` user IDs, treat those IDs as strong recall targets during verification; do not rely only on a high-level keyword match.
### Resolving chat_id from a chat name
When the user refers to a chat by name and you need its `chat_id` for the `--chat-id` filter, use [`+chat-search`](lark-im-chat-search.md) first:
```bash
# Step 1: Find the chat_id by name
lark-cli im +chat-search --query "<chat name keyword>" --format json
# Step 2: Use the chat_id to narrow down message search
lark-cli im +messages-search --query "keyword" --chat-id <chat_id>
```
**Do not use `im chats search` or `+chat-list` — always use the `+chat-search` shortcut.**
## Work Summary / Report Generation
When the user asks you to summarize work, generate a weekly report, or compile activity from chat messages, you should **paginate through all available results** to get a complete picture. A single page is rarely enough for thorough summarization.
### Strategy
1. **Start with targeted filters** — use `--chat-id`, `--sender`, `--start`, `--end` to narrow the scope as much as possible before paginating.
2. **Prefer auto-pagination** — for report and summary tasks, use `--page-all --format json` by default. If you need a bounded run, use `--page-limit <n> --format json`.
3. **Accumulate before summarizing** — collect all pages of messages first, then analyze and summarize. Do not summarize after the first page alone — you will miss important context.
4. **Fall back to `--page-token` when resuming** — if auto-pagination hits the configured page cap and the response still has `has_more=true`, continue from the returned `page_token`.
5. **Use `--format json`** — JSON output includes `has_more` and `page_token` fields needed for pagination. `pretty` and `table` formats are useful for reading but not for resuming pagination reliably.
### Example: Weekly work summary from a project chat
```bash
# Preferred: fetch automatically
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-all --format json
# If you need to cap the run explicitly
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-limit 5 --format json
# If the bounded run still returns has_more=true, continue manually
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-token <token_from_previous_run> --format json
```
### Key points
- **Always paginate exhaustively** for summary tasks. A single page of 20-50 messages is usually insufficient for a meaningful work summary.
- Prefer `--page-all`; use `--page-limit` only when you need to bound runtime or output volume.
- If the user does not specify a time range, default to the current week (Monday to today) for weekly reports, or ask for clarification.
- When summarizing, group messages by topic/thread rather than by chronological order for better readability.
## Common Errors and Troubleshooting
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| Too few results | The time range is too narrow or the keyword is too specific | Expand the time range and try broader keywords |
| No results | Missing permission or no match | Confirm `search:message` is authorized and relax the filters |
| Permission denied | Search scope not authorized | Run `auth login --scope "search:message"` |
## References
- [lark-im](../SKILL.md) - all message-related commands
- [lark-im-threads-messages-list](lark-im-threads-messages-list.md) - inspect thread replies
- [lark-shared](../../lark-shared/SKILL.md) - authentication and global parameters
- **Auto-enrich fields** (JSON): conversation `chat_type` (p2p/group) · `chat_name` · `chat_partner` (p2p only: other party `open_id`+`name`). Per message: `deleted` (true=recalled) · `updated` (edited after send) · `mentions` `[{id,key,name}]` **present only when @mentions exist** · `thread_id` (omt_xxx) **present only when replies exist**.
- **Follow-up clues**: each result carries `chat_id` (+ `thread_id` when present) → `im +chat-messages-list --chat-id <id>` / `im +threads-messages-list --thread <id>`.

View File

@@ -1,264 +1,53 @@
# im +messages-send
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
Send a message to a group chat or a direct message conversation. Supports both user identity (`--as user`) and bot identity (`--as bot`).
Maps to `lark-cli im +messages-send`. **Run `lark-cli im +messages-send --help` for the authoritative flag list, content/media flags, mutual-exclusion rules, msg-type inference, `--video`/`--video-cover` pairing, and the cwd-relative media path rules.** This file only covers what `--help` cannot.
This skill maps to the shortcut: `lark-cli im +messages-send` (internally calls `POST /open-apis/im/v1/messages`).
Safety: sent messages are visible to others — confirm recipient, content, and identity before sending (see lark-shared risk policy). `--as bot` requires the app to already be in the target chat.
## Safety Constraints
## Picking the content flag (when to use which)
Messages sent by this tool are visible to other people. Before calling it, you **must** confirm with the user:
- `--markdown` — headings / lists / links / summaries / reports. **Converted to a Feishu `post` payload (forces `msg_type=post`), single `zh_cn` locale.**
- `--text` — exact plain text: logs, code, indentation, or literal Markdown that must **not** render.
- `--content` — exact JSON when you need a post title, multiple locales, cards, or unsupported structures.
1. The recipient (which person or which group)
2. The message content
3. The sending identity (user or bot)
## `--markdown` boundaries (non-obvious)
**Do not** send messages without explicit user approval.
- Not full CommonMark/GFM. Always a single-`zh_cn` `post`; **cannot set a post title** — use `--content` for a title.
- Headings are rewritten: `# Title``#### Title`; `##``######``#####` when the content has H1H3.
- **Local image paths in `![x](./a.png)` are NOT auto-uploaded** and will not render. Pre-upload first to get an `img_xxx` key:
```bash
lark-cli im images create --data '{"image_type":"message"}' --file ./diagram.png # → {"image_key":"img_v3_xxxx"}
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Report\n\n![d](img_v3_xxxx)'
```
- Remote `https://` images are auto-downloaded + uploaded at runtime; on download/upload failure the image is silently dropped with a warning.
When using `--as bot`, the message is sent in the app's name, so make sure the app has already been added to the target chat.
## Preserve exact formatting → `--text` with `$'...'`
When using `--as user`, the message is sent as the authorized end user and requires the `im:message.send_as_user` and `im:message` scopes.
## Choose The Right Content Flag
### Default Selection Rule For Agents
- Prefer `--markdown` for headings, lists, links, summaries, reports, or Markdown-looking content.
- Use `--text` for exact plain text: logs, code, indentation-sensitive text, or literal Markdown.
- Use `--content` for exact `post` JSON, titles, multiple locales, cards, or unsupported structures.
| Need | Recommended flag | Why |
|------|------|------|
| Send headings, lists, links, summaries, or reports | `--markdown` | Best default for lightweight formatting; converted to Feishu `post` JSON |
| Send plain text exactly as written | `--text` | Preserves literal text; no Markdown conversion |
| Precisely control the final payload | `--content` | You provide the exact JSON for `text` / `post` / `interactive` / `share_*` / media payloads |
| Send image / file / video / audio | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads URLs, or cwd-relative local files automatically |
### `--text` vs `--markdown`
- Use `--markdown` for lightweight formatted messages.
- Use `--text` for exact plain text, especially logs, code, indentation, or Markdown characters that should **not** render.
- Use `--content` when `--markdown` is not enough, especially if you need exact `post` JSON, a title, multiple locales, cards, or unsupported rich structures.
## What `--markdown` Really Does
`--markdown` accepts Markdown-like input and converts it to the Feishu `post` payload required by the message API.
The shortcut does all of the following before sending:
1. Forces `msg_type=post`
2. Resolves remote Markdown images like `![x](https://...)` by downloading and uploading them first
3. Normalizes the Markdown for Feishu post rendering
4. Wraps the result as:
```json
{"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
```
This makes `--markdown` the simplest path for lightweight formatted messages.
### Markdown Boundaries
- It does **not** promise full CommonMark / GitHub Flavored Markdown support.
- It always becomes a `post` payload with a single `zh_cn` locale.
- It does **not** let you set a `post` title. If you need a title, use `--msg-type post --content ...`.
- Headings are rewritten:
- `# Title` becomes `#### Title`
- `##` to `######` are normalized to `#####` when the content contains H1-H3
- Consecutive headings are separated with blank lines after heading normalization.
- Block spacing and line breaks may be normalized during conversion.
- Code blocks are preserved as code blocks.
- Excess blank lines are compressed.
- Already-uploaded `img_xxx` image keys are the most reliable Markdown image input.
- Local paths in Markdown image syntax like `![x](./a.png)` are **not** supported and will not be auto-uploaded.
- Remote URLs (`https://...`) will be auto-downloaded and uploaded at runtime; if the download or upload fails, the image is removed with a warning.
If you need a title, multiple locales, cards, unsupported rich structures, or byte-for-byte post JSON control, use `--content` and provide the final JSON yourself.
### Image Constraint for `--markdown`
When using `--markdown` with images, prefer pre-uploading via `images.create` and referencing `![alt](img_xxx)` for predictable results. Remote URLs may work but are not guaranteed.
**Steps:**
For multi-line text, indentation, code blocks, or literal backslashes, use shell ANSI-C quoting so `\n` is honored literally instead of relying on the shell:
```bash
# 1. Upload image to get image_key
lark-cli im images create --data '{"image_type":"message"}' --file ./diagram.png
# Returns: {"image_key":"img_v3_xxxx"}
# 2. Use image_key in --markdown
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Report\n\n![diagram](img_v3_xxxx)\n\nSee above for details.'
lark-cli im +messages-send --chat-id oc_xxx --text $'Build failed\nBranch: feature/x\nAction: check logs'
```
## Preserving Formatting
## @Mention format (differs by message type)
If the message has multiple lines, indentation, code blocks, tabs, or many quotes/backslashes, prefer shell ANSI-C quoting with `$'...'` for either `--markdown` or `--text`.
The shortcut only normalizes mentions for `text` and `post`; **`interactive` card content is passed through verbatim**, so cards must use the card-native syntax — this asymmetry is the trap.
This is especially useful in `zsh` / `bash` because it lets you write `\n` explicitly instead of relying on the shell to preserve literal newlines.
- **`text`**: `<at user_id="ou_xxx">name</at>` (inner name optional); @all: `<at user_id="all"></at>`. The shortcut also normalizes `<at id=...>` / `<at open_id=...>` into `user_id`, but author examples with `user_id`.
- **`post`**: same inline form inside a `text`/`md` element, or a dedicated node `{"tag":"at","user_id":"ou_xxx"}` (`"all"` for everyone).
- **`interactive` (card)**: NOT normalized — use card-native `<at>` inside a `lark_md`/`markdown` element: single `<at id=ou_xxx></at>`, multiple `<at ids=ou_xxx1,ou_xxx2></at>`, by email `<at email=user@example.com></at>`.
### When formatting must be preserved
## Common mistakes (the non-obvious ones)
Use `--text` plus `$'...'`:
- `--text` for headings/lists/reports → use `--markdown`. `--markdown` when exact line breaks / logs / literal Markdown matter → use `--text` with `$'...'`.
- Local image paths inside `--markdown` (`![x](./a.png)`) — pre-upload via `images.create` instead.
```bash
lark-cli im +messages-send --chat-id oc_xxx --text $'Build failed\nBranch: feature/im-docs\nAction: please check logs'
```
## HELP-GAP — not yet in `--help`/schema; keep here until CLI adds it
```bash
lark-cli im +messages-send --chat-id oc_xxx --text $'```bash\nmake test\nmake lint\n```'
```
> These are USAGE that belongs in schema but isn't there yet. Once CLI adds them, delete this section.
Use this path when you want the receiver to see the text exactly as entered, not a converted Markdown post.
## Commands
```bash
# Send a formatted update
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Update\n\n- item 1\n- item 2'
# Send a plain one-line message
lark-cli im +messages-send --chat-id oc_xxx --text "Hello"
# Equivalent manual JSON
lark-cli im +messages-send --chat-id oc_xxx --content '{"text":"Hello"}'
# Send to a direct message (pass open_id)
lark-cli im +messages-send --user-id ou_xxx --text "Hello"
# Send multi-line text while preserving formatting
lark-cli im +messages-send --chat-id oc_xxx --text $'Line 1\nLine 2\n indented line'
# Send Markdown with an image (must pre-upload via images.create)
lark-cli im images create --data '{"image_type":"message"}' --file ./screenshot.png
# Use the returned image_key in the markdown content
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Status\n\n![screenshot](img_v3_xxxx)\n\nDone.'
# If you need exact post structure, send JSON directly
lark-cli im +messages-send --chat-id oc_xxx --msg-type post --content '{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"Body"}]]}}'
# Send a local image (uploaded automatically before sending)
lark-cli im +messages-send --chat-id oc_xxx --image ./photo.png
# Or send directly with an existing image_key
lark-cli im +messages-send --chat-id oc_xxx --image img_xxx
# Send a local file (uploaded automatically before sending)
lark-cli im +messages-send --chat-id oc_xxx --file ./report.pdf
# Send a video (--video-cover is required as the cover)
lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover ./cover.png
lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover img_xxx
# Send audio
lark-cli im +messages-send --chat-id oc_xxx --audio ./voice.opus
# Use an idempotency key (same key sends only once within 1 hour)
lark-cli im +messages-send --chat-id oc_xxx --text "Hello" --idempotency-key my-unique-id
# Preview the request without executing it
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry-run
```
## Media Input Rules
- Media flags accept an existing key (`img_xxx` / `file_xxx`), an `http://` or `https://` URL, or a local file path.
- Local paths must be relative to the current working directory and stay within it after resolving `..` and symlinks.
- Absolute paths such as `/tmp/photo.png` are rejected. Run the command from the file's directory and pass `./photo.png`, or copy the file into the current directory first.
## Parameters
| Parameter | Required | Description |
|------|------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--chat-id <id>` | One of two | Group chat ID (`oc_xxx`) |
| `--user-id <id>` | One of two | User open_id (`ou_xxx`) for direct messages |
| `--text <string>` | One content option | Plain text message. Use when exact text and formatting preservation matter. Automatically wrapped as `{"text":"..."}` |
| `--markdown <string>` | One content option | Best default for lightweight formatted messages such as headings, lists, links, summaries, and reports. Internally converted to `post` JSON with Feishu-specific normalization |
| `--content <json>` | One content option | Exact message content JSON string; use this when you need full control over `msg_type` and payload. The JSON must match the effective `--msg-type` |
| `--image <path\|url\|key>` | One content option | Cwd-relative local image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
| `--file <path\|url\|key>` | One content option | Cwd-relative local file path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
| `--video <path\|url\|key>` | One content option | Cwd-relative local video path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically. **Must be paired with `--video-cover`** |
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
| `--audio <path\|url\|key>` | One content option | Cwd-relative local audio path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
| `--dry-run` | No | Print the request only, do not execute it |
> **Mutual exclusivity rule:** `--text`, `--markdown`, `--content`, and `--image`/`--file`/`--video`/`--audio` cannot be used together. Media flags are also mutually exclusive with each other.
>
> **Video cover rule:** `--video` **must** be accompanied by `--video-cover`. Omitting `--video-cover` when using `--video` will fail validation. `--video-cover` cannot be used without `--video`.
## Common Mistakes
- Choosing `--text` for headings, lists, links, summaries, or reports. Use `--markdown`.
- Choosing `--markdown` when you actually need exact plain text. If exact line breaks, spacing, logs, code, or literal Markdown characters matter, use `--text`, usually with `$'...'`.
- Assuming `--markdown` supports every Markdown feature. It is converted into a Feishu `post` payload and normalized first.
- Putting local image paths inside Markdown like `![x](./a.png)`. `--markdown` does not auto-upload those paths.
- **Using local file paths inside Markdown image syntax** (e.g. `![x](./a.png)`) with `--markdown`. Local paths are not auto-uploaded and will not render as an image. Pre-upload via `images.create` to get an `image_key` instead.
- Using `--content` without making the JSON match the effective `--msg-type`.
- Explicitly setting `--msg-type` to something that conflicts with `--text`, `--markdown`, or media flags.
- Mixing `--text`, `--markdown`, or `--content` with media flags in one command.
## `content` Format Reference
| `msg_type` | Example `content` |
|----------|-------------|
| `text` | `{"text":"Hello <at user_id=\"ou_xxx\">name</at>"}` |
| `post` | `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"Body"}]]}}` |
| `image` | `{"image_key":"img_xxx"}` |
| `file` | `{"file_key":"file_xxx"}` |
| `audio` | `{"file_key":"file_xxx"}` |
| `media` | `{"file_key":"file_xxx","image_key":"img_xxx"}` (video; `image_key` is the cover from `--video-cover`**required**) |
| `share_chat` | `{"chat_id":"oc_xxx"}` |
| `share_user` | `{"user_id":"ou_xxx"}` |
| `interactive` | Card JSON (see Feishu interactive card documentation) |
## Return Value
```json
{
"message_id": "om_xxx",
"chat_id": "oc_xxx",
"create_time": "1234567890"
}
```
## @Mention Format
The `<at>` syntax differs by message type. The shortcut only normalizes mentions for `text` and `post`; `interactive` card content is passed through verbatim, so cards must use the card-native syntax below.
### `text`
- `<at user_id="ou_xxx">name</at>` — the inner text is the mentioned user's display name and is optional (`<at user_id="ou_xxx"></at>` also works)
- @all: `<at user_id="all"></at>`
### `post`
- Inside a `text` or `md` element, the same inline form as `text` works: `<at user_id="ou_xxx">name</at>`
- Or use a dedicated `at` element node: `{"tag":"at","user_id":"ou_xxx"}` (use `"all"` to mention everyone)
### `interactive` (card)
Card content is **not** normalized — use the card-native `<at>` syntax inside a `lark_md` / `markdown` element:
- single user by open_id: `<at id=ou_xxx></at>`
- multiple users: `<at ids=ou_xxx1,ou_xxx2></at>`
- by email: `<at email=user@example.com></at>`
## Notes
- `--chat-id` and `--user-id` are mutually exclusive; you must provide exactly one
- `--content` must be valid JSON
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
- `--image`/`--file`/`--video`/`--audio` support existing keys, URLs, and cwd-relative local file paths; the shortcut uploads local paths and URLs first, then sends the message; both the upload and send steps use the same identity (UAT when `--as user`, TAT when `--as bot`)
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
- `--markdown` always sends `msg_type=post`, even if you do not explicitly set `--msg-type post`
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
- When using `--video`, `--video-cover` is required as the video cover
- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
- Failures return an error code and message
- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the message is sent as the authorized end user
- `--as bot` uses a tenant access token (TAT) and requires the `im:message:send_as_bot` scope
- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
- When using `--markdown` with images, pre-uploading via `images.create` to obtain an `image_key` is recommended for reliability; remote URLs may be auto-resolved at runtime, but if download/upload fails the image is removed with a warning; local paths are not supported
- **`--content` JSON shape per `msg_type`**: `text` `{"text":"..."}` · `post` `{"zh_cn":{"title":"...","content":[[{"tag":"text","text":"..."}]]}}` · `image` `{"image_key":"img_xxx"}` · `file`/`audio` `{"file_key":"file_xxx"}` · `media` `{"file_key":"...","image_key":"<cover, required>"}` · `share_chat` `{"chat_id":"oc_xxx"}` · `share_user` `{"user_id":"ou_xxx"}` · `interactive` = card JSON.
- **Return value**: `{"message_id":"om_xxx","chat_id":"oc_xxx","create_time":"..."}`.
- **`--idempotency-key`**: the same key sends only one message within 1 hour.

View File

@@ -1,299 +1,23 @@
# im reactions
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
> **Heads-up — don't reach for `batch_query` by default.** The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) already call `im.reactions.batch_query` automatically and attach the result as a `reactions` block on each message (replies inside `thread_replies` included). Use those shortcuts for any "read reactions of messages I'm already pulling" task. Reach for the raw `batch_query` API only when you have a standalone `message_id` outside that pull flow. See the main [message enrichment](lark-im-message-enrichment.md) for the contract.
Maps to `lark-cli schema im.reactions.*` (no typed shortcut). **Run `lark-cli schema im.reactions.create|list|delete|batch_query --format pretty` for the authoritative parameters, the `reaction_type` emoji-key set, and field shapes.** This file covers only what `schema` cannot.
This reference is the shared annotation target for the IM reaction APIs:
## Gotchas
- `im.reactions.create`
- `im.reactions.list`
- `im.reactions.delete`
- `im.reactions.batch_query`
- **Caller must be in the conversation** for `create`, `list`, and `delete`. `batch_query` has no this constraint — it is the only reactions method where bot or user can query messages from conversations they are not currently in.
- **`delete` can only remove reactions added by itself** (the calling identity). You cannot delete another user's or another bot's reaction, even as bot admin.
- **`operator_id` is the `app_id` when `operator_type=app`**, not an `ou_xxx` open_id. The type of the user-operator ID is controlled by `user_id_type` (default: `open_id`).
- **Don't call `batch_query` directly for messages you are already pulling.** The four message shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) call `batch_query` automatically and attach the result as a `reactions` block on each message (including replies inside `thread_replies`). Use the raw `batch_query` only for standalone `message_id`s outside that pull flow. See [message enrichment](lark-im-message-enrichment.md) for the contract.
- **`batch_query` pagination is per-message, not global.** Each entry in `queries[]` carries its own `page_token`. To page a specific message, set `queries[].page_token` for that entry; other entries in the same request start fresh. `success_msg_reaction_details[].has_more` indicates whether a given message has more pages.
- **`batch_query` `page_size_per_message` is capped at 10** (range 1..10 per schema). For high-reaction messages, expect multiple round trips per message.
- **`list` `page_size` is capped at 50** (schema: `range: <=50`, default 20).
- **`reaction_type` filter naming asymmetry across methods:**
- `create` / `delete` response: nested as `reaction_type.emoji_type`
- `list` request filter: flat string param `reaction_type`; response: nested `reaction_type.emoji_type`
- `batch_query` request filter: flat string `reaction_type`; detail items: `message_reaction_items[].emoji_type` (flat, not nested); aggregated counts: `reaction_count[].reaction_type` (flat)
It focuses on:
## HELP-GAP — not yet in `--help`/schema; keep until CLI adds it
- What each reaction method does
- The request/response shape you need when calling the raw API commands
- The complete `emoji_type` list used in reaction payloads and filters
> **Important:** These raw API commands accept structured input through `--params '<json>'` and `--data '<json>'`. They do not expose typed flags such as `--message-id` or `--reaction-type` directly.
## Command Overview
| Method | HTTP | Path | Purpose |
|---|---|---|---|
| `im.reactions.create` | `POST` | `/open-apis/im/v1/messages/{message_id}/reactions` | Add a reaction to one message |
| `im.reactions.list` | `GET` | `/open-apis/im/v1/messages/{message_id}/reactions` | List reaction records on one message |
| `im.reactions.delete` | `DELETE` | `/open-apis/im/v1/messages/{message_id}/reactions/{reaction_id}` | Delete one specific reaction record |
| `im.reactions.batch_query` | `POST` | `/open-apis/im/v1/messages/reactions/batch_query` | Query reactions for multiple messages in one request |
## Common Notes
- `message_id` is always an IM message ID such as `om_xxx`
- `reaction_id` is the unique record ID returned after a reaction is added
- `reaction_type.emoji_type` is the enum-like emoji identifier used by both write and read APIs
- Reaction APIs return **reaction records**, not only aggregated counts
- When the operator is a human user, the returned ID type may depend on `user_id_type`
## Inspect Schema
```bash
lark-cli schema im.reactions
lark-cli schema im.reactions.create --format pretty
lark-cli schema im.reactions.list --format pretty
lark-cli schema im.reactions.delete --format pretty
```
If your local build has already exposed the batch API in `schema`, also check:
```bash
lark-cli schema im.reactions.batch_query --format pretty
```
## create
Add a reaction to one message.
```bash
lark-cli im reactions create \
--params '{"message_id":"om_xxx"}' \
--data '{"reaction_type":{"emoji_type":"SMILE"}}'
```
### Request
- `--params.message_id`: required message ID
- `--data.reaction_type.emoji_type`: required emoji type
### Response
```json
{
"reaction_id": "ZCaCIjUBVVWSrm5L-3ZTw_xxx",
"operator": {
"operator_id": "ou_xxx",
"operator_type": "user"
},
"action_time": "1663054162546",
"reaction_type": {
"emoji_type": "SMILE"
}
}
```
## list
List reaction records on one message.
```bash
lark-cli im reactions list --params '{"message_id":"om_xxx"}'
lark-cli im reactions list --params '{"message_id":"om_xxx","reaction_type":"SMILE"}'
lark-cli im reactions list --params '{"message_id":"om_xxx","page_size":50}'
lark-cli im reactions list --params '{"message_id":"om_xxx","page_token":"<PAGE_TOKEN>"}'
lark-cli im reactions list --params '{"message_id":"om_xxx","user_id_type":"open_id"}'
```
### Request Parameters (`--params`)
| Parameter | Required | Description |
|---|---|---|
| `message_id` | Yes | Message ID (`om_xxx`) |
| `reaction_type` | No | Filter by one emoji type such as `SMILE` or `LAUGH` |
| `page_size` | No | Number of records per page. Default is 20 |
| `page_token` | No | Pagination token from the previous page |
| `user_id_type` | No | Returned operator ID type when `operator_type=user`: `open_id`, `union_id`, or `user_id` |
### Response Shape
```json
{
"items": [
{
"reaction_id": "ZCaCIjUBVVWSrm5L-3ZTw_xxx",
"operator": {
"operator_id": "ou_xxx",
"operator_type": "user"
},
"action_time": "1663054162546",
"reaction_type": {
"emoji_type": "SMILE"
}
}
],
"has_more": true,
"page_token": "YhljsPiGfUgnVAg9urvRFd-BvSqRLxxxx"
}
```
### Top-Level Fields
| Field | Type | Meaning |
|---|---|---|
| `items` | `array<object>` | Reaction records for the current page |
| `has_more` | `boolean` | Whether more pages are available |
| `page_token` | `string` | Token for the next page when `has_more=true` |
### `items[]` Fields
| Field | Type | Meaning |
|---|---|---|
| `reaction_id` | `string` | Unique ID of this reaction record |
| `operator` | `object` | Identity of the user or app that added the reaction |
| `action_time` | `string` | Unix timestamp in milliseconds |
| `reaction_type` | `object` | Reaction payload. The key field is `emoji_type` |
### `operator` Fields
| Field | Type | Meaning |
|---|---|---|
| `operator.operator_id` | `string` | Operator ID. If `operator_type=user`, the returned ID type follows `user_id_type`; if `operator_type=app`, this is the app ID |
| `operator.operator_type` | `string` | `user` or `app` |
## delete
Delete one specific reaction record from one message.
```bash
lark-cli im reactions delete \
--params '{"message_id":"om_xxx","reaction_id":"ZCaCIjUBVVWSrm5L-3ZTw_xxx"}'
```
### Request
- `--params.message_id`: required message ID
- `--params.reaction_id`: required reaction record ID
### Response
The response shape is similar to `create`, and usually echoes:
- `reaction_id`
- `operator`
- `action_time`
- `reaction_type.emoji_type`
## batch_query
Query reactions for multiple messages in one request.
```bash
lark-cli im reactions batch_query \
--params '{"user_id_type":"open_id"}' \
--data '{
"queries":[
{"message_id":"om_xxx"},
{"message_id":"om_yyy","page_token":"<PAGE_TOKEN>"}
],
"page_size_per_message":10,
"reaction_type":"LAUGH"
}'
```
### Request
#### `--params`
| Parameter | Required | Description |
|---|---|---|
| `user_id_type` | No | Returned user ID type in operator info: `open_id`, `union_id`, or `user_id` |
#### `--data`
| Field | Required | Description |
|---|---|---|
| `queries` | Yes | Array of target messages |
| `queries[].message_id` | No | Message ID to query |
| `queries[].page_token` | No | Continuation token for that message |
| `page_size_per_message` | No | Max reactions returned per message |
| `reaction_type` | No | Filter by one emoji type |
### Response
The meta definition contains three top-level result groups:
| Field | Meaning |
|---|---|
| `success_msg_reaction_details` | Per-message reaction detail records |
| `success_msg_reaction_counts` | Per-message aggregated reaction counts |
| `fail_msg_reaction_details` | Query failures for individual messages |
#### `success_msg_reaction_details`
Each `message_reaction_items[]` element includes:
- `reaction_id`
- `operator`
- `action_time`
- `emoji_type`
#### `success_msg_reaction_counts`
Each aggregated count record includes:
- `message_id`
- `reaction_count[].reaction_type`
- `reaction_count[].count`
#### `fail_msg_reaction_details`
Each failed message record includes:
- `message_id`
- `fail_reason`
Supported `fail_reason` values from meta:
- `invalid`
- `invalid_page_token`
- `no_permission`
## `emoji_type` Field
Reaction emoji identifiers are used in slightly different field names across the APIs:
- `im.reactions.create`: request and response use `reaction_type.emoji_type`
- `im.reactions.list`: request filter uses `reaction_type`, response uses `reaction_type.emoji_type`
- `im.reactions.delete`: response uses `reaction_type.emoji_type`
- `im.reactions.batch_query`: request filter uses top-level `reaction_type`, detail results use `message_reaction_items[].emoji_type`, aggregated results use `reaction_count[].reaction_type`
## Complete `emoji_type` List
The following list is synchronized from the official Feishu reaction emoji documentation:
- Source page: `https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message-reaction/emojis-introduce`
- Markdown source: `https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message-reaction/emojis-introduce.md`
Current count in the fetched source: `185`.
```text
OK, THUMBSUP, THANKS, MUSCLE, FINGERHEART, APPLAUSE, FISTBUMP, JIAYI
DONE, SMILE, BLUSH, LAUGH, SMIRK, LOL, FACEPALM, LOVE
WINK, PROUD, WITTY, SMART, SCOWL, THINKING, SOB, CRY
ERROR, NOSEPICK, HAUGHTY, SLAP, SPITBLOOD, TOASTED, GLANCE, DULL
INNOCENTSMILE, JOYFUL, WOW, TRICK, YEAH, ENOUGH, TEARS, EMBARRASSED
KISS, SMOOCH, DROOL, OBSESSED, MONEY, TEASE, SHOWOFF, COMFORT
CLAP, PRAISE, STRIVE, XBLUSH, SILENT, WAVE, WHAT, FROWN
SHY, DIZZY, LOOKDOWN, CHUCKLE, WAIL, CRAZY, WHIMPER, HUG
BLUBBER, WRONGED, HUSKY, SHHH, SMUG, ANGRY, HAMMER, SHOCKED
TERROR, PETRIFIED, SKULL, SWEAT, SPEECHLESS, SLEEP, DROWSY, YAWN
SICK, PUKE, BETRAYED, HEADSET, EatingFood, MeMeMe, Sigh, Typing
Lemon, Get, LGTM, OnIt, OneSecond, VRHeadset, YouAreTheBest, SALUTE
SHAKE, HIGHFIVE, UPPERLEFT, ThumbsDown, SLIGHT, TONGUE, EYESCLOSED, RoarForYou
CALF, BEAR, BULL, RAINBOWPUKE, ROSE, HEART, PARTY, LIPS
BEER, CAKE, GIFT, CUCUMBER, Drumstick, Pepper, CANDIEDHAWS, BubbleTea
Coffee, Yes, No, OKR, CheckMark, CrossMark, MinusOne, Hundred
AWESOMEN, Pin, Alarm, Loudspeaker, Trophy, Fire, BOMB, Music
XmasTree, Snowman, XmasHat, FIREWORKS, 2022, REDPACKET, FORTUNE, LUCK
FIRECRACKER, StickyRiceBalls, HEARTBROKEN, POOP, StatusFlashOfInspiration, 18X, CLEAVER, Soccer
Basketball, GeneralDoNotDisturb, Status_PrivateMessage, GeneralInMeetingBusy, StatusReading, StatusInFlight, GeneralBusinessTrip, GeneralWorkFromHome
StatusEnjoyLife, GeneralTravellingCar, StatusBus, GeneralSun, GeneralMoonRest, MoonRabbit, Mooncake, JubilantRabbit
TV, Movie, Pumpkin, BeamingFace, Delighted, ColdSweat, FullMoonFace, Partying
GoGoGo, ThanksFace, SaluteFace, Shrug, ClownFace, HappyDragon
```
## References
- [lark-im](../SKILL.md) - all IM commands
- [lark-shared](../../lark-shared/SKILL.md) - authentication and global parameters
- Official emoji doc: `https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message-reaction/emojis-introduce`
- **Full `emoji_type` key list (185 values):** schema only links to the Feishu docs page (`https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message-reaction/emojis-introduce`); it does not enumerate the values inline. Keys include: `OK THUMBSUP THANKS MUSCLE FINGERHEART APPLAUSE FISTBUMP JIAYI DONE SMILE BLUSH LAUGH SMIRK LOL FACEPALM LOVE WINK PROUD WITTY SMART SCOWL THINKING SOB CRY ERROR NOSEPICK HAUGHTY SLAP SPITBLOOD TOASTED GLANCE DULL INNOCENTSMILE JOYFUL WOW TRICK YEAH ENOUGH TEARS EMBARRASSED KISS SMOOCH DROOL OBSESSED MONEY TEASE SHOWOFF COMFORT CLAP PRAISE STRIVE XBLUSH SILENT WAVE WHAT FROWN SHY DIZZY LOOKDOWN CHUCKLE WAIL CRAZY WHIMPER HUG BLUBBER WRONGED HUSKY SHHH SMUG ANGRY HAMMER SHOCKED TERROR PETRIFIED SKULL SWEAT SPEECHLESS SLEEP DROWSY YAWN SICK PUKE BETRAYED HEADSET EatingFood MeMeMe Sigh Typing Lemon Get LGTM OnIt OneSecond VRHeadset YouAreTheBest SALUTE SHAKE HIGHFIVE UPPERLEFT ThumbsDown SLIGHT TONGUE EYESCLOSED RoarForYou CALF BEAR BULL RAINBOWPUKE ROSE HEART PARTY LIPS BEER CAKE GIFT CUCUMBER Drumstick Pepper CANDIEDHAWS BubbleTea Coffee Yes No OKR CheckMark CrossMark MinusOne Hundred AWESOMEN Pin Alarm Loudspeaker Trophy Fire BOMB Music XmasTree Snowman XmasHat FIREWORKS 2022 REDPACKET FORTUNE LUCK FIRECRACKER StickyRiceBalls HEARTBROKEN POOP StatusFlashOfInspiration 18X CLEAVER Soccer Basketball GeneralDoNotDisturb Status_PrivateMessage GeneralInMeetingBusy StatusReading StatusInFlight GeneralBusinessTrip GeneralWorkFromHome StatusEnjoyLife GeneralTravellingCar StatusBus GeneralSun GeneralMoonRest MoonRabbit Mooncake JubilantRabbit TV Movie Pumpkin BeamingFace Delighted ColdSweat FullMoonFace Partying GoGoGo ThanksFace SaluteFace Shrug ClownFace HappyDragon`

View File

@@ -1,115 +1,15 @@
# im +threads-messages-list
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first for authentication, global parameters, and safety rules.
Fetch the reply message list inside a thread. When `im +chat-messages-list` returns messages that include a `thread_id` field, use this command to inspect all replies in that thread.
Maps to `lark-cli im +threads-messages-list`. **Run `lark-cli im +threads-messages-list --help` for the authoritative flags (`--thread` / `--order` / `--page-size` / `--page-token` / `--no-reactions` / `--download-resources` / `--as` / `--format`), the accepted `om_xxx`/`omt_xxx` input, and page-size range (1-500).** This file covers only what `--help` cannot.
By default each reply also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. Pass `--no-reactions` to skip the extra round-trip. Pass `--download-resources` to additionally download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block — off by default, no extra requests when omitted. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
Supports both `--as user` (default) and `--as bot`. Enriches replies with reactions / `update_time` per [message enrichment](lark-im-message-enrichment.md).
This skill maps to the shortcut: `lark-cli im +threads-messages-list` (internally calls `GET /open-apis/im/v1/messages` with `container_id_type=thread` to fetch thread messages).
## Gotchas
## Commands
```bash
# Get thread replies (ascending by time by default, table output)
lark-cli im +threads-messages-list --thread omt_xxx
# Reverse chronological order (latest first)
lark-cli im +threads-messages-list --thread omt_xxx --order desc
# Control page size
lark-cli im +threads-messages-list --thread omt_xxx --page-size 20
# Pagination
lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
# Output format options
lark-cli im +threads-messages-list --thread omt_xxx --format pretty
lark-cli im +threads-messages-list --thread omt_xxx --format table
lark-cli im +threads-messages-list --thread omt_xxx --format csv
# View as a bot
lark-cli im +threads-messages-list --thread omt_xxx --as bot
# Preview the request without executing it
lark-cli im +threads-messages-list --thread omt_xxx --dry-run
```
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--thread <id>` | Yes | Thread ID (`om_xxx` or `omt_xxx` format) |
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |
| `--order <order>` | No | Sort order: `asc` (default) / `desc` |
| `--page-size <n>` | No | Number of items per page (default 50, range 1-500) |
| `--page-token <token>` | No | Pagination token for the next page |
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
| `--as <identity>` | No | Identity type: `user` (default) / `bot` |
| `--dry-run` | No | Print the request only, do not execute it |
## Core Constraints
### 1. Source of `thread_id`
`thread_id` (`omt_xxx` or `om_xxx`) comes from the `thread_id` field in results returned by `im +chat-messages-list` or `im +messages-search`. Do not guess a thread ID. Fetch messages first and use the returned value.
### 2. No time filtering support
Thread messages do not support `start_time` / `end_time` filtering because of Feishu API limitations. Use pagination and sort order to control the scope.
### 3. Pagination (`has_more` / `page_token`)
- When the result includes `has_more=true`, use `page_token` to fetch the next page
- If you need the complete thread, keep paginating; if you only need an overview, the first page is often enough
### 4. Recommended expansion strategy
| Scenario | Recommended Parameters |
|------|---------|
| Quickly inspect recent replies | `--order desc --page-size 10` |
| Read the full thread in chronological order | `--order asc --page-size 50`, then paginate as needed |
| Just confirm whether replies exist | `--order desc --page-size 1` |
## Usage Scenarios
### Scenario 1: Expand a thread discovered in group messages
```bash
# Step 1: Fetch group messages and find one that contains thread_id
lark-cli im +chat-messages-list --chat-id oc_xxx
# Step 2: Extract thread_id from the JSON output and fetch thread replies
lark-cli im +threads-messages-list --thread omt_xxx
```
### Scenario 2: Paginate through a long thread
```bash
# First page
lark-cli im +threads-messages-list --thread omt_xxx
# If has_more=true is returned, continue with page_token
lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
```
## Resource Rendering
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `[Image: img_xxx]`; by default resource binaries are **not** downloaded.
Pass `--download-resources` to download every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` in one pass and attach a `resources` block to each reply (see [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in)). Otherwise download individual resources manually through `im +messages-resources-download` (see [lark-im-messages-resources-download](lark-im-messages-resources-download.md)).
## Common Errors and Troubleshooting
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| "Invalid thread ID format" | `thread_id` does not start with `om_` or `omt_` | Use a valid `om_xxx` or `omt_xxx` value |
| Empty thread result | Wrong thread_id or no replies in the thread | Confirm the thread_id came from `im +chat-messages-list` output |
| Permission denied | The user is not authorized or is not a conversation member | Make sure OAuth authorization is complete and the identity is a chat member |
## References
- [lark-im](../SKILL.md) - all message-related commands
- [lark-im-chat-messages-list](lark-im-chat-messages-list.md) - fetch conversation messages (source of `thread_id`)
- [lark-shared](../../lark-shared/SKILL.md) - authentication and global parameters
- **Don't guess a `thread_id`.** It comes from the `thread_id` field of [`im +chat-messages-list`](lark-im-chat-messages-list.md) or [`im +messages-search`](lark-im-messages-search.md) output. Passing a plain `om_` message ID also works — the CLI resolves it to that message's thread — but an invented ID returns empty, not an error, so an empty result usually means a wrong/stale ID rather than "no replies".
- **No time filtering** — unlike `+chat-messages-list`, threads accept no `--start`/`--end` (Feishu API limitation). Scope the read with `--order` + pagination only: `--order desc --page-size 1` to just confirm replies exist, `--order desc --page-size 10` for recent, `--order asc --page-size 50` (then paginate) for the full thread in order.
- **`--order` defaults to `asc`** here (opposite of `+chat-messages-list`'s `desc`). (Note: the flag is `--order`, not `--sort`.)
- **Image content is a placeholder, not bytes**: replies render images as `[Image: img_xxx]`; files/audio/video stay as resource keys. Nothing downloads unless you pass `--download-resources` (writes to `./lark-im-resources/`) or use [`im +messages-resources-download`](lark-im-messages-resources-download.md).
- Permission denied here usually means the calling identity is **not a member of the parent chat** — thread access is gated by chat membership, not just OAuth scope.

View File

@@ -1,7 +1,7 @@
---
name: lark-vc-agent
version: 1.0.0
description: "飞书视频会议会中能力:用于让应用机器人真实加入离开正在进行的会议,并读取当前身份可见的会中事件,如参会人加入/离开、发言、聊天、屏幕共享。适用于用户询问正在开的会议发生了什么、谁在发言、是否共享内容,或需要发现当前可读的进行中会议 ID。不负责已结束会议搜索、参会人快照、纪要逐字稿或录制查询,这些使用 lark-vc 技能。"
description: "飞书视频会议:让机器人代当前用户加入/离开正在进行的会议,并读取会议期间的实时事件(参会人加入离开、发言、聊天、屏幕共享。1. 用户提供 9 位会议号、要求代为入会或离会时使用 +meeting-join / +meeting-leave——会真实产生入会/离会记录。2. 会议进行中用户想知道“谁加入了”“谁离开了”“谁在发言”“有人共享屏幕吗”等会中动态时,机器人入会后用 +meeting-events 读取事件时间线。3. 典型场景:参会机器人、会中助手、代为旁听、代为参会。前提:机器人只能读到它自己参会过且仍在进行中会议的事件;查询已结束会议的参会名单、纪要逐字稿使用 lark-vc 技能。"
metadata:
requires:
bins: ["lark-cli"]
@@ -10,7 +10,7 @@ metadata:
# 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 直接复用,不再重复定义
@@ -18,7 +18,7 @@ metadata:
## 内测提示
- 当前功能正在内测中,仅少数用户可用。忽略 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 中的权限申请流程,以下方的指引为准。
- 如果 `lark-cli` 提示 `missing required scope(s)` / `permission_violations`,不要走普通权限申请流程;先提示用户加入早鸟群确认内测权限已开通,再按“应用身份权限配置检查”处理应用权限、安装和数据范围
- 如果 `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`,提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`
## 定位
@@ -33,99 +33,61 @@ metadata:
| 用户意图示例 | 应路由到 |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| "帮我入会 123456789"、"代我参会"、"让机器人进会旁听" | **本 skill** `+meeting-join` |
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"**进行中会议** | **本 skill** `+meeting-events` |
| "我/某个用户现在在哪个会里"、"给我找当前可拉事件的 meeting_id" | **本 skill** `+meeting-list-active` |
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"**进行中会议**,且**机器人已入会** | **本 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) 发群 |
## 身份路由
不要向用户暴露内部身份缩写;对用户只说“用户身份”或“应用身份”。
| 场景 | 使用身份 | 关键规则 |
| ---- | -------- | -------- |
| 查询当前登录用户正在参加的会议 | `--as user` | 不传 `--user-id`;拿到的 `meeting_id` 后续继续用 `--as user` 读事件 |
| 查询目标用户且应用机器人也在会中的会议 | `--as bot --user-id <user_open_id>` | `--user-id` 必须是 `ou_...`;拿到的 `meeting_id` 后续继续用 `--as bot` 读事件 |
| 用户明确要求应用机器人入会/旁听/代参会 | `--as bot` | 这是写操作,会真实产生入会记录;返回的 `meeting.id` 后续继续用 `--as bot` |
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份,除非用户明确要求切换场景(例如从“仅查询我当前会”改成“让应用机器人入会旁听”)。
## 核心场景
### 1. 加入正在进行的会议(写操作)
1. 只有用户明确表达"让 Agent **真实入会**"(参会机器人、会中助手、代为旁听、代参会)时才用 `+meeting-join`。只是查数据不要入会。
2. `+meeting-join --meeting-number` 只接受 **9 位纯数字**会议号,不是会议链接整串、也不是 `meeting_id`如果用户只是给了 9 位会议号并询问会中内容,先按 `+meeting-list-active` 的会议号匹配流程找 `meeting_id`,不要直接入会。
2. `+meeting-join --meeting-number` 只接受 **9 位纯数字**会议号,不是会议链接整串、也不是 `meeting_id`
3. 返回体中的 `meeting.id` **必须立刻记录**——后续 `+meeting-events` / `+meeting-leave` 都靠它,**不能用 9 位会议号替代**。
4. 入会对所有参会人可见,执行前核实 9 位会议号来源,避免误入错会。
5. 使用应用身份 `--as bot` 执行真实入会;不要用当前登录用户身份尝试让应用机器人入会
5. 仅支持 `user` 身份,需提前 `lark-cli auth login`
6. 若入会失败,优先查看 `+meeting-join` reference 的错误排查段落,重点确认会议号、密码、会议状态、等候室 / 审批以及会议是否禁止当前身份加入。
### 2. 感知会中事件(读操作)
1. 用户要看"会议里正在发生什么"(参会人加入/离开、聊天、转写、屏幕共享)时,用 `+meeting-events`
2. 输入是 **`meeting_id`**(长数字 ID不是 9 位会议号。
3. 不依赖默认身份。`meeting_id` 来自用户身份发现时,继续用 `--as user`;来自应用身份发现或 `+meeting-join` 时,继续用 `--as bot`。身份不一致会导致空结果或权限错误
3. Bot 必须**真实参会过**(先 `+meeting-join`),否则事件流通常不可见。具体的状态边界、结束后宽限窗口与错误码(如 `10005 / 20001 / 20002`)请查看 `+meeting-events` reference
4. **不能做会后复盘****不能替代参会人快照查询**。如果会议已结束:
- `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息。
- 再根据 `note_display_type``note_id``minute_token` 和用户意图,按 [`lark-vc`](../lark-vc/SKILL.md) 的产物决策读取正文、逐字稿或妙记。
- 想拿纪要文档或逐字稿文档 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`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
10. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择。
11. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`
### 3. 离开会议(写操作)
1. 只有用户明确要求机器人退出 / 离开 / 结束参会时,才用应用身份执行 `+meeting-leave --as bot --meeting-id <长数字 meeting_id>`;不应因任务完成而执行离会
2. `--meeting-id` **必须**是长数字会议 ID通常来自 `+meeting-join` 返回的 `meeting.id`也可以来自应用身份 `+meeting-list-active` 返回的 `meeting_id`。如果来自 list-active必须确认应用机器人当前就在该会中。**不接受 9 位会议号**
1. 只有用户明确要求机器人退出 / 离开 / 结束参会时,才用 `+meeting-leave --meeting-id <从 +meeting-join 拿到的 meeting.id>`;不要把任务完成当作离会指令
2. `--meeting-id` **必须**是 `+meeting-join` 返回的长数字 `meeting.id`**不接受 9 位会议号**
3. 离会**立即生效**,机器人从会议的参会人列表中消失,对其他参会人可见;若需要重新入会,再跑一次 `+meeting-join` 即可(非真正"不可逆")。
4. 使用与入会或 active meeting 发现相同的应用身份离会
4. 仅支持 `user` 身份
### 4. 获取当前可用的进行中会议 ID读操作
1. `+meeting-list-active` 用来发现当前进行中的会议,并拿到后续 `+meeting-events` 需要的长数字 `meeting_id`
2. 用户身份:`lark-cli vc +meeting-list-active --as user --format json`,用于发现当前登录用户正在参加的会议;后续 `+meeting-events` 继续 `--as user`
3. 应用身份:`lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json``--user-id` 必须是目标用户 open_id`ou_...`;返回该用户当前正在参加且应用机器人也在会中的会议。它不是全量会议搜索接口。后续 `+meeting-events` 继续 `--as bot`
4. 如果返回空,先按当前身份解释:用户身份下表示当前用户没有可见的进行中会议;应用身份下表示没有找到“目标用户在会中且应用机器人也在会中”的当前会。
5. 如果返回多个会议,不要自动任选一个;按 `meeting_title` / `meeting_no` / `meeting_id` 展示候选,等待用户明确选择后再调用 `+meeting-events`
6. 如果用户给了 9 位会议号,先在 active meeting 结果中按 `meeting_no` 匹配。匹配失败时,不要自动入会;只有用户明确要求应用机器人真实入会时,才询问或执行 `+meeting-join`
### 5. Agent 参会示范
### 4. Agent 参会示范
```bash
# 1. 入会,捕获 meeting.id
JOIN=$(lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json)
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 --as bot --meeting-id "$MID" --page-all --format pretty
lark-cli vc +meeting-events --meeting-id "$MID" --page-all --format pretty
# 3. 会后可选:进入 lark-vc 获取会议产物信息,再按 note_display_type / minute_token 决策读取
# 3. 会后可选:取纪要 / 逐字稿(跨到 lark-vc
lark-cli vc +notes --meeting-ids "$MID"
```
如果用户随后明确要求退出 / 离开 / 结束参会,再单独调用 `lark-cli vc +meeting-leave --as bot --meeting-id "$MID"`
如果已经知道目标用户 `open_id`,且 bot 已在会中,也可以先发现当前会:
```bash
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
```
如果只是回答当前登录用户所在会议发生了什么,使用用户身份一路查:
```bash
lark-cli vc +meeting-list-active --as user --format json
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
```
如果用户随后明确要求退出 / 离开 / 结束参会,再单独调用 `lark-cli vc +meeting-leave --meeting-id "$MID"`
## Shortcuts
@@ -134,31 +96,20 @@ 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-list-active`](references/lark-vc-agent-meeting-list-active.md) | 读 | List active meetings and discover meeting_id for event reads |
| [`+meeting-events`](references/lark-vc-agent-meeting-events.md) | 读 | List meeting events visible to the app agent (participant joined/left, transcript, chat, share) |
| [`+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)入参格式写操作可见性风险、入会失败排查
- [`+meeting-list-active`](references/lark-vc-agent-meeting-list-active.md):用户身份和应用身份的不同返回范围
- [`+meeting-events`](references/lark-vc-agent-meeting-events.md)`meeting_id` 来源、身份延续、分页和错误码10005 / 20001 / 20002
- [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md)`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` 来源与写操作可见性
## 应用身份权限配置检查
## 权限表
应用身份 `--as bot``no permission``missing required scope(s)``permission_violations``ErrNotInGray``20017` 时,不要引导用户执行 `auth login`。按顺序检查:
1. 以 CLI 返回的 metadata / error envelope 为准,确认提示的 VC Agent 相关权限已开通。常见读取 active meeting / events 需要会中事件读取权限;应用机器人入会 / 离会需要 bot 入会写权限。
2. 应用已发布并安装到当前租户。
3. 开放平台“权限可访问的数据范围”已开通并保存。
4. 数据范围选择“按条件筛选”,条件配置为:**会议的归属者 包含 与应用的可用范围一致**。
5. 如果 scope、安装和数据范围都正确仍返回 `ErrNotInGray` / `20017`,再按 VC Agent 内测 privilege / 灰度白名单处理,提示加入早鸟群或联系平台同学开通。
## 用户身份被拒绝时
用户身份 `--as user` 报权限或身份不支持类错误时,不要反复引导用户执行 `auth login`。先以 CLI 返回的 metadata / error envelope 为准判断:如果错误表明当前接口不支持用户身份访问,再按用户意图切换处理:
1. 如果用户只是查询当前登录用户所在的进行中会议,说明当前接口链路不支持用户身份访问,改用应用身份流程;需要目标用户 open_id并要求应用机器人已在会中或先按用户确认执行入会。
2. 如果用户明确要求应用机器人入会、旁听、代参会或读取应用机器人可见事件,直接切到 `--as bot`,并按上面的应用身份权限配置检查处理。
| Shortcut | 所需 scope |
| ----------------- | ------------------------------ |
| `+meeting-join` | `vc:meeting.bot.join:write` |
| `+meeting-events` | `vc:meeting.meetingevent:read` |
| `+meeting-leave` | `vc:meeting.bot.join:write` |
## 延伸

View File

@@ -1,30 +1,29 @@
# vc +meeting-events
查询一场正在进行的视频会议中的会中事件列表。该命令是**读操作**,必须沿用 `meeting_id` 的来源身份:用户身份发现的会议继续用用户身份读,应用身份发现或应用机器人入会得到的会议继续用应用身份读。对已结束会议,存在一个**结束后 5 分钟内的宽限窗口**;应用身份读取时,要求应用机器人曾经在这场会里出现过
> **前置条件:** 先阅读 [`../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`)。
可见性边界:
- `meeting_id` 来自 `+meeting-list-active --as user`:后续读取事件继续 `--as user`
- `meeting_id` 来自 `+meeting-list-active --as bot --user-id <user_open_id>``+meeting-join --as bot`:后续读取事件继续 `--as bot`
- 应用身份下,应用机器人必须在该会中或参会过;应用身份 active meeting 返回的是“目标用户在会中且应用机器人也在会中”的会议,不表示可以读取任意 `meeting_id`
## 命令
```bash
# 默认用法:全量拉取当前可见事件
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty
# 指定时间范围,并拉全该时间窗内当前可见事件
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --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 --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-token <last_page_token> --page-all --format pretty
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-token <last_page_token> --page-all --format pretty
# 调试或控制返回体大小时,显式只查一页
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json
# 预览 API 调用(不实际请求)
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --dry-run
```
## 参数
@@ -37,6 +36,8 @@ lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28
| `--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 调用,不执行 |
## 核心约束
@@ -44,55 +45,37 @@ lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28
`--meeting-id` 必须是会议的长数字 ID。它通常来自
- `+meeting-join` 返回体中的 `meeting.id`
- `+meeting-list-active` 返回体中的 `meeting_id`
- `+search` 结果中的 `id`
**不要**把 9 位会议号(`--meeting-number`)传给这个命令。
如果 `meeting_id` 来自 `+meeting-list-active`,后续 `+meeting-events` 必须沿用同一身份;如果返回多个会议,先让用户选择具体 `meeting_id`
如果用户提供的是 9 位会议号且没有明确要求应用机器人入会,先按当前场景身份查 active meetings 并按 `meeting_no` 匹配。匹配到唯一项后,取该项的长数字 `meeting_id`,再用同一身份调用本命令;匹配失败时不要自动入会,除非用户明确说“入会 / 让应用机器人旁听 / 代我参会”。
### 2. 仅支持 user 身份
### 2. 身份来源是读取事件的权限锚点
该命令仅支持 `user` 身份。
- 用户身份路径:先用 `+meeting-list-active --as user` 发现当前登录用户的会议,再用 `+meeting-events --as user` 读取该 `meeting_id`
- 应用身份路径:应用机器人必须在会中或参会过;不要拿任意 `meeting_id` 直接用 `--as bot` 查。
- 不要混用身份。身份不一致时,常见结果是空列表、`no permission``bot is not in meeting`
### 3. bot 必须在会中,或在会议结束后的 5 分钟宽限窗口内曾经在会中
### 3. 读取事件前必须先拿到可见的 meeting_id
这是查询“bot 在会中观察到的事件”的接口。若 bot 已离会、未入会、或会议已经无法再判断 bot 身份,后端通常会报:
- `bot is not in meeting, no permission`
最稳妥的调用顺序通常是:
因此,最稳妥的调用顺序通常是:
```bash
# 方式 1先入会直接记录返回的 meeting.id
lark-cli vc +meeting-join --as bot --meeting-number 123456789
# 先入会
lark-cli vc +meeting-join --meeting-number 123456789
# 记录返回的 meeting.id
# 再查询事件
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id>
lark-cli vc +meeting-events --meeting-id <meeting.id>
```
如果应用机器人已经在会中,也可以先通过 active meeting 找会:
```bash
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
```
如果只是查询当前登录用户所在会议:
```bash
lark-cli vc +meeting-list-active --as user --format json
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
```
若应用机器人已离会、未入会、或会议已经无法再判断身份,后端通常会报:
- `bot is not in meeting, no permission`
更精确地说,后端当前的判断规则是:
- **会议进行中**:要求应用机器人**当前仍在会中**
- **会议已结束后的 5 分钟内**:只要应用机器人**曾经在这场会中出现过**,仍可拉取事件
- **会议进行中**:要求 bot **当前仍在会中**
- **会议已结束后的 5 分钟内**:只要 bot **曾经在这场会中出现过**,仍可拉取事件
- **会议结束超过 5 分钟**:按会议结束处理,通常不再返回事件流
- **应用机器人从未真实入会过**:即使会议仍在进行或刚结束,也会返回 `10005 bot is not in meeting`
- **bot 从未真实入会过**:即使会议仍在进行或刚结束,也会返回 `10005 bot is not in meeting`
### 4. 自动分页规则
@@ -104,9 +87,9 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
执行准则:
- **默认命令模板**`lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-all --format pretty`
- **默认命令模板**`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 --as <same_identity> --meeting-id <meeting.id> --page-token <returned_page_token> --page-all --format pretty`
- 遇到上述情况,默认补救方式是继续使用返回的 `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`,确认拿到的是最新事件流,再回答用户。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
@@ -132,10 +115,7 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
执行准则:
- 如果上下文已有明确 `meeting_id` 和来源身份,直接用同一身份执行 `+meeting-events --page-all --format json`
- 如果上下文没有明确 `meeting_id`,先按用户当前意图选择身份:问“我/当前用户所在会议”用 `lark-cli vc +meeting-list-active --as user --format pretty`;问“应用机器人可见的目标用户会议”用 `lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format pretty`。返回多个会议时先让用户选择。
- 如果上下文只有 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配;匹配到唯一会议后再查事件。不要为了总结会议而自动调用 `+meeting-join`
- 这类问题拿到 `meeting_id` 后,用 `lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-all --format json` 拉取最新事件流。
- 这类问题默认先用 `lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format json` 拉取最新事件流
- 如果事件中出现共享文档线索,例如:
- `magic_share_started`
- `share_doc.title`
@@ -191,7 +171,7 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
| 输入参数 | 获取方式 |
|---------|---------|
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+meeting-list-active` 返回的 `meeting_id`;或 `+search` 结果中的 `id`。必须同时记录来源身份 |
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+search` 结果中的 `id` |
| `start` / `end` | 用户给出的时间范围;如未给出则默认取全量可见事件 |
| `page-token` | 上一页或上一次查询结果中保存的 `page_token`;建议持久化保存,便于下次继续拉取新增事件 |
@@ -201,31 +181,16 @@ lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --for
```bash
# 第 1 步:加入会议,记录返回的 meeting.id
lark-cli vc +meeting-join --as bot --meeting-number 123456789
lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:查询事件流
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
```
### 场景 1b应用机器人已在会中先发现 meeting_id 再读事件
```bash
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
```
### 场景 1c当前登录用户正在会中先发现 meeting_id 再读事件
```bash
lark-cli vc +meeting-list-active --as user --format json
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty
```
### 场景 2过滤某段时间内的事件
```bash
lark-cli vc +meeting-events \
--as <same_identity> \
--meeting-id <meeting.id> \
--start 2026-04-17T15:00:00+08:00 \
--end 2026-04-17T16:00:00+08:00 \
@@ -239,7 +204,6 @@ lark-cli vc +meeting-events \
# 上一次查询结束后,保留最后返回的 page_token
# 这次直接从该游标继续拉新增事件
lark-cli vc +meeting-events \
--as <same_identity> \
--meeting-id <meeting.id> \
--page-token <last_page_token> \
--page-all \
@@ -257,27 +221,23 @@ lark-cli vc +meeting-events \
| 错误现象 | 根本原因 | 解决方案 |
|---------|---------|---------|
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入长数字 `meeting.id` |
| `not a 9-digit meeting number` | 把 9 位会议号误传给 `--meeting-id` | 如果只是查询会中内容,先用 `+meeting-list-active``meeting_no` 匹配拿长数字 `meeting_id`;只有用户明确要求入会时才用 `+meeting-join --as bot --meeting-number <9位号>` |
| `10005 bot is not in meeting` | 使用应用身份读取,但应用机器人从未真实入会该会议;或会议已结束但应用机器人从未在会中出现过 | 如果本来是用户身份发现的 `meeting_id`,改回 `--as user`;如果确实要应用身份读取,先 `+meeting-join --as bot --meeting-number <9位号>` 真实入会再查。**如果只是想看参会人快照,改`lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`** |
| 用户身份不支持 | 当前事件读取接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先通过 `+meeting-list-active --as bot --user-id <user_open_id>` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 |
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_display_type` / `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
| `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 位会议号 |
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |
| `HTTP 404` / `HTTP 500` | 服务端当前无法找到或处理该会议实例 | 换一个正在进行且 bot 可见的 meeting_id或排查后端问题 |
## 提示
- 这是**会中事件流**查询,不适合拿来搜历史会议记录;搜历史会议请用 `+search`
- 如果会议已经结束,不要卡在 `+meeting-events`
- `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息。
- 再根据 `note_display_type``note_id``minute_token` 和用户意图,按 `lark-vc` 的产物决策读取纪要正文、逐字稿或妙记。
- 事件列表是否完整,取决于应用机器人何时入会、何时离会,以及后端当前可见的会中事件范围。对于已结束会议,通常只在**结束后 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>`
- 事件列表是否完整,取决于 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-list-active](lark-vc-agent-meeting-list-active.md) — 发现当前可读事件的进行中会议 ID
- [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

View File

@@ -1,29 +1,29 @@
# vc +meeting-join
通过 9 位会议号让应用机器人加入一场正在进行的视频会议。这是一次**写操作**,会实际让应用机器人加入会议
> **前置条件:** 先阅读 [`../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`)。
> **不要把 9 位会议号等同于入会意图。** 用户给出 9 位会议号并询问“会议讲了什么 / 查会中事件”时,先用 `+meeting-list-active` 查当前 active meetings 并按 `meeting_no` 匹配;只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才调用本命令。
## 命令
```bash
# 仅指定会议号(无密码)
lark-cli vc +meeting-join --as bot --meeting-number 123456789
lark-cli vc +meeting-join --meeting-number 123456789
# 指定会议号 + 密码
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --password 8888
lark-cli vc +meeting-join --meeting-number 123456789 --password 8888
# 从邀请事件透传 call_id参见「如何获取输入参数」
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --call-id a08e06bf-9a41-44e4-a89c-a7871899e783
lark-cli vc +meeting-join --meeting-number 123456789 --call-id a08e06bf-9a41-44e4-a89c-a7871899e783
# 输出格式
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json
lark-cli vc +meeting-join --meeting-number 123456789 --format json
# 预览 API 调用(不实际加入会议)
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --dry-run
lark-cli vc +meeting-join --meeting-number 123456789 --dry-run
```
## 参数
@@ -33,13 +33,14 @@ lark-cli vc +meeting-join --as bot --meeting-number 123456789 --dry-run
| `--meeting-number <no>` | 是 | 会议号,必须为 **9 位纯数字** |
| `--password <pw>` | 否 | 会议密码,仅在该会议设置了入会密码时传入 |
| `--call-id <id>` | 否 | 从 `vc.bot.meeting_invited_v1` 邀请事件透传的 `call_id`原样回传即可。Agent 主动入会或无邀请事件来源时不传 |
| `--dry-run` | 否 | 预览 API 调用,不实际加入会议;会议号或身份不确定时先用它确认请求 |
| `--format <fmt>` | 否 | 输出格式json (默认) / pretty / table / ndjson / csv |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 核心约束
### 1. 使用应用身份
### 1. 仅支持 user 身份
这是应用机器人入会能力,使用 `--as bot`。不要用当前登录用户身份尝试让应用机器人入会
该命令仅支持 `user` 身份
### 2. 会议号格式严格校验
@@ -52,8 +53,8 @@ lark-cli vc +meeting-join --as bot --meeting-number 123456789 --dry-run
### 3. 会议必须已开始且允许入会
- 会议必须处于**进行中**状态,应用机器人无法加入尚未开始或已结束的会议。
- 若会议设置了**等候室 / 入会审批**应用机器人可能需要主持人放行后才真正入会。
- 会议必须处于**进行中**状态,bot 无法加入尚未开始或已结束的会议。
- 若会议设置了**等候室 / 入会审批**bot 可能需要主持人放行后才真正入会。
- 若返回 `HTTP 403: no permission`(错误码 `121003`),不要只理解成“账号没权限”。这类报错更常见的原因是:会议参数或会控配置当前不满足入会条件,例如会议号填错、密码未传或错误、会议尚未开始、等候室 / 入会审批未放行、会议禁止外部/特定身份加入等。应先确认这些配置项,再重试。
### 4. 机器人入会后对其他参会人可见
@@ -66,7 +67,7 @@ lark-cli vc +meeting-join --as bot --meeting-number 123456789 --dry-run
| 字段 | 说明 |
|------|------|
| `meeting.id` | 会议 ID可后续传给 `+meeting-leave --as bot --meeting-id` |
| `meeting.id` | 会议 ID可后续传给 `+meeting-leave --meeting-id` |
| `meeting.meeting_no` | 会议号(与入参一致) |
| `meeting.topic` | 会议主题 |
| `meeting.start_time` | 会议开始时间 |
@@ -87,30 +88,25 @@ lark-cli vc +meeting-join --as bot --meeting-number 123456789 --dry-run
```bash
# 第 1 步:加入会议,记录返回的 meeting.id
lark-cli vc +meeting-join --as bot --meeting-number 123456789
lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:使用返回的 meeting.id 查询会中事件
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty
```
如果 bot 已经在会中,也可以通过 active meeting 找回 `meeting_id`
```bash
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
```
### 场景 2加入会议 → 会后进入 lark-vc 获取会议产物信息
### 场景 2加入会议 → 会后拉取纪要 / 录制
```bash
# 第 1 步:加入并参会
lark-cli vc +meeting-join --as bot --meeting-number 123456789
lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:会议结束后,查询会议产物
# 第 2 步:会议结束后,查询录制(拿到 minute_token
lark-cli vc +recording --meeting-ids <meeting.id>
# 第 3 步:查询会议纪要(总结 / 待办 / 章节 / 逐字稿)
lark-cli vc +notes --meeting-ids <meeting.id>
```
后续按 `lark-vc` 的产物决策处理:根据 `note_display_type``note_id``minute_token` 和用户意图选择纪要正文、逐字稿或妙记。
## 常见错误与排查
| 错误现象 | 根本原因 | 解决方案 |
@@ -118,20 +114,18 @@ 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会议未禁止当前身份加入如限制外部、限制应用机器人、仅特定成员可入会);确认后重试 |
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |
| `HTTP 403: no permission` / `121003` | 入会前置条件不满足,通常不是单纯 scope 问题 | 依次确认1会议允许智能体加入2会议号正确3如有密码已正确传入 `--password`4会议已开始5等候室 / 入会审批已放行6会议未禁止当前身份加入如限制外部、限制 bot、仅特定成员可入会);确认后重试 |
| 入会被拒绝 | 等候室 / 入会审批 / 限制外部入会 | 联系主持人放行或调整会议设置 |
## 提示
- 仅在 Agent 需要**真实加入**会议(例如参会机器人、会中助手)时使用;只拉取会议数据不需要入会。
- 入会会让机器人立即出现在参会列表;若用户要求退出 / 离开 / 结束参会,直接使用 `+meeting-leave --as bot --meeting-id <meeting.id>`。参数格式不确定时可选 `--dry-run` 预览,但不是必经步骤。
- 入会会让机器人立即出现在参会列表;若用户要求退出 / 离开 / 结束参会,直接 `+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-list-active](lark-vc-agent-meeting-list-active.md) — 发现当前可读事件的进行中会议 ID
- [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

View File

@@ -1,6 +1,8 @@
# 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`)。
@@ -9,13 +11,13 @@
```bash
# 通过 meeting_id 离会
lark-cli vc +meeting-leave --as bot --meeting-id 69xxxxxxxxxxxxx28
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28
# 输出格式
lark-cli vc +meeting-leave --as bot --meeting-id 69xxxxxxxxxxxxx28 --format json
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --format json
# 预览 API 调用(不实际离会)
lark-cli vc +meeting-leave --as bot --meeting-id 69xxxxxxxxxxxxx28 --dry-run
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --dry-run
```
## 参数
@@ -23,21 +25,22 @@ lark-cli vc +meeting-leave --as bot --meeting-id 69xxxxxxxxxxxxx28 --dry-run
| 参数 | 必填 | 说明 |
|------|------|------|
| `--meeting-id <id>` | 是 | 会议 ID**不是 9 位会议号** |
| `--dry-run` | 否 | 预览 API 调用不实际离会meeting_id 或身份不确定时先用它确认请求 |
| `--format <fmt>` | 否 | 输出格式json (默认) / pretty / table / ndjson / csv |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 核心约束
### 1. 入参是 meeting_id不是会议号
`--meeting-id` 必须是会议的长数字 ID通常由 `+meeting-join --as bot` 返回体中的 `meeting.id` 提供,也可从应用身份 `+meeting-list-active --as bot --user-id <user_open_id>` 返回体中的 `meeting_id` 获取。**传 9 位会议号会失败**。
`--meeting-id` 必须是会议的长数字 ID通常由 `+meeting-join` 返回体中的 `meeting.id` 提供,也可从 `+search` 结果中的 `id` 字段获取。**传 9 位会议号会失败**。
### 2. 优先使用 bot 身份
### 2. 仅支持 user 身份
这是应用机器人离会能力,使用与入会或 active meeting 发现相同的 `--as bot`。只能让当前身份自己离会,无法强制移出其他参会人。
该命令仅支持 `user` 身份。只能让当前身份自己离会,无法强制移出其他参会人。
### 3. 当前身份必须在会议中
应用机器人必须已经在该会议中,否则接口会报错。如果 `meeting_id` 来自 `+meeting-list-active`,必须确认这是应用身份发现到的会议
必须先通过 `+meeting-join` 或其他方式在该会议中,否则接口会报错
### 4. 离会立即生效,对其他参会人可见
@@ -52,7 +55,7 @@ lark-cli vc +meeting-leave --as bot --meeting-id 69xxxxxxxxxxxxx28 --dry-run
| 输入参数 | 获取方式 |
|---------|---------|
| `meeting-id` | `+meeting-join --as bot` 返回的 `meeting.id`;或应用身份 `+meeting-list-active --as bot --user-id <user_open_id>` 返回的 `meeting_id` |
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+search` 结果中的 `id` 字段 |
## Agent 组合场景
@@ -60,13 +63,13 @@ lark-cli vc +meeting-leave --as bot --meeting-id 69xxxxxxxxxxxxx28 --dry-run
```bash
# 第 1 步:加入会议,记录 meeting.id
lark-cli vc +meeting-join --as bot --meeting-number 123456789
lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:在会中处理用户请求(如监听发言、记录信息等)
# ...
# 第 3 步:仅在用户明确要求退出 / 离开 / 结束参会时,使用上一步记录的 meeting.id 离会
lark-cli vc +meeting-leave --as bot --meeting-id <meeting.id>
lark-cli vc +meeting-leave --meeting-id <meeting.id>
```
### 场景 2会后补拉产物不需要离会
@@ -74,7 +77,10 @@ lark-cli vc +meeting-leave --as bot --meeting-id <meeting.id>
如果用户只是要求会议结束后拉录制、纪要或逐字稿,不要先调用 `+meeting-leave`;直接跨到 `lark-vc` 查询会后产物。
```bash
# 第 1 步:会议结束后进入 lark-vc 获取会议产物信息
# 第 1 步:会议结束后查询录制
lark-cli vc +recording --meeting-ids <meeting.id>
# 第 2 步:查询会议纪要
lark-cli vc +notes --meeting-ids <meeting.id>
```
@@ -82,20 +88,19 @@ lark-cli vc +notes --meeting-ids <meeting.id>
| 错误现象 | 根本原因 | 解决方案 |
|---------|---------|---------|
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入从 `+meeting-join --as bot` 得到的 `meeting.id`,或应用身份 `+meeting-list-active` 返回的 `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-leave` 优先使用 `+meeting-join --as bot` 返回的 `meeting.id`,但不是每次 join 后都必须调用 leave。
- `meeting_id` 如果来自 `+meeting-list-active`,必须来自应用身份,并确认应用机器人就在该会议中。不要用 9 位会议号。
- `+meeting-leave` 依赖 `+meeting-join` 返回的 `meeting.id`,但不是每次 join 后都必须调用 leave。
- `meeting_id` 优先使用 `+meeting-join` 返回的 `meeting.id`;如果来自 `+search`必须先确认当前身份就在该会议中。不要用 9 位会议号。
## 参考
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 对应的入会命令
- [lark-vc-agent-meeting-list-active](lark-vc-agent-meeting-list-active.md) — 发现当前可读事件的进行中会议 ID
- [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

View File

@@ -1,91 +0,0 @@
# vc +meeting-list-active
列出当前进行中的会议,用来发现 `+meeting-events` 需要的长数字 `meeting_id`
本 skill 对应 shortcut`lark-cli vc +meeting-list-active`(调用 `GET /open-apis/vc/v1/bots/user_active_meeting`)。
## 命令
```bash
# 查询当前登录用户正在参加的会议
lark-cli vc +meeting-list-active --as user --format json
# 查询指定用户当前参加、且应用机器人也在会中的会议
lark-cli vc +meeting-list-active --as bot --user-id ou_xxx --format json
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--user-id <id>` | 应用身份必填 | 目标用户 open_id格式为 `ou_...`。用户身份不传;应用身份直接透传给接口,不接受 internal user_id 或数字 ID |
## 身份语义
不要向用户暴露内部身份缩写;对用户只说“用户身份”或“应用身份”。
| 身份 | 命令 | 返回范围 | 后续事件读取 |
| ---- | ---- | -------- | ------------ |
| 用户身份 | `--as user` | 当前登录用户正在参加的会议 | 继续 `+meeting-events --as user` |
| 应用身份 | `--as bot --user-id <user_open_id>` | 目标用户正在参加、且应用机器人也在会中的会议 | 继续 `+meeting-events --as bot` |
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份。不要把用户身份拿到的 `meeting_id` 改用应用身份查,也不要把应用身份拿到的 `meeting_id` 改用用户身份查,除非用户明确要求切换场景。
应用身份返回空,不代表目标用户不在任何会议中,只能说明没有找到“目标用户在会中且应用机器人也在会中”的当前会。
常见流程:
```bash
# 方式 1先让应用机器人入会直接从 join 响应拿 meeting.id
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
# 方式 2应用机器人已经在会中时用应用身份发现 meeting_id
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
# 方式 3只回答当前登录用户所在会议发生了什么
lark-cli vc +meeting-list-active --as user --format json
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
```
## 多会议选择
- 如果返回多个会议,不要自动挑第一个。
- 向用户展示每个候选的 `meeting_title` / `meeting_no` / `meeting_id`,等待用户选择。
- 选择后继续使用发现该会议时的同一身份调用 `+meeting-events`
## 9 位会议号匹配
用户提供 9 位会议号但没有明确要求应用机器人入会时,把会议号当作 active meeting 的筛选条件,而不是写操作指令。
```bash
# 用户问“我当前这个会讲了什么”
lark-cli vc +meeting-list-active --as user --format json
# 用户问“让应用机器人所在/可见的这个会讲了什么”
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
```
匹配规则:
- 在返回会议中匹配 `meeting_no == <9位会议号>`
- 匹配到唯一会议:取该项的长数字 `meeting_id`,后续用同一身份调用 `+meeting-events`
- 匹配到多个会议:展示候选,让用户选择。
- 没有匹配:说明当前身份没有发现该会议号对应的 active meeting不要自动调用 `+meeting-join`,除非用户明确要求应用机器人入会。
## 常见错误与排查
| 错误现象 | 根本原因 | 解决方案 |
|---------|---------|---------|
| `--user-id is required when --as bot` | 应用身份未传目标用户 | 传入目标用户 open_id |
| 用户身份返回空列表 | 当前登录用户没有可见的进行中会议 | 确认用户是否在会中,或是否切错身份 |
| 用户身份不支持 | 当前接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先拿目标用户 open_id再执行 `+meeting-list-active --as bot --user-id <user_open_id>`;同时按应用身份权限配置检查应用权限、安装、数据范围和灰度 |
| 应用身份返回空列表 | 没有满足“目标用户在会中且应用机器人也在会中”的当前会 | 先让应用机器人入会,或确认 `user_id` 和会议状态 |
| `--user-id` 格式错误 | 传入了 internal user_id 或其他非 `ou_...` 值 | 改传目标用户 open_id |
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |
## 参考
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 让应用机器人真实入会并拿 `meeting.id`
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 使用 `meeting_id` 读取会中事件

View File

@@ -14,7 +14,7 @@
- TestDriveAddCommentDryRun_File: dry-run coverage for `drive +add-comment` on supported Drive file targets; pins the `metas.batch_query -> files/:token/new_comments` request chain, `file_type=file`, and the required placeholder `anchor.block_id`.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
@@ -29,7 +29,7 @@
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema` | dry-run only; no live export workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir` | dry-run only; no live export workflow yet |
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
| ✕ | drive +import | shortcut | | none | no import workflow yet |
| ✕ | drive +move | shortcut | | none | no move workflow yet |

View File

@@ -99,44 +99,3 @@ func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
t.Fatalf("output_dir=%q, want ./md-exports\nstdout:\n%s", got, out)
}
}
func TestDriveExportDryRun_BitableBaseOnlySchema(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+export",
"--token", "bitableDryRunExport",
"--doc-type", "bitable",
"--file-extension", "base",
"--only-schema",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.token").String(); got != "bitableDryRunExport" {
t.Fatalf("body.token=%q, want bitableDryRunExport\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.type").String(); got != "bitable" {
t.Fatalf("body.type=%q, want bitable\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "base" {
t.Fatalf("body.file_extension=%q, want base\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.only_schema").Bool(); !got {
t.Fatalf("body.only_schema=%v, want true\nstdout:\n%s", got, out)
}
}

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveImportDryRunFolderTokenWikiProbe(t *testing.T) {
setDriveDryRunConfigEnv(t)
workDir := t.TempDir()
if err := os.WriteFile(workDir+"/notes.md", []byte("# dry run\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+import",
"--file", "notes.md",
"--type", "docx",
"--folder-token", "fldcnImportDryRunTarget",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("api.0.method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url = %q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("api.0.params.token = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("api.1.url = %q, want upload_all\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.2.body.point.mount_key").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("api.2.body.point.mount_key = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
}
}