mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 07:31:22 +08:00
Compare commits
4 Commits
feat/artif
...
v1.0.34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13411d9a51 | ||
|
|
939b7b6fb6 | ||
|
|
a4c5ec99c8 | ||
|
|
7c54f9b023 |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.34] - 2026-05-19
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Switch markdown export to V2 `docs_ai` fetch API (#948)
|
||||
- **drive**: Add `+inspect` shortcut for document URL inspection with wiki unwrapping (#947)
|
||||
- **wiki**: Add `+node-get` / `+node-delete` / `+space-create` shortcuts (#904)
|
||||
- **base**: Support Base attachment APIs (#887)
|
||||
- **mail**: Validate `bot` + `mailbox=me` and add dynamic `--as` help tests (#895)
|
||||
- **mail**: Expose draft priority in `--inspect` projection and document `--set-priority` (#779)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identitydiag**: Harden verify path and tighten status semantics (#961)
|
||||
- **wiki**: Surface real node URL for `+node-create` / `+node-copy` (#960)
|
||||
- **auth**: Split bot and user identity diagnostics (#957)
|
||||
- **base**: Address Base attachment review follow-ups (#958)
|
||||
- **docs**: Clarify `replace_all` selection errors (#954)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Clarify add comment constraints (#967)
|
||||
- **lark-im**: Clarify message activity search (#865)
|
||||
|
||||
### Tests
|
||||
|
||||
- Verify e2e resource cleanup (#949)
|
||||
- **lint**: Exclude `bidichk` from test files (#959)
|
||||
|
||||
## [v1.0.33] - 2026-05-18
|
||||
|
||||
### Features
|
||||
@@ -745,6 +774,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
|
||||
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
|
||||
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
|
||||
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.33",
|
||||
"version": "1.0.34",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,7 @@ var DriveExport = common.Shortcut{
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
"docs:document:export",
|
||||
"docx:document:readonly",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -52,16 +54,15 @@ var DriveExport = common.Shortcut{
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from docs content
|
||||
// directly instead of the Drive export task API.
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
GET("/open-apis/docs/v1/content").
|
||||
Params(map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
@@ -101,23 +102,33 @@ var DriveExport = common.Shortcut{
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk.
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/docs/v1/content",
|
||||
map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
},
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.DoAPIJSONWithLogID(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
@@ -130,7 +141,7 @@ var DriveExport = common.Shortcut{
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -141,7 +152,7 @@ var DriveExport = common.Shortcut{
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len([]byte(common.GetString(data, "content"))),
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -81,16 +81,19 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# hello\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# hello\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
@@ -118,6 +121,14 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -132,16 +143,19 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# custom\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# custom\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
@@ -158,6 +172,14 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -179,7 +201,7 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "markdown",
|
||||
wantURL: "/open-apis/docs/v1/content",
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
wantFileName: `"file_name": "notes.md"`,
|
||||
args: []string{
|
||||
"+export",
|
||||
@@ -233,16 +255,19 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# fallback\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# fallback\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
@@ -267,6 +292,14 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -279,6 +312,76 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing document object, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "missing document object") {
|
||||
t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing document.content, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "missing document.content") {
|
||||
t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//
|
||||
// Three mutually exclusive input modes (only one allowed per invocation):
|
||||
// meeting-ids: meeting.get → note_id → note detail API
|
||||
// minute-tokens: minutes API → note detail + AI artifacts (transcript inlined)
|
||||
// minute-tokens: minutes API → note detail + AI artifacts + transcript
|
||||
// calendar-event-ids: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id
|
||||
|
||||
package vc
|
||||
@@ -41,6 +41,7 @@ var (
|
||||
scopesMinuteTokens = []string{
|
||||
"minutes:minutes:readonly",
|
||||
"minutes:minutes.artifacts:read",
|
||||
"minutes:minutes.transcript:export",
|
||||
}
|
||||
scopesCalendarEventIDs = []string{
|
||||
"calendar:calendar:read",
|
||||
@@ -303,11 +304,13 @@ func fetchNoteByMinuteToken(ctx context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
}
|
||||
|
||||
// AI artifacts + transcript come from the same /artifacts endpoint now:
|
||||
// the server-side artifacts API checks View permission (not Export) and
|
||||
// inlines the transcript text into the response.
|
||||
// path 2 & 3: AI artifacts are collected under the artifacts field.
|
||||
artifacts := map[string]any{}
|
||||
fetchInlineArtifacts(runtime, minuteToken, title, artifacts)
|
||||
fetchInlineArtifacts(runtime, minuteToken, artifacts)
|
||||
transcriptPath := downloadTranscriptFile(runtime, minuteToken, title)
|
||||
if transcriptPath != "" {
|
||||
artifacts["transcript_file"] = transcriptPath
|
||||
}
|
||||
if len(artifacts) > 0 {
|
||||
result["artifacts"] = artifacts
|
||||
}
|
||||
@@ -334,10 +337,67 @@ func sanitizeDirName(title, minuteToken string) string {
|
||||
return fmt.Sprintf("artifact-%s-%s", safe, minuteToken)
|
||||
}
|
||||
|
||||
// fetchInlineArtifacts fetches summary/todos/chapters/keywords AND transcript from the
|
||||
// /artifacts API and writes them into the result map. The transcript text is
|
||||
// persisted to disk and the local path is exposed under "transcript_file".
|
||||
func fetchInlineArtifacts(runtime *common.RuntimeContext, minuteToken string, title string, result map[string]any) {
|
||||
// downloadTranscriptFile downloads transcript to a local file and returns the file path (empty on failure).
|
||||
func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string, title string) string {
|
||||
errOut := runtime.IO().ErrOut
|
||||
|
||||
// With no --output-dir the default layout shares the directory with
|
||||
// `minutes +download`. Legacy layout is preserved when the flag is set.
|
||||
var dirName string
|
||||
if outDir := runtime.Str("output-dir"); outDir != "" {
|
||||
dirName = filepath.Join(outDir, sanitizeDirName(title, minuteToken))
|
||||
} else {
|
||||
dirName = common.DefaultMinuteArtifactDir(minuteToken)
|
||||
}
|
||||
transcriptPath := filepath.Join(dirName, common.DefaultTranscriptFileName)
|
||||
|
||||
// Overwrite check via FileIO.Stat
|
||||
if !runtime.Bool("overwrite") {
|
||||
if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil {
|
||||
fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", logPrefix, transcriptPath)
|
||||
return transcriptPath
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(errOut, "%s downloading transcript: %s\n", logPrefix, transcriptPath)
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript", validate.EncodePathSegment(minuteToken)),
|
||||
QueryParams: larkcore.QueryParams{
|
||||
"need_speaker": []string{"true"},
|
||||
"need_timestamp": []string{"true"},
|
||||
"file_format": []string{"txt"},
|
||||
},
|
||||
}, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "%s failed to download transcript: %v\n", logPrefix, err)
|
||||
return ""
|
||||
}
|
||||
if apiResp.StatusCode >= 400 {
|
||||
fmt.Fprintf(errOut, "%s failed to download transcript: HTTP %d\n", logPrefix, apiResp.StatusCode)
|
||||
return ""
|
||||
}
|
||||
if len(apiResp.RawBody) == 0 {
|
||||
fmt.Fprintf(errOut, "%s transcript is empty (not available for this minute)\n", logPrefix)
|
||||
return ""
|
||||
}
|
||||
if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(apiResp.RawBody)); err != nil {
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err)
|
||||
case errors.As(err, &me):
|
||||
fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err)
|
||||
default:
|
||||
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return transcriptPath
|
||||
}
|
||||
|
||||
// fetchInlineArtifacts fetches summary/todos/chapters from artifacts API and writes them inline into result map.
|
||||
func fetchInlineArtifacts(runtime *common.RuntimeContext, minuteToken string, result map[string]any) {
|
||||
errOut := runtime.IO().ErrOut
|
||||
fmt.Fprintf(errOut, "%s fetching AI artifacts...\n", logPrefix)
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
@@ -354,53 +414,6 @@ func fetchInlineArtifacts(runtime *common.RuntimeContext, minuteToken string, ti
|
||||
if chapters, ok := data["minute_chapters"].([]any); ok && len(chapters) > 0 {
|
||||
result["chapters"] = chapters
|
||||
}
|
||||
if keywords, ok := data["keywords"].([]any); ok && len(keywords) > 0 {
|
||||
result["keywords"] = keywords
|
||||
}
|
||||
if transcript, ok := data["transcript"].(string); ok && transcript != "" {
|
||||
if path := saveTranscriptToFile(runtime, minuteToken, title, []byte(transcript)); path != "" {
|
||||
result["transcript_file"] = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// saveTranscriptToFile persists transcript bytes to the canonical artifact path
|
||||
// for the given minute_token. Returns the file path on success (or when the
|
||||
// file already exists and --overwrite is not set), empty string on any failure.
|
||||
func saveTranscriptToFile(runtime *common.RuntimeContext, minuteToken, title string, content []byte) string {
|
||||
errOut := runtime.IO().ErrOut
|
||||
|
||||
// With no --output-dir the default layout shares the directory with
|
||||
// `minutes +download`. Legacy layout is preserved when the flag is set.
|
||||
var dirName string
|
||||
if outDir := runtime.Str("output-dir"); outDir != "" {
|
||||
dirName = filepath.Join(outDir, sanitizeDirName(title, minuteToken))
|
||||
} else {
|
||||
dirName = common.DefaultMinuteArtifactDir(minuteToken)
|
||||
}
|
||||
transcriptPath := filepath.Join(dirName, common.DefaultTranscriptFileName)
|
||||
|
||||
if !runtime.Bool("overwrite") {
|
||||
if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil {
|
||||
fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", logPrefix, transcriptPath)
|
||||
return transcriptPath
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(errOut, "%s writing transcript: %s\n", logPrefix, transcriptPath)
|
||||
if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(content)); err != nil {
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err)
|
||||
case errors.As(err, &me):
|
||||
fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err)
|
||||
default:
|
||||
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return transcriptPath
|
||||
}
|
||||
|
||||
// parseArtifactType extracts artifact_type as int from varying JSON number representations.
|
||||
@@ -557,8 +570,9 @@ var VCNotes = common.Shortcut{
|
||||
GET("/open-apis/minutes/v1/minutes/{minute_token}").
|
||||
GET("/open-apis/vc/v1/notes/{note_id}").
|
||||
GET("/open-apis/minutes/v1/minutes/{minute_token}/artifacts").
|
||||
GET("/open-apis/minutes/v1/minutes/{minute_token}/transcript").
|
||||
Set("minute_tokens", common.SplitCSV(tokens)).
|
||||
Set("steps", "minutes API → note detail + AI artifacts (incl. transcript)")
|
||||
Set("steps", "minutes API → note detail + AI artifacts + transcript")
|
||||
}
|
||||
ids := runtime.Str("calendar-event-ids")
|
||||
return common.NewDryRunAPI().
|
||||
|
||||
@@ -116,29 +116,17 @@ func noteDetailStub(noteID string) *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
// artifactsStub builds the /artifacts response. Transcript text is inlined
|
||||
// here (since the server bundles it via View-permission GetMinutesResources);
|
||||
// callers pass an empty string when no transcript should be returned.
|
||||
func artifactsStub(token string) *httpmock.Stub {
|
||||
return artifactsStubWithTranscript(token, "")
|
||||
}
|
||||
|
||||
func artifactsStubWithTranscript(token, transcript string) *httpmock.Stub {
|
||||
data := map[string]interface{}{
|
||||
"summary": "Test summary content",
|
||||
"minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}},
|
||||
"minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}},
|
||||
"keywords": []interface{}{"budget", "roadmap"},
|
||||
}
|
||||
if transcript != "" {
|
||||
data["transcript"] = transcript
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/" + token + "/artifacts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": data,
|
||||
"data": map[string]interface{}{
|
||||
"summary": "Test summary content",
|
||||
"minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}},
|
||||
"minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -151,6 +139,24 @@ func emptyArtifactsStub(token string) *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
func transcriptStub(token string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/" + token + "/transcript",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
}
|
||||
}
|
||||
|
||||
// transcriptRawStub returns an actual transcript body so downloadTranscriptFile
|
||||
// writes a file to disk. Used by path-layout tests.
|
||||
func transcriptRawStub(token string, body []byte) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/" + token + "/transcript",
|
||||
RawBody: body,
|
||||
}
|
||||
}
|
||||
|
||||
func minuteGetStub(token, noteID, title string) *httpmock.Stub {
|
||||
minute := map[string]interface{}{"title": title}
|
||||
if noteID != "" {
|
||||
@@ -670,7 +676,8 @@ func TestNotes_TranscriptDefaultLayout(t *testing.T) {
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(minuteGetStub("tok001", "", "Meeting Title"))
|
||||
reg.Register(artifactsStubWithTranscript("tok001", "speaker1: hello world\n"))
|
||||
reg.Register(emptyArtifactsStub("tok001"))
|
||||
reg.Register(transcriptRawStub("tok001", []byte("speaker1: hello world\n")))
|
||||
|
||||
err := mountAndRun(t, VCNotes, []string{
|
||||
"+notes", "--minute-tokens", "tok001", "--as", "user",
|
||||
@@ -698,7 +705,8 @@ func TestNotes_TranscriptExplicitOutputDir_PreservesLegacyLayout(t *testing.T) {
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(minuteGetStub("tok001", "", "Meeting Title"))
|
||||
reg.Register(artifactsStubWithTranscript("tok001", "content"))
|
||||
reg.Register(emptyArtifactsStub("tok001"))
|
||||
reg.Register(transcriptRawStub("tok001", []byte("content")))
|
||||
|
||||
if err := os.MkdirAll("out", 0755); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
|
||||
@@ -150,8 +150,6 @@ lark-cli drive +add-comment \
|
||||
- `type=text` 的评论文本不能直接包含 `<`、`>`;应优先传 `<`、`>`。shortcut 在发送前也会自动将 `<`、`>` 转义为 `<`、`>` 作为兜底。
|
||||
- **所有 `type=text` 元素的字符总和 ≤ 10000**(按字符算,中英文 / 符号一视同仁)。超过会被 shortcut 在发送前拒绝,并指出累计超长的元素。**拆成多个 text element 不能绕过这个上限**——上限是总额,不是每元素。需要更长内容就缩短或拆成多条评论。
|
||||
- 长度限制只对 `type=text` 生效,`mention_user` / `link` 不计入。
|
||||
- 局部评论走 `locate-doc` 时,内部固定使用 `limit=10`。
|
||||
- 当 `locate-doc` 命中多处时,shortcut 会中止并提示用户继续收窄 `--selection-with-ellipsis`,不支持手动指定匹配序号。
|
||||
- 写入评论前会自动生成符合 OpenAPI 定义的请求体:
|
||||
- 统一接口:`POST /new_comments`
|
||||
- 统一字段:`file_type` + `reply_elements`
|
||||
|
||||
@@ -25,8 +25,8 @@ lark-cli drive +export \
|
||||
--doc-type doc \
|
||||
--file-extension docx
|
||||
|
||||
# 导出 docx 为 markdown
|
||||
# 注意:markdown 只支持 docx,底层走 /open-apis/docs/v1/content
|
||||
# 导出 docx 为 markdown(Lark-flavored Markdown)
|
||||
# 注意:markdown 只支持 docx
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
|
||||
@@ -96,8 +96,7 @@ Meeting (视频会议)
|
||||
├── Transcript (文字记录)
|
||||
├── Summary (总结)
|
||||
├── Todos (待办)
|
||||
├── Chapters (章节)
|
||||
└── Keywords (推荐关键词)
|
||||
└── Chapters (章节)
|
||||
```
|
||||
|
||||
> **注意**:`+search` 只能查询已结束的历史会议。查询未来的日程安排请使用 [lark-calendar](../lark-calendar/SKILL.md)。
|
||||
@@ -159,7 +158,7 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `+notes --meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read` |
|
||||
| `+notes --minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read` |
|
||||
| `+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` |
|
||||
| `+recording --meeting-ids` | `vc:record:readonly` |
|
||||
| `+recording --calendar-event-ids` | `vc:record:readonly`、`calendar:calendar:read`、`calendar:calendar.event:read` |
|
||||
|
||||
@@ -64,7 +64,7 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
||||
| 输入 | 所需权限 |
|
||||
|------|---------|
|
||||
| `--meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read` |
|
||||
| `--minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read` |
|
||||
| `--minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read`、`minutes:minutes.transcript:export` |
|
||||
| `--calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read` |
|
||||
|
||||
## 输出结果
|
||||
@@ -93,7 +93,6 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
||||
| `artifacts.summary` | AI 总结(JSON 内联) |
|
||||
| `artifacts.todos` | 待办事项(JSON 内联) |
|
||||
| `artifacts.chapters` | 章节纪要(JSON 内联) |
|
||||
| `artifacts.keywords` | 妙记推荐关键词(JSON 内联) |
|
||||
| `artifacts.transcript_file` | 逐字稿本地文件路径。默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 聚合);显式 `--output-dir` 时走旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt` |
|
||||
|
||||
## 如何获取输入参数
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +search`(调用 `POST /open-apis/vc/v1/meetings/search`)。
|
||||
|
||||
## 关键词使用边界
|
||||
|
||||
`--query` 只用于真实会议关键词,例如会议主题、项目名、评审名、客户名。用户只是说"我这月参加的所有视频会议"、"最近两周我组织的所有视频会议"、"总结主要议题 / 看看参会情况"时,本质是历史会议列表和后续总结,不要把"回顾"、"所有视频会议"、"总结主要议题"等动作词放进 `--query`。这类请求应先用时间范围 + `--participant-ids` / `--organizer-ids` 搜全量候选,再按结果继续取纪要或录制信息。
|
||||
|
||||
列表阶段只负责找会议记录;总结阶段必须继续取证。若用户要求"主要议题"、"主要决策"、"参会情况",先确认搜索结果的 `meeting_id`、时间、组织者/参与者符合过滤条件,然后用 `vc +notes` 或 `vc +recording` / `minutes` 读取纪要、妙记或录制信息。没有纪要或妙记时,如实说明只能基于会议标题/参会数据汇总,不要编造议题。
|
||||
|
||||
## 典型触发表达
|
||||
|
||||
以下说法通常应优先使用 `vc +search`:
|
||||
@@ -42,6 +48,12 @@ lark-cli vc +search --organizer-ids "ou_a,ou_b"
|
||||
# 按参与者过滤(open_id,逗号分隔)
|
||||
lark-cli vc +search --participant-ids "ou_x,ou_y"
|
||||
|
||||
# 查询我这个月参加过的历史会议,不带关键词
|
||||
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --participant-ids "ou_me"
|
||||
|
||||
# 查询最近两周我组织的历史会议,不带关键词
|
||||
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --organizer-ids "ou_me"
|
||||
|
||||
# 按会议室过滤
|
||||
lark-cli vc +search --room-ids "123,456"
|
||||
|
||||
@@ -76,6 +88,10 @@ lark-cli vc +search --query "周会" --format json
|
||||
|
||||
所有参数均可选,但必须至少提供一个过滤条件:`--query`、`--start`、`--end`、`--organizer-ids`、`--participant-ids` 或 `--room-ids`。
|
||||
|
||||
没有真实关键词时,时间范围或人员过滤已经满足这个约束,`--query` 可以省略。
|
||||
|
||||
涉及"本月"、"最近两周"这类相对时间时,先基于执行当天计算 `"<YYYY-MM-DD>"` 占位符,再运行命令;不要沿用文档示例生成时的具体日期。
|
||||
|
||||
### 2. 仅搜索历史会议
|
||||
|
||||
`vc +search` 只能搜索已结束的历史会议记录,不用于查询未来日程。查询未来会议安排请使用 [lark-calendar](../../lark-calendar/SKILL.md)。
|
||||
@@ -128,7 +144,8 @@ lark-cli vc +search --query "周会" --format json
|
||||
- 当结果中返回 `has_more=true` 时,说明还有更多页可继续获取。
|
||||
- 继续翻页时,使用响应中的 `page_token` 搭配 `--page-token` 发起下一次查询。
|
||||
- 不要假设调大 `--page-size` 就能拿全结果;分页遍历时应以 `has_more` 和 `page_token` 为准。
|
||||
- `total` 数量小于 50 时,自动分页获取所有结果;`total` 数量大于 50 时,向用户确认是否获取全部结果。
|
||||
- 未明确要求全量时,`total` 数量小于 50 可自动分页获取所有结果;`total` 数量大于 50 时,先向用户确认是否继续获取全部结果。
|
||||
- 用户明确说"所有 / 全部 / 统计 / 按时间排序"时,该全量意图优先于 `total > 50` 的确认门槛;直接完成分页和去重,再排序或统计,不要只用第一页回答。
|
||||
|
||||
```bash
|
||||
# First page
|
||||
|
||||
@@ -60,3 +60,42 @@ func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
|
||||
t.Fatalf("output_dir=%q, want ./exports\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+export",
|
||||
"--token", "docxMdDryRun",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--file-name", "my-notes",
|
||||
"--output-dir", "./md-exports",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/docxMdDryRun/fetch" {
|
||||
t.Fatalf("url=%q, want docs_ai fetch\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.format").String(); got != "markdown" {
|
||||
t.Fatalf("body.format=%q, want markdown\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_name").String(); got != "my-notes.md" {
|
||||
t.Fatalf("file_name=%q, want my-notes.md\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "output_dir").String(); got != "./md-exports" {
|
||||
t.Fatalf("output_dir=%q, want ./md-exports\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user