mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
1 Commits
feat/sidec
...
feat/artif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21fb74aab5 |
@@ -5,7 +5,7 @@
|
|||||||
//
|
//
|
||||||
// Three mutually exclusive input modes (only one allowed per invocation):
|
// Three mutually exclusive input modes (only one allowed per invocation):
|
||||||
// meeting-ids: meeting.get → note_id → note detail API
|
// meeting-ids: meeting.get → note_id → note detail API
|
||||||
// minute-tokens: minutes API → note detail + AI artifacts + transcript
|
// minute-tokens: minutes API → note detail + AI artifacts (transcript inlined)
|
||||||
// calendar-event-ids: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id
|
// calendar-event-ids: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id
|
||||||
|
|
||||||
package vc
|
package vc
|
||||||
@@ -41,7 +41,6 @@ var (
|
|||||||
scopesMinuteTokens = []string{
|
scopesMinuteTokens = []string{
|
||||||
"minutes:minutes:readonly",
|
"minutes:minutes:readonly",
|
||||||
"minutes:minutes.artifacts:read",
|
"minutes:minutes.artifacts:read",
|
||||||
"minutes:minutes.transcript:export",
|
|
||||||
}
|
}
|
||||||
scopesCalendarEventIDs = []string{
|
scopesCalendarEventIDs = []string{
|
||||||
"calendar:calendar:read",
|
"calendar:calendar:read",
|
||||||
@@ -304,13 +303,11 @@ func fetchNoteByMinuteToken(ctx context.Context, runtime *common.RuntimeContext,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// path 2 & 3: AI artifacts are collected under the artifacts field.
|
// 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.
|
||||||
artifacts := map[string]any{}
|
artifacts := map[string]any{}
|
||||||
fetchInlineArtifacts(runtime, minuteToken, artifacts)
|
fetchInlineArtifacts(runtime, minuteToken, title, artifacts)
|
||||||
transcriptPath := downloadTranscriptFile(runtime, minuteToken, title)
|
|
||||||
if transcriptPath != "" {
|
|
||||||
artifacts["transcript_file"] = transcriptPath
|
|
||||||
}
|
|
||||||
if len(artifacts) > 0 {
|
if len(artifacts) > 0 {
|
||||||
result["artifacts"] = artifacts
|
result["artifacts"] = artifacts
|
||||||
}
|
}
|
||||||
@@ -337,67 +334,10 @@ func sanitizeDirName(title, minuteToken string) string {
|
|||||||
return fmt.Sprintf("artifact-%s-%s", safe, minuteToken)
|
return fmt.Sprintf("artifact-%s-%s", safe, minuteToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadTranscriptFile downloads transcript to a local file and returns the file path (empty on failure).
|
// fetchInlineArtifacts fetches summary/todos/chapters/keywords AND transcript from the
|
||||||
func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string, title string) string {
|
// /artifacts API and writes them into the result map. The transcript text is
|
||||||
errOut := runtime.IO().ErrOut
|
// 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) {
|
||||||
// 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
|
errOut := runtime.IO().ErrOut
|
||||||
fmt.Fprintf(errOut, "%s fetching AI artifacts...\n", logPrefix)
|
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)
|
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||||
@@ -414,6 +354,53 @@ func fetchInlineArtifacts(runtime *common.RuntimeContext, minuteToken string, re
|
|||||||
if chapters, ok := data["minute_chapters"].([]any); ok && len(chapters) > 0 {
|
if chapters, ok := data["minute_chapters"].([]any); ok && len(chapters) > 0 {
|
||||||
result["chapters"] = chapters
|
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.
|
// parseArtifactType extracts artifact_type as int from varying JSON number representations.
|
||||||
@@ -570,9 +557,8 @@ var VCNotes = common.Shortcut{
|
|||||||
GET("/open-apis/minutes/v1/minutes/{minute_token}").
|
GET("/open-apis/minutes/v1/minutes/{minute_token}").
|
||||||
GET("/open-apis/vc/v1/notes/{note_id}").
|
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}/artifacts").
|
||||||
GET("/open-apis/minutes/v1/minutes/{minute_token}/transcript").
|
|
||||||
Set("minute_tokens", common.SplitCSV(tokens)).
|
Set("minute_tokens", common.SplitCSV(tokens)).
|
||||||
Set("steps", "minutes API → note detail + AI artifacts + transcript")
|
Set("steps", "minutes API → note detail + AI artifacts (incl. transcript)")
|
||||||
}
|
}
|
||||||
ids := runtime.Str("calendar-event-ids")
|
ids := runtime.Str("calendar-event-ids")
|
||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
|
|||||||
@@ -116,17 +116,29 @@ 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 {
|
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{
|
return &httpmock.Stub{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
URL: "/open-apis/minutes/v1/minutes/" + token + "/artifacts",
|
URL: "/open-apis/minutes/v1/minutes/" + token + "/artifacts",
|
||||||
Body: map[string]interface{}{
|
Body: map[string]interface{}{
|
||||||
"code": 0, "msg": "ok",
|
"code": 0, "msg": "ok",
|
||||||
"data": map[string]interface{}{
|
"data": data,
|
||||||
"summary": "Test summary content",
|
|
||||||
"minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}},
|
|
||||||
"minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,24 +151,6 @@ 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 {
|
func minuteGetStub(token, noteID, title string) *httpmock.Stub {
|
||||||
minute := map[string]interface{}{"title": title}
|
minute := map[string]interface{}{"title": title}
|
||||||
if noteID != "" {
|
if noteID != "" {
|
||||||
@@ -676,8 +670,7 @@ func TestNotes_TranscriptDefaultLayout(t *testing.T) {
|
|||||||
|
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
reg.Register(minuteGetStub("tok001", "", "Meeting Title"))
|
reg.Register(minuteGetStub("tok001", "", "Meeting Title"))
|
||||||
reg.Register(emptyArtifactsStub("tok001"))
|
reg.Register(artifactsStubWithTranscript("tok001", "speaker1: hello world\n"))
|
||||||
reg.Register(transcriptRawStub("tok001", []byte("speaker1: hello world\n")))
|
|
||||||
|
|
||||||
err := mountAndRun(t, VCNotes, []string{
|
err := mountAndRun(t, VCNotes, []string{
|
||||||
"+notes", "--minute-tokens", "tok001", "--as", "user",
|
"+notes", "--minute-tokens", "tok001", "--as", "user",
|
||||||
@@ -705,8 +698,7 @@ func TestNotes_TranscriptExplicitOutputDir_PreservesLegacyLayout(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
reg.Register(minuteGetStub("tok001", "", "Meeting Title"))
|
reg.Register(minuteGetStub("tok001", "", "Meeting Title"))
|
||||||
reg.Register(emptyArtifactsStub("tok001"))
|
reg.Register(artifactsStubWithTranscript("tok001", "content"))
|
||||||
reg.Register(transcriptRawStub("tok001", []byte("content")))
|
|
||||||
|
|
||||||
if err := os.MkdirAll("out", 0755); err != nil {
|
if err := os.MkdirAll("out", 0755); err != nil {
|
||||||
t.Fatalf("setup: %v", err)
|
t.Fatalf("setup: %v", err)
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ Meeting (视频会议)
|
|||||||
├── Transcript (文字记录)
|
├── Transcript (文字记录)
|
||||||
├── Summary (总结)
|
├── Summary (总结)
|
||||||
├── Todos (待办)
|
├── Todos (待办)
|
||||||
└── Chapters (章节)
|
├── Chapters (章节)
|
||||||
|
└── Keywords (推荐关键词)
|
||||||
```
|
```
|
||||||
|
|
||||||
> **注意**:`+search` 只能查询已结束的历史会议。查询未来的日程安排请使用 [lark-calendar](../lark-calendar/SKILL.md)。
|
> **注意**:`+search` 只能查询已结束的历史会议。查询未来的日程安排请使用 [lark-calendar](../lark-calendar/SKILL.md)。
|
||||||
@@ -158,7 +159,7 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa
|
|||||||
| 方法 | 所需 scope |
|
| 方法 | 所需 scope |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| `+notes --meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read` |
|
| `+notes --meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read` |
|
||||||
| `+notes --minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read`、`minutes:minutes.transcript:export` |
|
| `+notes --minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read` |
|
||||||
| `+notes --calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read` |
|
| `+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 --meeting-ids` | `vc:record:readonly` |
|
||||||
| `+recording --calendar-event-ids` | `vc:record:readonly`、`calendar:calendar:read`、`calendar:calendar.event:read` |
|
| `+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` |
|
| `--meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read` |
|
||||||
| `--minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read`、`minutes:minutes.transcript:export` |
|
| `--minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read` |
|
||||||
| `--calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read` |
|
| `--calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read` |
|
||||||
|
|
||||||
## 输出结果
|
## 输出结果
|
||||||
@@ -93,6 +93,7 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
|||||||
| `artifacts.summary` | AI 总结(JSON 内联) |
|
| `artifacts.summary` | AI 总结(JSON 内联) |
|
||||||
| `artifacts.todos` | 待办事项(JSON 内联) |
|
| `artifacts.todos` | 待办事项(JSON 内联) |
|
||||||
| `artifacts.chapters` | 章节纪要(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` |
|
| `artifacts.transcript_file` | 逐字稿本地文件路径。默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 聚合);显式 `--output-dir` 时走旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt` |
|
||||||
|
|
||||||
## 如何获取输入参数
|
## 如何获取输入参数
|
||||||
|
|||||||
Reference in New Issue
Block a user