Compare commits

...

7 Commits

Author SHA1 Message Date
zhanghuanxu
a84fe91fa1 feat: support PDF imports as slides 2026-06-10 14:13:02 +08:00
fangshuyu-768
99ceb2279c feat(markdown): harden create upload failures (#1325)
* feat(markdown): harden create upload failures

* test(markdown): address AI review follow-ups
2026-06-08 18:17:35 +08:00
Emrys1105
ec2ffebf47 fix: keep bounded event consume runs alive after stdin EOF (#1285) 2026-06-08 18:09:21 +08:00
hugang-lark
ee5113f9d0 fix: optimize calendar,vc,minutes skill (#1269) 2026-06-08 17:36:05 +08:00
liangshuo-1
7cce7468d6 docs(approval): restructure skill with intent table and scope boundaries (#1307)
* docs(approval): restructure skill with intent table and scope boundaries

Rewrite the description for intent-based routing (situation framing
instead of method enumeration) and add the lark-task disambiguation.
Replace the bare method list with an intent-to-command table including
topic and add_sign_type enums, document the query-to-operate workflow
chain with a runnable example, and add an out-of-scope section routing
definition creation to the Feishu client/admin console.

Bump version to 1.1.0.

Change-Id: I33b7b13b7855d67f40954701a09b115e3c91176c

* docs(approval): strengthen description coverage of edge actions

Restore the "all processing operations" phrasing so edge actions like
remind route to this skill; weak-model routing evals regressed on the
narrower "query and process" wording (2 misses in 4 runs vs 0 after
the fix).

Change-Id: Ica1928dacf879b6c7a46dfda37e35b1be9391432

* docs(approval): drop misleading 已发起 from tasks query row

tasks query 查的是本人作为审批人的任务;已发起(本人发起的实例)应走
instances initiated,该路径已在下方表行列出。移除 tasks query 的「已发起」
标签与 topic=3 枚举,避免 agent 误用 tasks query topic=3 查已发起。
2026-06-08 17:32:10 +08:00
fangshuyu-768
281cdbd37c feat(drive): harden inspect shortcut failures (#1324) 2026-06-08 17:09:53 +08:00
ViperCai
add079ea1c docs(lark-slides): tighten routing/boundary and reconcile in-slide whiteboard (#1169)
Land the high-value, low-risk items from the skill-quality audit; SKILL.md only.

- description: drop the '接口通过 XML 协议通信' impl detail; append a 不负责
  out-of-scope clause so 'make a deck' / 'draw a diagram' stop mis-routing.
- replace the 权限速查 scope table with a ## 不在本 skill 范围 routing table
  (doc / whiteboard / drive / sheets / base).
- reconcile the whiteboard boundary with the in-slide <whiteboard> element
  (added on main, #1029): lark-whiteboard owns only standalone whiteboard
  objects in cloud docs; flow/architecture diagrams drawn inside a slide stay
  in this skill via <whiteboard>. Clarified in description and out-of-scope note.
- defer auth / permissions / global params to lark-shared as single source.
- move native-API resource hint into prose; reword schema reminder; move the
  'schema is source of truth' note next to 核心规则.

Deliberately not adopted: moving Design Ideas out of the body, relocating the
wiki-token section, dropping the native-API schema guardrail, and the bulk
lark-slides- reference rename.
2026-06-08 16:37:09 +08:00
22 changed files with 1260 additions and 392 deletions

View File

@@ -64,8 +64,8 @@ Use 'event schema <EventKey>' for parameter details.`,
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
@@ -184,8 +184,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
errOut = io.Discard
}
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
if !f.IOStreams.IsTerminal {
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
// Bounded runs already have --max-events/--timeout as their lifecycle control.
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
watchStdinEOF(os.Stdin, cancel, errOut)
}
@@ -370,3 +371,8 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
cancel()
}()
}
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
return !isTerminal && maxEvents <= 0 && timeout <= 0
}

View File

@@ -61,3 +61,70 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
}
}
func TestShouldWatchStdinEOF(t *testing.T) {
tests := []struct {
name string
isTerminal bool
maxEvents int
timeout time.Duration
want bool
}{
{
name: "terminal",
isTerminal: true,
want: false,
},
{
name: "non terminal unbounded",
want: true,
},
{
name: "non terminal negative max events is unbounded",
maxEvents: -1,
want: true,
},
{
name: "non terminal negative timeout is unbounded",
timeout: -1 * time.Second,
want: true,
},
{
name: "non terminal max events bounded",
maxEvents: 1,
want: false,
},
{
name: "non terminal timeout bounded",
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal both bounds positive",
maxEvents: 1,
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal bounded max events with negative timeout",
maxEvents: 1,
timeout: -1 * time.Second,
want: false,
},
{
name: "non terminal bounded timeout with negative max events",
maxEvents: -1,
timeout: 10 * time.Minute,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
if got != tt.want {
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -130,7 +130,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
if !opts.Quiet {
fmt.Fprintln(errOut, listeningText(opts))
if !opts.IsTTY {
fmt.Fprintln(errOut, stopHintText())
fmt.Fprintln(errOut, stopHintText(opts))
}
}
@@ -213,7 +213,11 @@ func exitReason(ctx context.Context, emitted int64, opts Options) string {
return "signal"
}
func stopHintText() string {
func stopHintText(opts Options) string {
if opts.MaxEvents > 0 || opts.Timeout > 0 {
return "[event] to stop gracefully: send SIGTERM (kill <pid>). " +
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
}
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
}

View File

@@ -50,12 +50,32 @@ func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
}
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
func TestStopHintText_Content(t *testing.T) {
got := stopHintText()
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
func TestStopHintText_Unbounded(t *testing.T) {
got := stopHintText(Options{})
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
for _, s := range mustContain {
if !bytes.Contains([]byte(got), []byte(s)) {
t.Errorf("stopHintText missing %q; got %q", s, got)
t.Errorf("stopHintText(unbounded) missing %q; got %q", s, got)
}
}
}
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
func TestStopHintText_Bounded(t *testing.T) {
cases := []Options{
{MaxEvents: 1},
{Timeout: 30 * time.Second},
}
for _, opts := range cases {
got := stopHintText(opts)
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
for _, s := range mustContain {
if !bytes.Contains([]byte(got), []byte(s)) {
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got)
}
}
if bytes.Contains([]byte(got), []byte("close stdin")) {
t.Errorf("stopHintText(bounded) must not contain \"close stdin\"; got %q", got)
}
}
}

View File

@@ -23,7 +23,7 @@ func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool
body["with_url"] = true
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,

View File

@@ -10,6 +10,7 @@ import (
"sync/atomic"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -103,6 +104,13 @@ func TestFetchDriveMetaTitle(t *testing.T) {
if err == nil {
t.Fatal("FetchDriveMetaTitle() expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Code != 99991668 {
t.Fatalf("code = %d, want 99991668", p.Code)
}
})
}

View File

@@ -27,7 +27,7 @@ var DriveImport = common.Shortcut{
},
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: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx, .pdf; large files auto use multipart upload; .base is capped at 20MB, .pptx/.pdf at 500MB)", Required: true},
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},

View File

@@ -45,6 +45,7 @@ var driveImportExtToDocTypes = map[string][]string{
"csv": {"sheet", "bitable"},
"base": {"bitable"},
"pptx": {"slides"},
"pdf": {"slides"},
}
// driveImportSpec contains the user-facing import inputs after normalization.
@@ -153,7 +154,7 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
case "docx", "doc":
return driveImport600MBFileSizeLimit, true
case "pptx":
case "pptx", "pdf":
return driveImport500MBFileSizeLimit, true
case "txt", "md", "mark", "markdown", "html", "xls", "base":
return driveImport20MBFileSizeLimit, true
@@ -199,7 +200,7 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
func validateDriveImportSpec(spec driveImportSpec) error {
ext := spec.FileExtension()
if ext == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx, .pdf)").WithParam("--file")
}
switch spec.DocType {
@@ -210,7 +211,7 @@ func validateDriveImportSpec(spec driveImportSpec) error {
supportedTypes, ok := driveImportExtToDocTypes[ext]
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx, pdf", ext).WithParam("--file")
}
typeAllowed := false
@@ -231,8 +232,8 @@ func validateDriveImportSpec(spec driveImportSpec) error {
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
case "base":
hint = fmt.Sprintf(".base files can only be imported as 'bitable', not '%s'", spec.DocType)
case "pptx":
hint = fmt.Sprintf(".pptx files can only be imported as 'slides', not '%s'", spec.DocType)
case "pptx", "pdf":
hint = fmt.Sprintf(".%s files can only be imported as 'slides', not '%s'", ext, spec.DocType)
default:
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
}

View File

@@ -39,6 +39,10 @@ func TestValidateDriveImportSpec(t *testing.T) {
name: "pptx slides ok",
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "slides"},
},
{
name: "pdf slides ok",
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "slides"},
},
{
name: "base non bitable rejected",
spec: driveImportSpec{FilePath: "./snapshot.base", DocType: "sheet"},
@@ -49,6 +53,11 @@ func TestValidateDriveImportSpec(t *testing.T) {
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "docx"},
wantErr: ".pptx files can only be imported as 'slides'",
},
{
name: "pdf non slides rejected",
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "docx"},
wantErr: ".pdf files can only be imported as 'slides'",
},
{
name: "unknown extension rejected",
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
@@ -136,6 +145,19 @@ func TestValidateDriveImportFileSize(t *testing.T) {
docType: "slides",
fileSize: driveImport500MBFileSizeLimit,
},
{
name: "pdf exceeds 500mb limit",
filePath: "./deck.pdf",
docType: "slides",
fileSize: driveImport500MBFileSizeLimit + 1,
wantText: "exceeds 500.0 MB import limit for .pdf",
},
{
name: "pdf within 500mb limit",
filePath: "./deck.pdf",
docType: "slides",
fileSize: driveImport500MBFileSizeLimit,
},
{
name: "base exceeds 20mb limit",
filePath: "./snapshot.base",

View File

@@ -8,11 +8,19 @@ import (
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveInspectRateLimitRetries = 2
driveInspectRetryInitialBackoff = 200 * time.Millisecond
)
var driveInspectAfter = time.After
var DriveInspect = common.Shortcut{
Service: "drive",
Command: "+inspect",
@@ -35,32 +43,15 @@ var DriveInspect = common.Shortcut{
},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("url"))
if raw == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
}
_, ok := common.ParseResourceURL(raw)
if !ok {
// Not a recognized URL pattern.
if strings.Contains(raw, "://") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
}
// Bare token: --type is required.
if strings.TrimSpace(runtime.Str("type")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
}
if _, err := driveInspectResolveRef(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
raw := strings.TrimSpace(runtime.Str("url"))
ref, ok := common.ParseResourceURL(raw)
if !ok {
ref = common.ResourceRef{
Type: strings.TrimSpace(runtime.Str("type")),
Token: raw,
}
ref, err := driveInspectResolveRef(runtime)
if err != nil {
return common.NewDryRunAPI()
}
dry := common.NewDryRunAPI()
@@ -91,15 +82,9 @@ var DriveInspect = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("url"))
// Step 1: Parse URL to extract {type, token}.
ref, ok := common.ParseResourceURL(raw)
if !ok {
// Bare token: use --type.
ref = common.ResourceRef{
Type: strings.TrimSpace(runtime.Str("type")),
Token: raw,
}
ref, err := driveInspectResolveRef(runtime)
if err != nil {
return err
}
inputURL := raw
@@ -111,14 +96,19 @@ var DriveInspect = common.Shortcut{
// Step 2: If type is "wiki", unwrap via get_node API.
if docType == "wiki" {
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docToken},
nil,
data, err := driveInspectCallWithRetry(
ctx,
func() (map[string]interface{}, error) {
return runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docToken},
nil,
)
},
)
if err != nil {
return err
return driveInspectAnnotateError("resolve_wiki", err)
}
node := common.GetMap(data, "node")
@@ -145,9 +135,9 @@ var DriveInspect = common.Shortcut{
}
// Step 3: Call batch_query to verify and get title.
title, err := common.FetchDriveMetaTitle(runtime, docToken, docType)
title, err := driveInspectFetchMetaTitle(ctx, runtime, docToken, docType)
if err != nil {
return err
return driveInspectAnnotateError("query_meta", err)
}
// Step 4: Build the resolved URL.
@@ -181,3 +171,116 @@ var DriveInspect = common.Shortcut{
return nil
},
}
func driveInspectResolveRef(runtime *common.RuntimeContext) (common.ResourceRef, error) {
raw := strings.TrimSpace(runtime.Str("url"))
if raw == "" {
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
}
inputType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
ref, ok := common.ParseResourceURL(raw)
if ok {
if inputType != "" && inputType != ref.Type {
return common.ResourceRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
ref.Type,
).WithParam("--type")
}
return ref, nil
}
if strings.Contains(raw, "://") {
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
}
if strings.ContainsAny(raw, "/?#") {
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments and pass only the raw token with --type", raw).WithParam("--url")
}
if inputType == "" {
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
}
return common.ResourceRef{Type: inputType, Token: raw}, nil
}
func driveInspectFetchMetaTitle(ctx context.Context, runtime *common.RuntimeContext, token, docType string) (string, error) {
var title string
_, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
got, callErr := common.FetchDriveMeta(runtime, token, docType, false)
if callErr != nil {
return nil, callErr
}
title = got.Title
return map[string]interface{}{"title": got.Title}, nil
})
if err != nil {
return "", err
}
return title, nil
}
func driveInspectCallWithRetry(ctx context.Context, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
var lastErr error
for attempt := 0; attempt <= driveInspectRateLimitRetries; attempt++ {
data, err := call()
if err == nil {
return data, nil
}
lastErr = err
if !driveInspectShouldRetry(err) || attempt == driveInspectRateLimitRetries {
return nil, err
}
backoff := driveInspectRetryInitialBackoff * time.Duration(1<<attempt)
if waitErr := driveInspectWait(ctx, backoff); waitErr != nil {
return nil, waitErr
}
}
return nil, lastErr
}
func driveInspectShouldRetry(err error) bool {
problem, ok := errs.ProblemOf(err)
if !ok || problem == nil {
return false
}
return problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400 || problem.Retryable
}
func driveInspectWait(ctx context.Context, d time.Duration) error {
if d <= 0 {
return nil
}
select {
case <-ctx.Done():
return errs.WrapInternal(ctx.Err())
case <-driveInspectAfter(d):
return nil
}
}
func driveInspectAnnotateError(stage string, err error) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem == nil {
return err
}
label := map[string]string{
"resolve_wiki": "resolve wiki node",
"query_meta": "query document metadata",
}[stage]
if label == "" {
label = stage
}
problem.Message = fmt.Sprintf("%s failed: %s", label, problem.Message)
if strings.TrimSpace(problem.Hint) == "" {
switch stage {
case "resolve_wiki":
problem.Hint = "check that the wiki URL/token is valid and that the current identity can read the wiki node"
case "query_meta":
problem.Hint = "check that the resolved document still exists and that the current identity can read its metadata"
}
} else if !strings.Contains(problem.Hint, label) {
problem.Hint = label + ": " + problem.Hint
}
return err
}

View File

@@ -6,10 +6,13 @@ package drive
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -83,6 +86,34 @@ func TestDriveInspectValidate_BareTokenWithType(t *testing.T) {
}
}
func TestDriveInspectValidate_URLTypeConflict(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnBareToken")
_ = cmd.Flags().Set("type", "sheet")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected error for conflicting --type, got nil")
}
}
func TestDriveInspectValidate_BareTokenWithPathFragment(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "doxcnBareToken/extra")
_ = cmd.Flags().Set("type", "docx")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected error for bare token with path fragment, got nil")
}
}
func TestDriveInspectValidate_ValidDocxURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
@@ -540,6 +571,76 @@ func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
if err == nil {
t.Fatal("expected error for batch_query failure, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if !strings.Contains(p.Message, "query document metadata failed") {
t.Fatalf("message = %q, want query document metadata prefix", p.Message)
}
}
func TestDriveInspectExecute_RetriesRateLimitOnWikiResolve(t *testing.T) {
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 99991400,
"msg": "request trigger frequency limit",
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "doxcnUnwrapped",
"space_id": "space123",
"node_token": "wikcnNodeToken",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "doxcnUnwrapped", "doc_type": "docx", "title": "Wiki Doc"},
},
},
},
})
origAfter := driveInspectAfter
driveInspectAfter = func(time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
}
defer func() { driveInspectAfter = origAfter }()
err := mountAndRunDrive(t, DriveInspect, []string{
"+inspect",
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error after retry: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["token"] != "doxcnUnwrapped" {
t.Fatalf("token = %v, want doxcnUnwrapped", data["token"])
}
}
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {

View File

@@ -13,10 +13,12 @@ import (
"path"
"path/filepath"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -28,8 +30,17 @@ const markdownEmptyContentError = "empty markdown content is not supported; cann
const (
markdownUploadParentTypeExplorer = "explorer"
markdownUploadParentTypeWiki = "wiki"
markdownUploadAllAction = "upload markdown file failed"
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
markdownUploadFinishAction = "finalize markdown multipart upload failed"
markdownFetchNameAction = "fetch existing markdown file name failed"
)
var markdownUploadRetryBackoffs = []time.Duration{
200 * time.Millisecond,
500 * time.Millisecond,
}
type markdownUploadSpec struct {
FileToken string
FileName string
@@ -387,58 +398,68 @@ func uploadMarkdownContent(runtime *common.RuntimeContext, spec markdownUploadSp
fileName := finalMarkdownFileName(spec)
fileSize := int64(len(payload))
if fileSize > markdownSinglePartSizeLimit {
return uploadMarkdownFileMultipart(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
return uploadMarkdownFileMultipart(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(payload)), nil
})
}
return uploadMarkdownFileAll(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
return uploadMarkdownFileAll(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(payload)), nil
})
}
func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUploadSpec, fileSize int64) (markdownUploadResult, error) {
fileName := finalMarkdownFileName(spec)
f, err := runtime.FileIO().Open(spec.FilePath)
if err != nil {
return markdownUploadResult{}, common.WrapInputStatError(err)
}
defer f.Close()
if fileSize > markdownSinglePartSizeLimit {
return uploadMarkdownFileMultipart(runtime, spec, f, fileName, fileSize)
return uploadMarkdownFileMultipart(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
return runtime.FileIO().Open(spec.FilePath)
})
}
return uploadMarkdownFileAll(runtime, spec, f, fileName, fileSize)
return uploadMarkdownFileAll(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
return runtime.FileIO().Open(spec.FilePath)
})
}
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileName string, fileSize int64, openReader func() (io.ReadCloser, error)) (markdownUploadResult, error) {
target := spec.Target()
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", target.ParentType)
fd.AddField("parent_node", target.ParentNode)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
if spec.FileToken != "" {
fd.AddField("file_token", spec.FileToken)
}
fd.AddFile("file", fileReader)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return markdownUploadResult{}, err
return withMarkdownUploadRetryResult(runtime, markdownUploadAllAction, func() (markdownUploadResult, error) {
fileReader, err := openReader()
if err != nil {
return markdownUploadResult{}, common.WrapInputStatErrorTyped(err)
}
return markdownUploadResult{}, output.ErrNetwork("upload failed: %v", err)
}
defer fileReader.Close()
data, err := common.ParseDriveMediaUploadResponse(apiResp, "upload failed")
if err != nil {
return markdownUploadResult{}, err
}
return parseMarkdownUploadResult(data, spec.FileToken != "")
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", target.ParentType)
fd.AddField("parent_node", target.ParentNode)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
if spec.FileToken != "" {
fd.AddField("file_token", spec.FileToken)
}
fd.AddFile("file", fileReader)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return markdownUploadResult{}, markdownUploadProblem(client.WrapDoAPIError(err), markdownUploadAllAction)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadAllAction)
}
result, err := parseMarkdownUploadResult(data, spec.FileToken != "")
if err != nil {
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadAllAction)
}
return result, nil
})
}
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileName string, fileSize int64, openReader func() (io.ReadCloser, error)) (markdownUploadResult, error) {
target := spec.Target()
prepareBody := map[string]interface{}{
"file_name": fileName,
@@ -450,31 +471,53 @@ func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUp
prepareBody["file_token"] = spec.FileToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
prepareResult, err := withMarkdownUploadRetryData(runtime, markdownUploadPrepareAction, func() (map[string]interface{}, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return nil, markdownUploadProblem(err, markdownUploadPrepareAction)
}
return data, nil
})
if err != nil {
return markdownUploadResult{}, err
}
session, err := parseMarkdownMultipartSession(prepareResult)
if err != nil {
return markdownUploadResult{}, err
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadPrepareAction)
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, common.FormatSize(session.BlockSize))
fileReader, err := openReader()
if err != nil {
return markdownUploadResult{}, common.WrapInputStatErrorTyped(err)
}
defer fileReader.Close()
if err := uploadMarkdownMultipartParts(runtime, fileReader, fileSize, session); err != nil {
return markdownUploadResult{}, err
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
"upload_id": session.UploadID,
"block_num": session.BlockNum,
finishResult, err := withMarkdownUploadRetryData(runtime, markdownUploadFinishAction, func() (map[string]interface{}, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
"upload_id": session.UploadID,
"block_num": session.BlockNum,
})
if err != nil {
return nil, markdownUploadProblem(err, markdownUploadFinishAction)
}
return data, nil
})
if err != nil {
return markdownUploadResult{}, err
}
return parseMarkdownUploadResult(finishResult, spec.FileToken != "")
result, err := parseMarkdownUploadResult(finishResult, spec.FileToken != "")
if err != nil {
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadFinishAction)
}
return result, nil
}
func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipartSession, error) {
@@ -484,7 +527,7 @@ func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipa
BlockNum: int(common.GetFloat(data, "block_num")),
}
if session.UploadID == "" || session.BlockSize <= 0 || session.BlockNum <= 0 {
return markdownMultipartSession{}, output.Errorf(output.ExitAPI, "api_error",
return markdownMultipartSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse,
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
session.UploadID, session.BlockSize, session.BlockNum)
}
@@ -494,9 +537,8 @@ func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipa
func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.Reader, payloadSize int64, session markdownMultipartSession) error {
expectedBlocks := int((payloadSize + session.BlockSize - 1) / session.BlockSize)
if session.BlockNum != expectedBlocks {
return output.Errorf(
output.ExitAPI,
"api_error",
return errs.NewInternalError(
errs.SubtypeInvalidResponse,
"upload_prepare returned inconsistent chunk plan: block_size=%d, block_num=%d, expected_block_num=%d, payload_size=%d",
session.BlockSize,
session.BlockNum,
@@ -507,7 +549,7 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
maxInt := int64(^uint(0) >> 1)
if session.BlockSize > maxInt {
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
buffer := make([]byte, int(session.BlockSize))
@@ -528,22 +570,27 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
fd.AddField("upload_id", session.UploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", n))
fd.AddFile("file", bytes.NewReader(buffer[:n]))
action := fmt.Sprintf("upload markdown file part %d/%d failed", seq+1, session.BlockNum)
if err := withMarkdownUploadRetryVoid(runtime, action, func() error {
fd := larkcore.NewFormdata()
fd.AddField("upload_id", session.UploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", n))
fd.AddFile("file", bytes.NewReader(buffer[:n]))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return markdownUploadProblem(client.WrapDoAPIError(err), action)
}
return output.ErrNetwork("upload part %d/%d failed: %v", seq+1, session.BlockNum, err)
}
if _, err := common.ParseDriveMediaUploadResponse(apiResp, fmt.Sprintf("upload part %d/%d failed", seq+1, session.BlockNum)); err != nil {
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return markdownUploadProblem(err, action)
}
return nil
}); err != nil {
return err
}
@@ -551,9 +598,8 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
remaining -= int64(n)
}
if remaining != 0 {
return output.Errorf(
output.ExitAPI,
"api_error",
return errs.NewInternalError(
errs.SubtypeInvalidResponse,
"upload_prepare returned inconsistent chunk plan: %d bytes remain after %d blocks",
remaining,
session.BlockNum,
@@ -572,28 +618,34 @@ func parseMarkdownUploadResult(data map[string]interface{}, requireVersion bool)
result.Version = common.GetString(data, "data_version")
}
if result.FileToken == "" {
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return markdownUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
}
if requireVersion && result.Version == "" {
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "overwrite failed: no version returned")
return markdownUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite failed: no version returned")
}
return result, nil
}
func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (string, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": fileToken,
"doc_type": "file",
data, err := withMarkdownUploadRetryData(runtime, markdownFetchNameAction, func() (map[string]interface{}, error) {
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": fileToken,
"doc_type": "file",
},
},
},
},
)
)
if err != nil {
return nil, markdownUploadProblem(err, markdownFetchNameAction)
}
return data, nil
})
if err != nil {
return "", err
}
@@ -606,6 +658,97 @@ func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (st
return common.GetString(meta, "title"), nil
}
func withMarkdownUploadRetryResult(runtime *common.RuntimeContext, action string, fn func() (markdownUploadResult, error)) (markdownUploadResult, error) {
var zero markdownUploadResult
for attempt := 0; ; attempt++ {
result, err := fn()
if err == nil {
return result, nil
}
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
return zero, markdownUploadRetryExhausted(err, action, attempt)
}
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
time.Sleep(markdownUploadRetryBackoffs[attempt])
}
}
func withMarkdownUploadRetryData(runtime *common.RuntimeContext, action string, fn func() (map[string]interface{}, error)) (map[string]interface{}, error) {
for attempt := 0; ; attempt++ {
result, err := fn()
if err == nil {
return result, nil
}
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
return nil, markdownUploadRetryExhausted(err, action, attempt)
}
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
time.Sleep(markdownUploadRetryBackoffs[attempt])
}
}
func withMarkdownUploadRetryVoid(runtime *common.RuntimeContext, action string, fn func() error) error {
for attempt := 0; ; attempt++ {
err := fn()
if err == nil {
return nil
}
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
return markdownUploadRetryExhausted(err, action, attempt)
}
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
time.Sleep(markdownUploadRetryBackoffs[attempt])
}
}
func markdownUploadShouldRetry(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok || p == nil {
return false
}
return p.Retryable || p.Category == errs.CategoryNetwork
}
func markdownUploadRetryExhausted(err error, action string, retries int) error {
if retries <= 0 {
return err
}
return appendMarkdownProblemHint(err, fmt.Sprintf("%s remained retryable after %d attempts; retry later if the upstream service is throttling or temporarily unavailable", action, retries+1))
}
func markdownUploadProblem(err error, action string) error {
if p, ok := errs.ProblemOf(err); ok {
p.Message = action + ": " + p.Message
switch p.Code {
case 99991672, 99991679:
appendMarkdownProblemHint(err, "The current token or identity lacks the required document upload scope/capability. Grant the document upload scope or use a token with the appropriate permissions, then retry.")
case 10071:
appendMarkdownProblemHint(err, "The target document has reached its version limit. Clean up old versions or create a new file before retrying.")
case 90003087:
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
case 1061003, 1061044:
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
case 1061004, 1062501:
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
}
}
return err
}
func appendMarkdownProblemHint(err error, hint string) error {
if strings.TrimSpace(hint) == "" {
return err
}
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "\n" + hint
} else {
p.Hint = hint
}
}
return err
}
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))

View File

@@ -17,9 +17,11 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -603,6 +605,100 @@ func TestMarkdownCreateSuccessUploadAllToWikiReturnsMetaURL(t *testing.T) {
}
}
func TestMarkdownCreateUploadAllReturnsTypedScopeError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 99991672,
"msg": "Access denied. One of the following scopes is required: [drive:file:upload]",
"error": map[string]interface{}{
"log_id": "log-md-upload-scope",
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello\n",
}, f, stdout)
if err == nil {
t.Fatal("expected scope error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Code != 99991672 {
t.Fatalf("code = %d, want 99991672", p.Code)
}
if p.Subtype != errs.SubtypeAppScopeNotApplied {
t.Fatalf("subtype = %s, want %s", p.Subtype, errs.SubtypeAppScopeNotApplied)
}
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadAllAction+": ")
}
if !strings.Contains(p.Hint, "lacks the required document upload scope") {
t.Fatalf("hint = %q, want upload scope guidance", p.Hint)
}
}
func TestMarkdownCreateUploadAllRetriesRateLimit(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 99991400,
"msg": "request frequency limit exceeded",
"error": map[string]interface{}{
"log_id": "log-md-upload-ratelimit-1",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_retry_success",
"version": "1003",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "box_md_retry_success", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_retry_success"},
},
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello\n",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stderr.String(), "retrying (attempt 1/2)") {
t.Fatalf("stderr = %q, want retry log", stderr.String())
}
if !strings.Contains(stdout.String(), `"file_token": "box_md_retry_success"`) {
t.Fatalf("stdout missing retried upload token: %s", stdout.String())
}
}
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
@@ -1033,6 +1129,270 @@ func TestUploadMarkdownMultipartPartsRejectsOversizedBlockSize(t *testing.T) {
}
}
func TestWithMarkdownUploadRetryDataDoesNotRetryNonRetryable(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, markdownTestConfig())
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser)
attempts := 0
expected := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied").WithCode(1061004)
_, err := withMarkdownUploadRetryData(rt, markdownUploadAllAction, func() (map[string]interface{}, error) {
attempts++
return nil, expected
})
if err != expected {
t.Fatalf("err = %v, want original error", err)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want 1", attempts)
}
if stderr.String() != "" {
t.Fatalf("stderr = %q, want no retry log", stderr.String())
}
}
func TestWithMarkdownUploadRetryVoidExhaustedAppendsHint(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, markdownTestConfig())
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser)
orig := markdownUploadRetryBackoffs
markdownUploadRetryBackoffs = []time.Duration{0, 0}
t.Cleanup(func() { markdownUploadRetryBackoffs = orig })
attempts := 0
err := withMarkdownUploadRetryVoid(rt, markdownUploadFinishAction, func() error {
attempts++
return errs.NewAPIError(errs.SubtypeRateLimit, "too many requests").WithCode(99991400).WithRetryable()
})
if err == nil {
t.Fatal("expected retryable error")
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if !strings.Contains(p.Hint, "remained retryable after 3 attempts") {
t.Fatalf("hint = %q, want retry exhaustion guidance", p.Hint)
}
if strings.Count(stderr.String(), "retrying (attempt") != 2 {
t.Fatalf("stderr = %q, want 2 retry logs", stderr.String())
}
}
func TestMarkdownUploadShouldRetryBranches(t *testing.T) {
if markdownUploadShouldRetry(errors.New("plain")) {
t.Fatal("plain error should not be retryable")
}
if !markdownUploadShouldRetry(errs.NewAPIError(errs.SubtypeRateLimit, "slow down").WithRetryable()) {
t.Fatal("retryable API error should be retryable")
}
if !markdownUploadShouldRetry(errs.NewNetworkError(errs.SubtypeNetworkServer, "gateway").WithCode(502)) {
t.Fatal("network error should be retryable by category")
}
}
func TestMarkdownUploadRetryExhaustedZeroRetriesKeepsOriginal(t *testing.T) {
original := errs.NewAPIError(errs.SubtypeRateLimit, "slow down").WithRetryable()
got := markdownUploadRetryExhausted(original, markdownUploadAllAction, 0)
if got != original {
t.Fatalf("got = %v, want original error", got)
}
}
func TestMarkdownUploadProblemAppendsCodeSpecificHints(t *testing.T) {
tests := []struct {
name string
code int
want string
}{
{
name: "missing scope",
code: 99991672,
want: "lacks the required document upload scope",
},
{
name: "version limit",
code: 10071,
want: "reached its version limit",
},
{
name: "document capability",
code: 90003087,
want: "document capabilities enabled",
},
{
name: "target not found",
code: 1061044,
want: "target folder or wiki node still exists",
},
{
name: "no write access",
code: 1062501,
want: "has write access",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithCode(tt.code)
got := markdownUploadProblem(err, markdownUploadAllAction)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", got, got)
}
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
t.Fatalf("message = %q, want action prefix", p.Message)
}
if !strings.Contains(p.Hint, tt.want) {
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.want)
}
})
}
}
func TestUploadMarkdownFileAllMissingFileTokenGetsActionPrefix(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"version": "1001",
},
},
})
_, err := uploadMarkdownFileAll(
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
markdownUploadSpec{ContentSet: true},
"README.md",
int64(len("# hello\n")),
func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("# hello\n")), nil
},
)
if err == nil {
t.Fatal("expected parse error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadAllAction+": ")
}
}
func TestUploadMarkdownFileMultipartPrepareAndFinishParseErrorsGetActionPrefix(t *testing.T) {
t.Run("prepare", func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_num": 1,
},
},
})
_, err := uploadMarkdownFileMultipart(
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
markdownUploadSpec{ContentSet: true},
"README.md",
int64(len("# hello\n")),
func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("# hello\n")), nil
},
)
if err == nil {
t.Fatal("expected prepare parse error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if !strings.HasPrefix(p.Message, markdownUploadPrepareAction+": ") {
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadPrepareAction+": ")
}
})
t.Run("finish", func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(8),
"block_num": float64(1),
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"version": "1001",
},
},
})
_, err := uploadMarkdownFileMultipart(
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
markdownUploadSpec{ContentSet: true},
"README.md",
int64(len("# hello\n")),
func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("# hello\n")), nil
},
)
if err == nil {
t.Fatal("expected finish parse error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if !strings.HasPrefix(p.Message, markdownUploadFinishAction+": ") {
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadFinishAction+": ")
}
})
}
func TestAppendMarkdownProblemHintAppendsAndIgnoresBlank(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("first")
appendMarkdownProblemHint(err, "second")
appendMarkdownProblemHint(err, " ")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Hint != "first\nsecond" {
t.Fatalf("hint = %q, want newline-joined hints", p.Hint)
}
plain := errors.New("plain")
if got := appendMarkdownProblemHint(plain, "ignored"); got != plain {
t.Fatalf("plain error should pass through unchanged")
}
}
func TestMarkdownOverwriteUploadAllIncludesFileTokenAndVersion(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
@@ -1303,7 +1663,18 @@ func TestMarkdownOverwriteRejectsEmptyLocalFile(t *testing.T) {
}
func TestMarkdownOverwriteMetadataLookupFailure(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 1061044,
"msg": "parent node not exist",
"error": map[string]interface{}{
"log_id": "log-md-meta-notfound",
},
},
})
err := mountAndRunMarkdown(t, MarkdownOverwrite, []string{
"+overwrite",
@@ -1313,6 +1684,19 @@ func TestMarkdownOverwriteMetadataLookupFailure(t *testing.T) {
if err == nil {
t.Fatal("expected metadata lookup failure")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Code != 1061044 {
t.Fatalf("code = %d, want 1061044", p.Code)
}
if !strings.HasPrefix(p.Message, markdownFetchNameAction+": ") {
t.Fatalf("message = %q, want %q prefix", p.Message, markdownFetchNameAction+": ")
}
if !strings.Contains(p.Hint, "target folder or wiki node still exists") {
t.Fatalf("hint = %q, want target guidance", p.Hint)
}
}
func TestMarkdownOverwriteMissingFileReturnsReadError(t *testing.T) {

View File

@@ -1,56 +1,35 @@
---
name: lark-approval
version: 1.0.0
description: "飞书审批 API审批实例审批任务管理。"
version: 1.1.0
description: "飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例审批待办不是飞书任务(任务类待办走 lark-task不负责创建审批定义和发起新审批。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli approval --help"
---
# approval (v4)
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## API Resources
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
## 选哪个命令
| 想做什么 | 命令 |
|---|---|
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读|
| 看表单/进度/当前节点 | `instances get` |
| 同意/拒绝 | `tasks approve` / `tasks reject` |
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
| 催办 | `tasks remind` |
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
处理链:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作。
```bash
lark-cli schema approval.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli approval <resource> <method> [flags] # 调用 API
lark-cli approval tasks query --params '{"topic":"1"}' --as user
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### instances
- `get` — 获取单个审批实例详情
- `cancel` — 撤回审批实例
- `cc` — 抄送审批实例
- `initiated` — 查询用户的已发起列表
### tasks
- `remind` — 催办审批人
- `approve` — 同意审批任务
- `reject` — 拒绝审批任务
- `transfer` — 转交审批任务
- `query` — 查询用户的任务列表
- `add_sign` — 审批任务加签
- `rollback` — 退回审批任务
## 权限表
| 方法 | 所需 scope |
|------|-----------|
| `instances.get` | `approval:instance:read` |
| `instances.cancel` | `approval:instance:write` |
| `instances.cc` | `approval:instance:write` |
| `instances.initiated` | `approval:instance:read` |
| `tasks.remind` | `approval:instance:write` |
| `tasks.approve` | `approval:task:write` |
| `tasks.reject` | `approval:task:write` |
| `tasks.transfer` | `approval:task:write` |
| `tasks.query` | `approval:task:read` |
| `tasks.add_sign` | `approval:task:write` |
| `tasks.rollback` | `approval:task:write` |
## 不在本 skill 范围
创建审批定义/发起新审批(走飞书客户端或审批管理后台);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)

View File

@@ -1,7 +1,7 @@
---
name: lark-calendar
version: 1.0.0
description: "飞书日历calendar提供日历日程会议)的全面管理能力。核心场景包括:查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段、查询/搜索与预定会议室。注意:涉及【预约日程/会议】或【查询/预定会议室】时,必须先读取 references/lark-calendar-schedule-meeting.md 工作流!高频操作请优先使用 Shortcuts+agenda快速概览今日/近期行程)、+create创建日程并按需邀请参会人及预定会议室、+update更新既有日程字段或独立增删参会人/会议室)、+freebusy查询用户主日历的忙闲信息和rsvp的状态、+rsvp回复日程邀请"
description: "飞书日历:管理日历日程会议室。查看/搜索日程、创建/更新日程、管理参会人、查询忙闲和推荐时段、预定会议室。当用户需要查看日程安排、创建/修改会议、查询/预定会议室时使用。不负责:查询过去的视频会议记录(走 lark-vc、待办任务走 lark-task"
metadata:
requires:
bins: ["lark-cli"]
@@ -10,93 +10,88 @@ metadata:
# calendar (v4)
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**CRITICAL — 所有的 Shortcuts 在执行之前,务必先使用 Read 工具读取其对应的说明文档,禁止直接盲目调用命令。**
**CRITICAL — 凡涉及【预约日程/会议】或【查询/搜索会议室】,第一步 MUST 强制使用 Read 工具读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut**
**CRITICAL — 术语约束:用户日常表达中常说的“帮我约个日历”、“查一下今天的日历”等,其实际意图通常是针对 日程Event 的创建或查询,而非操作 日历Calendar 容器本身。请自动将口语化的“日历”意图映射为“日程”操作(如 `+create`, `+agenda`)。**
**CRITICAL — 会议与日程的意图路由:**
- **查询过去时间的会议**:如果用户明确查询过去时间的会议(如“昨天的会议”、“上周的会议”),**优先使用 [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) 搜索会议记录**。因为会议数据不仅包含从日程发起的视频会议,还包含即时会议,仅查询日程数据会导致结果不全。
- **查询日历/日程或未来时间的会议**:如果用户明确表达的是“日历”、“日程”,或者涉及**未来时间**的安排则属于本技能lark-calendar的业务域请继续使用本技能处理。
**CRITICAL — 任务类型分流:处理“预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间”时,必须先判断用户是在“新建日程”还是“编辑已有日程”。**
- **编辑已有日程的强信号**:用户明确提到某个已存在的日程锚点(如标题、时间段、`这个日程``这场会`)并表达修改动作(如“添加”“移除”“改到”“换会议室”“调整时间”)。这类请求默认走**编辑已有日程**,绝不能直接按新建处理。
- **编辑已有日程的前置步骤**一旦判定为编辑MUST 先定位目标日程或具体实例的 `event_id`再继续后续流程。若是重复性日程MUST 先定位到对应实例的 `event_id`
- **新建日程**:只有当用户表达的是“新约一个会/创建一个日程/安排一次会议”等新增意图,且没有指向某个既有日程的修改动作时,才进入新建流程。
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)认证、权限处理)。
**CRITICAL — 验证与同步延迟在涉及删除日程delete、修改日程patch或者涉及添加移除参与人/会议室之后如果需要进行二次查询验证操作结果MUST 等待至少 2 秒后再进行查询,以防止因数据同步延迟导致查不到最新数据。注意:不要向用户提及你等待了这 2 秒钟的事情。**
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut**
**CRITICAL — 重复性日程的实例操作:目前已经完全具备对重复性日程的某个具体实例进行操作的能力(例如:编辑某个实例、删除某个实例、为某个实例添加/删除参与人、为某个实例添加/移除会议室)。只要在对应的操作中传递对应实例的 `event_id` 即可。因此MUST 先定位到对应的那次实例的 `event_id`(可通过 `events search_event` 搜索日程,或 `+agenda` 查看对应时间范围的日程等相关查询获取),绝对禁止直接使用原重复性日程的 `event_id` 进行操作。**
## 身份
**时间与日期推断规范:**
为确保准确性,在涉及时间推断时,请严格遵循以下规则:
- **星期的定义**:周一是一周的第一天,周日是一周的最后一天。计算`下周一`等相对日期时,务必基于当前真实日期和星期基准进行推算,避免算错日期。
- **一天的范围**:当用户提到`明天``今天`等泛指某一天时,时间范围应默认覆盖整天时间范围。**切勿**自行缩减查询范围,以免遗漏晚上的时间安排。
- **历史时间约束**:不能预约已经完全过去的时间。唯一的例外情况是“跨越当前时间”的日程,即日程的开始时间在过去,但结束时间在未来。
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
## 核心场景
```bash
# BAD — bot 身份查用户日程,返回空列表
lark-cli calendar +agenda --as bot
### 1. 预约新日程/会议、编辑已有日程、查询/搜索可用会议室
**BLOCKING REQUIREMENT (阻塞性要求): 只要用户的意图包含“预约日程/会议”或“查询/搜索可用会议室”,你必须立即停止其他思考,优先使用 Read 工具完整读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)!未读取该文件前,绝对禁止执行任何日程创建或会议室查询操作。**
**CRITICAL: 必须严格按照上述文档中定义的工作流Workflow执行后续操作。处理该场景时默认做“智能助理”不要做“表单填写机”。能补全的默认值先补全只有在时间冲突、结果无法唯一确定、时间语义存在歧义时才主动追问。**
**CRITICAL: 执行顺序必须固定为:先判断任务类型(新建/编辑);若为编辑先定位目标日程 `event_id`;再补默认值或继承已定位日程的已知信息;再判断时间是否明确;最后进入“明确时间”或“模糊时间/无时间信息”分支。不要跳步。**
**CRITICAL: 明确时间且需要会议室时,先基于最终确定的时间块执行 `+room-find`,再按需执行 `+freebusy`;模糊时间或无时间信息时,先 `+suggestion`,如需会议室再批量 `+room-find`。如果是编辑已有日程且不改时间,只新增会议室,则必须基于已定位日程的原始时间执行 `+room-find`,且最终落地时默认保留已存在的会议室;只有用户明确表达“更换会议室”或“移除会议室”时,才删除原会议室。**
**CRITICAL: 当用户说“查会议室”“找会议室”“搜可用会议室”或“推荐常用会议室”时,默认是查会议室可用性,不是查会议室资源名录,更严禁拉取历史日程做统计分析。完整规则以 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) 为准。**
**BLOCKING REQUIREMENT: 即使用户的核心诉求是“查会议室”,只要【没有提供明确的起止时间】,绝对禁止直接调用 `+room-find`!必须先进入【无时间/模糊时间】分支,调用 `+suggestion` 拿到候选时间块后,再将时间块传给 `+room-find`。**
**BLOCKING REQUIREMENT: 只要面临时间方案或会议室方案的选择(如模糊时间、无时间或需要会议室),在最终执行创建新日程或更新既有日程之前,必须先向用户展示候选方案并等待用户明确确认。绝对禁止擅自替用户做决定。**
## 核心概念
- **日历Calendar**日程的容器。每个用户有一个主日历primary calendar也可以创建或订阅共享日历。
- **日程Event**日历中的单个日程包含起止时间、地点、标题、参与人等属性。支持单次日程和重复日程遵循RFC5545 iCalendar国际标准。
- ***全天日程All-day Event***: 只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
- **日程实例Instance**日程的具体时间实例本质是对日程的展开。普通日程和例外日程对应1个Instance重复性日程对应N个Instance。在按时间段查询时可通过实例视图将重复日程展开为独立的实例返回以便在时间线上准确展示和管理。
- **重复规则Rrule/Recurrence Rule**:定义重复性日程的重复规则,比如`FREQ=DAILY;UNTIL=20230307T155959Z;INTERVAL=14`表示每14天重复一次。
- **例外日程Exception**:重复性日程中与原重复性日程不一致的日程。
- **参会人Attendee**日程的参与者可以是用户、群、会议室资源、外部邮箱地址等。每个参与人有独立的RSVP状态。
- **响应状态RSVP**:参与人对日程邀请的回复状态(接受/拒绝/待定)。
- **忙闲时间FreeBusy**:查询用户在指定时间段的忙闲状态,用于会议时间协调。
- **会议室Room**“room”不是“房间”是“会议室”。请在理解和处理意图时将“room”和“房间”准确映射为“会议室”及其相关操作。
- **时间块Time Slot / Time Block**:指一个**具体且确定**的连续时间段(如 `14:00~15:00`)。在文档中,它与泛指的“时间范围/区间”(如“今天下午”、“下周”)有严格区别。在调用预定、查询可用会议室等确切操作时,必须基于确定的“时间块”而非模糊的“时间范围”。
## 资源关系
```
Calendar (日历)
└── Event (日程)
├── Attendee (参会人)
└── Reminder (提醒)
# GOOD — user 身份查日程
lark-cli calendar +agenda --as user
```
## Shortcuts(推荐优先使用)
Shortcut 是对常用操作的高级封装(`lark-cli calendar +<verb> [flags]`)。有 Shortcut 的操作优先使用。
## Shortcuts
| Shortcut | 说明 |
|----------|------|
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人ISO 8601 时间) |
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和rsvp的状态 |
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(**无明确时间时禁止直接调用,需先走 +suggestion** |
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion |
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
## 会议室相关规则
## 前置条件路由
- **会议室是日程的一种参与人resource attendee不能脱离日程单独存在或单独预定。**
- **凡是用户意图是“预定/查询/搜索可用会议室”时,都必须进入 `references/lark-calendar-schedule-meeting.md` 工作流处理。**
- `+room-find` 的时间输入必须是**确定时间块**,不能是时间区间搜索。
- **强制约束:如果用户仅要求“查询会议室”但未提供明确时间,必须先调用 `+suggestion` 获取可用时间块,然后再将时间块交给 `+room-find` 批量查询。严禁直接猜测时间并盲目调用 `+room-find`。**
- **编辑已有日程时,如果用户表达的是“添加会议室/再加一个会议室”,默认语义是增量添加,必须保留已有会议室;只有在用户明确表达“更换会议室”“把原会议室换掉”“移除会议室”时,才执行旧会议室删除。**
| 场景 | 前置要求 |
|------|----------|
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID |
| 删除/修改后验证 | 等待 2 秒再查询API 最终一致性),不要告知用户你等待了 |
| 调用任何 Shortcut | 先读其对应 reference 文档 |
## 核心概念
- **日程实例Instance**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程的 `event_id`
- **全天日程All-day Event**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
- **会议室Room**"room"不是"房间",是"会议室"。会议室是日程的一种参与人resource attendee不能脱离日程单独预定。
## 术语映射
用户日常说的"帮我约个日历""查一下今天的日历",实际意图是针对**日程Event**的创建或查询而非操作日历Calendar容器本身。自动将口语化的"日历"意图映射为"日程"操作。
## 意图路由
| 用户意图 | 路由到 |
|----------|--------|
| 查询过去的会议("昨天的会议""上周的会" | [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md)(会议数据含即时会议,仅查日程会遗漏) |
| 查询日历/日程或未来时间的会议 | 本 skill |
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
## 任务类型分流
处理"预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间"时,必须先判断新建 vs 编辑:
- **编辑已有日程的强信号**:用户提到已存在的日程锚点(标题、时间段、`这个日程``这场会`)并表达修改动作(添加、移除、改到、换会议室、调整时间)。默认走编辑流,绝不能按新建处理。
- **新建日程**:用户表达新增意图("新约一个会""创建一个日程""安排一次会议"),且没有指向既有日程的修改动作。
## 时间推断规范
- **星期的定义**:周一是一周的第一天,周日是最后一天。计算"下周一"等相对日期时,基于当前真实日期推算。
- **一天的范围**:用户提到"明天""今天"等泛指某天时,时间范围应覆盖整天,不要自行缩减。
- **历史时间约束**:不能预约已经完全过去的时间。唯一例外是"跨越当前时间"的日程(开始在过去、结束在未来)。
## 会议室规则
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
## API Resources
```bash
lark-cli schema calendar.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli calendar <resource> <method> [flags] # 调用 API
lark-cli calendar <resource> <method> [flags]
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### calendars
- `create` — 创建共享日历
@@ -120,35 +115,18 @@ lark-cli calendar <resource> <method> [flags] # 调用 API
- `get` — 获取日程
- `instance_view` — 查询日程视图
- `patch` — 更新日程
- `search_event` — 搜索日程(目前只会返回日程id、日程主题、日程时间的信息需要更多的日程详情需要`events get` 命令
- `search_event` — 搜索日程(仅返回 日程ID/主题/时间,详情需`events get`
- `share_info` — 获取日程分享链接
### freebusys
- `list` — 查询主日历日程忙闲信息
## 权限表
## 不在本 skill 范围
| 方法 | 所需 scope |
|------|-----------|
| `calendars.create` | `calendar:calendar:create` |
| `calendars.delete` | `calendar:calendar:delete` |
| `calendars.get` | `calendar:calendar:read` |
| `calendars.list` | `calendar:calendar:read` |
| `calendars.patch` | `calendar:calendar:update` |
| `calendars.primary` | `calendar:calendar:read` |
| `calendars.search` | `calendar:calendar:read` |
| `event.attendees.batch_delete` | `calendar:calendar.event:update` |
| `event.attendees.create` | `calendar:calendar.event:update` |
| `event.attendees.list` | `calendar:calendar.event:read` |
| `events.create` | `calendar:calendar.event:create` |
| `events.delete` | `calendar:calendar.event:delete` |
| `events.get` | `calendar:calendar.event:read` |
| `events.instance_view` | `calendar:calendar.event:read` |
| `events.patch` | `calendar:calendar.event:update` |
| `events.search_event` | `calendar:calendar.event:read` |
| `events.share_info` | `calendar:calendar.event:read` |
| `freebusys.list` | `calendar:calendar.free_busy:read` |
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
- 会议室物理设施管理 → 管理员后台
**注意(强制性):**
- 涉及日期(时间)字符串与时间戳的相互转换时,务必调用系统命令或脚本代码等外部工具进行处理,以确保转换的绝对准确。违者将导致严重的逻辑错误!

View File

@@ -2,7 +2,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
将本地文件(如 Word、TXT、Markdown、Excel、PPTX 等导入并转换为飞书在线云文档docx、sheet、bitable、slides。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`
将本地文件(如 Word、TXT、Markdown、Excel、PPTX、PDF导入并转换为飞书在线云文档docx、sheet、bitable、slides。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`
> [!IMPORTANT]
> 当用户说“把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable 文档”时,第一步必须使用 `drive +import --type bitable`。
@@ -40,8 +40,9 @@ lark-cli drive +import --file ./crm.xlsx --type bitable --name "客户台账"
# 导入 .base 快照为多维表格 / Base (bitable)(文件不能超过 20MB
lark-cli drive +import --file ./snapshot.base --type bitable --name "快照还原"
# 导入 PPTX 为飞书幻灯片 (slides)(文件不能超过 500MB
# 导入 PPTX / PDF 为飞书幻灯片 (slides)(文件不能超过 500MB
lark-cli drive +import --file ./deck.pptx --type slides --name "项目汇报"
lark-cli drive +import --file ./deck.pdf --type slides --name "项目汇报"
# 导入到指定文件夹,并指定导入后的文件名
lark-cli drive +import --file ./data.csv --type bitable --folder-token <FOLDER_TOKEN> --name "导入数据表"
@@ -89,6 +90,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
| `.csv` | `sheet`, `bitable` | CSV 数据文件 |
| `.base` | `bitable` | 多维表格快照文件 |
| `.pptx` | `slides` | Microsoft PowerPoint 演示文稿 |
| `.pdf` | `slides` | PDF 文档 |
> [!IMPORTANT]
> 用户口头说的 “Base” / “多维表格” / “bitable”在命令里统一对应 `--type bitable`。
@@ -98,7 +100,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
> - `.xlsx` / `.csv` 文件**只能**导入为 `sheet` 或 `bitable`
> - `.xls` 文件**只能**导入为 `sheet`
> - `.base` 文件**只能**导入为 `bitable`
> - `.pptx` 文件**只能**导入为 `slides`
> - `.pptx` / `.pdf` 文件**只能**导入为 `slides`
> - 例如:`.csv` 文件不能导入为 `docx``.md` 文件不能导入为 `sheet`
> [!IMPORTANT]
@@ -132,7 +134,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
| `.csv` | `bitable` | 100MB |
| `.xls` | `sheet` | 20MB |
| `.base` | `bitable` | 20MB |
| `.pptx` | `slides` | 500MB |
| `.pptx`, `.pdf` | `slides` | 500MB |
- 如果文件超出对应上限shortcut 会在真正上传前直接返回验证错误。
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart不代表所有格式都允许导入超过 20MB 的文件。

View File

@@ -69,7 +69,7 @@ wait
### stdin EOF = graceful exit
`event consume` treats stdin close as a shutdown signal (wired for AI subprocess callers). `< /dev/null` / `nohup` / systemd's default `StandardInput=null` will cause an immediate graceful exit (stderr `reason: signal`). To keep running:
`event consume` treats stdin close as a shutdown signal (wired for AI subprocess callers). **Bounded runs are exempt: when `--max-events` or `--timeout` is set (> 0), stdin EOF is ignored and the run exits only via its own bound, timeout, or SIGTERM.** For unbounded runs, `< /dev/null` / `nohup` / systemd's default `StandardInput=null` will cause an immediate graceful exit (stderr `reason: signal`). To keep an unbounded run alive:
- Feed stdin a source that never EOFs: `< <(tail -f /dev/null)`
- Or run bounded: `--max-events N` / `--timeout D`
@@ -82,7 +82,7 @@ On exit, the last stderr line is `[event] exited — received N event(s) in Xs (
|---|---|---|
| 0 | `reason: limit` | `--max-events` reached |
| 0 | `reason: timeout` | `--timeout` reached |
| 0 | `reason: signal` | Ctrl+C / SIGTERM / stdin EOF |
| 0 | `reason: signal` | Ctrl+C / SIGTERM / stdin EOF (stdin EOF applies to unbounded runs only) |
| non-0 | `Error: ...` (no `exited` line) | Startup / runtime failure (permissions, network, params, config) |
Orchestrators should treat `reason: limit/timeout/signal` (all exit 0) as "business completion" and non-zero as "failure".

View File

@@ -1,7 +1,7 @@
---
name: lark-minutes
version: 1.0.0
description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物总结、待办、章节5.上传音视频生成妙记,也支持将本地音视频文件转成纪要逐字稿、文字稿、撰写文字等产物6.更新妙记标题重命名妙记7.替换妙记逐字稿中的说话人。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要逐字稿(优先使用本 skill不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc"
metadata:
requires:
bins: ["lark-cli"]
@@ -18,10 +18,40 @@ metadata:
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
> 4. 了解会议总结、分析和信息提取的标准流程
## 身份
所有 minutes 命令默认使用 `--as user`
## Shortcuts
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-minutes-search.md) | 按关键词、所有者、参与者、时间范围搜索妙记 |
| [`+download`](references/lark-minutes-download.md) | 下载妙记音视频媒体文件 |
| [`+upload`](references/lark-minutes-upload.md) | 上传 file_token 生成妙记 |
| [`+update`](references/lark-minutes-update.md) | 更新妙记标题 |
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | 替换妙记逐字稿中的说话人(仅支持用户 ID不支持姓名 |
- 使用任何 Shortcut 前,必须先读其对应 reference 文档。
## 意图路由
| 用户意图 | 路由到 |
|----------|--------|
| "我的妙记""搜索妙记""妙记列表" | 本 skill`+search` |
| "这个妙记的标题/时长/封面/链接" | 本 skill`minutes get` |
| "下载妙记的视频/音频" | 本 skill`+download` |
| "把音视频转妙记/上传文件生成妙记" | 本 skill`+upload` |
| "重命名妙记/改妙记标题" | 本 skill`+update` |
| "替换说话人/把 A 的发言改成 B" | 本 skill`+speaker-replace` |
| "这个妙记的逐字稿/总结/待办/章节" | [lark-vc](../lark-vc/SKILL.md)`vc +notes --minute-tokens` |
| "把音视频文件转成纪要/逐字稿/文字稿" | 先本 skill`+upload`),再 [lark-vc](../lark-vc/SKILL.md)`vc +notes --minute-tokens` |
| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)`+search``+recording`),再本 skill |
## 核心概念
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,通过 `minute_token` 标识。
- **妙记 Tokenminute\_token**:妙记的唯一标识符,可从妙记 URL 末尾提取(`https://*.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx` 中的 `obcnxxxxxxxxxxxxxxxxxxxx`)。如果 URL 中包含额外参数(如 `?xxx`截取路径最后一段。
- **妙记 Tokenminute_token**:妙记的唯一标识符,可从妙记 URL 末尾提取(如 `https://*.feishu.cn/minutes/obcnxxx` 中的 `obcnxxx`)。如果 URL 中包含额外参数(如 `?xxx`),截取路径最后一段。
## 核心场景
@@ -30,7 +60,7 @@ metadata:
1. 当用户描述的是"我的妙记""包含某个关键词的妙记""某段时间内的妙记",优先使用 `minutes +search`
2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户。
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。
4. 如果是会议的妙记,应优先使用 [vc +search](../lark-vc/references/lark-vc-search.md) 先定位会议,再按需通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
4. 如果是会议的妙记,应优先通过 [lark-vc](../lark-vc/SKILL.md) 定位会议并获取 `minute_token`
5. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准。
@@ -46,7 +76,7 @@ metadata:
### 3. 下载妙记音视频文件
1. 下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
2. `minutes +download` 只负责音视频媒体文件。
2. `+download` 只负责音视频媒体文件。用户需要逐字稿、总结、待办、章节等纪要内容时,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
3. 用户只想拿可分享的下载地址时,使用 `--url-only`;用户要落地到本地文件时,直接下载。
4. 未显式指定路径时,文件默认落到 `./minutes/{minute_token}/<server-filename>`,与 `vc +notes` 的逐字稿共享同一目录便于聚合。
@@ -107,49 +137,20 @@ Minutes (妙记) ← minute_token 标识
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
| -------------------------------------------------- | --------------------------------------------------------------- |
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
| [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute |
| [`+update`](references/lark-minutes-update.md) | Update a minute's title |
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | Replace a speaker in a minute's transcript (rebind from one user to another) |
- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。
- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。
- 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。
- 使用 `+update` 命令时,必须阅读 [references/lark-minutes-update.md](references/lark-minutes-update.md),了解修改参数和返回值结构。
- 使用 `+speaker-replace` 命令时,必须阅读 [references/lark-minutes-speaker-replace.md](references/lark-minutes-speaker-replace.md),了解参数和限制(仅支持用户 ID不支持姓名
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
## API Resources
```bash
lark-cli schema minutes.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli minutes <resource> <method> [flags] # 调用 API
lark-cli minutes <resource> <method> [flags]
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### minutes
- `get` — 获取妙记信息
> **权限错误**:如果返回 `[2091005] permission deny`,表示用户没有对应妙记文件的阅读权限,需提示用户联系妙记 owner 申请权限。
## 权限表
## 不在本 skill 范围
| 方法 | 所需 scope |
| ------------- | ------------------------------ |
| `+search` | `minutes:minutes.search:read` |
| `minutes.get` | `minutes:minutes:readonly` |
| `+download` | `minutes:minutes.media:export` |
| `+update` | `minutes:minutes:update` |
| `+speaker-replace` | `minutes:minutes:update` |
<!-- AUTO-GENERATED-END -->
- 纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)`vc +notes --minute-tokens`
- 搜索历史会议记录 → [lark-vc](../lark-vc/SKILL.md)
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)

View File

@@ -1,7 +1,7 @@
---
name: lark-slides
version: 1.0.0
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责:本地 `.pptx` / `.pdf` 导入为 slides`lark-drive``drive +import --type slides`)、云文档内容编辑(走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide 内嵌的流程图/架构图仍属本 skill、上传或下载普通文件走 lark-drive"
metadata:
requires:
bins: ["lark-cli"]
@@ -25,7 +25,7 @@ metadata:
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)其中包含认证、权限处理**
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
@@ -267,12 +267,14 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
没有 Shortcut 覆盖时使用原生 API。高频资源`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
```bash
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli slides <resource> <method> [flags] # 调用 API
```
原生 API 高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜字段。
> **重要**使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜字段格式
## 核心规则
@@ -285,17 +287,4 @@ lark-cli slides <resource> <method> [flags] # 调用 API
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
## 权限速查
| 方法 | 所需 scope |
|------|-----------|
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload` |
| `slides +media-upload` | `docs:document.media:upload`wiki URL 解析还需 `wiki:node:read` |
| `slides +replace-slide` | `slides:presentation:update`wiki URL 解析还需 `wiki:node:read` |
| `xml_presentations.get` | `slides:presentation:read` |
| `xml_presentation.slide.create` | `slides:presentation:update``slides:presentation:write_only` |
| `xml_presentation.slide.delete` | `slides:presentation:update``slides:presentation:write_only` |
| `xml_presentation.slide.get` | `slides:presentation:read` |
| `xml_presentation.slide.replace` | `slides:presentation:update` |
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。

View File

@@ -1,7 +1,7 @@
---
name: lark-vc
version: 1.0.0
description: "飞书视频会议:搜索历史会议、查询会议纪要产物(总结待办章节逐字稿)、查询会议参会人快照。1. 查询已结束的会议数量或详情时使用本技能(如历史日期|昨天|上周|今天已经开过的会议等场景),查询未开始的会议日程使用 lark-calendar 技能。2. 支持通过关键词、时间范围、组织者、参与者、会议室等筛选条件搜索会议。3. 获取或整理会议纪要、逐字稿、录制产物时使用本技能。4. 查询“谁参加过某会议”“参会人列表”等参会人快照信息用 vc meeting get --with-participants任意时点可查含已结束会议。注意**Agent 真实入会/离会、感知正在进行中会议的实时事件**请使用 lark-vc-agent 技能,本技能不覆盖写操作和会中事件流。"
description: "飞书视频会议:搜索历史会议记录、查询会议纪要(总结/待办/章节/逐字稿)、查询参会人快照。当用户查询已结束的会议、获取会议产物(纪要/妙记)、查看参会人时使用;查询未来日程走 lark-calendar。不负责Agent 真实入会/离会、会中实时事件(走 lark-vc-agent。"
metadata:
requires:
bins: ["lark-cli"]
@@ -18,15 +18,59 @@ metadata:
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
> 4. 了解会议总结、分析和信息提取的标准流程
## 身份
所有 vc 命令默认使用 `--as user``+search``meeting get` 也支持 `--as bot`
```bash
# BAD — 查昨天的会议用 calendar会漏掉即时会议
lark-cli calendar events search_event --query "站会" --start-time ...
# GOOD — 查已结束的会议用 vc +search
lark-cli vc +search --query "站会" --start-time ...
```
## Shortcuts (推荐优先使用)
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-vc-search.md) | 搜索历史会议记录(需至少一个筛选条件) |
| [`+notes`](references/lark-vc-notes.md) | 查询会议纪要和妙记产物(通过 meeting-ids、minute-tokens 或 calendar-event-ids |
| [`+recording`](references/lark-vc-recording.md) | 通过 meeting-ids 或 calendar-event-ids 查询 minute_token |
- 使用任何 Shortcut 前,必须先读其对应 reference 文档。
## 意图路由
| 用户意图 | 路由到 |
|----------|--------|
| 查"昨天的会议""上周的会""已结束的会议" | 本 skill`+search`,含即时会议) |
| 查日历/日程或未来时间的会议 | [lark-calendar](../lark-calendar/SKILL.md) |
| 查"今天有哪些会议" | `vc +search`(已结束)+ lark-calendar未开始合并展示 |
| Agent 真实入会/离会、会中实时事件 | [lark-vc-agent](../lark-vc-agent/SKILL.md) |
| 本地音视频文件转纪要/逐字稿 | 先走 [lark-minutes](../lark-minutes/SKILL.md) 上传,再回 `vc +notes --minute-tokens` |
## 核心概念
- **视频会议Meeting**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`
- **会议纪要Note**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结待办)和逐字稿文档。
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。
- **视频会议Meeting**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索。
- **会议纪要Note**:视频会议结束后生成的结构化文档,包含纪要文档(总结+待办)和逐字稿文档。
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,包含总结、待办、章节和文字记录,通过 minute_token 标识。
- **纪要文档MainDoc**AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`
- **用户会议纪要MeetingNotes**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`。仅通过 `--calendar-event-ids` 路径返回。
- **逐字稿VerbatimDoc**:会议的逐句文字记录,包含说话人和时间戳。
## 产物选择决策
| 用户意图 | 必须读取的产物 | 禁止 |
|---------|-------------|------|
| 提炼/总结/重新总结/整理会议内容/回顾会议 | 逐字稿(`verbatim_doc_token`或妙记文字记录Transcript基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 |
| 查看待办/章节 | AI 纪要(`note_doc_token`)或妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — |
| 查看纪要链接/文档地址 | 仅返回文档链接,无需读取内容 | — |
| 直接看 AI 总结结果 | AI 纪要(`note_doc_token` | — |
| 谁说了什么/完整发言记录 | 逐字稿(`verbatim_doc_token` | — |
> **为什么"提炼/总结"必须从逐字稿出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。
## 核心场景
### 1. 搜索会议记录
@@ -36,23 +80,12 @@ metadata:
### 2. 整理会议纪要
> ⚠️ 在选择读取哪个产物前,先确认你理解 AI 总结链路 vs 录制链路的区别。如不确定,先读 [`references/vc-domain-boundaries.md`](references/vc-domain-boundaries.md) 的「两条链路的独立性」章节
**⚠️ 产物选择决策 — 根据用户意图严格区分:**
| 用户意图 | 必须读取的产物 | 禁止 |
|---------|-------------|------|
| **提炼/总结/重新总结/整理会议内容/回顾会议** | 逐字稿(`verbatim_doc_token`或妙记文字记录Transcript基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出|
| **查看待办/章节** | AI 纪要(`note_doc_token`)或妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — |
| **查看纪要链接/文档地址** | 仅返回文档链接,无需读取内容 | — |
| **直接看 AI 总结结果** | AI 纪要(`note_doc_token` | — |
| **谁说了什么/完整发言记录** | 逐字稿(`verbatim_doc_token` | — |
> **为什么"提炼/总结"必须从逐字稿出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。AI 纪要可作为补充参考,但不能作为唯一信息源。
> 在选择读取哪个产物前,先确认你理解 AI 总结链路 vs 录制链路的区别。如不确定,先读 [`references/vc-domain-boundaries.md`](references/vc-domain-boundaries.md)。
1. 整理纪要文档时默认给出纪要文档、逐字稿、妙记链接即可,无需读取纪要文档或逐字稿内容。
2. 用户明确需要获取总结、待办、章节产物时,再读取文档获取具体内容。
3. 读取智能纪要(`note_doc_token`)内容时,纪要文档的**第一个 `<whiteboard>`** 标签是封面图AI 生成的总结可视化),应同时下载展示给用户:
```bash
# 1. 读取纪要内容
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
@@ -121,70 +154,31 @@ Meeting (视频会议)
└── Keywords (推荐关键词)
```
> **注意**`+search` 只能查询已结束的历史会议。查询未来的日程安排请使用 [lark-calendar](../lark-calendar/SKILL.md)。
>
> **优先级**:当用户搜索历史会议时,应优先使用 `vc +search` 而非 `calendar events search`。calendar 的搜索面向日程vc 的搜索面向已结束的会议记录,支持按参会人、组织者、会议室等维度过滤。
>
> **路由规则**:如果用户在问“开过的会”“今天开了哪些会”“最近参加过什么会”“已结束的会议”“历史会议记录”,优先使用 `vc +search`。只有在查询未来日程、待开的会、agenda 时才优先使用 [lark-calendar](../lark-calendar/SKILL.md)。
>
> **妙记边界**`+notes` 负责纪要内容、逐字稿和 AI 产物;妙记基础信息请优先看 [`+recording`](references/lark-vc-recording.md) 与 [lark-minutes](../lark-minutes/SKILL.md)。
>
> **文件转纪要边界**:如果用户给的是本地音视频文件,并希望得到纪要、逐字稿、总结、待办或章节,入口应先走 [lark-minutes](../lark-minutes/SKILL.md) 的上传流程生成 `minute_url` / `minute_token`,再回到 `vc +notes --minute-tokens` 获取内容产物。
>
> **特殊情况**: 当用户查询“今天有哪些会议”时,通过 `vc +search` 查询今天开过的会议记录,同时使用 lark-calendar 技能查询今天还未开始的会议,统一整理后展示给用户。
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-vc-search.md) | Search meeting records (requires at least one filter) |
| [`+notes`](references/lark-vc-notes.md) | Query meeting notes and minutes (via meeting-ids, minute-tokens, or calendar-event-ids) |
| [`+recording`](references/lark-vc-recording.md) | Query minute_token from meeting-ids or calendar-event-ids |
- 使用 `+search` 命令时,必须阅读 [references/lark-vc-search.md](references/lark-vc-search.md),了解搜索参数和返回值结构。
- 使用 `+notes` 命令时,必须阅读 [references/lark-vc-notes.md](references/lark-vc-notes.md),了解查询参数、产物类型和返回值结构。
- 使用 `+recording` 命令时,必须阅读 [references/lark-vc-recording.md](references/lark-vc-recording.md),了解查询参数和返回值结构。
> **Agent 参会相关命令已独立**`+meeting-join` / `+meeting-leave` / `+meeting-events` 请使用 [`lark-vc-agent`](../lark-vc-agent/SKILL.md) 技能。
## API Resources
```bash
lark-cli schema vc.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli vc <resource> <method> [flags] # 调用 API
lark-cli vc <resource> <method> [flags]
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### meeting
- `get` — 获取会议详情主题、时间、参会人、note_id
```bash
# 获取会议基础信息:不包含参会人列表
# 获取会议基础信息(不含参会人
lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>"}'
# 获取会议基础信息:包含参会人列表
# 获取会议基础信息(含参会人)
lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participants": true}'
```
### minutes跨域详见 [lark-minutes](../lark-minutes/SKILL.md)
- `get` — 获取妙记基础信息(标题、时长、封面);查询纪要**内容**请用 `+notes --minute-tokens <minute-token>`
- `get` — 获取妙记基础信息(标题、时长、封面);查询妙记**内容**请用 `+notes --minute-tokens <minute-token>`
## 权限表
## 不在本 skill 范围
| 方法 | 所需 scope |
|------|-----------|
| `+notes --meeting-ids` | `vc:meeting.meetingevent:read``vc:note:read``vc:record:readonly` |
| `+notes --minute-tokens` | `vc:note:read``minutes:minutes:readonly``minutes:minutes.artifacts:read``minutes:minutes.transcript:export` |
| `+notes --calendar-event-ids` | `calendar:calendar:read``calendar:calendar.event:read``vc:meeting.meetingevent:read``vc:note:read``vc:record:readonly` |
| `+recording --meeting-ids` | `vc:record:readonly` |
| `+recording --calendar-event-ids` | `vc:record:readonly``calendar:calendar:read``calendar:calendar.event:read` |
| `+search` | `vc:meeting.search:read` |
| `meeting.get` | `vc:meeting.meetingevent:read` |
> Agent 参会相关 scope`vc:meeting.bot.join:write` / `vc:meeting.meetingevent:read`)见 [`lark-vc-agent`](../lark-vc-agent/SKILL.md)。
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)
- Agent 真实入会/离会、会中实时事件 → [lark-vc-agent](../lark-vc-agent/SKILL.md)
- 本地音视频文件转纪要/逐字稿 → [lark-minutes](../lark-minutes/SKILL.md)(上传后回 `vc +notes`
- 妙记搜索/下载/上传/重命名/替换说话人 → [lark-minutes](../lark-minutes/SKILL.md)

View File

@@ -2,8 +2,8 @@
## Metrics
- Denominator: 31 leaf commands
- Covered: 10
- Coverage: 32.3%
- Covered: 11
- Coverage: 35.5%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
@@ -15,6 +15,7 @@
- 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: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
- TestDriveImportDryRun_PDFToSlides: dry-run coverage for `drive +import`; asserts PDF-to-slides request shape across media upload `extra` and import task body without calling live APIs.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
@@ -31,7 +32,7 @@
| ✕ | drive +download | shortcut | | none | no file fixture 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 +import | shortcut | drive_import_dryrun_test.go::TestDriveImportDryRun_PDFToSlides | `.pdf` source with `--type slides`; media upload `extra.file_extension=pdf`; import task `file_extension=pdf`, `type=slides`, `file_name` | dry-run only; no live import workflow yet |
| ✕ | drive +move | shortcut | | none | no move workflow yet |
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveImportDryRun_PDFToSlides(t *testing.T) {
setDriveDryRunConfigEnv(t)
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "deck.pdf"), []byte("%PDF-1.7\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error: %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", "./deck.pdf",
"--type", "slides",
"--name", "pdf-deck",
"--dry-run",
},
DefaultAs: "bot",
WorkDir: tmpDir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("upload url=%q, want upload_all\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_name").String(); got != "deck.pdf" {
t.Fatalf("upload file_name=%q, want deck.pdf\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.extra").String(); got != `{"file_extension":"pdf","obj_type":"slides"}` {
t.Fatalf("upload extra=%q, want pdf/slides extra\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/import_tasks" {
t.Fatalf("import url=%q, want import_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.file_extension").String(); got != "pdf" {
t.Fatalf("body.file_extension=%q, want pdf\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.type").String(); got != "slides" {
t.Fatalf("body.type=%q, want slides\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.file_name").String(); got != "pdf-deck" {
t.Fatalf("body.file_name=%q, want pdf-deck\nstdout:\n%s", got, out)
}
}