Documentation
¶
Index ¶
Constants ¶
This section is empty.
Variables ¶
View Source
var MinutesDownload = common.Shortcut{ Service: "minutes", Command: "+download", Description: "Download audio/video media file of a minute", Risk: "read", Scopes: []string{"minutes:minutes.media:export"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ {Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch download (max 50)", Required: true}, {Name: "output", Desc: "output file path (single token)"}, {Name: "output-dir", Desc: "output directory (default: ./minutes/{minute_token}/)"}, {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, {Name: "url-only", Type: "bool", Desc: "only print the download URL(s) without downloading"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { tokens := common.SplitCSV(runtime.Str("minute-tokens")) if len(tokens) == 0 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens is required").WithParam("--minute-tokens") } if len(tokens) > maxBatchSize { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens") } for _, token := range tokens { if !validMinuteToken.MatchString(token) { return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token).WithParam("--minute-tokens") } } out := runtime.Str("output") outDir := runtime.Str("output-dir") if out != "" && outDir != "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --output-dir cannot both be set").WithParam("--output") } if out != "" { if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil { return err } } if outDir != "" { if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil { return err } } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { tokens := common.SplitCSV(runtime.Str("minute-tokens")) return common.NewDryRunAPI(). GET("/open-apis/minutes/v1/minutes/:minute_token/media"). Set("minute_tokens", tokens) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { tokens := common.SplitCSV(runtime.Str("minute-tokens")) rawOutput := runtime.Str("output") rawOutputDir := runtime.Str("output-dir") overwrite := runtime.Bool("overwrite") urlOnly := runtime.Bool("url-only") errOut := runtime.IO().ErrOut single := len(tokens) == 1 explicitOutputPath := rawOutput explicitOutputDir := rawOutputDir if explicitOutputPath != "" { fi, statErr := runtime.FileIO().Stat(explicitOutputPath) switch { case statErr == nil && fi.IsDir(): explicitOutputDir = explicitOutputPath explicitOutputPath = "" case statErr == nil && !fi.IsDir(): if !single { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath).WithParam("--output") } case errors.Is(statErr, fs.ErrNotExist): if !single { explicitOutputDir = explicitOutputPath explicitOutputPath = "" } default: return errs.NewInternalError(errs.SubtypeFileIO, "cannot access --output %q: %s", explicitOutputPath, statErr).WithCause(statErr) } } useDefaultLayout := explicitOutputPath == "" && explicitOutputDir == "" if !single { fmt.Fprintf(errOut, "[minutes +download] batch: %d token(s)\n", len(tokens)) } type result struct { MinuteToken string `json:"minute_token"` ArtifactType string `json:"artifact_type,omitempty"` SavedPath string `json:"saved_path,omitempty"` SizeBytes int64 `json:"size_bytes,omitempty"` DownloadURL string `json:"download_url,omitempty"` Error string `json:"error,omitempty"` err error // raw typed error for single-mode passthrough } results := make([]result, len(tokens)) seen := make(map[string]int) usedNames := make(map[string]bool) baseClient, err := runtime.Factory.HttpClient() if err != nil { return errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %s", err).WithCause(err) } clonedClient := *baseClient clonedClient.Timeout = disableClientTimeout clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { if len(via) >= maxDownloadRedirects { return fmt.Errorf("too many redirects") } if len(via) > 0 { prev := via[len(via)-1] if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") { return fmt.Errorf("redirect from https to http is not allowed") } } return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String()) } dlClient := &clonedClient ticker := time.NewTicker(time.Second / 5) defer ticker.Stop() for i, token := range tokens { if i > 0 { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: } } if err := validate.ResourceName(token, "--minute-tokens"); err != nil { results[i] = result{MinuteToken: token, Error: err.Error()} continue } if firstIdx, dup := seen[token]; dup { results[i] = result{MinuteToken: token, Error: fmt.Sprintf("duplicate token, same as index %d", firstIdx)} continue } seen[token] = i downloadURL, err := fetchDownloadURL(ctx, runtime, token) if err != nil { results[i] = result{MinuteToken: token, Error: err.Error(), err: err} continue } if urlOnly { results[i] = result{MinuteToken: token, DownloadURL: downloadURL} continue } fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token)) opts := downloadOpts{fio: runtime.FileIO(), overwrite: overwrite} switch { case useDefaultLayout: opts.outputDir = common.DefaultMinuteArtifactDir(token) case explicitOutputPath != "" && single: opts.outputPath = explicitOutputPath default: opts.outputDir = explicitOutputDir if !single { opts.usedNames = usedNames } } dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts) if err != nil { results[i] = result{MinuteToken: token, Error: err.Error(), err: err} continue } results[i] = result{ MinuteToken: token, ArtifactType: common.ArtifactTypeRecording, SavedPath: dl.savedPath, SizeBytes: dl.sizeBytes, } } if single { r := results[0] if r.Error != "" { if r.err != nil { return r.err } return runtime.OutPartialFailure(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}) } if urlOnly { runtime.Out(map[string]interface{}{ "minute_token": r.MinuteToken, "download_url": r.DownloadURL, }, nil) } else { runtime.Out(map[string]interface{}{ "minute_token": r.MinuteToken, "artifact_type": r.ArtifactType, "saved_path": r.SavedPath, "size_bytes": r.SizeBytes, }, nil) } return nil } successCount := 0 for _, r := range results { if r.Error == "" { successCount++ } } fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount) outData := map[string]interface{}{"downloads": results} meta := &output.Meta{Count: len(results)} if successCount == 0 && len(results) > 0 { return runtime.OutPartialFailure(outData, meta) } runtime.OutFormat(outData, meta, nil) return nil }, }
View Source
var MinutesSearch = common.Shortcut{ Service: "minutes", Command: "+search", Description: "Search minutes by keyword, owners, participants, and time range", Risk: "read", Scopes: []string{"minutes:minutes.search:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "query", Desc: "search keyword"}, {Name: "owner-ids", Desc: "owner open_id list, comma-separated (use \"me\" for current user)"}, {Name: "participant-ids", Desc: "participant open_id list, comma-separated (use \"me\" for current user)"}, {Name: "start", Desc: "time lower bound (ISO 8601 or YYYY-MM-DD)"}, {Name: "end", Desc: "time upper bound (ISO 8601 or YYYY-MM-DD)"}, {Name: "page-token", Desc: "page token for next page"}, {Name: "page-size", Default: "15", Desc: "page size, 1-30 (default 15)"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, _, err := parseTimeRange(runtime); err != nil { return err } if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query: length must be between 1 and 50 characters").WithParam("--query") } if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil { return err } ownerIDs, err := common.ResolveOpenIDsTyped("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime) if err != nil { return err } for _, id := range ownerIDs { if _, err := common.ValidateUserIDTyped("--owner-ids", id); err != nil { return err } } participantIDs, err := common.ResolveOpenIDsTyped("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime) if err != nil { return err } for _, id := range participantIDs { if _, err := common.ValidateUserIDTyped("--participant-ids", id); err != nil { return err } } for _, flag := range []string{"query", "owner-ids", "participant-ids", "start", "end"} { if strings.TrimSpace(runtime.Str(flag)) != "" { return nil } } return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --query, --owner-ids, --participant-ids, --start, or --end") }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { startTime, endTime, err := parseTimeRange(runtime) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } params := buildMinutesSearchParams(runtime) body, err := buildMinutesSearchBody(runtime, startTime, endTime) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } dryRun := common.NewDryRunAPI(). POST("/open-apis/minutes/v1/minutes/search") if len(params) > 0 { dryRun.Params(params) } return dryRun.Body(body) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { startTime, endTime, err := parseTimeRange(runtime) if err != nil { return err } body, err := buildMinutesSearchBody(runtime, startTime, endTime) if err != nil { return err } data, err := runtime.CallAPITyped(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body) if err != nil { return err } if data == nil { data = map[string]interface{}{} } items := minuteSearchItems(data) hasMore, _ := data["has_more"].(bool) pageToken, _ := data["page_token"].(string) rows := buildMinuteSearchRows(items) outData := map[string]interface{}{ "items": items, "total": data["total"], "has_more": data["has_more"], "page_token": data["page_token"], } runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) { if len(rows) == 0 { fmt.Fprintln(w, "No minutes.") return } output.PrintTable(w, rows) }) if hasMore && runtime.Format != "json" && runtime.Format != "" { fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken) } return nil }, }
MinutesSearch searches minutes by keyword, owners, participants, and time range.
View Source
var MinutesSpeakerReplace = common.Shortcut{ Service: "minutes", Command: "+speaker-replace", Description: "Replace a speaker in a minute's transcript (rebind from one user to another)", Risk: "write", Scopes: []string{"minutes:minutes:update"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "minute-token", Desc: "minute token", Required: true}, {Name: "from-user-id", Desc: "speaker to replace, must be an open_id starting with 'ou_'", Required: true}, {Name: "to-user-id", Desc: "new speaker, must be an open_id starting with 'ou_'", Required: true}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { minuteToken := strings.TrimSpace(runtime.Str("minute-token")) if minuteToken == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token") } if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token") } fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) if fromUserID == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id is required").WithParam("--from-user-id") } if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); err != nil { return err } toUserID := strings.TrimSpace(runtime.Str("to-user-id")) if toUserID == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--to-user-id is required").WithParam("--to-user-id") } if _, err := common.ValidateUserIDTyped("--to-user-id", toUserID); err != nil { return err } if fromUserID == toUserID { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id and --to-user-id must be different").WithParam("--to-user-id") } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { minuteToken := strings.TrimSpace(runtime.Str("minute-token")) fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) toUserID := strings.TrimSpace(runtime.Str("to-user-id")) return common.NewDryRunAPI(). PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))). Body(map[string]interface{}{ "minute_token": minuteToken, "from_user_id": fromUserID, "to_user_id": toUserID, }) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { minuteToken := strings.TrimSpace(runtime.Str("minute-token")) fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) toUserID := strings.TrimSpace(runtime.Str("to-user-id")) body := map[string]interface{}{ "minute_token": minuteToken, "from_user_id": fromUserID, "to_user_id": toUserID, } _, err := runtime.CallAPITyped(http.MethodPut, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)), nil, body) if err != nil { return minutesSpeakerReplaceError(err, minuteToken, fromUserID) } outData := map[string]interface{}{ "minute_token": minuteToken, "from_user_id": fromUserID, "to_user_id": toUserID, } runtime.OutFormat(outData, nil, nil) return nil }, }
MinutesSpeakerReplace replaces a speaker in a minute's transcript.
View Source
var MinutesUpdate = common.Shortcut{ Service: "minutes", Command: "+update", Description: "Update a minute's title", Risk: "write", Scopes: []string{"minutes:minutes:update"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "minute-token", Desc: "minute token", Required: true}, {Name: "topic", Desc: "new minute title", Required: true}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { minuteToken := strings.TrimSpace(runtime.Str("minute-token")) if minuteToken == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token") } if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token") } if strings.TrimSpace(runtime.Str("topic")) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--topic is required").WithParam("--topic") } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { minuteToken := strings.TrimSpace(runtime.Str("minute-token")) return common.NewDryRunAPI(). PATCH(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken))). Body(map[string]interface{}{"topic": runtime.Str("topic")}) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { minuteToken := strings.TrimSpace(runtime.Str("minute-token")) topic := runtime.Str("topic") body := map[string]interface{}{ "topic": topic, } _, err := runtime.CallAPITyped(http.MethodPatch, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, body) if err != nil { return minutesUpdateError(err, minuteToken) } outData := map[string]interface{}{ "minute_token": minuteToken, "topic": topic, } runtime.OutFormat(outData, nil, nil) return nil }, }
MinutesUpdate updates the title (topic) of a minute.
View Source
var MinutesUpload = common.Shortcut{ Service: "minutes", Command: "+upload", Description: "Upload a media file token to generate a minute", Risk: "write", Scopes: []string{"minutes:minutes.upload:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "file-token", Desc: "file_token of a supported audio/video file already uploaded to Drive", Required: true}, }, Tips: []string{ "This shortcut only accepts --file-token. Upload the local media file to Drive first with `lark-cli drive +upload`.", minutesUploadSupportedFormatsTip, minutesUploadLimitsTip, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { fileToken := runtime.Str("file-token") if fileToken == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token is required").WithParam("--file-token") } if err := validate.ResourceName(fileToken, "--file-token"); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). POST("/open-apis/minutes/v1/minutes/upload"). Body(map[string]interface{}{"file_token": runtime.Str("file-token")}) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { fileToken := runtime.Str("file-token") body := map[string]interface{}{ "file_token": fileToken, } data, err := runtime.CallAPITyped("POST", "/open-apis/minutes/v1/minutes/upload", nil, body) if err != nil { return err } minuteURL := common.GetString(data, "minute_url") outData := map[string]interface{}{ "minute_url": minuteURL, } runtime.OutFormat(outData, nil, nil) return nil }, }
MinutesUpload uploads a media file token to generate a minute.
Functions ¶
Types ¶
This section is empty.
Click to show internal directories.
Click to hide internal directories.