mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a268aa2d2 | ||
|
|
39d60cb706 | ||
|
|
d9330b7ab3 | ||
|
|
6b833257c7 | ||
|
|
ba51d4874e |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -2,6 +2,18 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.59] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
|
||||
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
|
||||
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **docs**: Hide docs `api-version` compat flag (#1580)
|
||||
|
||||
## [v1.0.58] - 2026-06-25
|
||||
|
||||
### Features
|
||||
@@ -1265,6 +1277,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
|
||||
@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
```
|
||||
|
||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||
|
||||
@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
```
|
||||
|
||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.58",
|
||||
"version": "1.0.59",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
215
shortcuts/calendar/calendar_meeting.go
Normal file
215
shortcuts/calendar/calendar_meeting.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +meeting — get meeting info for calendar events via mget_instance_relation_info
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const meetingLogPrefix = "[calendar +meeting]"
|
||||
|
||||
// mgetInstanceRelationRequestBody is the request body for mget_instance_relation_info API.
|
||||
type mgetInstanceRelationRequestBody struct {
|
||||
InstanceIDs []string `json:"instance_ids"`
|
||||
NeedMeetingInstanceIDs bool `json:"need_meeting_instance_ids"`
|
||||
NeedMeetingNotes bool `json:"need_meeting_notes"`
|
||||
NeedAIMeetingNotes bool `json:"need_ai_meeting_notes"`
|
||||
}
|
||||
|
||||
// meetingInfoItem represents a single event's meeting info in the output.
|
||||
type meetingInfoItem struct {
|
||||
EventID string `json:"event_id"`
|
||||
MeetingID string `json:"meeting_id,omitempty"`
|
||||
MeetingNote string `json:"meeting_note,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// translateFailMsg converts API fail_msg to a user-friendly error message.
|
||||
func translateFailMsg(failMsg string) string {
|
||||
switch failMsg {
|
||||
case "No Permission":
|
||||
return "no read permission for this calendar event (not a participant of the event)"
|
||||
case "Not Found":
|
||||
return "event not found on the specified calendar (event ID may be incorrect or does not belong to this calendar)"
|
||||
default:
|
||||
return failMsg
|
||||
}
|
||||
}
|
||||
|
||||
// fetchEventMeetingInfo queries mget_instance_relation_info for a single event instance.
|
||||
func fetchEventMeetingInfo(ctx context.Context, runtime *common.RuntimeContext, instanceID, calendarID string) *meetingInfoItem {
|
||||
body := &mgetInstanceRelationRequestBody{
|
||||
InstanceIDs: []string{instanceID},
|
||||
NeedMeetingInstanceIDs: true,
|
||||
NeedMeetingNotes: true,
|
||||
NeedAIMeetingNotes: false,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
msg := unwrapCalendarAPIError(err)
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return &meetingInfoItem{EventID: instanceID, Error: msg}
|
||||
}
|
||||
|
||||
// Check for failed instance IDs first
|
||||
if failedIDs, _ := data["failed_instance_ids"].([]any); len(failedIDs) > 0 {
|
||||
for _, raw := range failedIDs {
|
||||
if failInfo, ok := raw.(map[string]any); ok {
|
||||
if failID, _ := failInfo["instance_id"].(string); failID == instanceID {
|
||||
failMsg, _ := failInfo["fail_msg"].(string)
|
||||
return &meetingInfoItem{EventID: instanceID, Error: translateFailMsg(failMsg)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
infos, _ := data["instance_relation_infos"].([]any)
|
||||
if len(infos) == 0 {
|
||||
return &meetingInfoItem{EventID: instanceID, Error: "no event relation info found"}
|
||||
}
|
||||
|
||||
info, _ := infos[0].(map[string]any)
|
||||
result := &meetingInfoItem{EventID: instanceID}
|
||||
|
||||
// Extract meeting_id (return first if multiple) — API returns string
|
||||
if rawIDs, _ := info["meeting_instance_ids"].([]any); len(rawIDs) > 0 {
|
||||
if id, ok := rawIDs[0].(string); ok && id != "" {
|
||||
result.MeetingID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Extract meeting_note (return first if multiple)
|
||||
if notes, _ := info["meeting_notes"].([]any); len(notes) > 0 {
|
||||
if note, ok := notes[0].(string); ok && note != "" {
|
||||
result.MeetingNote = note
|
||||
}
|
||||
}
|
||||
|
||||
// Add hints for empty resources (independent checks)
|
||||
var emptyFields []string
|
||||
if result.MeetingID == "" {
|
||||
emptyFields = append(emptyFields, "meeting_id")
|
||||
}
|
||||
if result.MeetingNote == "" {
|
||||
emptyFields = append(emptyFields, "meeting_note")
|
||||
}
|
||||
if len(emptyFields) > 0 {
|
||||
result.Hint = fmt.Sprintf("%s not found for this event", strings.Join(emptyFields, ", "))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// CalendarMeeting gets meeting info for calendar events.
|
||||
var CalendarMeeting = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+meeting",
|
||||
Description: "Get meeting info for calendar events (meeting_id, meeting_note)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.event:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "event-ids", Desc: "calendar event instance IDs, comma-separated for batch", Required: true},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
ids := common.SplitCSV(runtime.Str("event-ids"))
|
||||
const maxBatchSize = 50
|
||||
if len(ids) > maxBatchSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--event-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--event-ids")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarID := runtime.Str("calendar-id")
|
||||
if calendarID == "" {
|
||||
calendarID = "<primary>"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID)).
|
||||
Set("event_ids", common.SplitCSV(runtime.Str("event-ids"))).
|
||||
Set("calendar_id", calendarID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
errOut := runtime.IO().ErrOut
|
||||
instanceIDs := common.SplitCSV(runtime.Str("event-ids"))
|
||||
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarID == "" {
|
||||
calendarID = PrimaryCalendarIDStr
|
||||
}
|
||||
|
||||
results := make([]*meetingInfoItem, 0, len(instanceIDs))
|
||||
fmt.Fprintf(errOut, "%s querying %d event_id(s)\n", meetingLogPrefix, len(instanceIDs))
|
||||
for _, id := range instanceIDs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s querying event_id=%s ...\n", meetingLogPrefix, id)
|
||||
results = append(results, fetchEventMeetingInfo(ctx, runtime, id, calendarID))
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", meetingLogPrefix, len(results), successCount, len(results)-successCount)
|
||||
|
||||
if successCount == 0 && len(results) > 0 {
|
||||
return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
|
||||
}
|
||||
|
||||
outData := map[string]any{"meetings": results}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
|
||||
if len(results) == 0 {
|
||||
fmt.Fprintln(w, "No events.")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, r := range results {
|
||||
row := map[string]interface{}{"event_id": r.EventID}
|
||||
if r.Error != "" {
|
||||
row["status"] = "FAIL"
|
||||
row["error"] = r.Error
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
if r.MeetingID != "" {
|
||||
row["meeting_id"] = r.MeetingID
|
||||
}
|
||||
if r.MeetingNote != "" {
|
||||
row["meeting_note"] = r.MeetingNote
|
||||
}
|
||||
if r.Hint != "" {
|
||||
row["hint"] = r.Hint
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d event(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
484
shortcuts/calendar/calendar_meeting_test.go
Normal file
484
shortcuts/calendar/calendar_meeting_test.go
Normal file
@@ -0,0 +1,484 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var calWarmOnce sync.Once
|
||||
|
||||
func calWarmTokenCache(t *testing.T) {
|
||||
t.Helper()
|
||||
calWarmOnce.Do(func() {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/warm",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+warm",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil)
|
||||
return err
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+warm"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
parent.Execute()
|
||||
})
|
||||
}
|
||||
|
||||
func calDefaultConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
}
|
||||
|
||||
func calMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
calWarmTokenCache(t)
|
||||
parent := &cobra.Command{Use: "calendar"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// calendar +meeting tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mgetInstanceRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes []string, aiMeetingNotes []string) *httpmock.Stub {
|
||||
infos := map[string]interface{}{
|
||||
"instance_id": instanceID,
|
||||
}
|
||||
mIDs := make([]interface{}, len(meetingIDs))
|
||||
for i, id := range meetingIDs {
|
||||
mIDs[i] = id
|
||||
}
|
||||
infos["meeting_instance_ids"] = mIDs
|
||||
if len(meetingNotes) > 0 {
|
||||
notes := make([]interface{}, len(meetingNotes))
|
||||
for i, n := range meetingNotes {
|
||||
notes[i] = n
|
||||
}
|
||||
infos["meeting_notes"] = notes
|
||||
}
|
||||
if len(aiMeetingNotes) > 0 {
|
||||
notes := make([]interface{}, len(aiMeetingNotes))
|
||||
for i, n := range aiMeetingNotes {
|
||||
notes[i] = n
|
||||
}
|
||||
infos["ai_meeting_notes"] = notes
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"instance_relation_infos": []interface{}{infos},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mgetInstanceRelationFailedStub(calendarID, instanceID, failMsg string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"instance_relation_infos": []interface{}{},
|
||||
"failed_instance_ids": []interface{}{
|
||||
map[string]interface{}{
|
||||
"instance_id": instanceID,
|
||||
"fail_msg": failMsg,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Validation_MissingEventIDs(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing --event-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Validation_BatchLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
ids := make([]string, 51)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("evt%d", i)
|
||||
}
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected batch limit error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many IDs") {
|
||||
t.Errorf("expected 'too many IDs' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mget_instance_relation_info") {
|
||||
t.Errorf("dry-run should show mget API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(mgetInstanceRelationStub("primary", "evt_m1", []string{"123456"}, []string{"doc_note1"}, []string{"doc_ai1"}))
|
||||
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_m1", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 1 {
|
||||
t.Fatalf("expected 1 meeting, got %d", len(meetings))
|
||||
}
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if m["meeting_id"] != "123456" {
|
||||
t.Errorf("meeting_id = %v, want 123456", m["meeting_id"])
|
||||
}
|
||||
if m["meeting_note"] != "doc_note1" {
|
||||
t.Errorf("meeting_note = %v, want doc_note1", m["meeting_note"])
|
||||
}
|
||||
if _, hasAI := m["ai_meeting_note"]; hasAI {
|
||||
t.Error("ai_meeting_note should not be present in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Execute_FailedInstance(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(mgetInstanceRelationFailedStub("primary", "evt_fail", "No Permission"))
|
||||
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_fail", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
// Verify translated fail_msg appears in output
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err == nil {
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) > 0 {
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if errMsg, _ := m["error"].(string); !strings.Contains(errMsg, "no read permission") {
|
||||
t.Errorf("expected translated fail_msg, got: %v", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Execute_NoMeeting(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(mgetInstanceRelationStub("primary", "evt_nomeet", []string{}, nil, nil))
|
||||
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_nomeet", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 1 {
|
||||
t.Fatalf("expected 1 meeting, got %d", len(meetings))
|
||||
}
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if hint, _ := m["hint"].(string); !strings.Contains(hint, "meeting_id") {
|
||||
t.Errorf("expected hint about meeting_id, got: %v", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// calendar +search-event tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSearchEvent_Validation_InvalidTimeRange(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "bad-format", "--end", "2026-04-27", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid --start")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_Validation_TimeRangeStartAfterEnd(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "2026-04-27", "--end", "2026-04-20", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for start after end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "search_event") {
|
||||
t.Errorf("dry-run should show search_event API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"display_info": "Q2 周会\n2026-04-23 15:00-16:00",
|
||||
"meta_data": map[string]interface{}{
|
||||
"event_id": "evt_search1",
|
||||
"summary": "Q2 周会",
|
||||
"start": map[string]interface{}{
|
||||
"date_time": "2026-04-23T15:00:00+08:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"end": map[string]interface{}{
|
||||
"date_time": "2026-04-23T16:00:00+08:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"is_all_day": false,
|
||||
"app_link": "https://applink.feishu.cn/...",
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
if data["calendar_id"] != "primary" {
|
||||
t.Errorf("calendar_id = %v, want primary", data["calendar_id"])
|
||||
}
|
||||
items, _ := data["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
item, _ := items[0].(map[string]any)
|
||||
if item["event_id"] != "evt_search1" {
|
||||
t.Errorf("event_id = %v, want evt_search1", item["event_id"])
|
||||
}
|
||||
if item["summary"] != "Q2 周会" {
|
||||
t.Errorf("summary = %v, want 'Q2 周会'", item["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_Execute_Empty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "nonexistent", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseSearchEventTimeRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", "", false},
|
||||
{"valid", "2026-04-20", "2026-04-27", false},
|
||||
{"start only defaults end", "2026-04-20", "", false},
|
||||
{"end only defaults start", "", "2026-04-27", false},
|
||||
{"invalid start format", "not-a-date", "2026-04-27", true},
|
||||
{"start after end", "2026-04-27", "2026-04-20", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
if tt.start != "" {
|
||||
_ = cmd.Flags().Set("start", tt.start)
|
||||
}
|
||||
if tt.end != "" {
|
||||
_ = cmd.Flags().Set("end", tt.end)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
_, _, err := parseSearchEventTimeRange(runtime)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseSearchEventTimeRange() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("start only fills end with end-of-day", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
_ = cmd.Flags().Set("start", "2026-04-20")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(startRFC, "2026-04-20T00:00:00") {
|
||||
t.Errorf("start = %s, want 2026-04-20T00:00:00...", startRFC)
|
||||
}
|
||||
if !strings.HasPrefix(endRFC, "2026-04-20T23:59:59") {
|
||||
t.Errorf("end = %s, want 2026-04-20T23:59:59...", endRFC)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("end only fills start with start-of-day", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
_ = cmd.Flags().Set("end", "2026-04-27")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(startRFC, "2026-04-27T00:00:00") {
|
||||
t.Errorf("start = %s, want 2026-04-27T00:00:00...", startRFC)
|
||||
}
|
||||
if !strings.HasPrefix(endRFC, "2026-04-27T23:59:59") {
|
||||
t.Errorf("end = %s, want 2026-04-27T23:59:59...", endRFC)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildSearchEventFilter(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("attendee-ids", "", "")
|
||||
_ = cmd.Flags().Set("attendee-ids", "ou_user1,oc_chat1,omm_room1")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
|
||||
filter := buildSearchEventFilter(runtime, "", "")
|
||||
if filter == nil {
|
||||
t.Fatal("expected filter to be non-nil")
|
||||
}
|
||||
if len(filter.AttendeeUserIDs) != 1 || filter.AttendeeUserIDs[0] != "ou_user1" {
|
||||
t.Errorf("attendee_user_ids = %v, want [ou_user1]", filter.AttendeeUserIDs)
|
||||
}
|
||||
if len(filter.AttendeeChatIDs) != 1 || filter.AttendeeChatIDs[0] != "oc_chat1" {
|
||||
t.Errorf("attendee_chat_ids = %v, want [oc_chat1]", filter.AttendeeChatIDs)
|
||||
}
|
||||
if len(filter.MeetingRoomIDs) != 1 || filter.MeetingRoomIDs[0] != "omm_room1" {
|
||||
t.Errorf("meeting_room_ids = %v, want [omm_room1]", filter.MeetingRoomIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchEventFilter_Empty(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("attendee-ids", "", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
|
||||
filter := buildSearchEventFilter(runtime, "", "")
|
||||
if filter != nil {
|
||||
t.Errorf("expected nil for empty filter, got %v", filter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchEventFilter_TimeRange(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("attendee-ids", "", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
|
||||
filter := buildSearchEventFilter(runtime, "2026-04-20T00:00:00+08:00", "2026-04-27T23:59:59+08:00")
|
||||
if filter == nil {
|
||||
t.Fatal("expected filter to be non-nil")
|
||||
}
|
||||
if filter.TimeRange == nil {
|
||||
t.Fatal("expected time_range in filter")
|
||||
}
|
||||
if filter.TimeRange.StartTime != "2026-04-20T00:00:00+08:00" {
|
||||
t.Errorf("start_time = %v, want 2026-04-20T00:00:00+08:00", filter.TimeRange.StartTime)
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,8 @@ type roomFindSlot struct {
|
||||
type roomFindTimeSlot struct {
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms,omitempty"`
|
||||
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindOutput struct {
|
||||
@@ -103,11 +104,18 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
|
||||
}
|
||||
return
|
||||
}
|
||||
out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{
|
||||
if suggestions == nil {
|
||||
suggestions = []*roomFindSuggestion{}
|
||||
}
|
||||
ts := &roomFindTimeSlot{
|
||||
Start: slot.Start,
|
||||
End: slot.End,
|
||||
MeetingRooms: suggestions,
|
||||
})
|
||||
}
|
||||
if len(suggestions) == 0 {
|
||||
ts.Hint = "no meeting room matches the current filters for this slot"
|
||||
}
|
||||
out.TimeSlots = append(out.TimeSlots, ts)
|
||||
}(slot)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -374,6 +382,10 @@ var CalendarRoomFind = common.Shortcut{
|
||||
}
|
||||
for _, slot := range out.TimeSlots {
|
||||
fmt.Fprintf(w, "%s - %s\n", slot.Start, slot.End)
|
||||
if len(slot.MeetingRooms) == 0 {
|
||||
fmt.Fprintf(w, "0 meeting room(s) found: %s\n", slot.Hint)
|
||||
continue
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, room := range slot.MeetingRooms {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
@@ -384,6 +396,7 @@ var CalendarRoomFind = common.Shortcut{
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "%d meeting room(s) found\n", len(slot.MeetingRooms))
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -82,3 +84,60 @@ func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) {
|
||||
t.Fatalf("expected %d time slots, got %d", len(slots), len(out.TimeSlots))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRoomFindResults_EmptySlotEmitsHintAndArray(t *testing.T) {
|
||||
slots := []roomFindSlot{
|
||||
{Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"},
|
||||
{Start: "2026-03-27T15:00:00+08:00", End: "2026-03-27T16:00:00+08:00"},
|
||||
}
|
||||
|
||||
out, err := collectRoomFindResults(slots, 2, func(slot roomFindSlot) ([]*roomFindSuggestion, error) {
|
||||
if strings.HasPrefix(slot.Start, "2026-03-27T14") {
|
||||
return []*roomFindSuggestion{{RoomID: "rm_1", RoomName: "Room A"}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("collectRoomFindResults returned error: %v", err)
|
||||
}
|
||||
if len(out.TimeSlots) != 2 {
|
||||
t.Fatalf("expected 2 time slots, got %d", len(out.TimeSlots))
|
||||
}
|
||||
|
||||
for _, ts := range out.TimeSlots {
|
||||
if ts.MeetingRooms == nil {
|
||||
t.Fatalf("meeting_rooms should be non-nil for slot %s", ts.Start)
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(ts.Start, "2026-03-27T14"):
|
||||
if len(ts.MeetingRooms) != 1 {
|
||||
t.Fatalf("expected 1 room for first slot, got %d", len(ts.MeetingRooms))
|
||||
}
|
||||
if ts.Hint != "" {
|
||||
t.Fatalf("non-empty slot should not carry hint, got %q", ts.Hint)
|
||||
}
|
||||
case strings.HasPrefix(ts.Start, "2026-03-27T15"):
|
||||
if len(ts.MeetingRooms) != 0 {
|
||||
t.Fatalf("expected 0 rooms for empty slot, got %d", len(ts.MeetingRooms))
|
||||
}
|
||||
if ts.Hint == "" {
|
||||
t.Fatal("empty slot should carry a hint explaining the filters")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emptySlot := out.TimeSlots[0]
|
||||
if !strings.HasPrefix(emptySlot.Start, "2026-03-27T15") {
|
||||
emptySlot = out.TimeSlots[1]
|
||||
}
|
||||
raw, err := json.Marshal(emptySlot)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal empty slot: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"meeting_rooms":[]`) {
|
||||
t.Fatalf("expected meeting_rooms:[] in JSON, got %s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"hint"`) {
|
||||
t.Fatalf("expected hint field in JSON, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
331
shortcuts/calendar/calendar_search_event.go
Normal file
331
shortcuts/calendar/calendar_search_event.go
Normal file
@@ -0,0 +1,331 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +search-event — search calendar events by keyword, time range, and attendees
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSearchEventPageSize = 20
|
||||
maxSearchEventPageSize = 30
|
||||
)
|
||||
|
||||
// searchEventTimeRange represents the time range filter for search_event API.
|
||||
type searchEventTimeRange struct {
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventFilter represents the filter object for the search_event API request.
|
||||
type searchEventFilter struct {
|
||||
AttendeeUserIDs []string `json:"attendee_user_ids,omitempty"`
|
||||
AttendeeChatIDs []string `json:"attendee_chat_ids,omitempty"`
|
||||
MeetingRoomIDs []string `json:"meeting_room_ids,omitempty"`
|
||||
TimeRange *searchEventTimeRange `json:"time_range,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventRequestBody is the request body for the search_event API.
|
||||
type searchEventRequestBody struct {
|
||||
Query string `json:"query"`
|
||||
Filter *searchEventFilter `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventTimeInfo represents start/end time info in the search result.
|
||||
type searchEventTimeInfo struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
DateTime string `json:"date_time,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventItem represents a single event in the search result output.
|
||||
type searchEventItem struct {
|
||||
EventID string `json:"event_id"`
|
||||
Summary string `json:"summary"`
|
||||
Start *searchEventTimeInfo `json:"start,omitempty"`
|
||||
End *searchEventTimeInfo `json:"end,omitempty"`
|
||||
IsAllDay bool `json:"is_all_day,omitempty"`
|
||||
AppLink string `json:"app_link,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventOutput is the structured output for +search-event.
|
||||
type searchEventOutput struct {
|
||||
CalendarID string `json:"calendar_id"`
|
||||
Items []searchEventItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
// parseSearchEventTimeRange parses --start / --end into RFC3339 strings.
|
||||
// When only one side is provided, the other defaults to the same day's
|
||||
// boundary (start → end-of-day, end → start-of-day).
|
||||
func parseSearchEventTimeRange(runtime *common.RuntimeContext) (string, string, error) {
|
||||
startInput := strings.TrimSpace(runtime.Str("start"))
|
||||
endInput := strings.TrimSpace(runtime.Str("end"))
|
||||
if startInput == "" && endInput == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
var startSec, endSec int64
|
||||
|
||||
if startInput != "" {
|
||||
ts, err := common.ParseTime(startInput)
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
}
|
||||
startSec, _ = strconv.ParseInt(ts, 10, 64)
|
||||
}
|
||||
if endInput != "" {
|
||||
ts, err := common.ParseTime(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
endSec, _ = strconv.ParseInt(ts, 10, 64)
|
||||
}
|
||||
|
||||
if startInput == "" {
|
||||
t := time.Unix(endSec, 0).In(time.Local)
|
||||
startSec = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).Unix()
|
||||
}
|
||||
if endInput == "" {
|
||||
t := time.Unix(startSec, 0).In(time.Local)
|
||||
endSec = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()).Unix()
|
||||
}
|
||||
|
||||
if startSec > endSec {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start must be before --end").WithParam("--start")
|
||||
}
|
||||
|
||||
return time.Unix(startSec, 0).Format(time.RFC3339), time.Unix(endSec, 0).Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// buildSearchEventFilter builds the filter object for the search_event API.
|
||||
func buildSearchEventFilter(runtime *common.RuntimeContext, startTime, endTime string) *searchEventFilter {
|
||||
attendeeIDs := common.SplitCSV(runtime.Str("attendee-ids"))
|
||||
|
||||
var userIDs, chatIDs, roomIDs []string
|
||||
for _, id := range attendeeIDs {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "ou_"):
|
||||
userIDs = append(userIDs, id)
|
||||
case strings.HasPrefix(id, "oc_"):
|
||||
chatIDs = append(chatIDs, id)
|
||||
case strings.HasPrefix(id, "omm_"):
|
||||
roomIDs = append(roomIDs, id)
|
||||
default:
|
||||
userIDs = append(userIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
var tr *searchEventTimeRange
|
||||
if startTime != "" || endTime != "" {
|
||||
tr = &searchEventTimeRange{StartTime: startTime, EndTime: endTime}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 && len(chatIDs) == 0 && len(roomIDs) == 0 && tr == nil {
|
||||
return nil
|
||||
}
|
||||
return &searchEventFilter{
|
||||
AttendeeUserIDs: userIDs,
|
||||
AttendeeChatIDs: chatIDs,
|
||||
MeetingRoomIDs: roomIDs,
|
||||
TimeRange: tr,
|
||||
}
|
||||
}
|
||||
|
||||
// extractTimeInfo extracts time info from a meta_data start/end map.
|
||||
func extractTimeInfo(m map[string]any) *searchEventTimeInfo {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
info := &searchEventTimeInfo{}
|
||||
if v, ok := m["date"].(string); ok && v != "" {
|
||||
info.Date = v
|
||||
}
|
||||
if v, ok := m["date_time"].(string); ok && v != "" {
|
||||
info.DateTime = v
|
||||
}
|
||||
if v, ok := m["timezone"].(string); ok && v != "" {
|
||||
info.Timezone = v
|
||||
}
|
||||
if info.Date == "" && info.DateTime == "" {
|
||||
return nil
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// CalendarSearchEvent searches calendar events by keyword, time range, and attendees.
|
||||
var CalendarSearchEvent = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+search-event",
|
||||
Description: "Search calendar events by keyword, time range, and attendees",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.event:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
||||
{Name: "start", Desc: "search time range start (ISO 8601 or YYYY-MM-DD)"},
|
||||
{Name: "end", Desc: "search time range end (ISO 8601 or YYYY-MM-DD)"},
|
||||
{Name: "page-token", Desc: "page token for next page"},
|
||||
{Name: "page-size", Default: "20", Desc: "page size, 1-30 (default 20)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseSearchEventTimeRange(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultSearchEventPageSize, 1, maxSearchEventPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarID := runtime.Str("calendar-id")
|
||||
if calendarID == "" {
|
||||
calendarID = "<primary>"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", calendarID)).
|
||||
Set("calendar_id", calendarID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarID == "" {
|
||||
calendarID = PrimaryCalendarIDStr
|
||||
}
|
||||
|
||||
startTime, endTime, err := parseSearchEventTimeRange(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build request body — always send query (even if empty)
|
||||
body := &searchEventRequestBody{
|
||||
Query: strings.TrimSpace(runtime.Str("query")),
|
||||
}
|
||||
if filter := buildSearchEventFilter(runtime, startTime, endTime); filter != nil {
|
||||
body.Filter = filter
|
||||
}
|
||||
|
||||
// Build query params
|
||||
params := map[string]any{}
|
||||
pageSize, _ := strconv.Atoi(strings.TrimSpace(runtime.Str("page-size")))
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultSearchEventPageSize
|
||||
}
|
||||
params["page_size"] = strconv.Itoa(pageSize)
|
||||
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
|
||||
params["page_token"] = pt
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", validate.EncodePathSegment(calendarID)),
|
||||
params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
|
||||
items := common.GetSlice(data, "items")
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
pageToken, _ := data["page_token"].(string)
|
||||
|
||||
// Transform items to structured output
|
||||
outItems := make([]searchEventItem, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]any)
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
meta, _ := item["meta_data"].(map[string]any)
|
||||
out := searchEventItem{}
|
||||
if meta != nil {
|
||||
if v, ok := meta["event_id"].(string); ok {
|
||||
out.EventID = v
|
||||
}
|
||||
if v, ok := meta["summary"].(string); ok {
|
||||
out.Summary = v
|
||||
}
|
||||
if v, ok := meta["is_all_day"].(bool); ok {
|
||||
out.IsAllDay = v
|
||||
}
|
||||
if v, ok := meta["app_link"].(string); ok {
|
||||
out.AppLink = v
|
||||
}
|
||||
if start, ok := meta["start"].(map[string]any); ok {
|
||||
out.Start = extractTimeInfo(start)
|
||||
}
|
||||
if end, ok := meta["end"].(map[string]any); ok {
|
||||
out.End = extractTimeInfo(end)
|
||||
}
|
||||
}
|
||||
outItems = append(outItems, out)
|
||||
}
|
||||
|
||||
outData := searchEventOutput{
|
||||
CalendarID: calendarID,
|
||||
Items: outItems,
|
||||
HasMore: hasMore,
|
||||
PageToken: pageToken,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(outItems)}, func(w io.Writer) {
|
||||
if len(outItems) == 0 {
|
||||
fmt.Fprintln(w, "No events found.")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, item := range outItems {
|
||||
row := map[string]interface{}{
|
||||
"event_id": item.EventID,
|
||||
"summary": common.TruncateStr(item.Summary, 40),
|
||||
}
|
||||
if item.Start != nil {
|
||||
if item.Start.DateTime != "" {
|
||||
row["start"] = item.Start.DateTime
|
||||
} else if item.Start.Date != "" {
|
||||
row["start"] = item.Start.Date
|
||||
}
|
||||
}
|
||||
if item.End != nil {
|
||||
if item.End.DateTime != "" {
|
||||
row["end"] = item.End.DateTime
|
||||
} else if item.End.Date != "" {
|
||||
row["end"] = item.End.Date
|
||||
}
|
||||
}
|
||||
if item.IsAllDay {
|
||||
row["is_all_day"] = true
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d event(s) found\n", len(outItems))
|
||||
})
|
||||
|
||||
if hasMore && runtime.Format != "json" && runtime.Format != "" {
|
||||
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -2234,10 +2234,10 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns7(t *testing.T) {
|
||||
func TestShortcuts_Returns9(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 7 {
|
||||
t.Fatalf("expected 7 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 9 {
|
||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
|
||||
@@ -42,3 +42,30 @@ func withParam(err error, flag string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// unwrapCalendarAPIError returns a user-facing message extracted from a
|
||||
// calendar business-domain *errs.APIError, or "" when the error is not an
|
||||
// APIError or its Code is not specialized here. Callers should fall back to
|
||||
// err.Error() on "".
|
||||
//
|
||||
// Today it handles:
|
||||
// - 190014 (invalid_parameters): returns Problem.Hint, which carries the
|
||||
// server-supplied field-level detail (e.g. "end_time should be later
|
||||
// than start_time") lifted by errclass.BuildAPIError.
|
||||
//
|
||||
// Add additional 19xxxx codes here as they become worth surfacing — keep this
|
||||
// the single switch site so call sites stay readable.
|
||||
func unwrapCalendarAPIError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var ae *errs.APIError
|
||||
if !errors.As(err, &ae) {
|
||||
return ""
|
||||
}
|
||||
switch ae.Code {
|
||||
case 190014:
|
||||
return ae.Hint
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -240,3 +240,62 @@ func TestParseCalendarAttendeeIDs_Valid(t *testing.T) {
|
||||
t.Errorf("dedup/trim failed: got %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// unwrapCalendarAPIError helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUnwrapCalendarAPIError_NilReturnsEmpty(t *testing.T) {
|
||||
if got := unwrapCalendarAPIError(nil); got != "" {
|
||||
t.Errorf("nil err should return empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapCalendarAPIError_NonAPIErrorReturnsEmpty(t *testing.T) {
|
||||
// Validation, internal, and plain errors are not calendar API business
|
||||
// errors; the helper must signal "no specialization" so callers fall back.
|
||||
cases := []error{
|
||||
errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input"),
|
||||
errs.NewInternalError(errs.SubtypeSDKError, "io failure"),
|
||||
errors.New("plain error"),
|
||||
}
|
||||
for _, e := range cases {
|
||||
if got := unwrapCalendarAPIError(e); got != "" {
|
||||
t.Errorf("unwrapCalendarAPIError(%T) = %q, want empty", e, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapCalendarAPIError_Code190014_ReturnsHint(t *testing.T) {
|
||||
ae := errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid params").
|
||||
WithCode(190014).
|
||||
WithHint("end_time should be later than start_time")
|
||||
got := unwrapCalendarAPIError(ae)
|
||||
if got != "end_time should be later than start_time" {
|
||||
t.Errorf("expected lifted hint, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapCalendarAPIError_Code190014_WrappedStillResolves(t *testing.T) {
|
||||
// withStepContext wraps the typed error but errors.As must still find it.
|
||||
inner := errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid params").
|
||||
WithCode(190014).
|
||||
WithHint("calendar_id is required")
|
||||
wrapped := withStepContext(inner, "while fetching meeting info for %s", "evt_x")
|
||||
got := unwrapCalendarAPIError(wrapped)
|
||||
if !strings.Contains(got, "calendar_id is required") {
|
||||
t.Errorf("expected wrapped 190014 to surface hint, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapCalendarAPIError_UnhandledCodeReturnsEmpty(t *testing.T) {
|
||||
// An APIError carrying a code that isn't specialized here should return
|
||||
// "" so callers fall back to err.Error() — keeps the helper conservative
|
||||
// while we add 19xxxx codes incrementally.
|
||||
ae := errs.NewAPIError(errs.SubtypeInvalidParameters, "some other error").
|
||||
WithCode(190099).
|
||||
WithHint("ignore me")
|
||||
if got := unwrapCalendarAPIError(ae); got != "" {
|
||||
t.Errorf("unhandled code should return empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,7 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarRoomFind,
|
||||
CalendarRsvp,
|
||||
CalendarSuggestion,
|
||||
CalendarMeeting,
|
||||
CalendarSearchEvent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// This file defines artifact-path conventions shared between
|
||||
// `minutes +download` and `vc +notes`. Callers outside those two shortcuts
|
||||
// `minutes +download` and `minutes +detail`. Callers outside those two shortcuts
|
||||
// should not take a dependency on these symbols.
|
||||
|
||||
package common
|
||||
|
||||
@@ -90,7 +90,6 @@ func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "bot",
|
||||
})
|
||||
@@ -125,7 +124,6 @@ func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "user",
|
||||
})
|
||||
@@ -163,7 +161,6 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "bot",
|
||||
})
|
||||
@@ -201,7 +198,6 @@ func TestDocsCreateV2FallbackURLWhenBackendOmitsIt(t *testing.T) {
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "user",
|
||||
})
|
||||
@@ -233,7 +229,6 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "user",
|
||||
})
|
||||
@@ -248,7 +243,7 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
func TestDocsCreateAPIVersionCompatFlagIsIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
@@ -262,7 +257,7 @@ func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v1",
|
||||
"--api-version", "legacy",
|
||||
"--content", "<title>项目计划</title>",
|
||||
"--as", "user",
|
||||
})
|
||||
|
||||
@@ -507,10 +507,10 @@ func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
func TestDocsFetchAPIVersionCompatFlagIsIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "v1", nil)
|
||||
runtime := newFetchShortcutTestRuntime(t, "legacy", nil)
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ func TestValidCommandsV2(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateDryRunAcceptsDeprecatedAPIVersionValues(t *testing.T) {
|
||||
for _, apiVersion := range []string{"v1", "v2"} {
|
||||
func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
for _, apiVersion := range []string{"v1", "v2", "legacy"} {
|
||||
t.Run(apiVersion, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ type docsLegacyFlag struct {
|
||||
|
||||
func docsAPIVersionCompatFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "api-version",
|
||||
Desc: "deprecated compatibility flag; docs shortcuts always use v2, and both v1/v2 are accepted for rollback-safe skill examples",
|
||||
Default: "v2",
|
||||
Name: "api-version",
|
||||
Desc: "deprecated compatibility flag; ignored by docs shortcuts",
|
||||
Hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func docsLegacyFlagDefinitions(flags []docsLegacyFlag) []common.Flag {
|
||||
for _, flag := range flags {
|
||||
out = append(out, common.Flag{
|
||||
Name: flag.Name,
|
||||
Desc: "deprecated v1 compatibility flag; run `lark-cli skills read lark-doc` for the v2 CLI skill",
|
||||
Desc: "deprecated compatibility flag; run `lark-cli skills read lark-doc` for the current CLI skill",
|
||||
Hidden: true,
|
||||
})
|
||||
}
|
||||
@@ -62,12 +62,6 @@ func docsLegacyFlagDefinitions(flags []docsLegacyFlag) []common.Flag {
|
||||
}
|
||||
|
||||
func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyFlags []docsLegacyFlag) error {
|
||||
switch apiVersion := strings.TrimSpace(runtime.Str("api-version")); apiVersion {
|
||||
case "", "v1", "v2":
|
||||
default:
|
||||
return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API", "--api-version")
|
||||
}
|
||||
|
||||
var used []string
|
||||
var replacements []string
|
||||
for _, flag := range legacyFlags {
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestValidateDocsV2OnlyAllowsDefaultAndDeprecatedAPIVersionValues(t *testing.T) {
|
||||
for _, apiVersion := range []string{"", "v1", "v2"} {
|
||||
func TestValidateDocsV2OnlyIgnoresAPIVersionValues(t *testing.T) {
|
||||
for _, apiVersion := range []string{"", "v1", "v2", "v0", "legacy"} {
|
||||
t.Run(apiVersion, func(t *testing.T) {
|
||||
runtime := docsV2OnlyTestRuntime(t, apiVersion, false)
|
||||
if err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}}); err != nil {
|
||||
@@ -22,28 +22,6 @@ func TestValidateDocsV2OnlyAllowsDefaultAndDeprecatedAPIVersionValues(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocsV2OnlyRejectsUnknownAPIVersion(t *testing.T) {
|
||||
runtime := docsV2OnlyTestRuntime(t, "v0", false)
|
||||
err := validateDocsV2Only(runtime, "+fetch", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown --api-version to be rejected")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"docs +fetch is v2-only",
|
||||
"--api-version is deprecated and only accepts v1 or v2",
|
||||
"both values execute the v2 API",
|
||||
"lark-cli skills read lark-doc references/lark-doc-fetch.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-md.md",
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"lark-cli docs +fetch --help",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocsV2OnlyRejectsChangedLegacyFlags(t *testing.T) {
|
||||
runtime := docsV2OnlyTestRuntime(t, "", true)
|
||||
err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}})
|
||||
|
||||
320
shortcuts/minutes/minutes_detail.go
Normal file
320
shortcuts/minutes/minutes_detail.go
Normal file
@@ -0,0 +1,320 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// minutes +detail — query minute details with selective artifact flags
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const minutesDetailLogPrefix = "[minutes +detail]"
|
||||
|
||||
// Error codes from the minutes API.
|
||||
const minutesDetailNoReadPermissionCode = 2091005
|
||||
|
||||
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
|
||||
var scopesDetailMinuteTokens = []string{
|
||||
"minutes:minutes.basic:read",
|
||||
"minutes:minutes.artifacts:read",
|
||||
}
|
||||
|
||||
// minuteDetailItem represents a single minute detail result.
|
||||
type minuteDetailItem struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
Title string `json:"title"`
|
||||
NoteID string `json:"note_id"`
|
||||
Artifacts map[string]any `json:"artifacts,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
||||
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
if err != nil {
|
||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
minute, _ := data["minute"].(map[string]any)
|
||||
if minute == nil {
|
||||
return &minuteDetailItem{MinuteToken: minuteToken, Error: "minute not found"}
|
||||
}
|
||||
|
||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||
if v, ok := minute["title"].(string); ok && v != "" {
|
||||
result.Title = v
|
||||
}
|
||||
if v, ok := minute["note_id"].(string); ok && v != "" {
|
||||
result.NoteID = v
|
||||
}
|
||||
|
||||
// Fetch artifacts selectively based on flags
|
||||
needSummary := runtime.Bool("summary")
|
||||
needTodo := runtime.Bool("todo")
|
||||
needChapter := runtime.Bool("chapter")
|
||||
needTranscript := runtime.Bool("transcript")
|
||||
needKeyword := runtime.Bool("keyword")
|
||||
|
||||
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
||||
artData, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
|
||||
} else {
|
||||
artifacts := make(map[string]any)
|
||||
if needSummary {
|
||||
if v, ok := artData["summary"].(string); ok && v != "" {
|
||||
artifacts["summary"] = v
|
||||
} else {
|
||||
artifacts["summary"] = ""
|
||||
}
|
||||
}
|
||||
if needTodo {
|
||||
if v, ok := artData["minute_todos"].([]any); ok && len(v) > 0 {
|
||||
artifacts["todos"] = v
|
||||
} else {
|
||||
artifacts["todos"] = []any{}
|
||||
}
|
||||
}
|
||||
if needChapter {
|
||||
if v, ok := artData["minute_chapters"].([]any); ok && len(v) > 0 {
|
||||
artifacts["chapters"] = v
|
||||
} else {
|
||||
artifacts["chapters"] = []any{}
|
||||
}
|
||||
}
|
||||
if needKeyword {
|
||||
if v, ok := artData["keywords"].([]any); ok && len(v) > 0 {
|
||||
artifacts["keywords"] = v
|
||||
} else {
|
||||
artifacts["keywords"] = []any{}
|
||||
}
|
||||
}
|
||||
if needTranscript {
|
||||
if v, ok := artData["transcript"].(string); ok && v != "" {
|
||||
if path := saveDetailTranscript(runtime, minuteToken, result.Title, []byte(v)); path != "" {
|
||||
artifacts["transcript_file"] = path
|
||||
} else {
|
||||
artifacts["transcript_file"] = ""
|
||||
}
|
||||
} else {
|
||||
artifacts["transcript_file"] = ""
|
||||
}
|
||||
}
|
||||
result.Artifacts = artifacts
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
||||
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
||||
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
||||
// ./minutes/<token>/ shared with `minutes +download`.
|
||||
func saveDetailTranscript(runtime *common.RuntimeContext, minuteToken, title string, content []byte) string {
|
||||
errOut := runtime.IO().ErrOut
|
||||
var dirName string
|
||||
if outDir := runtime.Str("output-dir"); outDir != "" {
|
||||
dirName = filepath.Join(outDir, sanitizeDetailDirName(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", minutesDetailLogPrefix, transcriptPath)
|
||||
return transcriptPath
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(errOut, "%s writing transcript: %s\n", minutesDetailLogPrefix, transcriptPath)
|
||||
if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(content)); err != nil {
|
||||
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", minutesDetailLogPrefix, err)
|
||||
return ""
|
||||
}
|
||||
return transcriptPath
|
||||
}
|
||||
|
||||
// sanitizeDetailDirName generates a filesystem-safe directory name using title
|
||||
// and minuteToken for uniqueness. Mirrors the layout produced by `vc +notes`
|
||||
// so both shortcuts write artifacts to identical paths under --output-dir.
|
||||
func sanitizeDetailDirName(title, minuteToken string) string {
|
||||
const maxLen = 200
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "_", "\\", "_", ":", "_", "*", "_", "?", "_",
|
||||
"\"", "_", "<", "_", ">", "_", "|", "_",
|
||||
"\n", "_", "\r", "_", "\t", "_", "\x00", "_",
|
||||
)
|
||||
safe := replacer.Replace(strings.TrimSpace(title))
|
||||
safe = strings.Trim(safe, ".")
|
||||
if len(safe) > maxLen {
|
||||
safe = safe[:maxLen]
|
||||
}
|
||||
if safe == "" {
|
||||
return fmt.Sprintf("artifact-%s", minuteToken)
|
||||
}
|
||||
return fmt.Sprintf("artifact-%s-%s", safe, minuteToken)
|
||||
}
|
||||
|
||||
// MinutesDetail queries minute details with selective artifact flags.
|
||||
var MinutesDetail = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+detail",
|
||||
Description: "Query minute details with selective artifact flags (summary, todo, chapter, transcript, keyword)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"minutes:minutes.basic:read", "minutes:minutes.artifacts:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch", Required: true},
|
||||
{Name: "summary", Type: "bool", Desc: "include summary"},
|
||||
{Name: "todo", Type: "bool", Desc: "include todos"},
|
||||
{Name: "chapter", Type: "bool", Desc: "include chapters"},
|
||||
{Name: "transcript", Type: "bool", Desc: "include transcript (saved to file)"},
|
||||
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
||||
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
const maxBatchSize = 50
|
||||
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 !validMinuteTokenDetail.MatchString(token) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters", token).WithParam("--minute-tokens")
|
||||
}
|
||||
}
|
||||
if outDir := runtime.Str("output-dir"); outDir != "" {
|
||||
if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// dynamic scope check
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err == nil && result != nil && result.Scopes != "" {
|
||||
if missing := auth.MissingScopes(result.Scopes, scopesDetailMinuteTokens); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
tokens := runtime.Str("minute-tokens")
|
||||
d := common.NewDryRunAPI().
|
||||
GET("/open-apis/minutes/v1/minutes/{minute_token}").
|
||||
Set("minute_tokens", common.SplitCSV(tokens))
|
||||
|
||||
if runtime.Bool("summary") || runtime.Bool("todo") || runtime.Bool("chapter") || runtime.Bool("transcript") || runtime.Bool("keyword") {
|
||||
d.GET("/open-apis/minutes/v1/minutes/{minute_token}/artifacts")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
errOut := runtime.IO().ErrOut
|
||||
minuteTokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
results := make([]*minuteDetailItem, 0, len(minuteTokens))
|
||||
|
||||
const batchDelay = 100 * time.Millisecond
|
||||
fmt.Fprintf(errOut, "%s querying %d minute_token(s)\n", minutesDetailLogPrefix, len(minuteTokens))
|
||||
for i, token := range minuteTokens {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(batchDelay)
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s querying minute_token=%s ...\n", minutesDetailLogPrefix, token)
|
||||
results = append(results, fetchMinuteDetail(ctx, runtime, token))
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", minutesDetailLogPrefix, len(results), successCount, len(results)-successCount)
|
||||
|
||||
if successCount == 0 && len(results) > 0 {
|
||||
return runtime.OutPartialFailure(map[string]any{"minutes": results}, &output.Meta{Count: len(results)})
|
||||
}
|
||||
|
||||
outData := map[string]any{"minutes": results}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
|
||||
if len(results) == 0 {
|
||||
fmt.Fprintln(w, "No minutes.")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, r := range results {
|
||||
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
||||
if r.Error != "" {
|
||||
row["status"] = "FAIL"
|
||||
row["error"] = r.Error
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
row["title"] = r.Title
|
||||
row["note_id"] = r.NoteID
|
||||
if len(r.Artifacts) > 0 {
|
||||
var parts []string
|
||||
if _, ok := r.Artifacts["summary"]; ok {
|
||||
parts = append(parts, "summary")
|
||||
}
|
||||
if _, ok := r.Artifacts["todos"]; ok {
|
||||
parts = append(parts, "todo")
|
||||
}
|
||||
if _, ok := r.Artifacts["chapters"]; ok {
|
||||
parts = append(parts, "chapter")
|
||||
}
|
||||
if _, ok := r.Artifacts["keywords"]; ok {
|
||||
parts = append(parts, "keyword")
|
||||
}
|
||||
if _, ok := r.Artifacts["transcript_file"]; ok {
|
||||
parts = append(parts, "transcript")
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
row["artifacts"] = strings.Join(parts, ", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d minute(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
394
shortcuts/minutes/minutes_detail_test.go
Normal file
394
shortcuts/minutes/minutes_detail_test.go
Normal file
@@ -0,0 +1,394 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var detailWarmOnce sync.Once
|
||||
|
||||
func detailWarmTokenCache(t *testing.T) {
|
||||
t.Helper()
|
||||
detailWarmOnce.Do(func() {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/warm",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+warm",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil)
|
||||
return err
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+warm"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
parent.Execute()
|
||||
})
|
||||
}
|
||||
|
||||
func detailMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
detailWarmTokenCache(t)
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func detailMinuteGetStub(token, noteID, title string) *httpmock.Stub {
|
||||
minute := map[string]interface{}{"title": title}
|
||||
if noteID != "" {
|
||||
minute["note_id"] = noteID
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/" + token,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"minute": minute},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func detailArtifactsStub(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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing --minute-tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_InvalidToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "INVALID!", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid token")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--minute-tokens" {
|
||||
t.Errorf("Param = %q, want --minute-tokens", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_BatchLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
tokens := make([]string, 51)
|
||||
for i := range tokens {
|
||||
tokens[i] = fmt.Sprintf("tok%d", i)
|
||||
}
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", strings.Join(tokens, ","), "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected batch limit error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many tokens") {
|
||||
t.Errorf("expected 'too many tokens' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DryRun tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/") {
|
||||
t.Errorf("dry-run should show minutes API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--summary", "--todo", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "artifacts") {
|
||||
t.Errorf("dry-run should show artifacts API path when artifact flags are set, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests with mocked HTTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_Execute_BasicInfo(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokbasic", "", "Test Meeting"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbasic", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
minutes, _ := data["minutes"].([]any)
|
||||
if len(minutes) != 1 {
|
||||
t.Fatalf("expected 1 minute, got %d", len(minutes))
|
||||
}
|
||||
m, _ := minutes[0].(map[string]any)
|
||||
if m["minute_token"] != "tokbasic" {
|
||||
t.Errorf("minute_token = %v, want tokbasic", m["minute_token"])
|
||||
}
|
||||
if m["title"] != "Test Meeting" {
|
||||
t.Errorf("title = %v, want Test Meeting", m["title"])
|
||||
}
|
||||
noteID, hasNoteID := m["note_id"]
|
||||
if !hasNoteID {
|
||||
t.Error("note_id should always be present in output (even when empty)")
|
||||
}
|
||||
if noteID != "" {
|
||||
t.Errorf("note_id should be empty string when minute has no note_id, got %v", noteID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_WithSummaryAndTodo(t *testing.T) {
|
||||
chdirForDetailTest(t)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokart", "note_art", "Artifact Meeting"))
|
||||
reg.Register(detailArtifactsStub("tokart", ""))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokart", "--summary", "--todo", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
minutes, _ := data["minutes"].([]any)
|
||||
if len(minutes) != 1 {
|
||||
t.Fatalf("expected 1 minute, got %d", len(minutes))
|
||||
}
|
||||
m, _ := minutes[0].(map[string]any)
|
||||
if m["note_id"] != "note_art" {
|
||||
t.Errorf("note_id = %v, want note_art", m["note_id"])
|
||||
}
|
||||
arts, _ := m["artifacts"].(map[string]any)
|
||||
if arts == nil {
|
||||
t.Fatal("expected artifacts to be present")
|
||||
}
|
||||
if _, ok := arts["summary"]; !ok {
|
||||
t.Error("expected summary in artifacts")
|
||||
}
|
||||
if _, ok := arts["todos"]; !ok {
|
||||
t.Error("expected todos in artifacts")
|
||||
}
|
||||
// chapter and keywords should NOT be present since flags not set
|
||||
if _, ok := arts["chapters"]; ok {
|
||||
t.Error("chapters should not be present when --chapter not set")
|
||||
}
|
||||
if _, ok := arts["keywords"]; ok {
|
||||
t.Error("keywords should not be present when --keyword not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_NoArtifactFlags(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("toknoart", "", "No Artifacts"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toknoart", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
minutes, _ := data["minutes"].([]any)
|
||||
if len(minutes) != 1 {
|
||||
t.Fatalf("expected 1 minute, got %d", len(minutes))
|
||||
}
|
||||
m, _ := minutes[0].(map[string]any)
|
||||
if _, ok := m["artifacts"]; ok {
|
||||
t.Error("artifacts should not be present when no artifact flags set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_Transcript(t *testing.T) {
|
||||
chdirForDetailTest(t)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("toktrans", "", "Transcript Meeting"))
|
||||
reg.Register(detailArtifactsStub("toktrans", "speaker1: hello world\n"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toktrans", "--transcript", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Check transcript file was saved
|
||||
wantPath := "minutes/toktrans/transcript.txt"
|
||||
data, err := os.ReadFile(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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_Transcript_OutputDir(t *testing.T) {
|
||||
chdirForDetailTest(t)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokod", "", "Output Dir Meeting"))
|
||||
reg.Register(detailArtifactsStub("tokod", "alice: hi\n"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokod", "--transcript", "--output-dir", "custom_out", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Mirrors `minutes +detail --output-dir` layout: artifact-<title>-<token>/transcript.txt
|
||||
wantPath := "custom_out/artifact-Output Dir Meeting-tokod/transcript.txt"
|
||||
data, err := os.ReadFile(wantPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected file at %s: %v", wantPath, err)
|
||||
}
|
||||
if string(data) != "alice: hi\n" {
|
||||
t.Errorf("content mismatch: %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_OutputDirEscape(t *testing.T) {
|
||||
chdirForDetailTest(t)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--output-dir", "../escape", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for escaping output-dir")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_MinuteNotFound(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/tokbad",
|
||||
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
|
||||
})
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbad", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidMinuteTokenDetail(t *testing.T) {
|
||||
tests := []struct {
|
||||
token string
|
||||
valid bool
|
||||
}{
|
||||
{"abc123", true},
|
||||
{"obcnmgn1429t5xt9j82i1p3h", true},
|
||||
{"INVALID!", false},
|
||||
{"has-space", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := validMinuteTokenDetail.MatchString(tt.token)
|
||||
if got != tt.valid {
|
||||
t.Errorf("validMinuteTokenDetail(%q) = %v, want %v", tt.token, got, tt.valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// chdirForDetailTest switches cwd to a temp dir for the test.
|
||||
func chdirForDetailTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
return dir
|
||||
}
|
||||
@@ -184,12 +184,6 @@ func minuteSearchAppLink(item map[string]interface{}) string {
|
||||
return common.GetString(meta, "app_link")
|
||||
}
|
||||
|
||||
// minuteSearchAvatar extracts the avatar URL from a search result item.
|
||||
func minuteSearchAvatar(item map[string]interface{}) string {
|
||||
meta := common.GetMap(item, "meta_data")
|
||||
return common.GetString(meta, "avatar")
|
||||
}
|
||||
|
||||
// buildMinuteSearchRows converts API items into pretty output rows.
|
||||
func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
|
||||
rows := make([]map[string]interface{}, 0, len(items))
|
||||
@@ -203,12 +197,27 @@ func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
|
||||
"display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40),
|
||||
"description": common.TruncateStr(minuteSearchDescription(item), 40),
|
||||
"app_link": common.TruncateStr(minuteSearchAppLink(item), 80),
|
||||
"avatar": common.TruncateStr(minuteSearchAvatar(item), 80),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// stripAvatarFromItems removes meta_data.avatar from each search item in place
|
||||
// so the structured output does not surface avatars to AI agents.
|
||||
func stripAvatarFromItems(items []interface{}) {
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
meta, _ := item["meta_data"].(map[string]interface{})
|
||||
if meta == nil {
|
||||
continue
|
||||
}
|
||||
delete(meta, "avatar")
|
||||
}
|
||||
}
|
||||
|
||||
// MinutesSearch searches minutes by keyword, owners, participants, and time range.
|
||||
var MinutesSearch = common.Shortcut{
|
||||
Service: "minutes",
|
||||
@@ -298,13 +307,13 @@ var MinutesSearch = common.Shortcut{
|
||||
}
|
||||
|
||||
items := minuteSearchItems(data)
|
||||
stripAvatarFromItems(items)
|
||||
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"],
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} {
|
||||
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "next_token", "more available"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q, got: %s", want, out)
|
||||
}
|
||||
@@ -672,7 +672,6 @@ func TestMinuteSearchFieldExtractors(t *testing.T) {
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -688,9 +687,6 @@ func TestMinuteSearchFieldExtractors(t *testing.T) {
|
||||
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q", got)
|
||||
}
|
||||
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data.
|
||||
@@ -703,7 +699,6 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "回退纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/fallback",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -716,9 +711,6 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
|
||||
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q", got)
|
||||
}
|
||||
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata.
|
||||
@@ -739,7 +731,32 @@ func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) {
|
||||
if got := minuteSearchAppLink(item); got != "" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q, want empty", got)
|
||||
}
|
||||
if got := minuteSearchAvatar(item); got != "" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q, want empty", got)
|
||||
}
|
||||
|
||||
// TestStripAvatarFromItems verifies the avatar field is removed from items in place.
|
||||
func TestStripAvatarFromItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
items := []interface{}{
|
||||
map[string]interface{}{
|
||||
"token": "minute_1",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
|
||||
},
|
||||
},
|
||||
nil,
|
||||
map[string]interface{}{"token": "minute_no_meta"},
|
||||
}
|
||||
|
||||
stripAvatarFromItems(items)
|
||||
|
||||
first, _ := items[0].(map[string]interface{})
|
||||
meta, _ := first["meta_data"].(map[string]interface{})
|
||||
if _, ok := meta["avatar"]; ok {
|
||||
t.Fatalf("avatar should be stripped, got meta = %v", meta)
|
||||
}
|
||||
if meta["description"] != "周会纪要" {
|
||||
t.Fatalf("description should be preserved, got %v", meta["description"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,13 @@ var MinutesSpeakerReplace = common.Shortcut{
|
||||
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"},
|
||||
Scopes: []string{"minutes:minutes:readonly", "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: "from-speaker-id", Desc: "speaker to replace: opaque speaker_id from transcript speakerlist API (do not pass display names)"},
|
||||
{Name: "from-user-id", Desc: "deprecated: open_id of the speaker to replace; prefer --from-speaker-id", Hidden: 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 {
|
||||
@@ -41,12 +42,10 @@ var MinutesSpeakerReplace = common.Shortcut{
|
||||
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
|
||||
}
|
||||
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
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
|
||||
if fromSpeakerID == "" && fromUserID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-speaker-id is required").WithParam("--from-speaker-id")
|
||||
}
|
||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||
if toUserID == "" {
|
||||
@@ -55,53 +54,93 @@ var MinutesSpeakerReplace = common.Shortcut{
|
||||
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")
|
||||
if fromSpeakerID == "" {
|
||||
if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); 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,
|
||||
})
|
||||
dr := common.NewDryRunAPI()
|
||||
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
|
||||
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
|
||||
}
|
||||
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
Body(buildSpeakerReplaceRequestBody(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-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)
|
||||
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return minutesSpeakerReplaceError(err, minuteToken, fromUserID)
|
||||
return err
|
||||
}
|
||||
|
||||
outData := 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)),
|
||||
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
||||
if err != nil {
|
||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, nil)
|
||||
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error {
|
||||
func buildSpeakerReplaceRequestBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||
return buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID)
|
||||
}
|
||||
|
||||
func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"to_user_id": toUserID,
|
||||
}
|
||||
if fromSpeakerID != "" {
|
||||
body["from_speaker_id"] = fromSpeakerID
|
||||
} else {
|
||||
body["from_user_id"] = fromUserID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"minute_token": minuteToken,
|
||||
"to_user_id": toUserID,
|
||||
}
|
||||
if fromSpeakerID != "" {
|
||||
out["from_speaker_id"] = fromSpeakerID
|
||||
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
|
||||
out["from_speaker_input"] = fromSpeakerInput
|
||||
}
|
||||
} else {
|
||||
out["from_user_id"] = fromUserID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
|
||||
if fromSpeakerInput != "" {
|
||||
return fromSpeakerInput
|
||||
}
|
||||
if fromSpeakerID != "" {
|
||||
return fromSpeakerID
|
||||
}
|
||||
return fromUserID
|
||||
}
|
||||
|
||||
func minutesSpeakerReplaceError(err error, minuteToken, sourceSpeaker string) error {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
@@ -112,8 +151,8 @@ func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error
|
||||
p.Hint = "Ask the minute owner for minute edit permission"
|
||||
case minutesSpeakerReplaceSpeakerNotFoundCode:
|
||||
p.Subtype = errs.SubtypeNotFound
|
||||
p.Message = fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID)
|
||||
p.Hint = "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry."
|
||||
p.Message = fmt.Sprintf("Speaker not found in minute %q: source speaker %q does not match an existing speaker in the transcript.", minuteToken, sourceSpeaker)
|
||||
p.Hint = "Verify --from-speaker-id is a valid speaker_id or display name from the transcript; if multiple speakers share the same name, pass the exact speaker_id after reviewing their utterances."
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) {
|
||||
{
|
||||
name: "missing from",
|
||||
args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--to-user-id", "ou_b", "--as", "user"},
|
||||
wantErr: "required flag(s) \"from-user-id\" not set",
|
||||
wantErr: "--from-speaker-id is required",
|
||||
},
|
||||
{
|
||||
name: "missing to",
|
||||
@@ -153,6 +153,129 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/transcript/speakerlist") {
|
||||
t.Errorf("expected speakerlist path, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ou_new_speaker") {
|
||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"speakers": []interface{}{
|
||||
map[string]interface{}{
|
||||
"speaker_id": "ENCRYPTED_TOKEN_ABC",
|
||||
"name": "说话人1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPut,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
FromSpeakerInput string `json:"from_speaker_input"`
|
||||
FromSpeakerID string `json:"from_speaker_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v", err)
|
||||
}
|
||||
if envelope.Data.FromSpeakerInput != "说话人1" {
|
||||
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
|
||||
}
|
||||
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
||||
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
||||
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "from_user_id") {
|
||||
t.Errorf("from_speaker_id path should not send from_user_id, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ou_new_speaker") {
|
||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_Execute(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
@@ -238,8 +361,8 @@ func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) {
|
||||
if !strings.Contains(p.Message, "ou_missing_speaker") {
|
||||
t.Errorf("message should include missing speaker id, got: %s", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "--from-user-id") {
|
||||
t.Errorf("hint should mention --from-user-id, got: %s", p.Hint)
|
||||
if !strings.Contains(p.Hint, "--from-speaker-id") {
|
||||
t.Errorf("hint should mention --from-speaker-id, got: %s", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
104
shortcuts/minutes/minutes_speakers.go
Normal file
104
shortcuts/minutes/minutes_speakers.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type minuteSpeaker struct {
|
||||
SpeakerID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
|
||||
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
|
||||
}
|
||||
|
||||
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
items := common.GetSlice(data, "speakers")
|
||||
speakers := make([]minuteSpeaker, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
|
||||
name := strings.TrimSpace(common.GetString(item, "name"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
|
||||
}
|
||||
return speakers, nil
|
||||
}
|
||||
|
||||
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
var matches []minuteSpeaker
|
||||
for _, s := range speakers {
|
||||
if s.Name == name {
|
||||
matches = append(matches, s)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", errs.NewValidationError(errs.SubtypeNotFound,
|
||||
"no speaker named %q in minute transcript", name).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
|
||||
case 1:
|
||||
return matches[0].SpeakerID, nil
|
||||
default:
|
||||
ids := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
ids[i] = m.SpeakerID
|
||||
}
|
||||
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
|
||||
// The input may already be an opaque speaker_id, or a display name that requires
|
||||
// an internal speaker-list fetch.
|
||||
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, s := range speakers {
|
||||
if s.SpeakerID == input {
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
return resolveSpeakerIDByName(speakers, input)
|
||||
}
|
||||
|
||||
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
|
||||
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
if fromUserID != "" {
|
||||
return "", fromUserID, nil
|
||||
}
|
||||
|
||||
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
|
||||
return fromSpeakerID, "", err
|
||||
}
|
||||
45
shortcuts/minutes/minutes_speakers_test.go
Normal file
45
shortcuts/minutes/minutes_speakers_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestResolveSpeakerIDByName(t *testing.T) {
|
||||
speakers := []minuteSpeaker{
|
||||
{SpeakerID: "id_a", Name: "Alice"},
|
||||
{SpeakerID: "id_b", Name: "Bob"},
|
||||
{SpeakerID: "id_c", Name: "Alice"},
|
||||
}
|
||||
|
||||
id, err := resolveSpeakerIDByName(speakers, "Bob")
|
||||
if err != nil || id != "id_b" {
|
||||
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Carol")
|
||||
if err == nil {
|
||||
t.Fatal("expected not found error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("want not-found validation error, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate name error")
|
||||
}
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
|
||||
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ var MinutesSummary = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
minutesSummaryMarkdownTip,
|
||||
"Use `lark-cli vc +notes --minute-tokens <token>` to read the current summary before replacing it.",
|
||||
"Use `lark-cli minutes +detail --minute-tokens <token> --summary` to read the current summary before replacing it.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := runtime.Str("minute-token")
|
||||
|
||||
@@ -59,7 +59,7 @@ var MinutesTodo = common.Shortcut{
|
||||
"Update: `--operation update --todo-id <id> --todo \"...\" --is-done`.",
|
||||
"Delete: `--operation delete --todo-id <id>`.",
|
||||
"`content` is plain text only; markdown formatting is not supported.",
|
||||
"Use `lark-cli vc +notes --minute-tokens <token>` to read current todos before writing.",
|
||||
"Use `lark-cli minutes +detail --minute-tokens <token> --todo` to read current todos before writing.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := runtime.Str("minute-token")
|
||||
|
||||
@@ -148,7 +148,7 @@ func minutesWordReplaceError(err error, minuteToken string) error {
|
||||
if strings.Contains(strings.ToLower(p.Message), "not found in transcript") {
|
||||
p.Subtype = errs.SubtypeNotFound
|
||||
p.Message = fmt.Sprintf("None of the source words were found in minute %q transcript; nothing was replaced.", minuteToken)
|
||||
p.Hint = "Verify each source_word's exact spelling and case against the current transcript (use vc +notes to read it), then retry"
|
||||
p.Hint = "Verify each source_word's exact spelling and case against the current transcript (use `minutes +detail --minute-tokens <token> --transcript` to read it), then retry"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,5 +16,6 @@ func Shortcuts() []common.Shortcut {
|
||||
MinutesTodo,
|
||||
MinutesSpeakerReplace,
|
||||
MinutesWordReplace,
|
||||
MinutesDetail,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func ensureUnifiedNote(ctx context.Context, runtime *common.RuntimeContext, note
|
||||
if detail.DisplayType != "unified" {
|
||||
if detail.VerbatimDocToken != "" {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=%s)", noteID, detail.DisplayType, detail.VerbatimDocToken).
|
||||
WithHint("Use docs +fetch --api-version v2 --doc %s for normal note transcripts", detail.VerbatimDocToken)
|
||||
WithHint("Use docs +fetch --doc %s for normal note transcripts", detail.VerbatimDocToken)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=)", noteID, detail.DisplayType).
|
||||
WithHint("Use note +detail to inspect document tokens")
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestNoteTranscriptRequiresUnifiedNote(t *testing.T) {
|
||||
if problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %v, want FailedPrecondition", problem.Subtype)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "docs +fetch --api-version v2 --doc doc_verbatim") {
|
||||
if !strings.Contains(problem.Hint, "docs +fetch --doc doc_verbatim") {
|
||||
t.Fatalf("hint = %q, want docs +fetch guidance", problem.Hint)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
|
||||
@@ -246,7 +246,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
shortcutHelp: "Create a Lark document",
|
||||
visibleFlag: "--content",
|
||||
skillCommand: "lark-cli skills read lark-doc references/lark-doc-create.md",
|
||||
hiddenFlags: []string{"markdown", "folder-token", "wiki-node", "wiki-space"},
|
||||
hiddenFlags: []string{"api-version", "markdown", "folder-token", "wiki-node", "wiki-space"},
|
||||
contentHelp: []string{
|
||||
"--title",
|
||||
"AI agents MUST read",
|
||||
@@ -258,7 +258,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"use --help for the latest command flags",
|
||||
},
|
||||
unwanted: []string{"--markdown", "--folder-token", "--wiki-node", "--wiki-space"},
|
||||
unwanted: []string{"--api-version", "--markdown", "--folder-token", "--wiki-node", "--wiki-space"},
|
||||
},
|
||||
{
|
||||
name: "fetch",
|
||||
@@ -266,8 +266,8 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
shortcutHelp: "Fetch Lark document content",
|
||||
visibleFlag: "read scope",
|
||||
skillCommand: "lark-cli skills read lark-doc references/lark-doc-fetch.md",
|
||||
hiddenFlags: []string{"offset", "limit"},
|
||||
unwanted: []string{"--offset", "--limit"},
|
||||
hiddenFlags: []string{"api-version", "offset", "limit"},
|
||||
unwanted: []string{"--api-version", "--offset", "--limit"},
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
@@ -275,7 +275,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
shortcutHelp: "Update a Lark document",
|
||||
visibleFlag: "--command",
|
||||
skillCommand: "lark-cli skills read lark-doc references/lark-doc-update.md",
|
||||
hiddenFlags: []string{"mode", "markdown", "selection-with-ellipsis", "selection-by-title", "new-title"},
|
||||
hiddenFlags: []string{"api-version", "mode", "markdown", "selection-with-ellipsis", "selection-by-title", "new-title"},
|
||||
contentHelp: []string{
|
||||
"AI agents MUST read",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
@@ -286,7 +286,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"use --help for the latest command flags",
|
||||
},
|
||||
unwanted: []string{"--mode", "--markdown", "--selection-with-ellipsis", "--selection-by-title", "--new-title"},
|
||||
unwanted: []string{"--api-version", "--mode", "--markdown", "--selection-with-ellipsis", "--selection-by-title", "--new-title"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -312,17 +312,6 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
t.Fatalf("docs %s flag %q should be hidden", tt.shortcut, flagName)
|
||||
}
|
||||
}
|
||||
apiVersionFlag := cmd.Flags().Lookup("api-version")
|
||||
if apiVersionFlag == nil {
|
||||
t.Fatalf("docs %s missing --api-version flag", tt.shortcut)
|
||||
}
|
||||
if apiVersionFlag.Hidden {
|
||||
t.Fatalf("docs %s --api-version should be visible", tt.shortcut)
|
||||
}
|
||||
if apiVersionFlag.DefValue != "v2" {
|
||||
t.Fatalf("docs %s --api-version default = %q, want v2", tt.shortcut, apiVersionFlag.DefValue)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
if err := cmd.Help(); err != nil {
|
||||
@@ -332,10 +321,6 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
tt.shortcutHelp,
|
||||
tt.visibleFlag,
|
||||
"--api-version",
|
||||
"deprecated compatibility flag; docs shortcuts always use v2",
|
||||
"both v1/v2 are accepted",
|
||||
"(default \"v2\")",
|
||||
"Start here (required for AI agents):",
|
||||
"AI agents MUST read the matching embedded skill",
|
||||
"Do not skip this step",
|
||||
|
||||
@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,13 +204,11 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// Build the presentation URL locally from the token. The brand-standard
|
||||
// host transparently redirects to the tenant domain (same fallback used by
|
||||
// drive +upload / wiki +node-create). This avoids the prior best-effort
|
||||
// drive metas/batch_query call, which needed an extra drive scope and 403'd
|
||||
// for users who only authorized slides scopes — without ever blocking an
|
||||
// otherwise-successful creation.
|
||||
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
// Prefer the URL returned by presentation.create. Fall back to a local
|
||||
// brand-standard URL only when the API omits it.
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
result["url"] = url
|
||||
} else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_abc123",
|
||||
"revision_id": 1,
|
||||
"url": "https://tenant.example.com/slides/pres_abc123",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -54,10 +55,8 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
if data["title"] != "项目汇报" {
|
||||
t.Fatalf("title = %v, want 项目汇报", data["title"])
|
||||
}
|
||||
// URL is built locally from the token (brand-standard host), not fetched from
|
||||
// drive metas, so it is deterministic and needs no drive scope.
|
||||
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
|
||||
if data["url"] != "https://tenant.example.com/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"])
|
||||
}
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode")
|
||||
@@ -647,12 +646,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
|
||||
// locally from the token — no drive metas/batch_query call is made, so creation
|
||||
// works for users who only authorized slides scopes. The httpmock registry has no
|
||||
// batch_query stub registered; if the shortcut tried to call it, the request would
|
||||
// fail the test (unregistered stub), proving the URL is built without a drive call.
|
||||
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is
|
||||
// constructed locally from the token when presentation.create omits url — no
|
||||
// drive metas/batch_query call is made, so creation works for users who only
|
||||
// authorized slides scopes. The httpmock registry has no batch_query stub
|
||||
// registered; if the shortcut tried to call it, the request would fail the test.
|
||||
func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -665,6 +664,7 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_local_url",
|
||||
"revision_id": 1,
|
||||
"url": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
426
shortcuts/slides/slides_replace_pages.go
Normal file
426
shortcuts/slides/slides_replace_pages.go
Normal file
@@ -0,0 +1,426 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesReplacePages rebuilds multiple pages inside an existing presentation.
|
||||
// It deliberately creates the new page before deleting the old one so a create
|
||||
// failure cannot remove existing user content. The operation is not atomic.
|
||||
const replacePagesInitialRevisionID = -1
|
||||
|
||||
var SlidesReplacePages = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+replace-pages",
|
||||
Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
|
||||
{Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateReplacePagesInput(pages)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI()
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return dry.Set("error", err.Error())
|
||||
}
|
||||
appendReplacePagesDryRunCalls(dry, resolved)
|
||||
return dry.
|
||||
Set("xml_presentation_id", resolved.PresentationID).
|
||||
Set("pages_count", len(resolved.Plan)).
|
||||
Set("plan", replacePagesPlanOutput(resolved.Plan)).
|
||||
Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("validate-only") {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"plan": replacePagesPlanOutput(resolved.Plan),
|
||||
"status": "validated",
|
||||
"note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed",
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
revisionID := replacePagesInitialRevisionID
|
||||
results := make([]replacePageResult, 0, len(resolved.Plan))
|
||||
for i, item := range resolved.Plan {
|
||||
result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID)
|
||||
results = append(results, result)
|
||||
if result.RevisionID != nil {
|
||||
revisionID = *result.RevisionID
|
||||
}
|
||||
if err != nil {
|
||||
if runtime.Bool("continue-on-error") {
|
||||
continue
|
||||
}
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results)))
|
||||
}
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"results": replacePageResultsOutput(results),
|
||||
"status": "completed",
|
||||
"summary": replacePagesSummaryOutput(results),
|
||||
"note": "batch replace is not atomic; each page was created before its old page was deleted",
|
||||
}
|
||||
if revisionID != replacePagesInitialRevisionID {
|
||||
out["revision_id"] = revisionID
|
||||
}
|
||||
if hasReplacePageFailures(results) {
|
||||
out["status"] = "partial_failure"
|
||||
return runtime.OutPartialFailure(out, nil)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type replacePageInput struct {
|
||||
SlideID string
|
||||
Content string
|
||||
}
|
||||
|
||||
type replacePagePlanItem struct {
|
||||
OldSlideID string
|
||||
Content string
|
||||
Locator string
|
||||
}
|
||||
|
||||
type replacePagesPrepared struct {
|
||||
PresentationID string
|
||||
Plan []replacePagePlanItem
|
||||
}
|
||||
|
||||
type replacePageResult struct {
|
||||
OldSlideID string
|
||||
NewSlideID string
|
||||
Status string
|
||||
Error string
|
||||
RevisionID *int
|
||||
}
|
||||
|
||||
func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateReplacePagesInput(pages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plan, err := buildReplacePagesPlan(pages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil
|
||||
}
|
||||
|
||||
func parseReplacePages(raw string) ([]replacePageInput, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages")
|
||||
}
|
||||
var decoded []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
out := make([]replacePageInput, 0, len(decoded))
|
||||
for i, m := range decoded {
|
||||
p := replacePageInput{}
|
||||
if v, ok := m["slide_number"]; ok {
|
||||
_ = v
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement")
|
||||
}
|
||||
if v, ok := m["slide_id"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.SlideID = s
|
||||
}
|
||||
if v, ok := m["content"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.Content = s
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateReplacePagesInput(pages []replacePageInput) error {
|
||||
if len(pages) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for i, p := range pages {
|
||||
id := strings.TrimSpace(p.SlideID)
|
||||
if id == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages")
|
||||
}
|
||||
if seenIDs[id] {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages")
|
||||
}
|
||||
seenIDs[id] = true
|
||||
if strings.TrimSpace(p.Content) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages")
|
||||
}
|
||||
if err := validateCompleteSlideXML(p.Content); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCompleteSlideXML(content string) error {
|
||||
dec := xml.NewDecoder(strings.NewReader(content))
|
||||
depth := 0
|
||||
seenRoot := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 0 {
|
||||
if seenRoot {
|
||||
return invalidSlideXMLStructureError("multiple root elements")
|
||||
}
|
||||
if t.Name.Local != "slide" {
|
||||
return invalidSlideXMLStructureError("root element is <%s>, want <slide>", t.Name.Local)
|
||||
}
|
||||
seenRoot = true
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
case xml.CharData:
|
||||
if depth == 0 && strings.TrimSpace(string(t)) != "" {
|
||||
return invalidSlideXMLStructureError("non-whitespace text outside root element")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seenRoot {
|
||||
return invalidSlideXMLStructureError("missing root element")
|
||||
}
|
||||
if depth != 0 {
|
||||
return invalidSlideXMLStructureError("unclosed XML element")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidSlideXMLStructureError(format string, args ...interface{}) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) {
|
||||
plan := make([]replacePagePlanItem, 0, len(pages))
|
||||
for _, page := range pages {
|
||||
id := strings.TrimSpace(page.SlideID)
|
||||
plan = append(plan, replacePagePlanItem{
|
||||
OldSlideID: id,
|
||||
Content: page.Content,
|
||||
Locator: "slide_id",
|
||||
})
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) {
|
||||
dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)")
|
||||
for i, item := range resolved.Plan {
|
||||
dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{"revision_id": "<latest_or_revision_returned_by_previous_step>"}).
|
||||
Body(map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
})
|
||||
dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": "<revision_returned_by_create>",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) {
|
||||
result := replacePageResult{
|
||||
OldSlideID: item.OldSlideID,
|
||||
Status: "pending",
|
||||
}
|
||||
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
|
||||
createData, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": revisionID},
|
||||
map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
newSlideID := common.GetString(createData, "slide_id")
|
||||
if newSlideID == "" {
|
||||
err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID)
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
result.NewSlideID = newSlideID
|
||||
if rev, ok := revisionFromData(createData); ok {
|
||||
revisionID = rev
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
|
||||
deleteData, err := runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
slideURL,
|
||||
map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": revisionID,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "delete_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
if rev, ok := revisionFromData(deleteData); ok {
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
result.Status = "replaced"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func revisionFromData(data map[string]interface{}) (int, bool) {
|
||||
if _, ok := data["revision_id"]; !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(common.GetFloat(data, "revision_id")), true
|
||||
}
|
||||
|
||||
func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(plan))
|
||||
for _, item := range plan {
|
||||
out = append(out, map[string]interface{}{
|
||||
"old_slide_id": item.OldSlideID,
|
||||
"insert_before_slide_id": item.OldSlideID,
|
||||
"locator": item.Locator,
|
||||
"action": "create_before_then_delete_old",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(results))
|
||||
for _, result := range results {
|
||||
m := map[string]interface{}{
|
||||
"old_slide_id": result.OldSlideID,
|
||||
"status": result.Status,
|
||||
}
|
||||
if result.NewSlideID != "" {
|
||||
m["new_slide_id"] = result.NewSlideID
|
||||
}
|
||||
if result.Error != "" {
|
||||
m["error"] = result.Error
|
||||
}
|
||||
if result.RevisionID != nil {
|
||||
m["revision_id"] = *result.RevisionID
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} {
|
||||
replaced := countReplacedPages(results)
|
||||
return map[string]interface{}{
|
||||
"replaced": replaced,
|
||||
"failed": len(results) - replaced,
|
||||
"total": len(results),
|
||||
}
|
||||
}
|
||||
|
||||
func countReplacedPages(results []replacePageResult) int {
|
||||
n := 0
|
||||
for _, result := range results {
|
||||
if result.Status == "replaced" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func hasReplacePageFailures(results []replacePageResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Status == "create_failed" || result.Status == "delete_failed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
341
shortcuts/slides/slides_replace_pages_test.go
Normal file
341
shortcuts/slides/slides_replace_pages_test.go
Normal file
@@ -0,0 +1,341 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestReplacePagesDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesReplacePages.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
if got := SlidesReplacePages.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
|
||||
got := SlidesReplacePages.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
var requestOrder []string
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
requestOrder = append(requestOrder, req.Method)
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
var deleteQuery map[string][]string
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
requestOrder = append(requestOrder, req.Method)
|
||||
deleteQuery = req.URL.Query()
|
||||
},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody struct {
|
||||
Slide struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"slide"`
|
||||
BeforeSlideID string `json:"before_slide_id"`
|
||||
}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody)
|
||||
}
|
||||
if createBody.BeforeSlideID != "old2" {
|
||||
t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID)
|
||||
}
|
||||
if !strings.Contains(createBody.Slide.Content, "<slide") {
|
||||
t.Fatalf("create content = %q", createBody.Slide.Content)
|
||||
}
|
||||
if !reflect.DeepEqual(requestOrder, []string{"POST", "DELETE"}) {
|
||||
t.Fatalf("request order = %#v, want POST then DELETE", requestOrder)
|
||||
}
|
||||
deleteURL := string(deleteStub.CapturedBody)
|
||||
if deleteURL != "" {
|
||||
t.Fatalf("delete body = %q, want empty", deleteURL)
|
||||
}
|
||||
if got := deleteQuery["slide_id"]; !reflect.DeepEqual(got, []string{"old2"}) {
|
||||
t.Fatalf("delete slide_id = %#v, want old2", got)
|
||||
}
|
||||
if got := deleteQuery["revision_id"]; !reflect.DeepEqual(got, []string{"11"}) {
|
||||
t.Fatalf("delete revision_id = %#v, want 11 from create response", got)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(12) {
|
||||
t.Fatalf("revision_id = %v, want 12", data["revision_id"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["failed"] != float64(0) {
|
||||
t.Fatalf("summary.failed = %v, want 0", summary["failed"])
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
|
||||
t.Fatalf("result = %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[
|
||||
{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"},
|
||||
{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}
|
||||
]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
data := env.Data
|
||||
if data["status"] != "partial_failure" {
|
||||
t.Fatalf("status = %v, want partial_failure", data["status"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) {
|
||||
t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary)
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results len = %d, want 2", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
second, _ := results[1].(map[string]interface{})
|
||||
if first["status"] != "create_failed" {
|
||||
t.Fatalf("first status = %v, want create_failed", first["status"])
|
||||
}
|
||||
if second["status"] != "replaced" || second["new_slide_id"] != "new2" {
|
||||
t.Fatalf("second result = %#v, want replaced with new2", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new1", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
results, _ := env.Data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["status"] != "delete_failed" {
|
||||
t.Fatalf("status = %v, want delete_failed", first["status"])
|
||||
}
|
||||
if first["new_slide_id"] != "new1" {
|
||||
t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesDryRunPlansOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if out["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
|
||||
}
|
||||
plan, _ := out["plan"].([]interface{})
|
||||
if len(plan) != 1 {
|
||||
t.Fatalf("plan len = %d, want 1", len(plan))
|
||||
}
|
||||
item, _ := plan[0].(map[string]interface{})
|
||||
if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
|
||||
t.Fatalf("plan item = %#v", item)
|
||||
}
|
||||
api, _ := out["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("api len = %d, want create/delete plan", len(api))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesValidationParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pages string
|
||||
}{
|
||||
{"empty pages", `[]`},
|
||||
{"slide number no longer supported", `[{"slide_number":1,"content":"<slide/>"}]`},
|
||||
{"no locator", `[{"content":"<slide/>"}]`},
|
||||
{"empty content", `[{"slide_id":"s1","content":" "}]`},
|
||||
{"not slide XML", `[{"slide_id":"s1","content":"<shape/>"}]`},
|
||||
{"duplicate id", `[{"slide_id":"s1","content":"<slide/>"},{"slide_id":"s1","content":"<slide/>"}]`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", tt.pages,
|
||||
"--as", "user",
|
||||
})
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %v, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--pages" {
|
||||
t.Fatalf("Param = %q, want --pages", ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type replacePagesEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope {
|
||||
t.Helper()
|
||||
var env replacePagesEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes()))
|
||||
}
|
||||
if env.Data == nil {
|
||||
t.Fatalf("missing data: %#v", env)
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -43,8 +43,10 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
Command: "+replace-slide",
|
||||
Description: "Replace elements on a slide via block_replace / block_insert parts (auto-injects id + <content/> on shape elements)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "slide-id", Desc: "slide page identifier (slide_id)", Required: true},
|
||||
@@ -53,9 +55,15 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("slide-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -15,6 +16,21 @@ import (
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestReplaceSlideDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesReplaceSlide.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
if got := SlidesReplaceSlide.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
|
||||
got := SlidesReplaceSlide.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceSlideBlockReplaceInjectsID is the core regression: users write
|
||||
// <shape>…</shape> as replacement and the CLI must stitch id="<block_id>"
|
||||
// onto the root before sending. The backend returns 3350001 otherwise.
|
||||
|
||||
@@ -34,7 +34,9 @@ var SlidesScreenshot = common.Shortcut{
|
||||
Command: "+screenshot",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
Scopes: []string{},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
// advertise/preflight its scope. Let the API fail and let callers degrade.
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -17,11 +17,23 @@ import (
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
|
||||
t.Fatalf("user preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("bot preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
want := []string{"wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, scope := range got {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
|
||||
144
shortcuts/slides/slides_xml_get.go
Normal file
144
shortcuts/slides/slides_xml_get.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content and writes it to a
|
||||
// local file, keeping the terminal output small for large decks.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
Description: "Fetch full presentation XML and save it to a local file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("output")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
if runtime.Int("revision-id") < -1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc("Fetch full presentation XML and save it to a local file")
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
dry.GET(fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Params(params)
|
||||
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
|
||||
params,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
presentation := common.GetMap(data, "xml_presentation")
|
||||
content := common.GetString(presentation, "content")
|
||||
if content == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
|
||||
}
|
||||
outputPath := runtime.Str("output")
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolvedPath, err := runtime.ResolveSavePath(outputPath)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
}
|
||||
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
|
||||
out["revision_id"] = int(revisionID)
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
out["remove_attr_id"] = true
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
165
shortcuts/slides/slides_xml_get_test.go
Normal file
165
shortcuts/slides/slides_xml_get_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"presentation_id": "pres_abc",
|
||||
"revision_id": 7,
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "readback.xml",
|
||||
"--revision-id", "7",
|
||||
"--remove-attr-id",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "readback.xml")
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved XML: %v", err)
|
||||
}
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
}
|
||||
if strings.Contains(stdout.String(), xml) {
|
||||
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
|
||||
}
|
||||
if got := capturedQuery.Get("revision_id"); got != "7" {
|
||||
t.Fatalf("revision_id query = %q, want 7", got)
|
||||
}
|
||||
if got := capturedQuery.Get("remove_attr_id"); got != "true" {
|
||||
t.Fatalf("remove_attr_id query = %q, want true", got)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(7) {
|
||||
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
|
||||
}
|
||||
if data["size"] != float64(len(xml)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(xml))
|
||||
}
|
||||
gotPath, _ := data["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, "readback.xml") {
|
||||
t.Fatalf("path = %v, want readback.xml suffix", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
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": "slides",
|
||||
"obj_token": "pres_real",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": `<presentation/>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
|
||||
"--output", "wiki.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_real" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "../readback.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output path error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--output" {
|
||||
t.Fatalf("param = %q, want --output", validationErr.Param)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
|
||||
VCSearch,
|
||||
VCNotes,
|
||||
VCRecording,
|
||||
VCDetail,
|
||||
VCMeetingJoin,
|
||||
VCMeetingLeave,
|
||||
VCMeetingListActive,
|
||||
|
||||
216
shortcuts/vc/vc_detail.go
Normal file
216
shortcuts/vc/vc_detail.go
Normal file
@@ -0,0 +1,216 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// vc +detail — get meeting details including note_id and minute_token
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const detailLogPrefix = "[vc +detail]"
|
||||
|
||||
var scopesDetailMeetingIDs = []string{
|
||||
"vc:meeting.meetingevent:read",
|
||||
"vc:record:readonly",
|
||||
}
|
||||
|
||||
// meetingDetailItem represents a single meeting detail result.
|
||||
type meetingDetailItem struct {
|
||||
MeetingID string `json:"meeting_id"`
|
||||
MeetingNo string `json:"meeting_no,omitempty"`
|
||||
Topic string `json:"topic"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
NoteID string `json:"note_id,omitempty"`
|
||||
MinuteToken string `json:"minute_token,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// fetchMeetingDetail queries meeting.get and recording API to return a
|
||||
// consolidated view of meeting metadata, note_id, and minute_token.
|
||||
// Error is only set when an API call actually fails; note_id and minute_token
|
||||
// are always present (empty string when not available).
|
||||
func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, meetingID string) *meetingDetailItem {
|
||||
result := &meetingDetailItem{MeetingID: meetingID}
|
||||
|
||||
// Step 1: query meeting detail
|
||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
|
||||
map[string]interface{}{"with_participants": "false", "query_mode": "0"}, nil)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to query meeting detail: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
meeting, _ := data["meeting"].(map[string]any)
|
||||
if meeting == nil {
|
||||
result.Error = "meeting not found in response"
|
||||
return result
|
||||
}
|
||||
|
||||
if v, ok := meeting["meeting_no"].(string); ok {
|
||||
result.MeetingNo = v
|
||||
}
|
||||
if v, ok := meeting["topic"].(string); ok {
|
||||
result.Topic = v
|
||||
}
|
||||
if v := common.FormatTime(meeting["start_time"]); v != "" {
|
||||
result.StartTime = v
|
||||
}
|
||||
if v := common.FormatTime(meeting["end_time"]); v != "" {
|
||||
result.EndTime = v
|
||||
}
|
||||
if v, ok := meeting["note_id"].(string); ok && v != "" {
|
||||
result.NoteID = v
|
||||
}
|
||||
|
||||
// Step 2: query minute_token via recording API
|
||||
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
||||
if minuteErr != nil {
|
||||
// Recording API failed — surface the error but keep data from step 1
|
||||
result.Error = fmt.Sprintf("failed to query minutes: %v", minuteErr)
|
||||
minuteHint = ""
|
||||
}
|
||||
if minuteToken != "" {
|
||||
result.MinuteToken = minuteToken
|
||||
}
|
||||
|
||||
// Add hints for empty resources (not errors, just informational)
|
||||
var emptyFields []string
|
||||
if result.NoteID == "" {
|
||||
emptyFields = append(emptyFields, "note_id")
|
||||
}
|
||||
if result.MinuteToken == "" && minuteErr == nil && minuteHint == "" {
|
||||
emptyFields = append(emptyFields, "minute_token")
|
||||
}
|
||||
if len(emptyFields) > 0 {
|
||||
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
|
||||
}
|
||||
if minuteHint != "" {
|
||||
if result.Hint != "" {
|
||||
result.Hint += "; " + minuteHint
|
||||
} else {
|
||||
result.Hint = minuteHint
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// VCDetail gets meeting details including note_id and minute_token.
|
||||
var VCDetail = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+detail",
|
||||
Description: "Get meeting details including note_id and minute_token by meeting IDs",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read", "vc:record:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ids := common.SplitCSV(runtime.Str("meeting-ids"))
|
||||
const maxBatchSize = 50
|
||||
if len(ids) > maxBatchSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--meeting-ids")
|
||||
}
|
||||
// dynamic scope check
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err == nil && result != nil && result.Scopes != "" {
|
||||
if missing := auth.MissingScopes(result.Scopes, scopesDetailMeetingIDs); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ids := runtime.Str("meeting-ids")
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/vc/v1/meetings/{meeting_id}").
|
||||
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
|
||||
Set("meeting_ids", common.SplitCSV(ids)).
|
||||
Set("steps", "meeting.get → note_id + recording API → minute_token")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
errOut := runtime.IO().ErrOut
|
||||
meetingIDs := common.SplitCSV(runtime.Str("meeting-ids"))
|
||||
results := make([]*meetingDetailItem, 0, len(meetingIDs))
|
||||
|
||||
const batchDelay = 100 * time.Millisecond
|
||||
fmt.Fprintf(errOut, "%s querying %d meeting_id(s)\n", detailLogPrefix, len(meetingIDs))
|
||||
for i, id := range meetingIDs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(batchDelay)
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s querying meeting_id=%s ...\n", detailLogPrefix, sanitizeLogValue(id))
|
||||
results = append(results, fetchMeetingDetail(ctx, runtime, id))
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", detailLogPrefix, len(results), successCount, len(results)-successCount)
|
||||
|
||||
if successCount == 0 && len(results) > 0 {
|
||||
return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
|
||||
}
|
||||
|
||||
outData := map[string]any{"meetings": results}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
|
||||
if len(results) == 0 {
|
||||
fmt.Fprintln(w, "No meetings.")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, r := range results {
|
||||
row := map[string]interface{}{"meeting_id": r.MeetingID}
|
||||
if r.Error != "" {
|
||||
row["status"] = "FAIL"
|
||||
row["error"] = r.Error
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
}
|
||||
if r.NoteID != "" {
|
||||
row["note_id"] = r.NoteID
|
||||
}
|
||||
if r.MinuteToken != "" {
|
||||
row["minute_token"] = r.MinuteToken
|
||||
}
|
||||
row["topic"] = r.Topic
|
||||
if r.Hint != "" {
|
||||
row["hint"] = r.Hint
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d meeting(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
282
shortcuts/vc/vc_detail_test.go
Normal file
282
shortcuts/vc/vc_detail_test.go
Normal file
@@ -0,0 +1,282 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_Validation_MissingMeetingIDs(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing --meeting-ids")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "meeting-ids") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_BatchLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
ids := make([]string, 51)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("m%d", i)
|
||||
}
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected batch limit error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many IDs") {
|
||||
t.Errorf("expected 'too many IDs' error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DryRun tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m001", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/vc/v1/meetings/") {
|
||||
t.Errorf("dry-run should show meeting API path, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "recording") {
|
||||
t.Errorf("dry-run should show recording API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests with mocked HTTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_detail1", "note_001"))
|
||||
reg.Register(recordingOKStub("m_detail1", "https://meetings.feishu.cn/minutes/obc_detail1"))
|
||||
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_detail1", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 1 {
|
||||
t.Fatalf("expected 1 meeting, got %d", len(meetings))
|
||||
}
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if m["meeting_id"] != "m_detail1" {
|
||||
t.Errorf("meeting_id = %v, want m_detail1", m["meeting_id"])
|
||||
}
|
||||
if m["note_id"] != "note_001" {
|
||||
t.Errorf("note_id = %v, want note_001", m["note_id"])
|
||||
}
|
||||
if m["minute_token"] != "obc_detail1" {
|
||||
t.Errorf("minute_token = %v, want obc_detail1", m["minute_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_NoNoteNoMinute(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_nonote", ""))
|
||||
reg.Register(recordingErrStub("m_nonote", 121004, "not found"))
|
||||
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_nonote", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify hint is present for empty note_id and missing recording
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if hint, _ := m["hint"].(string); !strings.Contains(hint, "note_id") || !strings.Contains(hint, "no minute file for this meeting") {
|
||||
t.Errorf("hint should mention note_id and minute file missing, got: %v", hint)
|
||||
}
|
||||
if errMsg, _ := m["error"].(string); errMsg != "" {
|
||||
t.Errorf("error should be empty, got: %v", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_MeetingNotFound(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m_bad",
|
||||
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_bad", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_Batch(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
// m1 succeeds with note and minute
|
||||
reg.Register(meetingGetStub("m_batch1", "note_b1"))
|
||||
reg.Register(recordingOKStub("m_batch1", "https://meetings.feishu.cn/minutes/obc_b1"))
|
||||
// m2 has no note_id but has minute
|
||||
reg.Register(meetingGetStub("m_batch2", ""))
|
||||
reg.Register(recordingOKStub("m_batch2", "https://meetings.feishu.cn/minutes/obc_b2"))
|
||||
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_batch1,m_batch2", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 2 {
|
||||
t.Fatalf("expected 2 meetings, got %d", len(meetings))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFetchMeetingDetail_MeetingWithNoteAndMinute(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_fn", "note_fn"))
|
||||
reg.Register(recordingOKStub("m_fn", "https://meetings.feishu.cn/minutes/obc_fn"))
|
||||
|
||||
if err := botExec(t, "detail-fn", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchMeetingDetail(context.Background(), rctx, "m_fn")
|
||||
if result.MeetingID != "m_fn" {
|
||||
t.Errorf("meeting_id = %v, want m_fn", result.MeetingID)
|
||||
}
|
||||
if result.NoteID != "note_fn" {
|
||||
t.Errorf("note_id = %v, want note_fn", result.NoteID)
|
||||
}
|
||||
if result.MinuteToken != "obc_fn" {
|
||||
t.Errorf("minute_token = %v, want obc_fn", result.MinuteToken)
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Errorf("unexpected error: %v", result.Error)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMeetingDetail_MeetingNotFound(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m_nf",
|
||||
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
|
||||
})
|
||||
|
||||
if err := botExec(t, "detail-nf", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchMeetingDetail(context.Background(), rctx, "m_nf")
|
||||
if result.Error == "" {
|
||||
t.Error("expected error for meeting not found")
|
||||
}
|
||||
// note_id and minute_token should still be present (empty)
|
||||
if result.NoteID != "" {
|
||||
t.Errorf("note_id = %q, want empty", result.NoteID)
|
||||
}
|
||||
if result.MinuteToken != "" {
|
||||
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMeetingDetail_RecordingFailsButNoteOK(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_partial", "note_partial"))
|
||||
reg.Register(recordingErrStub("m_partial", 121004, "not found"))
|
||||
|
||||
if err := botExec(t, "detail-partial", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchMeetingDetail(context.Background(), rctx, "m_partial")
|
||||
if result.NoteID != "note_partial" {
|
||||
t.Errorf("note_id = %v, want note_partial", result.NoteID)
|
||||
}
|
||||
if result.MinuteToken != "" {
|
||||
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Errorf("error = %q, want empty", result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Hint, "no minute file for this meeting") {
|
||||
t.Errorf("hint = %q, want contains 'no minute file for this meeting'", result.Hint)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMeetingDetail_RecordingAPIErrorButNoteOK(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_api_err", "note_apierr"))
|
||||
reg.Register(recordingErrStub("m_api_err", 99999, "weird API error"))
|
||||
|
||||
if err := botExec(t, "detail-apierr", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchMeetingDetail(context.Background(), rctx, "m_api_err")
|
||||
if result.NoteID != "note_apierr" {
|
||||
t.Errorf("note_id = %v, want note_apierr", result.NoteID)
|
||||
}
|
||||
if result.MinuteToken != "" {
|
||||
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
|
||||
}
|
||||
if !strings.Contains(result.Error, "failed to query minutes") || !strings.Contains(result.Error, "weird API error") {
|
||||
t.Errorf("error = %q, want contains 'failed to query minutes' and 'weird API error'", result.Error)
|
||||
}
|
||||
if strings.Contains(result.Hint, "minute_token") {
|
||||
t.Errorf("hint = %q, should not mention minute_token when there is an error", result.Hint)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -838,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
|
||||
for _, shortcut := range got {
|
||||
commands = append(commands, shortcut.Command)
|
||||
}
|
||||
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
|
||||
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
|
||||
if !reflect.DeepEqual(commands, want) {
|
||||
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
|
||||
}
|
||||
|
||||
@@ -263,42 +263,35 @@ func asStringSlice(v any) []string {
|
||||
}
|
||||
|
||||
// fetchMeetingMinuteToken queries the recording API of a meeting and returns
|
||||
// the associated minute_token (parsed from the recording URL) and an
|
||||
// optional human-friendly error message. On success token is non-empty and
|
||||
// errMsg is empty; on failure token is empty and errMsg describes the cause:
|
||||
// - 121004: meeting has no minute file
|
||||
// - 121005: caller has no permission for the meeting recording
|
||||
// - 124002: recording / minute file is still being generated
|
||||
//
|
||||
// Other failures fall back to the raw API error description so Agents can
|
||||
// still parse the underlying cause.
|
||||
func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, errMsg string) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||
// the associated minute_token (parsed from the recording URL), an optional
|
||||
// hint for expected missing states, and an error for unexpected failures.
|
||||
func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, hint string, err error) {
|
||||
data, apiErr := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if apiErr != nil {
|
||||
if p, ok := errs.ProblemOf(apiErr); ok {
|
||||
switch p.Code {
|
||||
case recordingNotFoundCode:
|
||||
return "", "no minute file for this meeting"
|
||||
return "", "no minute file for this meeting", nil
|
||||
case recordingNoPermissionCode:
|
||||
return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute"
|
||||
return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute", nil
|
||||
case recordingGeneratingCode:
|
||||
return "", "minute file is still being generated; please retry later"
|
||||
return "", "minute file is still being generated; please retry later", nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Sprintf("failed to query recording: %v", err)
|
||||
return "", "", apiErr
|
||||
}
|
||||
|
||||
recording, _ := data["recording"].(map[string]any)
|
||||
if recording == nil {
|
||||
return "", "no recording available for this meeting"
|
||||
return "", "no recording available for this meeting", nil
|
||||
}
|
||||
recordingURL, _ := recording["url"].(string)
|
||||
if t := extractMinuteToken(recordingURL); t != "" {
|
||||
return t, ""
|
||||
return t, "", nil
|
||||
}
|
||||
return "", "no minute_token found in recording URL"
|
||||
return "", "no minute_token found in recording URL", nil
|
||||
}
|
||||
|
||||
// fetchNoteByMeetingID queries notes via meeting_id and additionally fetches
|
||||
@@ -321,7 +314,7 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
|
||||
// Always attempt to query the meeting's minute_token via the recording API,
|
||||
// regardless of whether the meeting has a note_id, so callers always see
|
||||
// minute state for follow-up calls (e.g. `vc +notes --minute-tokens=...`).
|
||||
minuteToken, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
||||
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
||||
|
||||
var result map[string]any
|
||||
var noteErr string
|
||||
@@ -340,7 +333,13 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
|
||||
if minuteToken != "" {
|
||||
result["minute_token"] = minuteToken
|
||||
}
|
||||
if combined := joinErrors(noteErr, minuteErr); combined != "" {
|
||||
var minuteErrMsg string
|
||||
if minuteHint != "" {
|
||||
minuteErrMsg = minuteHint
|
||||
} else if minuteErr != nil {
|
||||
minuteErrMsg = minuteErr.Error()
|
||||
}
|
||||
if combined := joinErrors(noteErr, minuteErrMsg); combined != "" {
|
||||
result["error"] = combined
|
||||
}
|
||||
return result
|
||||
@@ -538,6 +537,7 @@ var VCNotes = common.Shortcut{
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:note:read"}, // minimum scope; additional per-flag scopes checked in Validate
|
||||
AuthTypes: []string{"user"},
|
||||
Hidden: true, // hidden from --help; prefer vc +detail, minutes +detail, or note +detail
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},
|
||||
|
||||
@@ -792,12 +792,15 @@ func TestFetchMeetingMinuteToken_Success(t *testing.T) {
|
||||
reg.Register(recordingOKStub("m_ok", "https://meetings.feishu.cn/minutes/obctoken_ok"))
|
||||
|
||||
if err := botExec(t, "fmmt-ok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_ok")
|
||||
token, hint, err := fetchMeetingMinuteToken(rctx, "m_ok")
|
||||
if token != "obctoken_ok" {
|
||||
t.Errorf("token = %q, want obctoken_ok", token)
|
||||
}
|
||||
if msg != "" {
|
||||
t.Errorf("errMsg = %q, want empty", msg)
|
||||
if hint != "" {
|
||||
t.Errorf("hint = %q, want empty", hint)
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil", err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -823,12 +826,15 @@ func TestFetchMeetingMinuteToken_KnownErrorCodes(t *testing.T) {
|
||||
reg.Register(recordingErrStub(tt.meetingID, tt.code, "err"))
|
||||
|
||||
if err := botExec(t, "fmmt-"+tt.meetingID, f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, msg := fetchMeetingMinuteToken(rctx, tt.meetingID)
|
||||
token, hint, err := fetchMeetingMinuteToken(rctx, tt.meetingID)
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty on error", token)
|
||||
}
|
||||
if !strings.Contains(msg, tt.wantMsg) {
|
||||
t.Errorf("errMsg = %q, want contains %q", msg, tt.wantMsg)
|
||||
if !strings.Contains(hint, tt.wantMsg) {
|
||||
t.Errorf("hint = %q, want contains %q", hint, tt.wantMsg)
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil", err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -844,12 +850,15 @@ func TestFetchMeetingMinuteToken_GenericAPIError(t *testing.T) {
|
||||
reg.Register(recordingErrStub("m_other", 99999, "weird"))
|
||||
|
||||
if err := botExec(t, "fmmt-generic", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_other")
|
||||
token, hint, err := fetchMeetingMinuteToken(rctx, "m_other")
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
if !strings.Contains(msg, "failed to query recording") {
|
||||
t.Errorf("errMsg = %q, want contains 'failed to query recording'", msg)
|
||||
if hint != "" {
|
||||
t.Errorf("hint = %q, want empty", hint)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "weird") {
|
||||
t.Errorf("err = %v, want contains 'weird'", err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -866,12 +875,15 @@ func TestFetchMeetingMinuteToken_NoRecording(t *testing.T) {
|
||||
}))
|
||||
|
||||
if err := botExec(t, "fmmt-norec", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_norec")
|
||||
token, hint, err := fetchMeetingMinuteToken(rctx, "m_norec")
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
if !strings.Contains(msg, "no recording available") {
|
||||
t.Errorf("errMsg = %q, want contains 'no recording available'", msg)
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil", err)
|
||||
}
|
||||
if !strings.Contains(hint, "no recording available") {
|
||||
t.Errorf("hint = %q, want contains 'no recording available'", hint)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -885,12 +897,15 @@ func TestFetchMeetingMinuteToken_URLWithoutToken(t *testing.T) {
|
||||
reg.Register(recordingOKStub("m_notok", "https://example.com/no/minute/path"))
|
||||
|
||||
if err := botExec(t, "fmmt-notok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_notok")
|
||||
token, hint, err := fetchMeetingMinuteToken(rctx, "m_notok")
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
if !strings.Contains(msg, "no minute_token found") {
|
||||
t.Errorf("errMsg = %q, want contains 'no minute_token found'", msg)
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil", err)
|
||||
}
|
||||
if !strings.Contains(hint, "no minute_token found") {
|
||||
t.Errorf("hint = %q, want contains 'no minute_token found'", hint)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -983,7 +998,7 @@ func TestNotes_MeetingPath_OnlyMinuteFails_PartialSuccess(t *testing.T) {
|
||||
t.Errorf("note_doc_token = %v, want doc_main", got)
|
||||
}
|
||||
assertNoteFieldAbsent(t, note, "minute_token")
|
||||
assertNoteError(t, note, "no permission to access this meeting's minute")
|
||||
assertNoteError(t, note, "no permission to access this meeting's minute; ask the meeting owner to share the minute")
|
||||
}
|
||||
|
||||
func TestNotes_MeetingPath_NoNote_ButMinuteOK(t *testing.T) {
|
||||
@@ -1068,6 +1083,7 @@ func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) {
|
||||
assertNoteError(t, note,
|
||||
"[121005]",
|
||||
"no read permission for this meeting note",
|
||||
"no permission to access this meeting's minute",
|
||||
"; ", // note + minute causes joined with semicolon
|
||||
)
|
||||
}
|
||||
|
||||
@@ -230,9 +230,16 @@ var VCSearch = common.Shortcut{
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
items := common.GetSlice(data, "items")
|
||||
// Strip avatar from meta_data — not useful for AI agents.
|
||||
for _, raw := range items {
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if meta, ok := m["meta_data"].(map[string]interface{}); ok {
|
||||
delete(meta, "avatar")
|
||||
}
|
||||
}
|
||||
}
|
||||
outData := map[string]interface{}{
|
||||
"items": items,
|
||||
"total": data["total"],
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@ Drive Folder (云空间文件夹)
|
||||
|
||||
## 重要说明:画板编辑
|
||||
> **⚠️ lark-doc skill 不能直接编辑已有画板内容,但 `docs +update` 可以新建空白画板**
|
||||
### 场景 1:已通过 docs +fetch --api-version v2 获取到文档内容和画板 token
|
||||
如果用户已经通过 `docs +fetch --api-version v2` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户:
|
||||
### 场景 1:已通过 docs +fetch 获取到文档内容和画板 token
|
||||
如果用户已经通过 `docs +fetch` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户:
|
||||
1. 记录画板的 token
|
||||
2. 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容
|
||||
### 场景 2:刚创建画板,需要编辑
|
||||
|
||||
@@ -111,7 +111,7 @@ Drive Folder (云空间文件夹)
|
||||
|
||||
| 操作 | 需要的 Token | 说明 |
|
||||
|------|-------------|------|
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `<sheetId>!<cell>`,`slides` 使用 `<slide-block-type>!<xml-id>`;Base / bitable 只有记录局部评论,定位为 file_token(base token) + `--block-id <table-id>!<record-id>!<view-id>` |
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
|
||||
@@ -31,6 +31,8 @@ lark-cli calendar +agenda --as user
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
|
||||
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` |
|
||||
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id |
|
||||
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
||||
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
|
||||
@@ -53,6 +55,7 @@ lark-cli calendar +agenda --as user
|
||||
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
||||
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
|
||||
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
|
||||
- **日程会议 ID(Meeting ID)**:日程的历史视频会议 ID,在日程上开过视频会议才会有。
|
||||
|
||||
## 术语映射
|
||||
|
||||
@@ -64,6 +67,9 @@ lark-cli calendar +agenda --as user
|
||||
|----------|--------|
|
||||
| 查询过去的会议("昨天的会议""上周的会") | [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md)(会议数据含即时会议,仅查日程会遗漏) |
|
||||
| 查询日历/日程或未来时间的会议 | 本 skill |
|
||||
| 按关键词搜索日程 | 本 skill(`+search-event`) |
|
||||
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
||||
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
|
||||
## 任务类型分流
|
||||
@@ -115,7 +121,6 @@ lark-cli calendar <resource> <method> [flags]
|
||||
- `get` — 获取日程
|
||||
- `instance_view` — 查询日程视图
|
||||
- `patch` — 更新日程
|
||||
- `search_event` — 搜索日程(仅返回 日程ID/主题/时间,详情需走 `events get`)
|
||||
- `share_info` — 获取日程分享链接
|
||||
|
||||
### freebusys
|
||||
|
||||
40
skills/lark-calendar/references/lark-calendar-meeting.md
Normal file
40
skills/lark-calendar/references/lark-calendar-meeting.md
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
# calendar +meeting
|
||||
|
||||
通过日程 ID(`event_id`) 获取关联的视频会议信息(`meeting_id`、`meeting_note`)。只读。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 单个 / 批量(逗号分隔,最多 50 个)
|
||||
lark-cli calendar +meeting --event-ids <event_id1>,<event_id2>
|
||||
|
||||
# 默认使用主日历,需要时显式传 --calendar-id
|
||||
lark-cli calendar +meeting --event-ids <event_id> --calendar-id <calendar_id>
|
||||
```
|
||||
|
||||
## 输出字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event_id` | 日程 ID |
|
||||
| `meeting_id` | 关联的视频会议 ID |
|
||||
| `meeting_note` | 用户主动绑定到日程的纪要文档 Token(`MeetingNotes`,由用户在日程页手动添加;)。**与会中产生的 AI 智能纪要 `note_doc_token` 是两份不同文档**,要拿 AI 纪要请继续走 `vc +detail` → `note +detail`。 |
|
||||
|
||||
## 下游链路
|
||||
|
||||
`calendar +meeting` 只把日程 ID 翻译为 `meeting_id` / `meeting_note`,要拿会中产生的产物(AI 智能纪要、逐字稿、妙记)需继续调用:
|
||||
|
||||
```bash
|
||||
# 1. meeting_id → note_id + minute_token(同一会议两份产物,可能各自为空)
|
||||
lark-cli vc +detail --meeting-ids <meeting_id>
|
||||
|
||||
# 2a. note_id → 纪要文档 token(note_doc_token / verbatim_doc_token / shared_doc_tokens)
|
||||
lark-cli note +detail --note-id <note_id>
|
||||
|
||||
# 2b. minute_token → 妙记 AI 产物(按需获取,不传不返回任何 AI 内容)
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --keyword --transcript
|
||||
|
||||
# 3. 任意文档 token(meeting_note / note_doc_token / verbatim_doc_token / shared_doc_token)→ 正文
|
||||
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
|
||||
```
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
定位规则:
|
||||
|
||||
- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`events search_event` 或实例视图缩小范围。
|
||||
- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围。
|
||||
- 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。
|
||||
- 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`。
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
|
||||
# calendar +search-event
|
||||
|
||||
按关键词、时间范围和参会人搜索日历日程。只读。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按关键词
|
||||
lark-cli calendar +search-event --query "周会"
|
||||
|
||||
# 按时间范围(ISO 8601 或 YYYY-MM-DD)
|
||||
lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
|
||||
|
||||
# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
|
||||
lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
|
||||
|
||||
# 组合
|
||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
|
||||
```
|
||||
|
||||
## 输出字段
|
||||
|
||||
`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more`、`page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false,不要遗漏;`page-size` 最大 30。
|
||||
- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。
|
||||
@@ -65,7 +65,7 @@ lark-cli calendar +update \
|
||||
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
||||
- 更新重复性日程的某一次实例时,必须先通过 `+agenda`、`events search_event` 或实例视图定位该实例的 `event_id`。
|
||||
- 更新重复性日程的某一次实例时,必须先通过 `+agenda`、`+search-event` 或实例视图定位该实例的 `event_id`。
|
||||
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档(Docx / Wiki 文档,v2 API):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
|
||||
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
|
||||
---
|
||||
|
||||
# docs (v2)
|
||||
# docs
|
||||
|
||||
**身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。**
|
||||
|
||||
> **CRITICAL — API 版本:本 skill 使用 v2 API。执行 `docs +create`、`docs +fetch`、`docs +update` 时必须显式传入 `--api-version v2`。**
|
||||
|
||||
```bash
|
||||
# 常用示例
|
||||
lark-cli docs +fetch --api-version v2 --doc "文档URL或token"
|
||||
lark-cli docs +create --api-version v2 --content '<title>标题</title><p>内容</p>'
|
||||
lark-cli docs +update --api-version v2 --doc "文档URL或token" --command append --content '<p>内容</p>'
|
||||
lark-cli docs +fetch --doc "文档URL或token"
|
||||
lark-cli docs +create --content '<title>标题</title><p>内容</p>'
|
||||
lark-cli docs +update --doc "文档URL或token" --command append --content '<p>内容</p>'
|
||||
```
|
||||
|
||||
## 前置条件 — 执行操作前必读
|
||||
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch --api-version v2`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)(元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
@@ -57,7 +55,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
|
||||
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
|
||||
| `<vc-transcribe-tab vc-node-id="...">` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id <vc-node-id>` |
|
||||
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block |
|
||||
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
|
||||
```bash
|
||||
# 创建 XML 文档(默认格式,推荐)
|
||||
lark-cli docs +create --api-version v2 --content '<title>项目计划</title><h1>目标</h1><p>记录本周重点。</p>'
|
||||
lark-cli docs +create --content '<title>项目计划</title><h1>目标</h1><p>记录本周重点。</p>'
|
||||
|
||||
# 仅当用户明确要求导入 Markdown 时才使用;文档标题用 --title,正文标题按内容自然组织
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办'
|
||||
lark-cli docs +create --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办'
|
||||
```
|
||||
|
||||
## 返回值
|
||||
@@ -58,7 +58,6 @@ lark-cli docs +create --api-version v2 --doc-format markdown --title "项目计
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| ------------------- | -- |---------------------------------------------|
|
||||
| `--api-version` | 是 | 固定传 `v2` |
|
||||
| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `<title>...</title>`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
|
||||
| `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
|
||||
@@ -5,27 +5,27 @@
|
||||
|
||||
```bash
|
||||
# 获取文档(默认 XML,simple)
|
||||
lark-cli docs +fetch --api-version v2 --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc"
|
||||
lark-cli docs +fetch --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc"
|
||||
|
||||
# Markdown 格式
|
||||
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --doc-format markdown
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc --doc-format markdown
|
||||
|
||||
# 带 block ID(用于后续 block 级更新)
|
||||
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --detail with-ids
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc --detail with-ids
|
||||
|
||||
# 只拿目录
|
||||
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --scope outline --max-depth 3
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc --scope outline --max-depth 3
|
||||
|
||||
# 按 block id 区间精读
|
||||
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc \
|
||||
--scope range --start-block-id blkA --end-block-id blkB --detail with-ids
|
||||
|
||||
# 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前)
|
||||
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc \
|
||||
--scope section --start-block-id <标题id> --detail with-ids
|
||||
|
||||
# 按关键词定位(多关键词用 | 分隔,任一命中即返回)
|
||||
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc \
|
||||
--scope keyword --keyword "部署|发布|上线"
|
||||
```
|
||||
|
||||
@@ -97,7 +97,6 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--api-version` | 是 | 固定传 `v2` |
|
||||
| `--doc` | 是 | 文档 URL 或 token(支持 `/docx/` 和 `/wiki/`) |
|
||||
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `im-markdown`(仅用于获取内容后在 `lark-im` 场景下使用) |
|
||||
| `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` |
|
||||
@@ -128,7 +127,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
|
||||
## 嵌入电子表格 / 多维表格
|
||||
|
||||
返回中可能含 `<sheet>`、`<bitable>`、`<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch --api-version v2` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
|
||||
返回中可能含 `<sheet>`、`<bitable>`、`<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Markdown 格式参考
|
||||
|
||||
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用;fetch 的 `--doc-format im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用,不作为 create/update 写入格式。
|
||||
`docs +fetch` / `docs +create` / `docs +update` 使用 `--doc-format markdown` 时适用;fetch 的 `--doc-format im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用,不作为 create/update 写入格式。
|
||||
|
||||
## 转义规则
|
||||
|
||||
@@ -34,14 +34,14 @@
|
||||
- `$...$` 数学公式内部,符号为 LaTeX 语法,不受 Markdown 转义影响
|
||||
|
||||
**导出已转义,不要反转义:**
|
||||
`docs +fetch --api-version v2 --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[`、`\|`、`\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
|
||||
`docs +fetch --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[`、`\|`、`\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
|
||||
|
||||
**写入时必须转义:**
|
||||
使用 `docs +create` 或 `docs +update` 的 `--doc-format markdown` 写入内容时,字面文本中的特殊字符同样必须转义。`--pattern` 参数中也必须使用转义形式才能正确匹配。
|
||||
|
||||
**导出 → 更新 工作流示例:**
|
||||
|
||||
1. `docs +fetch --api-version v2` 导出得到 `C:\\Users\\test\[1\]`
|
||||
1. `docs +fetch` 导出得到 `C:\\Users\\test\[1\]`
|
||||
2. 用 `str_replace --pattern 'C:\\Users\\test\[1\]'` 匹配(直接使用导出的转义形式)
|
||||
3. `--content` 中的替换内容也要保持转义:`C:\\Users\\prod\[2\]`
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ lark-cli docs +media-insert --doc doxcnXXX --from-clipboard
|
||||
|
||||
# 从本地文件插入
|
||||
# 除了上传本地文件,还可以在 `docs +update` 时直接通过网络 URL 插入图片,无需先下载到本地:
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_insert_after \
|
||||
--block-id "目标 block_id" \
|
||||
--content '<img href="https://example.com/photo.png"/>'
|
||||
|
||||
|
||||
@@ -14,13 +14,12 @@
|
||||
> - **局部精修**(`str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after`):优先使用 XML(默认)。XML 能稳定表达 block 结构和样式,精准编辑更可控;不要因为 Markdown 写起来更简单就自行切换。
|
||||
> - **整段写入**(`append` / `overwrite`):XML 和 Markdown 都可以。用户提供 `.md` 本地文件或明确要求 Markdown 时直接用 Markdown;否则默认 XML。
|
||||
>
|
||||
> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --api-version v2 --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。
|
||||
> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--api-version` | 是 | 固定传 `v2` |
|
||||
| `--doc` | 是 | 文档 URL 或 token |
|
||||
| `--command` | 是 | 操作指令(见下方指令速查表) |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
@@ -64,20 +63,20 @@
|
||||
|
||||
```bash
|
||||
# 简单文本替换
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--pattern "张三" --content "李四"
|
||||
|
||||
# 替换为富文本(加粗 + 链接)
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--pattern "旧链接" --content '<b>新链接</b> <a href="https://example.com">点击查看</a>'
|
||||
|
||||
# 仅当用户明确要求时才使用 Markdown
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--doc-format markdown --pattern "旧内容" --content "新内容"
|
||||
|
||||
# Markdown 模式下支持跨行匹配(--pattern 与 --content 都需要真实换行;"..."/'...' 里的 \n 是字面量)
|
||||
# 多行内容推荐 heredoc 或 --content @file.md,避免 shell 转义踩坑
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--doc-format markdown \
|
||||
--pattern "$(printf '## 旧标题\n\n第一段原文\n\n第二段原文')" \
|
||||
--content - <<'EOF'
|
||||
@@ -90,7 +89,7 @@ EOF
|
||||
|
||||
# Markdown 模式下使用 `前缀...后缀` 省略号匹配首尾特征明显的大段内容
|
||||
# 下例会把「## 旧标题」到「结束语。」之间的所有内容整体替换
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--doc-format markdown \
|
||||
--pattern "## 旧标题...结束语。" \
|
||||
--content - <<'EOF'
|
||||
@@ -102,14 +101,14 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
EOF
|
||||
|
||||
# 删除文本:--content 传空字符串即可
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--pattern "废弃的内容" --content ""
|
||||
```
|
||||
|
||||
### block_insert_after — 在指定 block 之后插入
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_insert_after \
|
||||
--block-id "目标 block_id" \
|
||||
--content '<h2>新章节</h2><ul><li>要点 1</li><li>要点 2</li></ul>'
|
||||
```
|
||||
@@ -117,7 +116,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_a
|
||||
### block_replace — 替换指定 block
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace \
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_replace \
|
||||
--block-id "目标 block_id" \
|
||||
--content '<p>替换后的段落内容</p>'
|
||||
```
|
||||
@@ -126,14 +125,14 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace
|
||||
|
||||
```bash
|
||||
# 删除多个块时用逗号 "," 分隔
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_delete \
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_delete \
|
||||
--block-id "block_id_1,block_id_2,block_id_3"
|
||||
```
|
||||
|
||||
### overwrite — 全文覆盖
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command overwrite \
|
||||
lark-cli docs +update --doc "<doc_id>" --command overwrite \
|
||||
--content '<title>全新文档</title><h1>概述</h1><p>新的内容</p>'
|
||||
```
|
||||
|
||||
@@ -142,7 +141,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command overwrite \
|
||||
### append — 在文档末尾追加
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command append \
|
||||
lark-cli docs +update --doc "<doc_id>" --command append \
|
||||
--content '<h2>新增章节</h2><p>追加的内容</p>'
|
||||
```
|
||||
|
||||
@@ -154,7 +153,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command append \
|
||||
|
||||
```bash
|
||||
# 复制多个块(按顺序插入:anchor → a → b → c)
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_copy_insert_after \
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_copy_insert_after \
|
||||
--block-id "锚点 block_id" \
|
||||
--src-block-ids "block_a,block_b,block_c"
|
||||
```
|
||||
@@ -165,7 +164,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_copy_ins
|
||||
|
||||
```bash
|
||||
# 移动到页面末尾
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_move_after \
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_move_after \
|
||||
--block-id "-1表示末尾,page_id表示开头,blk" \
|
||||
--src-block-ids "block_a,block_b"
|
||||
```
|
||||
@@ -203,7 +202,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_move_aft
|
||||
|
||||
1. **获取文档内容和 block ID**:
|
||||
```bash
|
||||
lark-cli docs +fetch --api-version v2 --doc "<doc_id>" --detail with-ids
|
||||
lark-cli docs +fetch --doc "<doc_id>" --detail with-ids
|
||||
```
|
||||
|
||||
2. **定位目标 block**:从返回的 XML 中找到要修改的 block 及其 `id` 属性
|
||||
@@ -211,11 +210,11 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_move_aft
|
||||
3. **执行更新**:
|
||||
```bash
|
||||
# 替换特定 block
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace \
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_replace \
|
||||
--block-id "blkcnXXXX" --content "<p>新内容</p>"
|
||||
|
||||
# 在某 block 后插入
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_insert_after \
|
||||
--block-id "blkcnXXXX" --content "<h2>追加的章节</h2>"
|
||||
```
|
||||
|
||||
@@ -224,13 +223,13 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_move_aft
|
||||
不需要 block ID,直接匹配替换:
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--pattern "v1.0" --content "v2.0"
|
||||
```
|
||||
|
||||
## 画板处理
|
||||
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch --api-version v2` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
|
||||
|
||||
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|-------------------------|-----------------------------------------------------------|
|
||||
| 文档中需要思维导图、时序图、类图、饼图、甘特图 | 步骤 2A:使用 mermaid 插入图表 |
|
||||
| 文档中需要插入其他图表/自定义图形 | 步骤 2B: 使用 SVG 插入图表 |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch` 获取 `board_token`,跳至步骤 3B |
|
||||
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -46,7 +46,7 @@ SubAgent 插入 SVG。
|
||||
|
||||
### 步骤 2B: SubAgent 使用 SVG 插入图表
|
||||
|
||||
主 Agent 启动 SubAgent,让它用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入:
|
||||
主 Agent 启动 SubAgent,让它用 `docs +create` / `docs +update` 插入:
|
||||
|
||||
```xml
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
1. 分析用户需求:受众、目的、范围
|
||||
2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
|
||||
3. `docs +create --api-version v2` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
|
||||
3. `docs +create` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
|
||||
- ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
|
||||
- 完整内容留到步骤二,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。
|
||||
- ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
### 步骤三:整合审查与画板识别(串行)
|
||||
|
||||
5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
|
||||
6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
### 步骤一:分析与画板识别(串行)
|
||||
|
||||
1. **选择读取范围**(节省上下文的关键):
|
||||
- 用户只改某一节 / 文档较大 → 先 `docs +fetch --api-version v2 --scope outline --max-depth 2` 拿目录,再 `docs +fetch --api-version v2 --scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id)
|
||||
- 需要精确跨节区间 → `docs +fetch --api-version v2 --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
|
||||
- 用户只给了模糊关键词 → `docs +fetch --api-version v2 --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
|
||||
- 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids`
|
||||
- 用户只改某一节 / 文档较大 → 先 `docs +fetch --scope outline --max-depth 2` 拿目录,再 `docs +fetch --scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id)
|
||||
- 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
|
||||
- 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
|
||||
- 用户明确要改整篇 → `docs +fetch --detail with-ids`
|
||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
|
||||
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
|
||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||
@@ -52,4 +52,4 @@ SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --api-version v2 --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
|
||||
**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
|
||||
|
||||
@@ -69,7 +69,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
|
||||
| 操作 | 需要的 Token | 说明 |
|
||||
|------|-------------|------|
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `<sheetId>!<cell>`,`slides` 使用 `<slide-block-type>!<xml-id>`;Base 只有记录局部评论,定位为 file_token(base_token) + `--block-id <table-id>!<record-id>!<view-id>` |
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
|
||||
@@ -35,7 +35,7 @@ lark-cli drive +add-comment \
|
||||
--doc "<FILE_TOKEN>" --type file \
|
||||
--content '[{"type":"text","text":"请补充目录说明"}]'
|
||||
|
||||
# 给 docx 文档的指定 block 添加局部评论(block_id 可通过 docs +fetch --api-version v2 --detail with-ids 获取)
|
||||
# 给 docx 文档的指定 block 添加局部评论(block_id 可通过 docs +fetch --detail with-ids 获取)
|
||||
lark-cli drive +add-comment \
|
||||
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
|
||||
--block-id "<BLOCK_ID>" \
|
||||
@@ -155,11 +155,11 @@ lark-cli drive +add-comment \
|
||||
| `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`file`、`sheet`、`slides`、`bitable`、`base`;评论 Base 文档推荐传 `bitable`,`base` 仅作为兼容别名兜底。URL 输入时自动识别,无需传 |
|
||||
| `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` |
|
||||
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论(仅适用于 doc/docx、白名单 Drive file,以及解析为这些类型的 wiki;不适用于 sheet、slides、Base / bitable) |
|
||||
| `--block-id` | 局部评论时必填 | 目标块 ID,可通过 `docs +fetch --api-version v2 --detail with-ids` 获取;sheet 用 `<sheetId>!<cell>`,slides 用 `<slide-block-type>!<xml-id>`,Base 用 `<table-id>!<record-id>!<view-id>` |
|
||||
| `--block-id` | 局部评论时必填 | 目标块 ID,可通过 `docs +fetch --detail with-ids` 获取;sheet 用 `<sheetId>!<cell>`,slides 用 `<slide-block-type>!<xml-id>`,Base 用 `<table-id>!<record-id>!<view-id>` |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **局部评论需要先获取 block ID**:先调用 `docs +fetch --api-version v2 --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。
|
||||
- **局部评论需要先获取 block ID**:先调用 `docs +fetch --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。
|
||||
- **Review 场景优先局部评论**:审阅、校对、逐条指出问题时,必须先尝试定位到具体 block / 单元格 / slide 元素,并逐问题创建局部评论;不要把所有问题合并成一条全文评论。
|
||||
- 未传 `--block-id` 时,shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终可解析为 `doc`/`docx`/`file` 的 wiki URL。
|
||||
- **Drive file 评论**:仅支持白名单扩展名的普通文件。当前支持:`.md`、`.txt`、`.json`、`.csv`、`.go`、`.js`、`.py`、`.pptx`、`.png`、`.jpg`、`.jpeg`、`.zip`、`.mp3`、`.mp4`。
|
||||
|
||||
@@ -27,7 +27,7 @@ lark-cli drive file.comments batch_query \
|
||||
同时获取文档内容,并要求返回 block id:
|
||||
|
||||
```bash
|
||||
lark-cli docs +fetch --api-version v2 --doc '<doc_token_or_url>' --detail with-ids
|
||||
lark-cli docs +fetch --doc '<doc_token_or_url>' --detail with-ids
|
||||
```
|
||||
|
||||
## 字段含义
|
||||
@@ -127,7 +127,7 @@ lark-cli docs +fetch --api-version v2 --doc '<doc_token_or_url>' --detail with-i
|
||||
|
||||
1. 确认目标是 `file_type=docx`;只有 docx 文档支持通过 `need_relation` 查询评论位置。
|
||||
2. 用 `drive file.comments list` 或 `drive file.comments batch_query` 获取评论,并带 `need_relation=true`。
|
||||
3. 用 `docs +fetch --api-version v2 --detail with-ids` 获取文档内容。
|
||||
3. 用 `docs +fetch --detail with-ids` 获取文档内容。
|
||||
4. 对每条评论先看 `relation`:
|
||||
- 如果存在 `relation.relation`,解析这个 JSON 字符串。
|
||||
- 从解析结果里取 `positionInfo.blockID`。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| 盘点用户明确确认的 Drive 根目录 | 使用 | 第一层用空 `folder_token`,子文件夹继续按普通文件夹递归 |
|
||||
| 验证移动 / 创建后的实际位置 | 使用 | 读取目标目录直接子项,再按需递归验证 |
|
||||
| 根据关键词、标题、时间、owner 找资源 | 不使用 | 优先用 `drive +search` |
|
||||
| 读取 Docx 正文内容 | 不使用 | 用 `docs +fetch --api-version v2` |
|
||||
| 读取 Docx 正文内容 | 不使用 | 用 `docs +fetch` |
|
||||
| 读取 Sheet / Base 内部数据 | 不使用 | 切到 `lark-sheets` / `lark-base` |
|
||||
|
||||
## 标准命令模板
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-minutes
|
||||
version: 1.0.0
|
||||
description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill,不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记,或仅按自然语言标题定位纪要"
|
||||
description: "飞书妙记:搜索妙记、查看妙记基础信息、下载/上传音视频、读取或编辑妙记的产物内容、改标题、替换说话人/关键词。当给出minute_token、本地音视频文件,要查/改/转妙记产物时使用;本地音视频转纪要/逐字稿优先走本 skill,不要用 ffmpeg/whisper 本地转写。不负责:获取会议关联妙记,或仅按自然语言标题定位纪要"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -27,27 +27,34 @@ metadata:
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+search`](references/lark-minutes-search.md) | 按关键词、所有者、参与者、时间范围搜索妙记 |
|
||||
| [`+detail`](references/lark-minutes-detail.md) | 查询妙记详情(标题和关联的纪要note_id),按需获取 AI 产物(总结、待办、章节、逐字稿、关键词) |
|
||||
| [`+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,不支持姓名) |
|
||||
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | 替换妙记逐字稿中的说话人(须先 `lark-cli api GET .../speakerlist` 取 `speaker_id`) |
|
||||
| `+word-replace` | 批量替换逐字稿关键词(详见 `lark-cli minutes +word-replace --help`) |
|
||||
| [`+summary`](references/lark-minutes-summary.md) | 替换妙记 AI 总结全文 |
|
||||
| [`+todo`](references/lark-minutes-todo.md) | 新建/更新/删除妙记 AI 待办(单条或 `--todos` 批量;不是 lark-task) |
|
||||
|
||||
- 使用任何 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`) |
|
||||
| "xx 纪要的逐字稿/原始记录/谁说了什么" 且没有 `minute_token` / 妙记 URL / 本地音视频文件 | 不走本 skill;路由到 [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md),必要时再到 [lark-note](../lark-note/SKILL.md) |
|
||||
| "把音视频文件转成纪要/逐字稿/文字稿" | 先本 skill(`+upload`),再 [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) |
|
||||
| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)(`+search` → `+recording`),再本 skill |
|
||||
| 用户意图 | 命令 |
|
||||
|---------|------|
|
||||
| 我的妙记 / 搜索妙记 / 某段时间的妙记 | `+search` |
|
||||
| 妙记基础信息:标题 / 时长 / 封面 / 链接 | `minutes get` |
|
||||
| 下载妙记音视频文件、获取媒体下载链接 | `+download`(仅媒体;要妙记内容用 `+detail`) |
|
||||
| 妙记总结 / 章节 / 待办 / 关键词 / 逐字稿 | `+detail --minute-tokens <token>` + 显式产物 flag |
|
||||
| 基于妙记**提炼/总结/分析/回顾**会议 | `+detail --minute-tokens <token> --transcript`,再独立分析(**禁止照搬 AI 总结**) |
|
||||
| 拿这条妙记关联的纪要文档(`note_doc_token` / `verbatim_doc_token` / `shared_doc_tokens`) | `+detail` 取顶层 `note_id` → [`note +detail --note-id`](../lark-note/SKILL.md) |
|
||||
| 把本地音视频转纪要 / 逐字稿 / 文字稿 | `drive +upload` 取 `file_token` → `+upload` 生成 `minute_url` → `+detail` 拿产物 |
|
||||
| 在妙记里增加 / 更改 / 删除 AI 待办 | `+todo`(**禁止走 lark-task**) |
|
||||
| 替换妙记的AI 总结 | `+summary` |
|
||||
| 重命名妙记/改妙记标题 | `+update` |
|
||||
| 替换说话人/把 A 的发言改成 B/重新归属发言人/把外部(非飞书)说话人改成飞书用户" | 先 `lark-cli api GET .../transcript/speakerlist` 取 `speaker_id`,再 [`minutes +speaker-replace`](references/lark-minutes-speaker-replace.md);`--from-speaker-id` 只传 id,不传展示名 |
|
||||
| 批量替换逐字稿关键词 | `+word-replace` |
|
||||
| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)(`+search` → `+recording`)获取 `minute_token`,再本 skill |
|
||||
|
||||
## 核心概念
|
||||
|
||||
@@ -58,60 +65,30 @@ metadata:
|
||||
|
||||
### 1. 搜索妙记
|
||||
|
||||
1. 当用户描述的是"我的妙记""包含某个关键词的妙记""某段时间内的妙记",优先使用 `minutes +search`。
|
||||
2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户。
|
||||
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。
|
||||
4. 如果是会议的妙记,应优先通过 [lark-vc](../lark-vc/SKILL.md) 定位会议并获取 `minute_token`。
|
||||
5. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准。
|
||||
1. 如果是会议的妙记,应优先通过 [lark-vc](../lark-vc/SKILL.md) 定位会议并获取 `minute_token`。
|
||||
2. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准。
|
||||
|
||||
|
||||
### 2. 查看妙记基础信息
|
||||
|
||||
1. 当用户只需要确认某条妙记的标题、封面、时长、所有者、URL 等基础信息时,使用 `minutes minutes get`。
|
||||
2. 如果用户给的是妙记 URL,应先从 URL 末尾提取 `minute_token`,再调用 `minutes minutes get`。
|
||||
3. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC 链路拿到 `minute_token`,再调用 `minutes minutes get`。
|
||||
4. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。
|
||||
2. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC/Calendar 链路拿到 `minute_token`,再调用 `minutes minutes get`。
|
||||
3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。
|
||||
|
||||
> 使用 `lark-cli schema minutes.minutes.get` 可查看完整返回值结构。核心字段包含:`title`(标题)、`cover`(封面 URL)、`duration`(时长,毫秒)、`owner_id`(所有者 ID)、`url`(妙记链接)。
|
||||
|
||||
### 3. 下载妙记音视频文件
|
||||
### 3. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
|
||||
|
||||
1. 下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
|
||||
2. `+download` 只负责音视频媒体文件。用户需要逐字稿、总结、待办、章节等纪要内容时,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
|
||||
3. 用户只想拿可分享的下载地址时,使用 `--url-only`;用户要落地到本地文件时,直接下载。
|
||||
4. 未显式指定路径时,文件默认落到 `./minutes/{minute_token}/<server-filename>`,与 `vc +notes` 的逐字稿共享同一目录便于聚合。
|
||||
|
||||
> **注意**:`+download` 只负责音视频媒体文件。如果用户需要的是逐字稿、总结、待办、章节等纪要内容,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
|
||||
|
||||
### 4. 读取妙记的逐字稿、总结、待办、章节(只读)
|
||||
|
||||
1. 当用户要**查看 / 读取**"这个妙记的逐字稿""总结""待办""章节"时,使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
|
||||
2. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL,先提取 `minute_token`。
|
||||
3. 如果用户给的是**本地音视频文件**,但目标是"转成纪要""转成逐字稿""转成文字稿""转成撰写文字",应先按下文第 5 节上传文件生成妙记,再把返回的 `minute_url` 提取成 `minute_token`,继续调用 `vc +notes --minute-tokens`。
|
||||
4. 用户如果直接给出本地文件名或路径,并要求"转逐字稿""转文字稿""整理成撰写文字",这也是本 skill 的明确触发信号。
|
||||
|
||||
```bash
|
||||
# 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节)
|
||||
lark-cli vc +notes --minute-tokens <minute_token>
|
||||
```
|
||||
|
||||
> **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供
|
||||
> **读 vs 写**:`vc +notes` 只负责**读取** AI 产物。用户要**新建 / 修改 / 删除**妙记内的 AI 待办或替换 AI 总结,见下文第 6 节,**不要**走 [lark-task](../lark-task/SKILL.md)。
|
||||
|
||||
### 5. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
|
||||
|
||||
1. 当用户需要通过上传本地音视频文件来生成妙记时使用。
|
||||
2. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。
|
||||
3. **处理流程**:
|
||||
1. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。
|
||||
2. **处理流程**:
|
||||
- **上传音视频获取 `file_token`**:使用 [`lark-cli drive +upload`](../lark-drive/references/lark-drive-upload.md) 上传本地文件到云空间(云盘/云存储)并获取 `file_token`。
|
||||
- **生成妙记**:获取到 `file_token` 后,调用 [`lark-cli minutes +upload`](references/lark-minutes-upload.md) 将文件转换为妙记并获取 `minute_url` 链接。
|
||||
- **继续获取纪要 / 逐字稿(按需)**:如果用户目标不是只要妙记链接,而是要纪要、逐字稿、总结、待办或章节,则从 `minute_url` 中提取 `minute_token`,再调用 [`lark-cli vc +notes --minute-tokens`](../lark-vc/references/lark-vc-notes.md) 获取对应产物。
|
||||
- **继续获取纪要 / 逐字稿(按需)**:如果用户目标不是只要妙记链接,而是要纪要、逐字稿、总结、待办或章节,则从 `minute_url` 中提取 `minute_token`,再调用 [`lark-cli minutes +detail --minute-tokens`](references/lark-minutes-detail.md) 获取对应产物。
|
||||
|
||||
> **注意**:必须先获取飞书云空间(云盘/云存储)的 `file_token` 才能进行转换。
|
||||
>
|
||||
> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> vc +notes --minute-tokens`。
|
||||
> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> minutes +detail --minute-tokens`。
|
||||
|
||||
### 6. 编辑妙记的 AI 待办与 AI 总结(写入)
|
||||
### 5. 编辑妙记的 AI 待办与 AI 总结(写入)
|
||||
|
||||
当用户要在**某条妙记内**操作 AI 待办或 AI 总结时使用本节。**不是**飞书任务(Task)清单里的待办。
|
||||
|
||||
@@ -141,73 +118,58 @@ lark-cli minutes +todo --minute-token <token> --as user --todos '[
|
||||
]'
|
||||
```
|
||||
|
||||
**更新 / 删除前**:先用 `vc +notes --minute-tokens <token>` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
|
||||
**更新 / 删除前**:先用 `minutes +detail --minute-tokens <token> --todo` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
|
||||
|
||||
**无编辑权限**:若 CLI 返回 `error.type=no_edit_permission`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`。
|
||||
|
||||
**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `vc +notes --minute-tokens <token>` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
|
||||
**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
|
||||
|
||||
**替换 AI 总结全文**:见 [minutes +summary](references/lark-minutes-summary.md)。
|
||||
|
||||
> 使用 `+todo` 前必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md);使用 `+summary` 前必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md)。
|
||||
|
||||
## 资源关系
|
||||
### 7. 替换妙记逐字稿说话人
|
||||
|
||||
```text
|
||||
Minutes (妙记) ← minute_token 标识
|
||||
├── Metadata (标题、封面、时长、owner、url) → minutes minutes get
|
||||
└── MediaFile (音频/视频文件) → minutes +download
|
||||
当用户要把妙记里某说话人的发言改绑到另一位飞书用户时使用。
|
||||
|
||||
**触发信号**:「替换说话人」「把 A 的发言改成 B」「说话人识别错了」「把外部说话人改成飞书用户」等。
|
||||
|
||||
**Agent 必读流程**(详见 [minutes +speaker-replace](references/lark-minutes-speaker-replace.md)):
|
||||
|
||||
1. 确认 `minute_token`。
|
||||
2. **先**用 `lark-cli api GET "/open-apis/minutes/v1/minutes/<token>/transcript/speakerlist"` 查说话人列表(内部 HTTP,无 shortcut、无公开 OpenAPI 文档页)。
|
||||
3. 根据用户描述的原说话人展示名,在返回的 `data.speakers[]` 中匹配 `name` → 得到 `speaker_id`;同名多人时结合 `vc +notes` 逐字稿请用户确认,**不要擅自挑选**。
|
||||
4. 新说话人姓名用 [lark-contact](../lark-contact/SKILL.md) 解析为 `ou_` open_id。
|
||||
5. 调用 `minutes +speaker-replace`,**`--from-speaker-id` 只传步骤 3 的 `speaker_id`,禁止传展示名**。
|
||||
|
||||
## 行为规则
|
||||
|
||||
### 1. `+detail` 必须显式声明产物 flag
|
||||
|
||||
不传 `--summary` / `--todo` / `--chapter` / `--keyword` / `--transcript` 时只返回基础信息(含顶层 `note_id`),AI 产物字段一律不返回。即使产物为空也会返回空值字段,便于程序化处理。
|
||||
|
||||
```bash
|
||||
# 拿全产物
|
||||
lark-cli minutes +detail --minute-tokens <token> --summary --todo --chapter --keyword --transcript
|
||||
```
|
||||
|
||||
> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载/上传音视频、编辑妙记 AI 待办与 AI 总结、重命名、逐字稿说话人/关键词替换**。
|
||||
>
|
||||
> **路由规则**:
|
||||
>
|
||||
> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search`
|
||||
> - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill
|
||||
> - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
|
||||
> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要**读取**逐字稿、文字稿、撰写文字、总结、待办、章节,再走 `vc +notes --minute-tokens`
|
||||
> - “我的妙记”“参与的妙记”等自然语言映射细则,以 [minutes +search](references/lark-minutes-search.md) 为准
|
||||
> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果
|
||||
> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限
|
||||
> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get`
|
||||
> - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download`
|
||||
> - 用户要**读取**"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
|
||||
> - 用户要在**妙记内新建 / 修改 / 删除 AI 待办**(含「妙记里加待办」「任务1 已完成」等)→ [`minutes +todo`](references/lark-minutes-todo.md),**禁止**走 lark-task
|
||||
> - 用户要**替换妙记 AI 总结全文** → [`minutes +summary`](references/lark-minutes-summary.md)
|
||||
> - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload`
|
||||
> - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens`
|
||||
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
|
||||
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
|
||||
> - 用户说"批量替换逐字稿关键词" → `minutes +word-replace`
|
||||
>
|
||||
> **Note 域边界(禁止规则)**:`minute_token` 是妙记文件标识,**不是** `note_id`。
|
||||
> - 不要把 `minute_token` 传给 `note +detail` 或 `note +transcript`。
|
||||
> - 已有 `minute_token` 且要读取纪要产物时,先走 [lark-vc](../lark-vc/SKILL.md);只有自然语言纪要标题时不要从 Minutes 反查。
|
||||
### 2. "提炼 / 总结"必须基于 Transcript,不要照搬 AI 总结
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
AI 总结是模型对会议的二次压缩,可能遗漏争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望基于原始发言独立分析,而非搬运 AI 产物。**优先 `--transcript`,再独立写结论**。
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
### 3. 从妙记反查纪要:不绕 lark-vc
|
||||
|
||||
| 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) |
|
||||
| [`+summary`](references/lark-minutes-summary.md) | Replace the full AI summary text of a minute |
|
||||
| [`+todo`](references/lark-minutes-todo.md) | Add, update, or delete **AI todo(s) inside a minute** (single or batch via `--todos`; not Feishu Task) |
|
||||
`minutes +detail` 顶层直接返回 `note_id`(仅在该妙记关联纪要时存在)。不需要绕回 [lark-vc](../lark-vc/SKILL.md),直接:
|
||||
|
||||
- 使用 `+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,不支持姓名)。
|
||||
- 使用 `+summary` 命令时,必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md),了解全文替换参数。
|
||||
- 使用 `+todo` 命令时,必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md),了解单条与 `--todos` 批量模式;**不要**用 lark-task。
|
||||
```bash
|
||||
# 1) 取 note_id(顶层 .minutes[0].note_id)
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --format json
|
||||
# 2) 用上一步拿到的 note_id 读纪要 token
|
||||
lark-cli note +detail --note-id <note_id> # 拿 note_doc_token / verbatim_doc_token / shared_doc_tokens
|
||||
```
|
||||
|
||||
顶层无 `note_id` 字段即代表无关联纪要,到此为止——不要继续尝试用 `minute_token` 当 `note_id`。
|
||||
|
||||
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -223,7 +185,8 @@ lark-cli minutes <resource> <method> [flags]
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 已有 `minute_token` 的纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`)
|
||||
- 只有自然语言纪要标题的逐字稿查询 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)
|
||||
- 搜索历史会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)
|
||||
- 搜索历史会议记录、查参会人快照 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 未来日程 / 日历查询 → [lark-calendar](../lark-calendar/SKILL.md)
|
||||
- 已知 `note_id` 直接读纪要详情 → [lark-note](../lark-note/SKILL.md)
|
||||
- 飞书任务清单(个人 Todo / 共享清单) → [lark-task](../lark-task/SKILL.md)
|
||||
- 只有自然语言纪要标题、没有 `minute_token` / 妙记 URL / 本地音视频时定位逐字稿 → 文档搜索([lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md))
|
||||
|
||||
62
skills/lark-minutes/references/lark-minutes-detail.md
Normal file
62
skills/lark-minutes/references/lark-minutes-detail.md
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
# minutes +detail
|
||||
|
||||
通过 `minute_token` 查询妙记详情,按需获取 AI 产物(总结/待办/章节/逐字稿/关键词)。只读。
|
||||
|
||||
> `--summary` / `--todo` / `--chapter` / `--keyword` / `--transcript` 至少一个;不传任何产物 flag 时只返回基础信息(如 `title`),AI 产物字段都不会出现。一次性获取所有产物:`--summary --todo --chapter --keyword --transcript`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 仅基础信息
|
||||
lark-cli minutes +detail --minute-tokens obcxxxxxxxxxx
|
||||
|
||||
# 批量(逗号分隔,最多 50 个)
|
||||
lark-cli minutes +detail --minute-tokens obcxxx,obcyyy --summary --todo
|
||||
|
||||
# 全产物
|
||||
lark-cli minutes +detail --minute-tokens obcxxx --summary --todo --chapter --keyword --transcript
|
||||
|
||||
# 仅逐字稿,覆盖已有文件,指定输出目录
|
||||
lark-cli minutes +detail --minute-tokens obcxxx --transcript --overwrite --output-dir ./out
|
||||
```
|
||||
|
||||
## 输出
|
||||
|
||||
`minutes` 数组每条含 `minute_token`、`title`、`note_id`、`artifacts`。`note_id` 仅在该妙记关联了会议纪要时返回,可直接传给 [`note +detail`](../../lark-note/references/lark-note-detail.md) 拿纪要文档 token,无需再绕回 `vc +detail`。`artifacts` 中**只包含本次请求的产物**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `artifacts.summary` | string | AI 总结。 |
|
||||
| `artifacts.todos` | array | 待办事项列表。 |
|
||||
| `artifacts.chapters` | array | 章节列表。 |
|
||||
| `artifacts.keywords` | array | 关键词列表。 |
|
||||
| `artifacts.transcript_file` | string | 逐字稿本地文件路径。 |
|
||||
|
||||
逐字稿默认落地 `./minutes/{minute_token}/transcript.txt`,与 `minutes +download` 同目录便于聚合。指定 `--output-dir <dir>` 时改写到 `<dir>/artifact-{title}-{minute_token}/transcript.txt`。
|
||||
|
||||
## minute_token 来源
|
||||
|
||||
| 来源 | 取值字段 |
|
||||
|------|---------|
|
||||
| 妙记 URL `https://*.feishu.cn/minutes/obcxxx` | 截路径最后一段 `obcxxx` |
|
||||
| `vc +detail --meeting-ids` | `minute_token` |
|
||||
| `vc +recording --meeting-ids` | `minute_token` |
|
||||
| `minutes +search` | `minute_token` |
|
||||
|
||||
## 典型链路:从 minute_token 拿纪要文档 token
|
||||
|
||||
只持有 `minute_token`(如妙记 URL 入口),又想拿 AI 智能纪要 / 逐字稿文档时:
|
||||
|
||||
```bash
|
||||
# 1. 取妙记关联的 note_id,没有关联会议纪要则为空
|
||||
lark-cli minutes +detail --minute-tokens <minute_token>
|
||||
|
||||
# 2. 用 note_id 拿 note_doc_token / verbatim_doc_token / shared_doc_tokens
|
||||
lark-cli note +detail --note-id <note_id>
|
||||
|
||||
# 3. 读纪要 / 逐字稿正文
|
||||
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
|
||||
```
|
||||
|
||||
> `minute_token` 不要直接传给 `note +detail`:必须先用本命令拿到 `note_id` 再调用 `note +detail`。
|
||||
@@ -43,7 +43,7 @@ lark-cli minutes +download --minute-tokens obcnxxxxxxxxxxxxxxxxxxxx --dry-run
|
||||
| `--url-only` | 否 | 仅返回下载链接,不下载文件 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
> **默认落点**:未指定 `--output` / `--output-dir` 时,文件落到 `./minutes/{minute_token}/<server-filename>`。文件名沿用服务端 Content-Disposition / Content-Type 推断,Agent 可从 `saved_path` 字段读取实际路径。同一 minute_token 的录像和 `vc +notes` 的逐字稿默认会落在**同一目录**下,方便聚合。
|
||||
> **默认落点**:未指定 `--output` / `--output-dir` 时,文件落到 `./minutes/{minute_token}/<server-filename>`。文件名沿用服务端 Content-Disposition / Content-Type 推断,Agent 可从 `saved_path` 字段读取实际路径。同一 minute_token 的录像和 `minutes +detail` 的逐字稿默认会落在**同一目录**下,方便聚合。
|
||||
|
||||
## 核心约束
|
||||
|
||||
@@ -85,7 +85,7 @@ API 限流 5 次/秒,批量下载时需注意控制频率。
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `minute_token` | 妙记 Token(用于 Agent 索引) |
|
||||
| `artifact_type` | 固定为 `"recording"`(与 `vc +notes` 的 `"transcript"` 区分) |
|
||||
| `artifact_type` | 固定为 `"recording"`(与 `minutes +detail` 的 `"transcript"` 区分) |
|
||||
| `saved_path` | 文件保存的本地路径(绝对路径) |
|
||||
| `size_bytes` | 文件大小(字节) |
|
||||
|
||||
@@ -125,13 +125,13 @@ API 限流 5 次/秒,批量下载时需注意控制频率。
|
||||
## 提示
|
||||
|
||||
- 音视频文件可能较大,下载无固定超时限制(由用户 Ctrl+C 控制取消)。
|
||||
- 默认落点 `./minutes/{minute_token}/` 与 `vc +notes` 的逐字稿共享同一目录,方便 Agent 聚合同一会议的所有产物。
|
||||
- 默认落点 `./minutes/{minute_token}/` 与 `minutes +detail` 的逐字稿共享同一目录,方便 Agent 聚合同一会议的所有产物。
|
||||
- 单 token 模式下 `--output` 若传入已存在目录(如 `--output ./existing-dir`),等价于 `--output-dir`,文件落入该目录(cp 语义)。
|
||||
- 批量模式下 `--output` 不接受已存在的文件路径(会报错),应改用 `--output-dir`。
|
||||
- 如需获取妙记的纪要内容(逐字稿、AI 总结等),请使用 [vc +notes](../../lark-vc/references/lark-vc-notes.md)。
|
||||
- 如需获取妙记的纪要内容(逐字稿、AI 总结等),请使用 [minutes +detail](lark-minutes-detail.md)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) — 妙记全部命令
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 会议纪要查询
|
||||
- [lark-minutes-detail](lark-minutes-detail.md) — 妙记详情与 AI 产物查询
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -129,8 +129,6 @@ CLI 会先按输入的本地日历日语义解析,再标准化为 RFC3339 时
|
||||
2. `vc +recording` 获取 `minute_token`
|
||||
3. `minutes minutes get` 查询妙记基础信息
|
||||
|
||||
不要为了查"妙记信息"直接走 `vc +notes --meeting-ids`。`vc +notes` 只适用于逐字稿、总结、待办、章节等纪要内容。
|
||||
|
||||
<br />
|
||||
|
||||
## 时间格式
|
||||
@@ -145,14 +143,14 @@ CLI 会先按输入的本地日历日语义解析,再标准化为 RFC3339 时
|
||||
|
||||
## 输出结果
|
||||
|
||||
- 默认输出包含 `items`、`total`、`has_more` 和 `page_token`。
|
||||
- 默认输出包含 `items`、`has_more` 和 `page_token`。
|
||||
|
||||
## Pagination (`has_more` / `page_token`)
|
||||
|
||||
- 当结果中返回 `has_more=true` 时,说明还有更多页可继续获取。
|
||||
- 继续翻页时,使用响应中的 `page_token` 搭配 `--page-token` 发起下一次查询。
|
||||
- 不要假设调大 `--page-size` 就能拿全结果;分页遍历时应以 `has_more` 和 `page_token` 为准。
|
||||
- `total` 数量小于 50 时,自动分页获取所有结果;`total` 数量大于 50 时,向用户确认是否获取全部结果。
|
||||
- 当 `has_more=true` 时,逐页累计已读取的 `items` 数:累计不到 50 条之前可自动继续翻页;超过 50 条后应停下来向用户确认是否获取全部结果。
|
||||
|
||||
```bash
|
||||
# First page
|
||||
@@ -173,8 +171,8 @@ lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PA
|
||||
# 首先查询妙记元信息(标题、时长、封面) → 用本 skill
|
||||
lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}'
|
||||
|
||||
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes
|
||||
lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN
|
||||
# 查妙记关联的产物(--summary --todo --chapter --keyword --transcript 按需返回)
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --summary
|
||||
```
|
||||
|
||||
## 常见错误与排查
|
||||
@@ -192,7 +190,7 @@ lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN
|
||||
- 当用户说“我的妙记”时,优先理解为 `--owner-ids me`。
|
||||
- 当用户说“我参与的妙记”“我参加过的妙记”时,默认理解为 `--owner-ids me` 与 `--participant-ids me` 两次查询后的并集。
|
||||
- 当用户明确说“仅我参与但不是我拥有”时,才优先理解为 `--participant-ids me`。
|
||||
- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;如果要的是妙记信息,走 `vc +recording` → `minutes minutes get`,只有要纪要内容时才走 `vc +notes --minute-tokens`。
|
||||
- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;如果要的是妙记信息,走 `vc +recording` 获取 `minute_token` → `minutes minutes get`,只有要妙记产物内容时才走 `minutes +detail --minute-tokens`。
|
||||
- 必须使用 `--format json` 输出,你更加擅长解析 JSON 数据。
|
||||
- 排查参数与请求结构时优先使用 `--dry-run`。
|
||||
- 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的妙记,需要拆分为多次时间范围为一个月查询。
|
||||
@@ -200,7 +198,7 @@ lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关命令
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
|
||||
- [lark-minutes-detail](lark-minutes-detail.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-vc](../../lark-vc/SKILL.md) -- 视频会议全部命令
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要手工把某段语音绑定到正确用户的场景。
|
||||
替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要把外部/非飞书说话人改绑到正确飞书用户的场景。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli minutes +speaker-replace`。
|
||||
|
||||
@@ -10,15 +10,60 @@
|
||||
|
||||
- "把这条妙记里 A 的发言改成 B"
|
||||
- "妙记说话人识别错了,帮我把张三的部分换成李四"
|
||||
- "把妙记里外部说话人 / 非飞书说话人的发言改成某个飞书用户"
|
||||
- "妙记说话人修改 / 替换 / 重新归属"
|
||||
- "改一下妙记的说话人"
|
||||
|
||||
## 完整工作流
|
||||
|
||||
识别到「修改妙记说话人」需求后,**必须**按以下顺序执行;**禁止**把展示名直接传给 `--from-speaker-id`。
|
||||
|
||||
1. **确认 `minute_token`**
|
||||
- 从妙记 URL、搜索或 VC 链路取得 `minute_token`。
|
||||
|
||||
2. **查说话人列表(必须先做)**
|
||||
- 用 **`lark-cli api`** 直接调用内部 HTTP 接口:
|
||||
```bash
|
||||
lark-cli api GET "/open-apis/minutes/v1/minutes/<minute_token>/transcript/speakerlist" --as user
|
||||
```
|
||||
- 返回 `data.speakers[]`,每项含 `speaker_id`(不透明 id)与 `name`(逐字稿展示名)。示例:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"speakers": [
|
||||
{"speaker_id": "ENCRYPTED_TOKEN_ABC", "name": "说话人1"},
|
||||
{"speaker_id": "ENCRYPTED_TOKEN_DEF", "name": "说话人2"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **解析 `--from-speaker-id`**
|
||||
- 根据用户描述的原说话人(展示名,如「说话人1」「张三」),在 `speakers[]` 里按 `name` **精确匹配**,取对应的 **`speaker_id`** 作为 `--from-speaker-id` 的值。
|
||||
- **`--from-speaker-id` 只传 `speaker_id`,不传展示名。**
|
||||
- 若同名有多条(`name` 相同、`speaker_id` 不同):**不要擅自挑选**。可结合 [`vc +notes --minute-tokens`](../../lark-vc/references/lark-vc-notes.md) 对照各人发言内容,请用户确认后再用精确的 `speaker_id`。
|
||||
- 若列表中无匹配展示名:告知用户并核对拼写,或请用户在妙记页面确认标签。
|
||||
|
||||
4. **解析 `--to-user-id`**
|
||||
- 新说话人必须是 `ou_` 开头的 open_id。用户只给姓名时,先用 [lark-contact](../../lark-contact/SKILL.md) 解析。
|
||||
|
||||
5. **执行替换**
|
||||
```bash
|
||||
lark-cli minutes +speaker-replace \
|
||||
--minute-token obcnxxxxxxxxxxxxxxxxxxxx \
|
||||
--from-speaker-id ENCRYPTED_TOKEN_ABC \
|
||||
--to-user-id ou_new_speaker_open_id
|
||||
```
|
||||
|
||||
## 命令示例
|
||||
|
||||
```bash
|
||||
# 1. 先查列表(裸调 HTTP)
|
||||
lark-cli api GET "/open-apis/minutes/v1/minutes/obcnxxxxxxxxxxxxxxxxxxxx/transcript/speakerlist" --as user
|
||||
|
||||
# 2. 再替换(from-speaker-id 来自上一步的 speaker_id)
|
||||
lark-cli minutes +speaker-replace \
|
||||
--minute-token obcnxxxxxxxxxxxxxxxxxxxx \
|
||||
--from-user-id ou_old_speaker_open_id \
|
||||
--from-speaker-id ENCRYPTED_TOKEN_ABC \
|
||||
--to-user-id ou_new_speaker_open_id
|
||||
```
|
||||
|
||||
@@ -27,21 +72,33 @@ lark-cli minutes +speaker-replace \
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--minute-token <token>` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 |
|
||||
| `--from-user-id <ou_xxx>` | 是 | 被替换的原说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 |
|
||||
| `--from-speaker-id <id>` | 是 | 被替换的原说话人 **`speaker_id`**(来自 speakerlist API 的 `data.speakers[].speaker_id`) |
|
||||
| `--to-user-id <ou_xxx>` | 是 | 新的说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 |
|
||||
|
||||
> **重要**:`--from-user-id` 和 `--to-user-id` 仅支持 `ou_` 开头的用户 ID,**不支持直接传姓名**。如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`,再调用本命令。
|
||||
## 核心约束
|
||||
|
||||
### 1. 必须先查 speakerlist,再替换
|
||||
|
||||
Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`--from-speaker-id` 只接受 `speaker_id`。
|
||||
|
||||
### 2. 新说话人必须是 open_id
|
||||
|
||||
`--to-user-id` 仅支持 `ou_` 开头的 open_id,**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。
|
||||
|
||||
### 3. 历史参数
|
||||
|
||||
存在一个隐藏的历史参数 `--from-user-id`(飞书说话人的 open_id),仅为向后兼容保留;新流程请一律使用 `--from-speaker-id` + `speaker_id`。
|
||||
|
||||
## 认证与权限
|
||||
|
||||
- 所需 scope:`minutes:minutes:update`。
|
||||
- 所需 scope:`minutes:minutes:readonly`(内部解析说话人)、`minutes:minutes:update`(执行替换)。
|
||||
|
||||
## 输出结果
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `minute_token` | 被修改的妙记 Token,与输入的 `--minute-token` 一致 |
|
||||
| `from_user_id` | 被替换的原说话人 open_id,与输入的 `--from-user-id` 一致;必须是妙记逐字稿中已存在的说话人 |
|
||||
| `from_speaker_id` | 实际用于替换的不透明说话人标识 |
|
||||
| `to_user_id` | 替换后的新说话人 open_id,与输入的 `--to-user-id` 一致 |
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -40,7 +40,7 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
|
||||
|
||||
### 1. 先读后写
|
||||
|
||||
替换前建议先用 `lark-cli vc +notes --minute-tokens <token>` 读取当前总结,确认 `minute_token` 与待替换内容无误。
|
||||
替换前建议先用 `lark-cli minutes +detail --minute-tokens <token> --summary` 读取当前总结,确认 `minute_token` 与待替换内容无误。
|
||||
|
||||
### 2. Markdown 展示说明
|
||||
|
||||
@@ -104,7 +104,7 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
|
||||
|------|---------|
|
||||
| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx` |
|
||||
| 妙记搜索 | `lark-cli minutes +search --query "关键词"` |
|
||||
| 会议产物查询 | `lark-cli vc +notes --minute-tokens <token>` |
|
||||
| 会议产物查询 | `lark-cli vc +detail --meeting-ids <id>` 或 `vc +recording`, 拿到 `minute_token`, 然后走 `minutes +detail` |
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
@@ -118,5 +118,5 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
|
||||
|
||||
- [lark-minutes](../SKILL.md) — 妙记全部命令
|
||||
- [minutes +todo](lark-minutes-todo.md) — 替换待办项
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 读取总结、待办等 AI 产物
|
||||
- [minutes +detail](lark-minutes-detail.md) — 读取总结、待办等 AI 产物
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -94,7 +94,7 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
|
||||
|
||||
### 1. 先读后写,待办 id 如何获取
|
||||
|
||||
更新 / 删除前先用 `lark-cli vc +notes --minute-tokens <token>` 读取当前待办。返回的每条待办带 `todo_id` 字段。
|
||||
更新 / 删除前先用 `lark-cli minutes +detail --minute-tokens <token> --todo` 读取当前待办。返回的每条待办带 `todo_id` 字段。
|
||||
|
||||
> 待办 id 仅用于程序内部定位,不必展示给用户。
|
||||
|
||||
@@ -134,5 +134,5 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
|
||||
|
||||
- [lark-minutes](../SKILL.md)
|
||||
- [minutes +summary](lark-minutes-summary.md)
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md)
|
||||
- [minutes +detail](lark-minutes-detail.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
|
||||
@@ -31,13 +31,13 @@
|
||||
```
|
||||
- 命令执行成功后,将返回生成的妙记链接 `minute_url`。
|
||||
|
||||
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `vc +notes`**
|
||||
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `minutes +detail`**
|
||||
- 从返回的 `minute_url` 中提取路径最后一段,得到 `minute_token`。
|
||||
- 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,继续调用:
|
||||
```bash
|
||||
lark-cli vc +notes --minute-tokens <minute_token>
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --keyword --transcript
|
||||
```
|
||||
- `vc +notes --minute-tokens` 会返回纪要文档、逐字稿文档,以及 AI 内置产物(总结、待办、章节);必要时还会把逐字稿落地到本地文件。
|
||||
- `minutes +detail --minute-tokens` 会返回妙记产物(总结、待办、章节、关键词、逐字稿);必要时还会把逐字稿落地到本地文件。
|
||||
|
||||
> **异步生成提示**:API 会立即返回 `minute_url`,但妙记可能仍在异步生成中,您可以直接通过该妙记链接查看当前的处理状态和转写结果。
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
# 通过已上传到云空间(云盘/云存储)的 file_token 生成妙记
|
||||
lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx
|
||||
|
||||
# 通过 minute_token 继续获取纪要 / 逐字稿 / 文字稿 / AI 产物
|
||||
lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx
|
||||
# 通过 minute_token 继续获取妙记产物(--summary --todo --chapter --keyword --transcript 按需传入)
|
||||
lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --summary
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -81,9 +81,9 @@ lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx
|
||||
1. 使用 `lark-cli drive +upload --file <path>` 上传本地音视频文件到云空间(云盘/云存储)
|
||||
2. 从返回结果中取出 `file_token`
|
||||
3. 调用 `lark-cli minutes +upload --file-token <file_token>` 生成妙记
|
||||
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli vc +notes --minute-tokens <minute_token>`
|
||||
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli minutes +detail --minute-tokens <minute_token>`
|
||||
|
||||
> **边界说明**:`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [vc +notes](../../lark-vc/references/lark-vc-notes.md) 承接。
|
||||
> **边界说明**:`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [minutes +detail](lark-minutes-detail.md) 承接。
|
||||
|
||||
## 输出结果示例
|
||||
|
||||
|
||||
@@ -12,15 +12,23 @@ metadata:
|
||||
|
||||
身份:仅使用 `--as user`。使用前阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
|
||||
|
||||
Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api-version v2` 返回的 `<vc-transcribe-tab vc-node-id="...">` 中的 `vc-node-id`。不要从 `doc_token`、标题、正文或 backlink 反推 `note_id`。
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-vc/references/vc-domain-boundaries.md`](../lark-vc/references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误:
|
||||
> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分
|
||||
> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立**
|
||||
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
|
||||
|
||||
Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch` 返回的 `<vc-transcribe-tab vc-node-id="...">` 中的 `vc-node-id`。不要从 `doc_token`、标题、正文或 backlink 反推 `note_id`。
|
||||
|
||||
## 命令路由
|
||||
|
||||
| 用户表达 / 上下文 | 路由 |
|
||||
|---------|------|
|
||||
| 已知 `note_id`,查纪要类型 / 文档 token | `note +detail --note-id NOTE_ID` |
|
||||
| `docs +fetch --api-version v2` 返回 `<vc-transcribe-tab vc-node-id="...">` | 取 `vc-node-id` 作为 `NOTE_ID`,先 `note +detail --note-id NOTE_ID` |
|
||||
| 已知 `note_id`,读纪要正文 | `note +detail` → `docs +fetch --api-version v2 --doc <note_doc_token>` |
|
||||
| `docs +fetch` 返回 `<vc-transcribe-tab vc-node-id="...">` | 取 `vc-node-id` 作为 `NOTE_ID`,先 `note +detail --note-id NOTE_ID` |
|
||||
| 只持有 `meeting_id` | 先 `vc +detail --meeting-ids <id>` 拿 `note_id`,再 `note +detail --note-id NOTE_ID` |
|
||||
| 只持有 `minute_token`(妙记 URL) | 先 `minutes +detail --minute-tokens <token>` 顶层取 `note_id`,再 `note +detail --note-id NOTE_ID`(不要把 `minute_token` 当 `note_id`) |
|
||||
| 只持有日程 `event_id` | 先 `calendar +meeting --event-ids <id>` 拿 `meeting_id`,再按上一行继续 |
|
||||
| 已知 `note_id`,读纪要正文 | `note +detail` → `docs +fetch --doc <note_doc_token>` |
|
||||
| 已知 `note_id`,查 unified 原始记录 / 逐字稿 | `note +transcript --note-id NOTE_ID` |
|
||||
| 只有自然语言纪要标题,用户要逐字稿 / 原始记录 / 谁说了什么 | 不进本 skill;先走文档搜索与 `docs +fetch`,拿到 `vc-node-id` 后再回来 |
|
||||
|
||||
@@ -28,7 +36,7 @@ Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api-
|
||||
|
||||
| `note +detail` 结果 | 用户要逐字稿 / 原始记录时 |
|
||||
|------|---------------|
|
||||
| `normal` + `verbatim_doc_token` 非空 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
|
||||
| `normal` + `verbatim_doc_token` 非空 | `docs +fetch --doc <verbatim_doc_token>` |
|
||||
| `unknown` + `verbatim_doc_token` 非空 | 先按独立文档处理;不要猜成 unified |
|
||||
| `unknown` + 无逐字稿 token | 停止重试并说明无法确定逐字稿入口 |
|
||||
| `unified` | `note +transcript --note-id <note_id>` |
|
||||
@@ -44,7 +52,9 @@ Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api-
|
||||
|
||||
## 不在本 Skill 范围
|
||||
|
||||
- 通过 `meeting_id` / `calendar_event_id` / `minute_token` 定位纪要 → [lark-vc](../lark-vc/SKILL.md)。
|
||||
- 通过 `meeting_id` 定位纪要(`note_id`)→ [lark-vc](../lark-vc/SKILL.md)(`vc +detail`)。
|
||||
- 通过 `minute_token` 定位纪要(`note_id`)→ [lark-minutes](../lark-minutes/SKILL.md)(`minutes +detail` 顶层返回 `note_id`)。
|
||||
- 通过日程 `event_id` 定位会议(`meeting_id`) / 用户绑定纪要(`meeting_note`) → [lark-calendar](../lark-calendar/SKILL.md)(`calendar +meeting`)。
|
||||
- 自然语言纪要标题搜索 → [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md)。
|
||||
- Docx 正文读取 → [lark-doc](../lark-doc/SKILL.md)。
|
||||
- 妙记基础信息与媒体文件 → [lark-minutes](../lark-minutes/SKILL.md)。
|
||||
@@ -55,3 +65,30 @@ Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api-
|
||||
|----------|------|
|
||||
| [`+detail`](references/lark-note-detail.md) | 需要解释输出字段或根据展示类型继续路由 |
|
||||
| [`+transcript`](references/lark-note-transcript.md) | 需要拉取 unified 原始记录或处理本地输出文件 |
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,通过 `note_id` 标识。一个 Note 包含 AI 智能纪要文档、逐字稿文档和会中共享文档。
|
||||
- **note_id**:纪要的唯一标识符,可通过 `vc +detail --meeting-ids` 获取。
|
||||
- **AI 智能纪要(MainDoc)**:AI 生成的会议总结与待办,对应 `note_doc_token`。
|
||||
- **逐字稿(VerbatimDoc)**:会议的逐句发言记录,含说话人和时间戳,对应 `verbatim_doc_token`。
|
||||
- **共享文档(SharedDoc)**:会中投屏共享的文档,对应 `shared_doc_tokens`。
|
||||
|
||||
## 核心场景
|
||||
|
||||
### 1. 通过 note_id 获取纪要文档 Token
|
||||
|
||||
1. 当用户已有 `note_id`,需要获取对应的 `note_doc_token`、`verbatim_doc_token` 或 `shared_doc_tokens` 时,使用 `note +detail`。
|
||||
2. `note_id` 通常来自 `vc +detail` 的返回结果。
|
||||
3. 获取到文档 Token 后,可使用 `docs +fetch` 读取文档内容,或使用 `drive metas batch_query` 获取文档元信息。
|
||||
|
||||
```bash
|
||||
# 1. 从会议获取 note_id
|
||||
lark-cli vc +detail --meeting-ids <meeting_id>
|
||||
|
||||
# 2. 用 note_id 拿文档 Token
|
||||
lark-cli note +detail --note-id <note_id>
|
||||
|
||||
# 3. 读取纪要文档内容
|
||||
lark-cli docs +fetch --doc <note_doc_token> --doc-format markdown
|
||||
```
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# note +detail
|
||||
|
||||
`note +detail` 只做一件事:按显式 `note_id` 返回纪要展示类型和相关文档 token。
|
||||
通过 `note_id` 查询会议纪要详情,获取下挂文档 Token(AI 智能纪要、逐字稿、会中共享文档)。只读,仅支持 `--as user`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli note +detail --note-id NOTE_ID --format json
|
||||
lark-cli note +detail --note-id <note_id>
|
||||
```
|
||||
|
||||
## `note_id` 来源
|
||||
@@ -16,8 +18,8 @@ lark-cli note +detail --note-id NOTE_ID --format json
|
||||
|
||||
| detail 字段 | 后续动作 |
|
||||
|---------|---------|
|
||||
| `note_doc_token` | 读纪要正文 / 总结 / 待办 / 章节:`docs +fetch --api-version v2 --doc <note_doc_token>` |
|
||||
| `note_display_type=normal` + `verbatim_doc_token` | 读逐字稿:`docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
|
||||
| `note_doc_token` | 读纪要正文 / 总结 / 待办 / 章节:`docs +fetch --doc <note_doc_token>` |
|
||||
| `note_display_type=normal` + `verbatim_doc_token` | 读逐字稿:`docs +fetch --doc <verbatim_doc_token>` |
|
||||
| `note_display_type=unknown` + `verbatim_doc_token` | 先按普通独立逐字稿文档读取;不要猜成 unified |
|
||||
| `note_display_type=unified` | 读逐字稿 / 原始记录:转 [`note +transcript`](lark-note-transcript.md) |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# note +transcript
|
||||
|
||||
只在 `note +detail` 或 `vc +notes` 已确认 `note_display_type=unified` 时使用。普通纪要逐字稿是独立 Docx 文档,应回到 [lark-doc](../../lark-doc/SKILL.md) 读取 `verbatim_doc_token`。
|
||||
只在 `note +detail` 已确认 `note_display_type=unified` 时使用。普通纪要逐字稿是独立 Docx 文档,应回到 [lark-doc](../../lark-doc/SKILL.md) 读取 `verbatim_doc_token`。
|
||||
|
||||
```bash
|
||||
lark-cli note +transcript --note-id NOTE_ID
|
||||
@@ -17,7 +17,7 @@ lark-cli note +transcript --note-id NOTE_ID
|
||||
|
||||
| 场景 | 正确路由 |
|
||||
|------|---------|
|
||||
| 只有纪要文档标题 | 先文档搜索,再 `docs +fetch --api-version v2`;有 `vc-node-id` 才回 Note 域 |
|
||||
| 只有 Docx URL / `doc_token` | 先 `docs +fetch --api-version v2`;不要从 `doc_token` 反推 `note_id` |
|
||||
| `note_display_type=normal` | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
|
||||
| 只有纪要文档标题 | 先文档搜索,再 `docs +fetch`;有 `vc-node-id` 才回 Note 域 |
|
||||
| 只有 Docx URL / `doc_token` | 先 `docs +fetch`;不要从 `doc_token` 反推 `note_id` |
|
||||
| `note_display_type=normal` | `docs +fetch --doc <verbatim_doc_token>` |
|
||||
| `note_display_type=unknown` 且 `verbatim_doc_token` 非空 | 先按独立逐字稿文档读取 |
|
||||
|
||||
@@ -15,7 +15,7 @@ metadata:
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
@@ -47,7 +47,7 @@ metadata:
|
||||
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
|
||||
|
||||
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
## 身份选择
|
||||
|
||||
@@ -82,7 +82,7 @@ lark-cli auth login --domain slides
|
||||
按需再读:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
@@ -268,6 +268,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+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/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
@@ -286,7 +287,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
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 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 编辑已有 PPT:读-改-写闭环
|
||||
|
||||
编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。
|
||||
局部编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。已有 Slides 的多页整页重建走 **[`+replace-pages`](lark-slides-replace-pages.md)**,保持原 presentation 链接不变。
|
||||
|
||||
> 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
| 已知某块的 `block_id`,要换这块内容(改标题、换图、挪坐标) | `block_replace` | 精准替换,原子性好;`replacement` 根 `id` 由 CLI 自动注入为 `block_id` |
|
||||
| 只加 1~N 个元素、不动现有布局 | `block_insert` | 新增不覆盖,可选 `insert_before_block_id` 指定位置 |
|
||||
| 一次动多个元素(如:换标题 + 加图) | 单次 `--parts` 里拼多条 | 整批作为原子事务,任一失败整批不生效;`block_replace` 和 `block_insert` 可混用 |
|
||||
| 多页版式重建、整页坐标重排 | `+replace-pages` | 原 presentation 内批量 create-before/delete-old,不生成新 Slides 链接 |
|
||||
|
||||
> **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。
|
||||
|
||||
@@ -136,6 +137,7 @@ cat parts.json | lark-cli slides +replace-slide --as user --presentation "$PID"
|
||||
## 相关文档
|
||||
|
||||
- [lark-slides-replace-slide.md](lark-slides-replace-slide.md) — +replace-slide shortcut 参数详情
|
||||
- [lark-slides-replace-pages.md](lark-slides-replace-pages.md) — 多页整页重建 shortcut
|
||||
- [lark-slides-xml-presentation-slide-get.md](lark-slides-xml-presentation-slide-get.md) — slide.get 参考(拿 `block_id` / `revision_id`)
|
||||
- [lark-slides-xml-presentation-slide-replace.md](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考(一般直接用 shortcut 即可)
|
||||
- [lark-slides-media-upload.md](lark-slides-media-upload.md) — 上传图片拿 file_token
|
||||
|
||||
95
skills/lark-slides/references/lark-slides-replace-pages.md
Normal file
95
skills/lark-slides/references/lark-slides-replace-pages.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# slides +replace-pages(多页整页重建)
|
||||
|
||||
批量替换已有演示文稿里的多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合多页版式大改、坐标重排、整页视觉重建;单个文本框、图片或 shape 的局部编辑仍优先用 [`+replace-slide`](lark-slides-replace-slide.md)。
|
||||
|
||||
> 重要:这是多步编排,不是后端原子事务。CLI 对每页执行“先创建新页到旧页前,再删除旧页”;创建失败时旧页会保留。删除失败时可能出现新旧页同时存在,需要按返回结果继续处理。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli slides +replace-pages \
|
||||
--as user \
|
||||
--presentation <slides_url_or_xml_presentation_id> \
|
||||
--pages @pages.json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL 或 `/wiki/` URL |
|
||||
| `--pages` | 是 | JSON 数组,每项包含 `slide_id` 和 `content`;支持 literal、`@file`、stdin `-` |
|
||||
| `--dry-run` | 否 | 基于 `slide_id` 输入输出替换计划,不执行 create/delete |
|
||||
| `--continue-on-error` | 否 | 默认失败即停;开启后继续处理后续页,并在结果中标记失败项 |
|
||||
| `--validate-only` | 否 | 只校验输入并生成替换计划,不执行 Slides get/create/delete |
|
||||
|
||||
## pages.json
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"slide_id": "slide_short_id_1",
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_short_id_2",
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 每项必须提供 `slide_id`;不支持 `slide_number`。
|
||||
- `content` 必须是完整 `<slide>...</slide>` XML。
|
||||
- 同一批次不能重复 `slide_id`。
|
||||
- CLI 不会回读整份 presentation;如果 `slide_id` 已失效,create/delete 阶段会返回对应错误。
|
||||
|
||||
## Dry Run
|
||||
|
||||
```bash
|
||||
lark-cli slides +replace-pages --as user \
|
||||
--presentation "$PID" \
|
||||
--pages @pages.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
输出包含 `xml_presentation_id`、`pages_count`、`plan`,以及每页的 `old_slide_id`、`insert_before_slide_id` 和动作 `create_before_then_delete_old`。Dry-run 只基于输入的 `slide_id` 构造计划,不会调用 `xml_presentations.get`,也不会执行 create/delete。
|
||||
|
||||
## 成功输出
|
||||
|
||||
```json
|
||||
{
|
||||
"xml_presentation_id": "xxx",
|
||||
"pages_count": 2,
|
||||
"status": "completed",
|
||||
"summary": {
|
||||
"replaced": 2,
|
||||
"failed": 0,
|
||||
"total": 2
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"old_slide_id": "old3",
|
||||
"new_slide_id": "new3",
|
||||
"status": "replaced"
|
||||
}
|
||||
],
|
||||
"revision_id": 123
|
||||
}
|
||||
```
|
||||
|
||||
如果使用 `--continue-on-error` 且任一页面失败,CLI 会继续处理后续页,但最终以 partial failure 非零退出;stdout 仍保留完整 `results`,顶层 `ok` 为 `false`,`status` 为 `partial_failure`。
|
||||
|
||||
`status` 可能为:
|
||||
|
||||
- `replaced`:新页创建成功,旧页删除成功。
|
||||
- `create_failed`:新页创建失败,旧页保留。
|
||||
- `delete_failed`:新页已创建,但旧页删除失败。
|
||||
|
||||
## 使用建议
|
||||
|
||||
1. 大幅改写前先 `xml_presentations.get` 保存当前 XML,并记录要替换页面的 `slide_id`。
|
||||
2. 生成只含 `slide_id` 的 `pages.json` 后先跑 `--dry-run` 或 `--validate-only`。
|
||||
3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。
|
||||
4. 替换后再回读全文 XML 并截图检查,确认页序、视觉和文本没有破损。
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
|
||||
|
||||
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误。
|
||||
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ metadata:
|
||||
2. 输入是 **`meeting_id`**(长数字 ID),不是 9 位会议号。
|
||||
3. 不依赖默认身份。`meeting_id` 来自用户身份发现时,继续用 `--as user`;来自应用身份发现或 `+meeting-join` 时,继续用 `--as bot`。身份不一致会导致空结果或权限错误。
|
||||
4. **不能做会后复盘**,**不能替代参会人快照查询**。如果会议已结束:
|
||||
- 先用 `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息。
|
||||
- 再根据 `note_display_type`、`note_id`、`minute_token` 和用户意图,按 [`lark-vc`](../lark-vc/SKILL.md) 的产物决策读取正文、逐字稿或妙记。
|
||||
- 先用 `lark-cli vc +detail --meeting-ids <meeting.id>` 获取会议产物信息。
|
||||
- 再根据 `note_id`、`minute_token` 和用户意图,按 [`lark-vc`](../lark-vc/SKILL.md) 的产物决策读取正文、逐字稿或妙记。
|
||||
- 想看参会人快照:用 `vc meeting get --with-participants`(见 [`lark-vc`](../lark-vc/SKILL.md))
|
||||
5. **默认必须使用** **`--page-all`**,除非用户明确要求“只查一页”,或确实需要控制返回体大小。
|
||||
6. 输出格式默认优先 `--format pretty`(时间线更易读);只有在需要完整保留原始消息流与结构化字段时,才使用 `--format json`。
|
||||
@@ -107,8 +107,8 @@ MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
|
||||
# 典型间隔 10-30 秒
|
||||
lark-cli vc +meeting-events --as bot --meeting-id "$MID" --page-all --format pretty
|
||||
|
||||
# 3. 会后可选:进入 lark-vc 获取会议产物信息,再按 note_display_type / minute_token 决策读取
|
||||
lark-cli vc +notes --meeting-ids "$MID"
|
||||
# 3. 会后可选:进入 lark-vc 获取会议产物信息,再按 note_id / minute_token 决策读取
|
||||
lark-cli vc +detail --meeting-ids "$MID"
|
||||
```
|
||||
|
||||
如果用户随后明确要求退出 / 离开 / 结束参会,再单独调用 `lark-cli vc +meeting-leave --as bot --meeting-id "$MID"`。
|
||||
@@ -163,7 +163,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
|
||||
## 延伸
|
||||
|
||||
- 查已结束会议、参会人快照、搜索历史会议 → [`lark-vc`](../lark-vc/SKILL.md)
|
||||
- 会议纪要、逐字稿 → [`lark-vc`](../lark-vc/SKILL.md) 的 `+notes`
|
||||
- 会议纪要、逐字稿 → [`lark-vc`](../lark-vc/SKILL.md) 的 `+detail`
|
||||
- 妙记产物(AI 总结 / 转写 / 章节)→ [`lark-minutes`](../lark-minutes/SKILL.md)
|
||||
- 会后把产物发到群 / 私聊 → [`lark-im`](../lark-im/SKILL.md)
|
||||
- 认证、身份切换、scope 管理 → [`lark-shared`](../lark-shared/SKILL.md)
|
||||
|
||||
@@ -260,7 +260,7 @@ lark-cli vc +meeting-events \
|
||||
| `not a 9-digit meeting number` | 把 9 位会议号误传给 `--meeting-id` | 如果只是查询会中内容,先用 `+meeting-list-active` 按 `meeting_no` 匹配拿长数字 `meeting_id`;只有用户明确要求入会时才用 `+meeting-join --as bot --meeting-number <9位号>` |
|
||||
| `10005 bot is not in meeting` | 使用应用身份读取,但应用机器人从未真实入会该会议;或会议已结束但应用机器人从未在会中出现过 | 如果本来是用户身份发现的 `meeting_id`,改回 `--as user`;如果确实要应用身份读取,先 `+meeting-join --as bot --meeting-number <9位号>` 真实入会再查。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`** |
|
||||
| 用户身份不支持 | 当前事件读取接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先通过 `+meeting-list-active --as bot --user-id <user_open_id>` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 |
|
||||
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_display_type` / `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
|
||||
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +detail --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
|
||||
| `20002 meeting not exist` | `meeting_id` 错误,或会议实例当前已不可获取(常见于把 9 位会议号当 meeting_id 传) | 确认传入的是长数字 `meeting_id`,不是 9 位会议号 |
|
||||
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |
|
||||
| `HTTP 404` / `HTTP 500` | 服务端当前无法找到或处理该会议实例 | 换一个正在进行且 bot 可见的 meeting_id,或排查后端问题 |
|
||||
@@ -269,7 +269,7 @@ lark-cli vc +meeting-events \
|
||||
|
||||
- 这是**会中事件流**查询,不适合拿来搜历史会议记录;搜历史会议请用 `+search`。
|
||||
- 如果会议已经结束,不要卡在 `+meeting-events`:
|
||||
- 先用 `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息。
|
||||
- 先用 `lark-cli vc +detail --meeting-ids <meeting.id>` 获取会议产物信息。
|
||||
- 再根据 `note_display_type`、`note_id`、`minute_token` 和用户意图,按 `lark-vc` 的产物决策读取纪要正文、逐字稿或妙记。
|
||||
- 事件列表是否完整,取决于应用机器人何时入会、何时离会,以及后端当前可见的会中事件范围。对于已结束会议,通常只在**结束后 5 分钟内**、且应用机器人**曾经在会中**时还能继续拉到事件。
|
||||
- 查询"谁参加过某会议"请用 `vc meeting get --params '{"meeting_id":"<id>","with_participants":true}'`——这是参会人**快照** API,不依赖 bot 是否参会,对已结束会议也可查;**不要** 用 `+meeting-events` 做参会人查询。
|
||||
@@ -281,7 +281,7 @@ lark-cli vc +meeting-events \
|
||||
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 用户明确要求时离会
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
|
||||
- [lark-vc-detail](../../lark-vc/references/lark-vc-detail.md) — 获取会议详情
|
||||
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill)
|
||||
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -106,7 +106,7 @@ lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789
|
||||
|
||||
# 第 2 步:会议结束后,先查询会议产物
|
||||
lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
lark-cli vc +detail --meeting-ids <meeting.id>
|
||||
```
|
||||
|
||||
后续按 `lark-vc` 的产物决策处理:根据 `note_display_type`、`note_id`、`minute_token` 和用户意图选择纪要正文、逐字稿或妙记。
|
||||
@@ -135,7 +135,7 @@ lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议记录
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
|
||||
- [lark-vc-detail](../../lark-vc/references/lark-vc-detail.md) — 获取会议详情
|
||||
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill)
|
||||
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -75,7 +75,7 @@ lark-cli vc +meeting-leave --as bot --meeting-id <meeting.id>
|
||||
|
||||
```bash
|
||||
# 第 1 步:会议结束后进入 lark-vc 获取会议产物信息
|
||||
lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
lark-cli vc +detail --meeting-ids <meeting.id>
|
||||
```
|
||||
|
||||
## 常见错误与排查
|
||||
@@ -99,7 +99,7 @@ lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
|
||||
- [lark-vc-detail](../../lark-vc/references/lark-vc-detail.md) — 获取会议详情
|
||||
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill)
|
||||
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -24,18 +24,18 @@ metadata:
|
||||
|
||||
```bash
|
||||
# BAD — 查昨天的会议用 calendar,会漏掉即时会议
|
||||
lark-cli calendar events search_event --query "站会" --start-time ...
|
||||
lark-cli calendar +search-event --query "站会" --start <start_time> --end <end_time>
|
||||
|
||||
# GOOD — 查已结束的会议用 vc +search
|
||||
lark-cli vc +search --query "站会" --start-time ...
|
||||
lark-cli vc +search --query "站会" --start <start_time> --end <end_time>
|
||||
```
|
||||
|
||||
## Shortcuts (推荐优先使用)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+search`](references/lark-vc-search.md) | 搜索历史会议记录(需至少一个筛选条件) |
|
||||
| [`+notes`](references/lark-vc-notes.md) | 查询会议纪要和妙记产物(通过 meeting-ids、minute-tokens 或 calendar-event-ids) |
|
||||
| [`+search`](references/lark-vc-search.md) | 搜索历史会议记录(需至关键词、时间范围、组织者、参与者、会议室少一个筛选条件) |
|
||||
| [`+detail`](references/lark-vc-detail.md) | 通过 meeting-ids 获取会议详情,包括 note_id 和 minute_token |
|
||||
| [`+recording`](references/lark-vc-recording.md) | 通过 meeting-ids 或 calendar-event-ids 查询 minute_token |
|
||||
|
||||
- 使用任何 Shortcut 前,必须先读其对应 reference 文档。
|
||||
@@ -49,7 +49,8 @@ lark-cli vc +search --query "站会" --start-time ...
|
||||
| 查"今天有哪些会议" | `vc +search`(已结束)+ lark-calendar(未开始),合并展示 |
|
||||
| 只按自然语言标题查"xx 纪要的逐字稿 / 原始记录 / 谁说了什么" | 先到 [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md);仅在已拿到 `note_id` / `vc-node-id` 后再到 [lark-note](../lark-note/SKILL.md) |
|
||||
| Agent 真实入会/离会、会中实时事件 | [lark-vc-agent](../lark-vc-agent/SKILL.md) |
|
||||
| 本地音视频文件转纪要/逐字稿 | 先走 [lark-minutes](../lark-minutes/SKILL.md) 上传,再回 `vc +notes --minute-tokens` |
|
||||
| 妙记信息/时长/封面/链接 | 先走 `vc +detail` 或 `vc +recording` 获取 `minute_token`,再用 [lark-minutes](../lark-minutes/SKILL.md) 的 `minutes get` |
|
||||
| 本地音视频文件转纪要/逐字稿 | 先走 [lark-minutes](../lark-minutes/SKILL.md) 上传,再用 `minutes +detail --minute-tokens` |
|
||||
|
||||
## 核心概念
|
||||
|
||||
@@ -57,20 +58,20 @@ lark-cli vc +search --query "站会" --start-time ...
|
||||
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,通过 `note_id` 标识,包含纪要文档(总结、待办)和逐字稿文档。`note_display_type` 区分**普通纪要(`normal`)**和 **unified 纪要**;已知 `note_id` 的直查与 unified 原始记录请用 [lark-note](../lark-note/SKILL.md)。
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。
|
||||
- **纪要文档(MainDoc)**:AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`。
|
||||
- **用户会议纪要(MeetingNotes)**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`。仅通过 `--calendar-event-ids` 路径返回。
|
||||
- **用户会议纪要(MeetingNotes)**:用户主动绑定到日程的纪要文档,对应 `meeting_note`。需先通过 [`calendar +meeting`](../lark-calendar/references/lark-calendar-meeting.md) 由 `event_id` 获取。
|
||||
- **逐字稿(VerbatimDoc)**:会议的逐句文字记录,包含说话人和时间戳。
|
||||
|
||||
## 产物选择决策
|
||||
|
||||
| 用户意图 | 必须读取的产物 | 禁止 |
|
||||
|---------|-------------|------|
|
||||
| 提炼/总结/重新总结/整理会议内容/回顾会议 | 原始对话记录(按下方逐字稿路由取得)或妙记文字记录(Transcript),基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 |
|
||||
| 提炼/总结/重新总结/整理会议内容/回顾会议 | 为降低 token 消耗,非必须不得获取 AI 纪要。必须使用原始对话记录(按下方逐字稿路由取得)或妙记文字记录(Transcript),基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 |
|
||||
| 查看待办/章节 | AI 纪要(`note_doc_token`)或妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — |
|
||||
| 查看纪要链接/文档地址 | 仅返回文档链接,无需读取内容 | — |
|
||||
| 直接看 AI 总结结果 | AI 纪要(`note_doc_token`) | — |
|
||||
| 谁说了什么/完整发言记录 | 原始对话记录(按下方逐字稿路由取得) | — |
|
||||
|
||||
> **逐字稿路由**:先看 `vc +notes` 返回的 `note_display_type`,不要只看 `verbatim_doc_token` 是否为空。具体路由以 [`+notes`](references/lark-vc-notes.md) 和 [lark-note](../lark-note/SKILL.md) 为准。
|
||||
> **逐字稿路由**:先用 `vc +detail` 拿到 `note_id`,再 [`note +detail`](../lark-note/SKILL.md) 看 `note_display_type`,**不要只看 `verbatim_doc_token` 是否为空**。具体路由以 [lark-note](../lark-note/SKILL.md) 的 `note_display_type` 规则为准。
|
||||
>
|
||||
> **为什么"提炼/总结"必须从原始对话记录出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。
|
||||
|
||||
@@ -92,21 +93,21 @@ lark-cli vc +search --query "站会" --start-time ...
|
||||
|
||||
```bash
|
||||
# 1. 读取纪要内容
|
||||
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
|
||||
lark-cli docs +fetch --doc <note_doc_token> --doc-format markdown
|
||||
# 2. 从返回的 markdown 中提取第一个 <whiteboard token="xxx"/> 的 token
|
||||
# 3. 下载封面图到聚合目录(和逐字稿、录像同目录,保持产物归拢)
|
||||
# 并非所有纪要都有封面画板,没有 <whiteboard> 标签时跳过即可
|
||||
lark-cli docs +media-download --type whiteboard --token <whiteboard_token> --output ./minutes/<minute_token>/cover
|
||||
```
|
||||
> **产物目录规范**:同一会议的所有下载产物(录像、逐字稿、封面图等)统一放到 `./minutes/{minute_token}/` 目录下。这与 `minutes +download` 和 `vc +notes --minute-tokens` 的默认落点保持一致,便于 Agent 聚合。显式路径(如封面图)需手动对齐到同一目录。
|
||||
> **产物目录规范**:同一会议的所有下载产物(录像、逐字稿、封面图等)统一放到 `./minutes/{minute_token}/` 目录下。这与 `minutes +download` 和 `minutes +detail --minute-tokens` 的默认落点保持一致,便于 Agent 聚合。显式路径(如封面图)需手动对齐到同一目录。
|
||||
|
||||
> **纪要相关文档 — 根据用户意图选择:**
|
||||
> - `note_doc_token` → **AI 智能纪要**(AI 总结 + 待办)
|
||||
> - `meeting_notes` → **用户绑定的会议纪要**(用户主动关联到会议的文档,仅 `--calendar-event-ids` 路径返回)
|
||||
> - 用户说"逐字稿""完整记录""谁说了什么"时 → 按 `note_display_type` 路由,详见 [`+notes`](references/lark-vc-notes.md)
|
||||
> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有)
|
||||
> - `note_doc_token` → **AI 智能纪要**(AI 总结 + 待办),由 `note +detail --note-id <note_id>` 返回
|
||||
> - `meeting_note` → **用户绑定到日程的会议纪要**,由 [`calendar +meeting --event-ids <event_id>`](../lark-calendar/references/lark-calendar-meeting.md) 返回
|
||||
> - 用户说"逐字稿""完整记录""谁说了什么"时 → 按 `note_display_type` 路由,详见 [lark-note](../lark-note/SKILL.md)
|
||||
> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_note`(如有)
|
||||
> - 用户意图不明确时,应展示所有文档链接让用户选择,而不是替用户决定
|
||||
> - 如果用户提供的是**本地音视频文件**并说"转纪要""转逐字稿",不要直接从 `vc +notes` 开始;应先用 [minutes +upload](../lark-minutes/references/lark-minutes-upload.md) 生成 `minute_url`,再提取 `minute_token` 调用 `vc +notes --minute-tokens`
|
||||
> - 如果用户提供的是**本地音视频文件**并说"转纪要""转逐字稿",不要直接从 `vc +detail` 开始;应先用 [minutes +upload](../lark-minutes/references/lark-minutes-upload.md) 生成 `minute_url`,再提取 `minute_token` 调用 `minutes +detail --minute-tokens`
|
||||
|
||||
### 3. 纪要文档与逐字稿链接
|
||||
1. 纪要文档、逐字稿文档与关联的共享文档默认使用文档 Token 返回。
|
||||
@@ -118,10 +119,10 @@ lark-cli schema drive.metas.batch_query
|
||||
# 批量获取文档基本信息: 一次最多查询 10 个文档
|
||||
lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx", "doc_token": "<doc_token>"}], "with_url": true}'
|
||||
```
|
||||
3. 需要获取文档内容时,使用 `lark-cli docs +fetch --api-version v2`。
|
||||
3. 需要获取文档内容时,使用 `lark-cli docs +fetch`。
|
||||
```bash
|
||||
# 获取文档内容
|
||||
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
|
||||
lark-cli docs +fetch --doc <doc_token> --doc-format markdown
|
||||
```
|
||||
|
||||
### 4. 查询参会人快照(读操作)
|
||||
@@ -137,7 +138,7 @@ lark-cli vc meeting get --params '{"meeting_id":"<meeting_id>","with_participant
|
||||
| 用户意图 | 推荐命令 | 所在 skill |
|
||||
|---------|---------|--------|
|
||||
| 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill |
|
||||
| 已结束会议的发言内容 | 先 `vc +notes`,再按 `note_display_type` 路由 | 本 skill / [`lark-note`](../lark-note/SKILL.md) |
|
||||
| 已结束会议的发言内容 | 优先:`vc +detail` 取 `note_id` 再 `note +detail` 取 `verbatim_doc_token` 后 `docs +fetch`;备选:`vc +detail` 取 `minute_token` 再 `minutes +detail --transcript` | [lark-note](../lark-note/SKILL.md) / [lark-minutes](../lark-minutes/SKILL.md) |
|
||||
| **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `vc +meeting-events` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
|
||||
| **Agent 真实入会 / 离会** | `vc +meeting-join` / `vc +meeting-leave` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
|
||||
|
||||
@@ -151,7 +152,7 @@ Meeting (视频会议)
|
||||
│ ├── VerbatimDoc (逐字稿, verbatim_doc_token) ← normal 路径
|
||||
│ ├── UnifiedTranscript (unified 原始记录) ← unified 路径,note +transcript(lark-note)
|
||||
│ └── SharedDoc (会中共享文档)
|
||||
└── Minutes (妙记) ← minute_token 标识,+recording 从 meeting_id 获取
|
||||
└── Minutes (妙记) ← minute_token 标识,由 `vc +detail` 或 `vc +recording` 桥接获取,产物详情走 [lark-minutes](../lark-minutes/SKILL.md)
|
||||
├── Transcript (文字记录)
|
||||
├── Summary (总结)
|
||||
├── Todos (待办)
|
||||
@@ -159,12 +160,16 @@ Meeting (视频会议)
|
||||
└── Keywords (推荐关键词)
|
||||
```
|
||||
|
||||
> **妙记边界**:`+notes` 负责纪要内容、逐字稿和 AI 产物;妙记基础信息请优先看 [`+recording`](references/lark-vc-recording.md) 与 [lark-minutes](../lark-minutes/SKILL.md)。
|
||||
> **MeetingNotes 边界**:用户绑定到日程的会议纪要文档(`meeting_note`)属于日程域,不在 VC 资源关系内;从 `event_id` 用 [`calendar +meeting`](../lark-calendar/references/lark-calendar-meeting.md) 获取。
|
||||
>
|
||||
> **Note 域边界**:`vc +notes` 是从**会议线索**(`meeting_id` / `calendar_event_id` / `minute_token`)定位纪要的入口,返回 `note_id` 和 `note_display_type`。
|
||||
> - 已有 `note_id` → [lark-note](../lark-note/SKILL.md)。
|
||||
> **妙记边界**:`+recording` 仅负责把 `meeting_id` / `calendar_event_id` 桥接到 `minute_token`;妙记的总结/待办/章节/逐字稿等产物归 [lark-minutes](../lark-minutes/SKILL.md)(`minutes +detail`)。
|
||||
>
|
||||
> **Note 域边界**:VC 域只负责把 `meeting_id` 转成 `note_id` / `minute_token`,纪要详情归 [lark-note](../lark-note/SKILL.md)。
|
||||
> - 入口选择:从 `meeting_id` 出发用 `vc +detail` 拿 `note_id` 和 `minute_token`;从 `minute_token` 出发用 [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) 也会返回关联的 `note_id`,可继续走 `note +detail` 拿纪要文档 token。
|
||||
> - 已有 `note_id` → 直接走 [`note +detail`](../lark-note/SKILL.md) / [`note +transcript`](../lark-note/SKILL.md),不要绕回 VC。
|
||||
> - 已有 `doc_token` 且目标是读正文 → [lark-doc](../lark-doc/SKILL.md)。
|
||||
> - 只有自然语言纪要标题 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)。
|
||||
> - 从日程出发(只有 `event_id`)→ 先走 [`calendar +meeting`](../lark-calendar/references/lark-calendar-meeting.md) 拿到 `meeting_id` 或 `meeting_note`,再按上述路径继续。
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -186,12 +191,12 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa
|
||||
|
||||
### minutes(跨域,详见 [lark-minutes](../lark-minutes/SKILL.md))
|
||||
|
||||
- `get` — 获取妙记基础信息(标题、时长、封面);查询妙记**内容**请用 `+notes --minute-tokens <minute-token>`
|
||||
- `get` — 获取妙记基础信息(标题、时长、封面);查询妙记**内容**(总结/待办/章节/逐字稿)请用 [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md)
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)
|
||||
- Agent 真实入会/离会、会中实时事件 → [lark-vc-agent](../lark-vc-agent/SKILL.md)
|
||||
- 只有纪要文档标题的逐字稿查询 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)
|
||||
- 本地音视频文件转纪要/逐字稿 → [lark-minutes](../lark-minutes/SKILL.md)(上传后回 `vc +notes`)
|
||||
- 妙记搜索/下载/上传/重命名/替换说话人 → [lark-minutes](../lark-minutes/SKILL.md)
|
||||
- 本地音视频文件转纪要/逐字稿、妙记搜索/下载/上传/重命名/替换说话人 → [lark-minutes](../lark-minutes/SKILL.md)
|
||||
- 通过 `note_id` 取纪要文档 Token → [lark-note](../lark-note/SKILL.md)
|
||||
|
||||
44
skills/lark-vc/references/lark-vc-detail.md
Normal file
44
skills/lark-vc/references/lark-vc-detail.md
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
# vc +detail
|
||||
|
||||
通过会议 ID 获取会议详情,包括基本信息、关联的纪要 ID(`note_id`)和妙记 Token(`minute_token`)。只读。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 单个 / 批量(逗号分隔,最多 50 个)
|
||||
lark-cli vc +detail --meeting-ids <meeting_id1>,<meeting_id2>
|
||||
```
|
||||
|
||||
## 输出字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `meeting_id` | 会议 ID |
|
||||
| `meeting_no` | 会议 9 位号码 |
|
||||
| `topic` | 会议主题 |
|
||||
| `start_time` | 开始时间 |
|
||||
| `end_time` | 结束时间 |
|
||||
| `note_id` | 关联的纪要 ID。 |
|
||||
| `minute_token` | 关联的妙记 Token。 |
|
||||
|
||||
## 典型场景
|
||||
|
||||
### 场景 1:获取会议的纪要和妙记关联
|
||||
|
||||
`vc +detail` 只能拿到 `note_id` 和 `minute_token`,不直接返回纪要文档 token 与妙记产物内容。要获取实际产物,需根据用户诉求继续调用 `note +detail` 或 `minutes +detail`:
|
||||
|
||||
```bash
|
||||
# 1. 获取会议详情,拿到 note_id 和 minute_token
|
||||
lark-cli vc +detail --meeting-ids <meeting_id>
|
||||
|
||||
# 2. 用 note_id 获取纪要文档 Token(note_doc_token / verbatim_doc_token / shared_doc_tokens)
|
||||
lark-cli note +detail --note-id <note_id>
|
||||
|
||||
# 3. 用 minute_token 获取妙记产物
|
||||
# ⚠️ 必须显式指定 --summary / --todo / --chapter / --keyword / --transcript 中至少一个 flag,
|
||||
# 不传任何 flag 则不会返回任何产物内容。
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --todo --transcript
|
||||
```
|
||||
|
||||
> **路由建议**:当用户未明确指定使用妙记时,**优先**走 `note +detail` 链路(纪要文档信息更完整、含逐字稿原文),仅在 `note_id` 为空或用户要求妙记产物时才走 `minutes +detail`。
|
||||
@@ -1,148 +0,0 @@
|
||||
|
||||
# vc +notes
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查询会议纪要,支持通过会议 ID、妙记 Token 或日程事件 ID 获取纪要文档、逐字稿、AI 总结、待办和章节。只读操作。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +notes`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 通过会议 ID 查询(逗号分隔支持批量,最多 50 个)
|
||||
lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28
|
||||
lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28,69xxxxxxxxxxxxx29
|
||||
|
||||
# 通过妙记 Token 查询(从妙记 URL 中提取)
|
||||
lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx
|
||||
lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx,obbyyyyyyyyyyyyyyyyyy
|
||||
|
||||
# 指定逐字稿输出目录(仅 --minute-tokens 路径有效)
|
||||
lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx --output-dir ./output
|
||||
lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx --overwrite
|
||||
|
||||
# 通过日程事件 ID 查询(从 calendar +agenda 获取 event_id)
|
||||
lark-cli vc +notes --calendar-event-ids xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx_0
|
||||
|
||||
# 输出格式
|
||||
lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --format json
|
||||
|
||||
# 预览 API 调用
|
||||
lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--meeting-ids <ids>` | 三选一 | 会议 ID,逗号分隔支持批量 |
|
||||
| `--minute-tokens <tokens>` | 三选一 | 妙记 Token,逗号分隔支持批量 |
|
||||
| `--calendar-event-ids <ids>` | 三选一 | 日程事件 ID,逗号分隔支持批量 |
|
||||
| `--output-dir <dir>` | 否 | 逐字稿输出目录。未指定时默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 共享目录);显式指定时沿用旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt`。仅 `--minute-tokens` 路径有效 |
|
||||
| `--overwrite` | 否 | 覆盖已存在的逐字稿文件,仅 `--minute-tokens` 路径有效 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 三种参数互斥
|
||||
|
||||
每次只能指定一种输入方式。同时传入多种会报错。
|
||||
|
||||
### 2. 仅支持 user 身份
|
||||
|
||||
该命令仅支持 `user` 身份,使用前需完成 `lark-cli auth login`。
|
||||
|
||||
### 3. 批量上限
|
||||
|
||||
每次最多传入 50 个 ID/Token。
|
||||
|
||||
### 4. 按路径检查权限
|
||||
|
||||
不同输入方式需要不同权限,命令会自动检查对应路径所需的 scope:
|
||||
|
||||
| 输入 | 所需权限 |
|
||||
|------|---------|
|
||||
| `--meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read`、`vc:record:readonly` |
|
||||
| `--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`、`vc:record:readonly` |
|
||||
|
||||
## 输出结果
|
||||
|
||||
### 有纪要文档时
|
||||
|
||||
返回 `notes` 数组,每条记录包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `meeting_id` | 会议 ID(`--meeting-ids` / `--calendar-event-ids` 路径) |
|
||||
| `minute_token` | **会议对应的妙记 Token**(`--meeting-ids` / `--calendar-event-ids` 路径自动通过录制 API 反查并附加)|
|
||||
| `note_id` | **纪要 ID** — 用于继续进入 Note 域(`note +detail` / `note +transcript`) |
|
||||
| `note_display_type` | **纪要展示类型**:`unknown` / `normal` / `unified`,区分普通纪要和 unified 纪要 |
|
||||
| `note_doc_token` | **AI 智能纪要**文档 Token — AI 生成的总结、待办、章节 |
|
||||
| `meeting_notes` | **用户绑定的会议纪要**文档 Token 列表 — 用户主动关联到会议的文档(仅 `--calendar-event-ids` 路径返回) |
|
||||
| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳;unified 纪要的逐字稿请改用 `note +transcript` |
|
||||
| `shared_doc_tokens` | 会中共享文档 Token 列表 |
|
||||
| `creator_id` | 创建者 ID |
|
||||
| `create_time` | 创建时间(格式化) |
|
||||
|
||||
> **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 见下方「按 `note_display_type` 路由逐字稿」。意图不明确时,展示所有文档链接让用户选择。
|
||||
>
|
||||
> 📌 不确定该返回哪个 token?参见 [`vc-domain-boundaries.md`](vc-domain-boundaries.md) 的产物链路对比表,了解 AI 总结链路 vs 录制链路的区别。
|
||||
|
||||
### 按 `note_display_type` 路由逐字稿 / 原始记录
|
||||
|
||||
逐字稿走哪条路由由 `note_display_type` 决定,**不要只看 `verbatim_doc_token` 是否为空**:
|
||||
|
||||
| 字段 / 条件 | Agent 动作 |
|
||||
|------------|-----------|
|
||||
| 用户要纪要正文 / 总结 / 待办 / 章节 | `docs +fetch --api-version v2 --doc <note_doc_token>` |
|
||||
| `note_display_type=normal` + 用户要逐字稿 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
|
||||
| `note_display_type=unknown` + `verbatim_doc_token` 非空 + 用户要逐字稿 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>`;不要猜成 unified |
|
||||
| `note_display_type=unknown` + 无可用逐字稿 token | 先 `note +detail --note-id <note_id>` 复核,再按返回的展示类型路由 |
|
||||
| `note_display_type=unified` + 用户要逐字稿 / 原始记录 | `note +transcript --note-id <note_id>` → 切到 [lark-note](../../lark-note/SKILL.md) |
|
||||
| `minute_token` 存在 + 用户要音视频媒体 | `minutes +download --minute-tokens <minute_token>` |
|
||||
|
||||
> **`unified` 纪要的逐字稿不是独立文档**,必须用 `note +transcript` 按 `note_id` 拉取,输出更结构化。即使 unified 也返回了非空 `verbatim_doc_token`,仍以 `note_display_type` 为准。
|
||||
|
||||
### minute-tokens 路径的 AI 产物
|
||||
|
||||
通过 `--minute-tokens` 查询时,返回的 `artifacts` 字段包含 AI 内置产物:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `artifacts.summary` | AI 总结(JSON 内联) |
|
||||
| `artifacts.todos` | 待办事项(JSON 内联,**只读**);每条含 `content`、`is_done` 及 `todo_id`。`todo_id` 仅供 [`minutes +todo`](../../lark-minutes/references/lark-minutes-todo.md) 更新/删除待办时使用,不必展示给用户。**新建**妙记内待办请用 `minutes +todo`,不要用 lark-task |
|
||||
| `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` |
|
||||
|
||||
## 如何获取输入参数
|
||||
|
||||
| 输入参数 | 获取方式 |
|
||||
|---------|---------|
|
||||
| `meeting_id` | `vc +search` 搜索历史会议 → 结果中的 `id` 字段 |
|
||||
| `minute_token` | 从妙记 URL 中提取,如 `https://sample.feishu.cn/minutes/obbyyyyyyyyyyyyyyyyyy` → `obbyyyyyyyyyyyyyyyyyy` |
|
||||
| `calendar_event_id` | `calendar +agenda` 查看日程 → 结果中的 `event_id` 字段 |
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| `exactly one of ... is required` | 未传入参数或同时传了多种 | 只指定一种输入方式 |
|
||||
| `no notes available for this meeting` | 该会议未生成纪要 | 尝试用 `--minute-tokens` 路径 |
|
||||
| `121005 no permission` | 非会议参与者无权查看 | 使用 `--minute-tokens` 降级到内置产物 |
|
||||
| `missing required scope(s)` | 权限不足 | 按提示运行 `auth login --scope` |
|
||||
| `too many IDs` | 超过批量上限 | 分批查询,每批最多 50 个 |
|
||||
|
||||
## 提示
|
||||
- 默认使用 `--format json` 输出,你更佳擅长解析 JSON 数据。
|
||||
- 排查参数与请求结构时优先使用 `--dry-run`。
|
||||
- `--meeting-ids` 和 `--calendar-event-ids` 路径最终都走纪要详情 API,需要 `vc:note:read` 权限。
|
||||
- `--minute-tokens` 路径无纪要权限时会自动降级,**不会报错**,而是下载内置产物到本地。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc](../SKILL.md) — 视频会议全部命令
|
||||
- [lark-vc-search](lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
通过 meeting_id 或 calendar_event_id 查询对应的 minute_token。这是 VC 域和 Minutes 域之间的桥梁命令。只读操作。
|
||||
|
||||
> **边界提醒:** 如果用户明确要的是"妙记信息""妙记详情""妙记链接""minute_token""标题""时长""owner"这类妙记元信息,先用本命令拿到 `minute_token`,再调用 `minutes minutes get`。不要直接切到 `vc +notes`;`vc +notes` 只用于纪要内容和逐字稿。
|
||||
> **边界提醒:** 如果用户明确要的是"妙记信息""妙记详情""妙记链接""minute_token""标题""时长""owner"这类妙记元信息,先用本命令拿到 `minute_token`,再调用 `minutes minutes get`。不要直接切到 `minutes +detail`;`minutes +detail` 只用于纪要内容和逐字稿。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +recording`。
|
||||
|
||||
@@ -102,7 +102,8 @@ lark-cli minutes minutes get --params '{"minute_token":"<minute_token>"}'
|
||||
lark-cli vc +recording --meeting-ids xxx
|
||||
|
||||
# 第 2 步:使用上一步返回的 minute_token 获取完整纪要
|
||||
lark-cli vc +notes --minute-tokens <minute_token>
|
||||
# ⚠️ 必须显式指定要获取的产物 flag(--summary, --keyword, --todo, --chapter, --transcript)
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --transcript
|
||||
```
|
||||
|
||||
### 场景 4:先搜索会议,再获取录制并下载
|
||||
@@ -143,11 +144,11 @@ lark-cli minutes +download --minute-tokens <minute_token>
|
||||
- 默认使用 `--format json` 输出,Agent 更擅长解析 JSON 数据。
|
||||
- 排查参数与请求结构时优先使用 `--dry-run`。
|
||||
- `minute_token` 从录制 URL 尾段解析(`https://meetings.feishu.cn/minutes/{minute_token}`)。
|
||||
- 拿到 `minute_token` 后,如果要妙记基础信息,优先传给 `minutes minutes get`;如果要下载媒体文件,传给 `minutes +download`;如果要逐字稿、总结、待办、章节,再传给 `vc +notes --minute-tokens`。
|
||||
- 拿到 `minute_token` 后,如果要妙记基础信息,优先传给 `minutes minutes get`;如果要下载媒体文件,传给 `minutes +download`;如果要逐字稿、总结、待办、章节,再传给 `minutes +detail --minute-tokens`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc](../SKILL.md) — 视频会议全部命令
|
||||
- [lark-vc-search](lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-vc-notes](lark-vc-notes.md) — 获取会议纪要
|
||||
- [lark-minutes-detail](../../lark-minutes/references/lark-minutes-detail.md) — 获取会议纪要
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
|
||||
# vc +search
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
搜索已结束的历史会议记录,支持关键词、时间范围、组织者、参与者以及会议室等多条件过滤。只读操作,不修改任何会议数据。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +search`(调用 `POST /open-apis/vc/v1/meetings/search`)。
|
||||
搜索已结束的历史会议记录,支持关键词、时间范围、组织者、参与者、会议室多条件过滤。只读,仅 `--as user`。
|
||||
|
||||
## 关键词使用边界
|
||||
|
||||
`--query` 只用于真实会议关键词,例如会议主题、项目名、评审名、客户名。用户只是说"我这月参加的所有视频会议"、"最近两周我组织的所有视频会议"、"总结主要议题 / 看看参会情况"时,本质是历史会议列表和后续总结,不要把"回顾"、"所有视频会议"、"总结主要议题"等动作词放进 `--query`。这类请求应先用时间范围 + `--participant-ids` / `--organizer-ids` 搜全量候选,再按结果继续取纪要或录制信息。
|
||||
|
||||
列表阶段只负责找会议记录;总结阶段必须继续取证。若用户要求"主要议题"、"主要决策"、"参会情况",先确认搜索结果的 `meeting_id`、时间、组织者/参与者符合过滤条件,然后用 `vc +notes` 或 `vc +recording` / `minutes` 读取纪要、妙记或录制信息。没有纪要或妙记时,如实说明只能基于会议标题/参会数据汇总,不要编造议题。
|
||||
列表阶段只负责找会议记录;总结阶段必须继续取证。若用户要求"主要议题"、"主要决策"、"参会情况",先确认搜索结果的 `meeting_id`、时间、组织者/参与者符合过滤条件,然后用 `vc +detail` 或 `minutes` 读取纪要、妙记或录制信息。没有纪要或妙记时,如实说明只能基于会议标题/参会数据汇总,不要编造议题。
|
||||
|
||||
## 典型触发表达
|
||||
|
||||
@@ -35,37 +31,17 @@ lark-cli vc +search --start 2026-03-10 --end 2026-03-10
|
||||
|
||||
# 按时间范围搜索
|
||||
lark-cli vc +search --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
|
||||
lark-cli vc +search --start 2026-03-10 --end 2026-03-17
|
||||
|
||||
# 关键词 + 时间范围
|
||||
lark-cli vc +search --query "周会" --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
|
||||
lark-cli vc +search --query "周会" --start "2026-03-10T00:00+08:00"
|
||||
lark-cli vc +search --query "周会" --end "2026-03-17T00:00+08:00"
|
||||
|
||||
# 按组织者过滤(open_id,逗号分隔)
|
||||
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 --organizer-ids "ou_user1,ou_user2"
|
||||
lark-cli vc +search --participant-ids "ou_user1,ou_user2"
|
||||
lark-cli vc +search --room-ids "123,456"
|
||||
|
||||
# 多条件组合查询
|
||||
lark-cli vc +search --organizer-ids "ou_a" --room-ids "123" --start "2026-03-10T00:00+08:00"
|
||||
# 多条件组合
|
||||
lark-cli vc +search --organizer-ids "ou_user1" --room-ids "123" --start "2026-03-10T00:00+08:00"
|
||||
|
||||
# 分页查询
|
||||
lark-cli vc +search --query "周会" --page-size 15
|
||||
lark-cli vc +search --query "周会" --page-token "next_page_token"
|
||||
|
||||
# 输出为表格/可读格式
|
||||
lark-cli vc +search --query "周会" --format json
|
||||
# 翻页
|
||||
lark-cli vc +search --query "周会" --page-token "<PAGE_TOKEN>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -137,15 +113,15 @@ lark-cli vc +search --query "周会" --format json
|
||||
|
||||
## 输出结果
|
||||
|
||||
- 默认输出 JSON,包含 `items`、`total`、`has_more` 和 `page_token`。
|
||||
- 默认输出 JSON,包含 `items`、`has_more` 和 `page_token`。
|
||||
|
||||
## Pagination (`has_more` / `page_token`)
|
||||
|
||||
- 当结果中返回 `has_more=true` 时,说明还有更多页可继续获取。
|
||||
- 继续翻页时,使用响应中的 `page_token` 搭配 `--page-token` 发起下一次查询。
|
||||
- 不要假设调大 `--page-size` 就能拿全结果;分页遍历时应以 `has_more` 和 `page_token` 为准。
|
||||
- 未明确要求全量时,`total` 数量小于 50 可自动分页获取所有结果;`total` 数量大于 50 时,先向用户确认是否继续获取全部结果。
|
||||
- 用户明确说"所有 / 全部 / 统计 / 按时间排序"时,该全量意图优先于 `total > 50` 的确认门槛;直接完成分页和去重,再排序或统计,不要只用第一页回答。
|
||||
- 未明确要求全量时,逐页累计已读取的 `items` 数:累计不到 50 条之前可自动继续翻页(`has_more=true` 即继续);超过 50 条且仍 `has_more=true` 时,先向用户确认是否继续获取全部结果。
|
||||
- 用户明确说"所有 / 全部 / 统计 / 按时间排序"时,该全量意图优先于 50 条的确认门槛;直接按 `has_more` 翻完所有页并去重,再排序或统计,不要只用第一页回答。
|
||||
|
||||
```bash
|
||||
# First page
|
||||
@@ -161,7 +137,7 @@ lark-cli vc +search --query "周会" --page-size 15 --page-token "<PAGE_TOKEN>"
|
||||
|
||||
```bash
|
||||
# 如果要会议纪要 / 逐字稿 / AI 总结 / 待办 / 章节
|
||||
lark-cli vc +notes --meeting-ids <MEETING_ID>
|
||||
lark-cli vc +detail --meeting-ids <MEETING_ID>
|
||||
|
||||
# 如果要会议对应的妙记信息 / minute_token / 妙记链接
|
||||
lark-cli vc +recording --meeting-ids <MEETING_ID>
|
||||
@@ -183,11 +159,5 @@ lark-cli minutes minutes get --params '{"minute_token":"<MINUTE_TOKEN>"}'
|
||||
- 排查参数与请求结构时优先使用 `--dry-run`。
|
||||
- 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的会议,需要拆分为多次时间范围为一个月查询。
|
||||
- 不要使用 `yesterday`、`today` 这类相对时间字面量;请先转换成明确日期,例如 `2026-03-10`。
|
||||
- 用户如果明确问的是“妙记信息”而不是“纪要内容”,不要默认走 `vc +notes`;应先用 `vc +recording`。
|
||||
- 用户如果明确问的是“妙记信息”而不是“纪要内容”,不要默认走 `vc +detail`;应先用 `vc +recording`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc](../SKILL.md) -- 视频会议全部命令
|
||||
- [lark-vc-recording](lark-vc-recording.md) -- 查询会议对应的 minute_token
|
||||
- [lark-vc-notes](lark-vc-notes.md) -- 获取会议纪要
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳)— **仅 `note_display_type=normal` 时是可读的独立文档**;`unified` 纪要的逐字稿用 `note +transcript --note-id <note_id>` 拉取(见下方 [Note 域](#note-域)) |
|
||||
| 共享文档 | `shared_doc_token` | 飞书文档 | 会中投屏共享的文档信息 |
|
||||
|
||||
此外,还存在**用户会议纪要(MeetingNotes)**,对应 `meeting_notes` 字段。这是用户主动绑定到会议的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 `+notes --calendar-event-ids` 路径返回。
|
||||
此外,还存在**用户会议纪要(MeetingNotes)**,对应 `meeting_note` 字段。这是用户主动绑定到日程的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 [`calendar +meeting --event-ids`](../../lark-calendar/references/lark-calendar-meeting.md) 路径返回。
|
||||
|
||||
#### 链路二:开启「录制」
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
> **产物选择决策**:
|
||||
> - **AI 产物 vs 原始记录**:智能总结、待办、章节都属于 AI 分析产物,可能只包含最终结论和关键信息。
|
||||
> - **用户要求"提炼/总结/重新总结/整理/回顾"会议内容时** → **内容总结必须从逐字稿/文字记录出发,基于原始对话独立分析**。禁止直接搬运 AI 纪要的总结作为最终输出——那只是对 AI 产物的重新排版,不是独立提炼。AI 纪要可作为补充参考,但不能作为内容总结的唯一信息源。
|
||||
> - **用户要求"提炼/总结/重新总结/整理/回顾"会议内容时** → **内容总结必须从逐字稿/文字记录出发,基于原始对话独立分析**。禁止直接搬运 AI 纪要的总结作为最终输出——那只是对 AI 产物的重新排版,不是独立提炼。
|
||||
> - **用户要求查看待办或章节时** → **应参考 AI 产物的待办和章节**,因为 AI 产物的待办更友好(包含提出人和负责人),章节按话题划分更结构化。
|
||||
> - **用户只想直接看 AI 总结结果** → 使用 AI 产物的总结。
|
||||
> - **链路优先级**:如果用户没有明确偏好,对于重复的内容(如智能总结、待办),**优先查询智能纪要(Note),不存在时再降级到妙记(Minutes)**。
|
||||
@@ -81,7 +81,7 @@
|
||||
|
||||
根据关键字、组织者、参与人、会议室等条件搜索会议,获取会议列表。
|
||||
|
||||
> **不要把纪要标题当会议线索:** 如果用户说“查询 xx 纪要的逐字稿 / 原始记录 / 谁说了什么”,且没有 `meeting_id`、`calendar_event_id`、会议号、参会人或时间范围,先用 `drive +search --query <标题>` 搜索纪要文档,拿到 Docx URL/token 后再 `docs +fetch --api-version v2`。若返回 `<vc-transcribe-tab vc-node-id="...">`,提取 `note_id` 后进入 Note 域判断 `normal` / `unified`;若没有该 block,但有“文字记录/逐字稿” Docx 链接,直接用 `docs +fetch --api-version v2` 读取该链接。
|
||||
> **不要把纪要标题当会议线索:** 如果用户说“查询 xx 纪要的逐字稿 / 原始记录 / 谁说了什么”,且没有 `meeting_id`、`calendar_event_id`、会议号、参会人或时间范围,先用 `drive +search --query <标题>` 搜索纪要文档,拿到 Docx URL/token 后再 `docs +fetch`。若返回 `<vc-transcribe-tab vc-node-id="...">`,提取 `note_id` 后进入 Note 域判断 `normal` / `unified`;若没有该 block,但有“文字记录/逐字稿” Docx 链接,直接用 `docs +fetch` 读取该链接。
|
||||
|
||||
```bash
|
||||
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json
|
||||
@@ -93,8 +93,20 @@ lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json
|
||||
|
||||
##### 获取会议产物
|
||||
|
||||
当用户提供 `meeting_id` 并需要会议产物时,先用 `vc +detail` 拿到 `note_id` 和 `minute_token`:
|
||||
|
||||
```bash
|
||||
lark-cli vc +notes --meeting-ids '<meeting_id1>,<meeting_id2>'
|
||||
lark-cli vc +detail --meeting-ids '<meeting_id1>,<meeting_id2>'
|
||||
```
|
||||
|
||||
详细用法请阅读 [`lark-vc-detail.md`](lark-vc-detail.md)。
|
||||
|
||||
**优先路径:通过 `note_id` 获取纪要产物**
|
||||
|
||||
如果用户未明确要求使用妙记,且返回了 `note_id`,**优先**使用 `note +detail` 获取纪要文档的 token 信息:
|
||||
|
||||
```bash
|
||||
lark-cli note +detail --note-id <note_id>
|
||||
```
|
||||
|
||||
可获取会议的所有产物信息,包括:
|
||||
@@ -102,28 +114,31 @@ lark-cli vc +notes --meeting-ids '<meeting_id1>,<meeting_id2>'
|
||||
- 智能纪要(`note_doc_token`)— AI 生成的总结和待办信息
|
||||
- 逐字稿(`verbatim_doc_token`)— 完整的会中发言记录(仅 `normal` 纪要可直接读取该文档)
|
||||
- 共享文档(`shared_doc_token`)— 会中投屏共享的文档
|
||||
- 妙记 Token(`minute_token`)— 如存在录制产物则返回
|
||||
|
||||
详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。
|
||||
拿到文档 token 后,再通过 Doc 域 `docs +fetch` 拉取文档正文内容(见 Step 3)。详细用法请阅读 [`lark-note-detail.md`](../../lark-note/references/lark-note-detail.md)。
|
||||
|
||||
如果返回了 `minute_token`,可通过以下命令获取妙记的详细信息(总结、待办、章节、文字记录):
|
||||
**备选路径:通过 `minute_token` 获取妙记产物**
|
||||
|
||||
如果 `note_id` 为空,或用户明确要求使用妙记产物,则使用 `minutes +detail` 获取妙记的具体产物:
|
||||
|
||||
```bash
|
||||
lark-cli vc +notes --minute-tokens '<minute_token1>,<minute_token2>'
|
||||
# 必须显式指定要获取的产物 flag,至少传一个;不传则不会返回任何产物内容
|
||||
lark-cli minutes +detail --minute-tokens '<minute_token1>,<minute_token2>' \
|
||||
--summary --todo --chapter --keyword --transcript
|
||||
```
|
||||
|
||||
可获取妙记的总结、待办、章节、文字记录等信息。详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。
|
||||
> **注意**:`minutes +detail` 需要**手动指定**要获取的产物 flag,可选 `--summary`(总结)、`--todo`(待办)、`--chapter`(章节)、`--keyword`(关键词)、`--transcript`(文字记录)。**未传任何产物 flag 时不会返回产物内容**,请按用户诉求按需指定。详细用法请阅读 [`lark-minutes-detail.md`](../../lark-minutes/references/lark-minutes-detail.md)。
|
||||
|
||||
#### Step 3: 按 `note_display_type` 拉取正文 / 逐字稿
|
||||
|
||||
智能纪要(`note_doc_token`)是飞书文档,使用 `docs +fetch --api-version v2` 读取正文内容;**逐字稿的读取方式由 `note_display_type` 决定**:
|
||||
智能纪要(`note_doc_token`)是飞书文档,使用 `docs +fetch` 读取正文内容;**逐字稿的读取方式由 `note_display_type` 决定**:
|
||||
|
||||
```bash
|
||||
# 纪要正文(两种展示类型都适用)
|
||||
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
|
||||
lark-cli docs +fetch --doc <note_doc_token> --doc-format markdown
|
||||
|
||||
# note_display_type=normal:逐字稿是独立文档
|
||||
lark-cli docs +fetch --api-version v2 --doc <verbatim_doc_token> --doc-format markdown
|
||||
lark-cli docs +fetch --doc <verbatim_doc_token> --doc-format markdown
|
||||
|
||||
# note_display_type=unified:逐字稿不是独立文档,按 note_id 拉取
|
||||
lark-cli note +transcript --note-id <note_id>
|
||||
@@ -138,16 +153,18 @@ lark-cli note +transcript --note-id <note_id>
|
||||
|
||||
## Note 域
|
||||
|
||||
- VC 只负责从 `meeting_id` / `calendar_event_id` / `minute_token` 定位会议产物和 `note_id`。
|
||||
- VC 只负责从 `meeting_id` 定位会议产物和 `note_id` / `minute_token`([`vc +detail`](lark-vc-detail.md))。
|
||||
- 已知 `note_id` 后切到 [lark-note](../../lark-note/SKILL.md);逐字稿路由以 `lark-note` 的 `note_display_type` 规则为准。
|
||||
- 只有自然语言纪要标题时,先走文档搜索与 `docs +fetch --api-version v2`;只有 `<vc-transcribe-tab vc-node-id="...">` 的 `vc-node-id` 可以进入 Note 域。
|
||||
- 已知 `minute_token` 时,[`minutes +detail`](../../lark-minutes/references/lark-minutes-detail.md) 顶层会一并返回该妙记关联的 `note_id`(如有);可直接传给 `note +detail` 取纪要文档 token,无需绕回 VC。
|
||||
- 仅有日程 `event_id` 时,先走 [`calendar +meeting`](../../lark-calendar/references/lark-calendar-meeting.md) 拿到 `meeting_id` 或用户绑定的 `meeting_note`,再按上述路径继续。
|
||||
- 只有自然语言纪要标题时,先走文档搜索与 `docs +fetch`;只有 `<vc-transcribe-tab vc-node-id="...">` 的 `vc-node-id` 可以进入 Note 域。
|
||||
- `doc_token` / Docx URL 不是 `note_id`。没有 `vc-node-id` 时不要反推 Note,继续按 Doc 域读取正文或正文中明确给出的逐字稿文档。
|
||||
|
||||
## Doc 域
|
||||
|
||||
- **lark-doc skill** 负责飞书云文档管理,包括获取文档元信息、读取文档内容、创建和编辑文档等操作。
|
||||
- **会议产物的文档本质**:智能纪要(`note_doc_token`)和 `normal` 纪要的逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API(如 `docs +fetch --api-version v2`)查询其内容和元信息;`unified` 纪要的逐字稿不是独立文档,用 `note +transcript` 拉取([lark-note](../../lark-note/SKILL.md))。
|
||||
- **文档元信息查询**:获取文档名称、URL 等基本信息时,使用 `drive metas batch_query`;获取文档正文内容时,使用 `docs +fetch --api-version v2`。
|
||||
- **会议产物的文档本质**:智能纪要(`note_doc_token`)和 `normal` 纪要的逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API(如 `docs +fetch`)查询其内容和元信息;`unified` 纪要的逐字稿不是独立文档,用 `note +transcript` 拉取([lark-note](../../lark-note/SKILL.md))。
|
||||
- **文档元信息查询**:获取文档名称、URL 等基本信息时,使用 `drive metas batch_query`;获取文档正文内容时,使用 `docs +fetch`。
|
||||
|
||||
## 三域关联总览
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
| 用户给了什么 | 怎么获取 |
|
||||
|---|---|
|
||||
| 直接给了 whiteboard token(`wbcnXXX`)| 直接使用 |
|
||||
| 文档 URL 或 doc_id,文档中已有画板 | `lark-cli docs +fetch --api-version v2 --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
|
||||
| 文档 URL 或 doc_id,需要新建画板 | `lark-cli docs +update --api-version v2 --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.new_blocks[0].block_token` 取得(`block_type == "whiteboard"` 的那条;参数详见 lark-doc SKILL.md)|
|
||||
| 文档 URL 或 doc_id,文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
|
||||
| 文档 URL 或 doc_id,需要新建画板 | `lark-cli docs +update --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.new_blocks[0].block_token` 取得(`block_type == "whiteboard"` 的那条;参数详见 lark-doc SKILL.md)|
|
||||
|
||||
**Step 2:渲染 & 写入**
|
||||
|
||||
|
||||
@@ -37,7 +37,10 @@ lark-cli auth login --domain vc,drive # 含读取纪要文档正文、生成
|
||||
{时间范围} ─► vc +search ──► 会议列表 (meeting_ids)
|
||||
│
|
||||
▼
|
||||
vc +notes ──► 纪要文档 tokens
|
||||
vc +detail ──► 获取 note_id
|
||||
│
|
||||
▼
|
||||
note +detail ──► 纪要文档 tokens
|
||||
│
|
||||
▼
|
||||
drive metas batch_query 纪要元数据
|
||||
@@ -69,12 +72,16 @@ lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json --
|
||||
|
||||
1. 查询会议关联的纪要信息
|
||||
```bash
|
||||
lark-cli vc +notes --meeting-ids "id1,id2,...,idN"
|
||||
# 首先获取 note_id 和 minute_token
|
||||
lark-cli vc +detail --meeting-ids "id1,id2,...,idN"
|
||||
|
||||
# 然后用 note_id 获取文档 tokens(如有多个需分别获取)
|
||||
lark-cli note +detail --note-id "note_id"
|
||||
```
|
||||
- 根据上一步搜集到的 `meeting-id` 查询会议纪要。
|
||||
- 单次最多查询 50 个纪要信息,超过 50 个需分批调用。
|
||||
- 部分会议返回 `no notes available`,在最终输出中标注"无纪要"
|
||||
- 记录每个会议的 `note_id`(纪要 ID)、`note_display_type`(展示类型:`unknown` / `normal` / `unified`)、`note_doc_token`(纪要文档 Token)和 `verbatim_doc_token`(逐字稿文档 Token)
|
||||
- 根据上一步搜集到的 `meeting-id` 查询。
|
||||
- 单次最多查询 50 个,超过 50 个需分批调用。
|
||||
- 部分会议没有 `note_id` 或报错 `no notes available`,在最终输出中标注"无纪要"。
|
||||
- 记录每个纪要的 `note_id`(纪要 ID)、`note_display_type`(展示类型:`unknown` / `normal` / `unified`)、`note_doc_token`(纪要文档 Token)和 `verbatim_doc_token`(逐字稿文档 Token)。
|
||||
|
||||
> **逐字稿路由按 `note_display_type` 决定**(详见 [vc-domain-boundaries.md](../lark-vc/references/vc-domain-boundaries.md) 的 Note 域):
|
||||
> - `normal`:逐字稿是独立文档,链接/正文走 `verbatim_doc_token`。
|
||||
@@ -102,14 +109,14 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
|
||||
阅读 [`../lark-doc/SKILL.md`](../lark-doc/SKILL.md) 学习云文档技能。
|
||||
|
||||
```bash
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>会议纪要汇总 (<start> - <end>)</title>\n<内容>'
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>会议纪要汇总 (<start> - <end>)</title>\n<内容>'
|
||||
# 或追加到已有文档
|
||||
lark-cli docs +update --api-version v2 --doc "<url_or_token>" --command append --doc-format markdown --content $'<内容>'
|
||||
lark-cli docs +update --doc "<url_or_token>" --command append --doc-format markdown --content $'<内容>'
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-shared](../lark-shared/SKILL.md) — 认证、权限(必读)
|
||||
- [lark-vc](../lark-vc/SKILL.md) — `+search`、`+notes` 详细用法
|
||||
- [lark-vc](../lark-vc/SKILL.md) — `+search`、`+detail` 详细用法
|
||||
- [lark-note](../lark-note/SKILL.md) — `note +detail`、`note +transcript`(unified 纪要逐字稿)
|
||||
- [lark-doc](../lark-doc/SKILL.md) — `+fetch`、`+create`、`+update` 详细用法
|
||||
52
tests/cli_e2e/doc/docs_dryrun_test.go
Normal file
52
tests/cli_e2e/doc/docs_dryrun_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
setDocsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", "doxcnDryRunCompat",
|
||||
"--api-version", "legacy",
|
||||
"--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/doxcnDryRunCompat/fetch" {
|
||||
t.Fatalf("url=%q, want docs fetch endpoint\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.format").String(); got != "xml" {
|
||||
t.Fatalf("format=%q, want xml\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func setDocsDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "docs_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "docs_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user