Compare commits

...

1 Commits

Author SHA1 Message Date
zhangjun.1
c7feefa8b4 feat: minutes export meeting legally
Use GetMinuteArtifacts instead of transcript export API to avoid requiring
minutes:minutes.transcript:export scope for users with view-only access.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 21:24:05 +08:00
4 changed files with 19 additions and 45 deletions

View File

@@ -41,7 +41,6 @@ var (
scopesMinuteTokens = []string{
"minutes:minutes:readonly",
"minutes:minutes.artifacts:read",
"minutes:minutes.transcript:export",
}
scopesCalendarEventIDs = []string{
"calendar:calendar:read",
@@ -359,15 +358,10 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string,
}
}
fmt.Fprintf(errOut, "%s downloading transcript: %s\n", logPrefix, transcriptPath)
fmt.Fprintf(errOut, "%s writing transcript file: %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"},
},
ApiPath: fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)),
}, larkcore.WithFileDownload())
if err != nil {
fmt.Fprintf(errOut, "%s failed to download transcript: %v\n", logPrefix, err)
@@ -570,9 +564,8 @@ 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 + transcript")
Set("steps", "minutes API → note detail + AI artifacts (also streamed to transcript.txt)")
}
ids := runtime.Str("calendar-event-ids")
return common.NewDryRunAPI().

View File

@@ -131,32 +131,6 @@ func artifactsStub(token string) *httpmock.Stub {
}
}
func emptyArtifactsStub(token string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/" + token + "/artifacts",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
}
}
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 != "" {
@@ -671,13 +645,19 @@ func chdirForTest(t *testing.T) string {
return dir
}
// TestNotes_TranscriptDefaultLayout verifies the file-layout contract: with no
// --output-dir flag, the transcript file lands at ./minutes/{minute_token}/transcript.txt.
// The transcript file is sourced from GetMinuteArtifacts (HTTP path
// /v1/minutes/:minute_token/artifacts) — the same endpoint also feeds inline
// AI artifact parsing, so the stub is marked Reusable to satisfy both calls.
func TestNotes_TranscriptDefaultLayout(t *testing.T) {
chdirForTest(t)
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(minuteGetStub("tok001", "", "Meeting Title"))
reg.Register(emptyArtifactsStub("tok001"))
reg.Register(transcriptRawStub("tok001", []byte("speaker1: hello world\n")))
artStub := artifactsStub("tok001")
artStub.Reusable = true
reg.Register(artStub)
err := mountAndRun(t, VCNotes, []string{
"+notes", "--minute-tokens", "tok001", "--as", "user",
@@ -687,12 +667,12 @@ func TestNotes_TranscriptDefaultLayout(t *testing.T) {
}
wantPath := "minutes/tok001/transcript.txt"
data, err := os.ReadFile(wantPath)
info, err := os.Stat(wantPath)
if err != nil {
t.Fatalf("expected file at %s: %v", wantPath, err)
}
if string(data) != "speaker1: hello world\n" {
t.Errorf("content mismatch: %q", string(data))
if info.Size() == 0 {
t.Errorf("expected non-empty transcript file streamed from artifacts response, got 0 bytes")
}
if _, err := os.Stat("artifact-Meeting Title-tok001"); err == nil {
@@ -705,8 +685,9 @@ func TestNotes_TranscriptExplicitOutputDir_PreservesLegacyLayout(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(minuteGetStub("tok001", "", "Meeting Title"))
reg.Register(emptyArtifactsStub("tok001"))
reg.Register(transcriptRawStub("tok001", []byte("content")))
artStub := artifactsStub("tok001")
artStub.Reusable = true
reg.Register(artStub)
if err := os.MkdirAll("out", 0755); err != nil {
t.Fatalf("setup: %v", err)

View File

@@ -158,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``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` |
| `+recording --meeting-ids` | `vc:record:readonly` |
| `+recording --calendar-event-ids` | `vc:record:readonly``calendar:calendar:read``calendar:calendar.event:read` |

View File

@@ -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``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` |
## 输出结果