Compare commits

..

7 Commits

Author SHA1 Message Date
xzcong0820
c0091b3ed9 emlbuilder: add LMSReplyType method for reply/forward type header
Add LMSReplyType method to the EML builder that sets the
X-LMS-Reply-Type header. This allows reply, reply-all, and forward
shortcuts to tag the EML with REPLY or FORWARD so that downstream
services can label the original message accordingly.

Call LMSReplyType("REPLY") in mail_reply.go and mail_reply_all.go,
and LMSReplyType("FORWARD") in mail_forward.go, immediately after
the existing LMSReplyToMessageID call.
2026-06-26 22:03:30 +08:00
liangshuo-1
8a268aa2d2 chore: release v1.0.59 (#1617) 2026-06-26 20:46:51 +08:00
ethan-zhx
39d60cb706 feat: add slides replace-pages and xml-get shortcuts (#1585)
* feat: add slides replace-pages shortcut

* feat: add slides xml get shortcut

* fix: stop advertising slides screenshot scope

* feat: expose slides presentation url
2026-06-26 15:56:55 +08:00
SunPeiYang996
d9330b7ab3 fix(docs): hide docs api-version compat flag (#1580) 2026-06-26 14:32:09 +08:00
hugang-lark
6b833257c7 fix: optimize calendar,vc,minutes,note shortcut and skill (#1571) 2026-06-26 12:24:03 +08:00
zhangjun-bytedance
ba51d4874e feat: support speaker list and nolark speaker replace (#1594) 2026-06-26 11:41:32 +08:00
liangshuo-1
40a09c8957 chore: release v1.0.58 (#1586) 2026-06-25 21:57:36 +08:00
105 changed files with 3754 additions and 907 deletions

View File

@@ -2,6 +2,47 @@
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
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
- **base**: Add Base URL and title resolve shortcuts (#1338)
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
- **doc**: Support `create` title option (#1536)
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
- **task**: Add task event consumer (#1510)
### Bug Fixes
- **doc**: Prefix docs resource shortcuts (#1564)
- **binding**: Skip unix mode audit on Windows (#1525)
### Documentation
- **approval**: Sync approval skill for meta API commands (#1499)
- **doc**: Restore lark-doc style requirements (#1579)
- **im**: Document `chat.nickname` get/update/delete (#1378)
- **im**: Clarify audio message opus requirement (#1271)
### Build
- **ci**: Add public content safeguards and reduce false positives
## [v1.0.57] - 2026-06-23
### Features
@@ -1236,6 +1277,8 @@ 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
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55

View File

@@ -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.

View File

@@ -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` 查看所有快捷命令。

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.57",
"version": "1.0.59",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View 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
},
}

View 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)
}
}

View File

@@ -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)
}
})

View File

@@ -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)
}
}

View 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
},
}

View File

@@ -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{}

View File

@@ -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 ""
}

View File

@@ -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)
}
}

View File

@@ -15,5 +15,7 @@ func Shortcuts() []common.Shortcut {
CalendarRoomFind,
CalendarRsvp,
CalendarSuggestion,
CalendarMeeting,
CalendarSearchEvent,
}
}

View File

@@ -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

View File

@@ -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",
})

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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"}})

View File

@@ -28,7 +28,7 @@ var DriveImport = common.Shortcut{
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx, .pdf; large files auto use multipart upload; .base is capped at 20MB, .pptx/.pdf at 500MB)", Required: true},
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},

View File

@@ -45,7 +45,6 @@ var driveImportExtToDocTypes = map[string][]string{
"csv": {"sheet", "bitable"},
"base": {"bitable"},
"pptx": {"slides"},
"pdf": {"slides"},
}
// driveImportSpec contains the user-facing import inputs after normalization.
@@ -154,7 +153,7 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
case "docx", "doc":
return driveImport600MBFileSizeLimit, true
case "pptx", "pdf":
case "pptx":
return driveImport500MBFileSizeLimit, true
case "txt", "md", "mark", "markdown", "html", "xls", "base":
return driveImport20MBFileSizeLimit, true
@@ -200,7 +199,7 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
func validateDriveImportSpec(spec driveImportSpec) error {
ext := spec.FileExtension()
if ext == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx, .pdf)").WithParam("--file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file")
}
switch spec.DocType {
@@ -211,7 +210,7 @@ func validateDriveImportSpec(spec driveImportSpec) error {
supportedTypes, ok := driveImportExtToDocTypes[ext]
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx, pdf", ext).WithParam("--file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file")
}
typeAllowed := false
@@ -232,8 +231,8 @@ func validateDriveImportSpec(spec driveImportSpec) error {
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
case "base":
hint = fmt.Sprintf(".base files can only be imported as 'bitable', not '%s'", spec.DocType)
case "pptx", "pdf":
hint = fmt.Sprintf(".%s files can only be imported as 'slides', not '%s'", ext, spec.DocType)
case "pptx":
hint = fmt.Sprintf(".pptx files can only be imported as 'slides', not '%s'", spec.DocType)
default:
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
}

View File

@@ -41,10 +41,6 @@ func TestValidateDriveImportSpec(t *testing.T) {
name: "pptx slides ok",
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "slides"},
},
{
name: "pdf slides ok",
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "slides"},
},
{
name: "base non bitable rejected",
spec: driveImportSpec{FilePath: "./snapshot.base", DocType: "sheet"},
@@ -55,11 +51,6 @@ func TestValidateDriveImportSpec(t *testing.T) {
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "docx"},
wantErr: ".pptx files can only be imported as 'slides'",
},
{
name: "pdf non slides rejected",
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "docx"},
wantErr: ".pdf files can only be imported as 'slides'",
},
{
name: "unknown extension rejected",
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
@@ -147,19 +138,6 @@ func TestValidateDriveImportFileSize(t *testing.T) {
docType: "slides",
fileSize: driveImport500MBFileSizeLimit,
},
{
name: "pdf exceeds 500mb limit",
filePath: "./deck.pdf",
docType: "slides",
fileSize: driveImport500MBFileSizeLimit + 1,
wantText: "exceeds 500.0 MB import limit for .pdf",
},
{
name: "pdf within 500mb limit",
filePath: "./deck.pdf",
docType: "slides",
fileSize: driveImport500MBFileSizeLimit,
},
{
name: "base exceeds 20mb limit",
filePath: "./snapshot.base",

View File

@@ -57,6 +57,10 @@ import (
"github.com/larksuite/cli/shortcuts/mail/filecheck"
)
// lmsReplyTypeHeaderKey is the MIME header name for the reply type
// ("REPLY" or "FORWARD"), read by data-access to populate BodyExtra.ReplyType.
const lmsReplyTypeHeaderKey = "X-LMS-Reply-Type"
// MaxEMLSize is the maximum allowed raw EML size in bytes.
const MaxEMLSize = 25 * 1024 * 1024 // 25 MB
@@ -90,6 +94,7 @@ type Builder struct {
inReplyTo string // raw value, without angle brackets
references string // space-separated list of message IDs, with angle brackets
lmsReplyToMessageID string // Lark internal message_id of the original message
lmsReplyType string // "REPLY" or "FORWARD", written as X-LMS-Reply-Type
textBody []byte
htmlBody []byte
calendarBody []byte
@@ -395,6 +400,22 @@ func (b Builder) LMSReplyToMessageID(id string) Builder {
return b
}
// LMSReplyType sets the reply type header X-LMS-Reply-Type.
// t must be "REPLY" or "FORWARD"; other values are silently ignored.
// This header is written unconditionally when set, independent of In-Reply-To.
// Returns an error builder if t contains CR, LF, or other dangerous characters.
func (b Builder) LMSReplyType(t string) Builder {
if b.err != nil {
return b
}
if err := validateHeaderValue(t); err != nil {
b.err = err
return b
}
b.lmsReplyType = t
return b
}
// References sets the References header value verbatim.
// Typically a space-separated list of message IDs including angle brackets,
// e.g. "<id1@host> <id2@host>".
@@ -732,6 +753,9 @@ func (b Builder) Build() ([]byte, error) {
for _, kv := range b.extraHeaders {
writeHeader(&buf, kv[0], kv[1])
}
if b.lmsReplyType != "" {
writeHeader(&buf, lmsReplyTypeHeaderKey, b.lmsReplyType)
}
// ── Body ───────────────────────────────────────────────────────────────────
// Full MIME hierarchy (outer layers only present when needed):

View File

@@ -285,6 +285,48 @@ func TestBuild_LMSReplyToMessageID_NotWrittenWithoutInReplyTo(t *testing.T) {
}
}
// TestBuild_LMSReplyType verifies LMS reply type header writing.
func TestBuild_LMSReplyType(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("Re: hello").
Date(fixedDate).
LMSReplyType("REPLY").
TextBody([]byte("my reply")).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
got := headerValue(eml, "X-LMS-Reply-Type")
if got != "REPLY" {
t.Errorf("X-LMS-Reply-Type: got %q, want REPLY", got)
}
}
// TestBuild_LMSReplyType_Forward verifies LMSReplyType with FORWARD value.
func TestBuild_LMSReplyType_Forward(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("Fw: hello").
Date(fixedDate).
LMSReplyType("FORWARD").
TextBody([]byte("fw body")).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
got := headerValue(eml, "X-LMS-Reply-Type")
if got != "FORWARD" {
t.Errorf("X-LMS-Reply-Type: got %q, want FORWARD", got)
}
}
// ── Disposition-Notification-To (read receipt) ───────────────────────────────
// TestBuild_DispositionNotificationTo verifies build disposition notification to.

View File

@@ -256,6 +256,7 @@ var MailForward = common.Shortcut{
}
if messageId != "" {
bld = bld.LMSReplyToMessageID(messageId)
bld = bld.LMSReplyType("FORWARD")
}
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {

View File

@@ -269,6 +269,7 @@ var MailReply = common.Shortcut{
}
if messageId != "" {
bld = bld.LMSReplyToMessageID(messageId)
bld = bld.LMSReplyType("REPLY")
}
var autoResolvedPaths []string
var composedHTMLBody string

View File

@@ -278,6 +278,7 @@ var MailReplyAll = common.Shortcut{
}
if messageId != "" {
bld = bld.LMSReplyToMessageID(messageId)
bld = bld.LMSReplyType("REPLY")
}
var autoResolvedPaths []string
var composedHTMLBody string

View 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
},
}

View 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
}

View File

@@ -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"],
}

View File

@@ -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"])
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View 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
}

View 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)
}
}

View File

@@ -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")

View File

@@ -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")

View File

@@ -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"
}
}

View File

@@ -16,5 +16,6 @@ func Shortcuts() []common.Shortcut {
MinutesTodo,
MinutesSpeakerReplace,
MinutesWordReplace,
MinutesDetail,
}
}

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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",

View File

@@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
VCSearch,
VCNotes,
VCRecording,
VCDetail,
VCMeetingJoin,
VCMeetingLeave,
VCMeetingListActive,

216
shortcuts/vc/vc_detail.go Normal file
View 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
},
}

View 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)
}
}

View File

@@ -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)
}

View File

@@ -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"},

View File

@@ -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
)
}

View File

@@ -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"],
}

View File

@@ -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刚创建画板需要编辑

View File

@@ -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 中直接提取 |

View File

@@ -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不能脱离日程单独预定。
- **日程会议 IDMeeting 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

View 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 → 纪要文档 tokennote_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. 任意文档 tokenmeeting_note / note_doc_token / verbatim_doc_token / shared_doc_token→ 正文
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
```

View File

@@ -75,7 +75,7 @@
定位规则:
- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda``events search_event` 或实例视图缩小范围。
- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda``+search-event` 或实例视图缩小范围。
- 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。
- 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`

View File

@@ -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`——日历不收录"即时会议",只查日程会漏。

View File

@@ -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 秒后再查询,避免同步延迟导致读到旧数据。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。

View File

@@ -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推荐优先使用

View File

@@ -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`(仅用户明确要求时) |

View File

@@ -5,27 +5,27 @@
```bash
# 获取文档(默认 XMLsimple
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) 路由表。
## 参考

View File

@@ -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\]`

View File

@@ -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"/>'

View File

@@ -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) 的「画板语法与插入」章节。

View File

@@ -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

View File

@@ -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 路径和用于画图的源内容

View File

@@ -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` 精确区间,只拉自己的章节,不要重复拉全文。

View File

@@ -25,7 +25,7 @@ metadata:
- 用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时仍按资源类型、URL 路径和 token 路由到本 skill不要因为域名不是飞书而回退到 WebFetch。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.pptx` / `.pdf` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX/PDF 导入上限是 500MB。
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
@@ -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 中直接提取 |

View File

@@ -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`

View File

@@ -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`

View File

@@ -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` |
## 标准命令模板

View File

@@ -2,7 +2,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
将本地文件(如 Word、TXT、Markdown、Excel、PPTX、PDF导入并转换为飞书在线云文档docx、sheet、bitable、slides。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`
将本地文件(如 Word、TXT、Markdown、Excel、PPTX 等导入并转换为飞书在线云文档docx、sheet、bitable、slides。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`
> [!IMPORTANT]
> 当用户说“把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable 文档”时,第一步必须使用 `drive +import --type bitable`。
@@ -45,9 +45,8 @@ lark-cli drive +import --file ./crm.xlsx --type bitable --name "客户台账"
# 导入 .base 快照为多维表格 / Base (bitable)(文件不能超过 20MB
lark-cli drive +import --file ./snapshot.base --type bitable --name "快照还原"
# 导入 PPTX / PDF 为飞书幻灯片 (slides)(文件不能超过 500MB
# 导入 PPTX 为飞书幻灯片 (slides)(文件不能超过 500MB
lark-cli drive +import --file ./deck.pptx --type slides --name "项目汇报"
lark-cli drive +import --file ./deck.pdf --type slides --name "项目汇报"
# 导入到指定文件夹,并指定导入后的文件名
lark-cli drive +import --file ./data.csv --type bitable --folder-token <FOLDER_TOKEN> --name "导入数据表"
@@ -95,7 +94,6 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
| `.csv` | `sheet`, `bitable` | CSV 数据文件 |
| `.base` | `bitable` | 多维表格快照文件 |
| `.pptx` | `slides` | Microsoft PowerPoint 演示文稿 |
| `.pdf` | `slides` | PDF 文档 |
> [!IMPORTANT]
> 用户口头说的 “Base” / “多维表格” / “bitable”在命令里统一对应 `--type bitable`。
@@ -105,7 +103,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
> - `.xlsx` / `.csv` 文件**只能**导入为 `sheet` 或 `bitable`
> - `.xls` 文件**只能**导入为 `sheet`
> - `.base` 文件**只能**导入为 `bitable`
> - `.pptx` / `.pdf` 文件**只能**导入为 `slides`
> - `.pptx` 文件**只能**导入为 `slides`
> - 例如:`.csv` 文件不能导入为 `docx``.md` 文件不能导入为 `sheet`
> [!IMPORTANT]
@@ -139,7 +137,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
| `.csv` | `bitable` | 100MB |
| `.xls` | `sheet` | 20MB |
| `.base` | `bitable` | 20MB |
| `.pptx`, `.pdf` | `slides` | 500MB |
| `.pptx` | `slides` | 500MB |
- 如果文件超出对应上限shortcut 会在真正上传前直接返回验证错误。
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart不代表所有格式都允许导入超过 20MB 的文件。

View File

@@ -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)

View 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`。

View File

@@ -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) — 认证和全局参数

View File

@@ -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) -- 视频会议全部命令

View File

@@ -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` 一致 |
## 参考

View File

@@ -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) — 认证和全局参数

View File

@@ -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)

View File

@@ -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) 承接。
## 输出结果示例

View File

@@ -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
```

View File

@@ -1,9 +1,11 @@
# note +detail
`note +detail` 只做一件事:按显式 `note_id` 返回纪要展示类型和相关文档 token。
通过 `note_id` 查询会议纪要详情,获取下挂文档 TokenAI 智能纪要、逐字稿、会中共享文档)。只读,仅支持 `--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) |

View File

@@ -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` 非空 | 先按独立逐字稿文档读取 |

View File

@@ -1,7 +1,7 @@
---
name: lark-slides
version: 1.0.0
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用;当用户给定 PPTX/PDF 作为模板、底稿或视觉参考时,也用本 skill 统筹导入后的二次创作(导入命令本身走 `lark-drive``drive +import --type slides`。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide 内嵌的流程图/架构图仍属本 skill、上传或下载普通文件走 lark-drive。"
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide 内嵌的流程图/架构图仍属本 skill、上传或下载普通文件走 lark-drive。"
metadata:
requires:
bins: ["lark-cli"]
@@ -14,17 +14,16 @@ metadata:
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 用户提供 PDF/PPTX/slides 材料并要求生成或改写 PPT | 必须先导入/回读材料,并以导入后的 presentation 作为目标底稿二次创作;除非 PDF 明显是长文档/资料而非演示稿,不要跳过导入 | `drive +import --type slides``planning-layer.md``asset-planning.md``lark-slides-edit-workflows.md` |
| 新建 PPT | 仅在没有用户提供可导入材料、用户明确要求另建,或导入失败/回读失败时,先规划 `slide_plan.json`,再按复杂度创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 已有 PPT 大幅改写 | 优先用 `+replace-pages` 做页面级重建,哪怕只替换 1 页也传 1 个 page item在完整新页 XML 里复用旧页背景、图片、图表、表格等素材 | `xml_presentations.get``lark-slides-replace-pages.md``lark-slides-edit-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 只有明确是小型块级编辑且拿到了最新 block_id 时才用 `+replace-slide` | `slides +replace-slide``lark-slides-replace-slide.md` |
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 已有 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` |
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides``@./path` 占位符 |
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
| 使用语义图标 | 先检索 IconPark再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve``iconpark.md` |
| 用户提到模板、主题、版式但没有提供本地/在线模板材料 | 先检索内置模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
@@ -41,16 +40,14 @@ metadata:
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
**CRITICAL — 用户提供 PPTX/PDF/slides 作为模板、底稿或视觉参考时MUST 先导入或回读为 Slides保存原 XML并做素材清单每页的 `<style>` 背景、`<img src="file_token">`、`<chart>`、`<table>`、`<whiteboard>`、关键 shape/motif。二次创作默认在同一个 `xml_presentation_id` 内用 `+replace-pages` 页面级替换;同一 presentation 内旧页的图片 file token 可在新页 XML 中复用。不要用 `slides +create` 新建脱离模板的 deck除非导入或回读失败**
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),只有在用户没有提供本地/在线模板材料时,才用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做内置模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”或用户需求明显落在已有场景模板内如工作汇报、产品介绍、商业计划书、培训、晋升汇报等MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML**
> [!NOTE]
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
**CRITICAL — 使用模板生成或改写页面时MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
**编辑已有幻灯片页面**页面级重写、导入模板二创、布局/素材保留优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内重建页面,避免 `slides +create` 生成新链接;[`+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)。
## 身份选择
@@ -136,9 +133,7 @@ lark-cli auth login --domain slides
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
### 无用户导入材料时的创建方式
以下创建方式仅适用于没有用户提供可导入材料、用户明确要求另建 deck或导入失败/`xml_presentations.get` 无法回读的异常场景。
### 创建方式选择
| 场景 | 推荐方式 |
|------|----------|
@@ -154,7 +149,7 @@ lark-cli auth login --domain slides
### 模板与脚本优先流程
模板细则见 [template-catalog.md](references/template-catalog.md)。仅在用户没有提供本地/在线模板材料时使用内置模板流程:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
```bash
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
@@ -164,30 +159,28 @@ python3 skills/lark-slides/scripts/template_tool.py extract --template <template
```text
Step 1: 需求澄清 & 读取知识
- 澄清主题、受众、页数、风格;没有用户提供模板材料时,模板需求按“模板与脚本优先流程”处理
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
- 读取 xml-schema-quick-ref.md新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
- 如果用户提供 PPTX/PDF/slides 附件、文件路径、素材目录或类似“附件文件路径:...”的文本,先按 asset-planning.md 解析路径PPTX 必须导入为 slidesPDF 只在明显不是演示稿/模板时才跳过导入
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
- 生成结构化大纲供用户确认;如使用用户附件、导入后的 slides 或模板材料,标明每类素材如何参与二次创作
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
- plan 字段、路径命名、模板边界、导入底稿保留策略和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
Step 3: 按 slide_plan.json 生成 XML → 创建
- 逐页消费 plankey_message 定主结论layout_type 定几何visual_focus 定主视觉text_density 定文本量
- 如果 plan 有 `target_xml_presentation_id`,默认在该 presentation 内用 `+replace-pages` 替换页面;生成完整新页 XML 时复用源页 `<style>`、图片 file token、图表、表格、关键装饰 shape 等素材
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
- 只有无导入材料、用户明确要求另建,或导入失败/回读失败时,才按“无用户导入材料时的创建方式”新建 deck
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
Step 4: 审查 & 交付
- 创建完成后,必须用 xml_presentations.get 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 失败或部分成功按 troubleshooting.md 处理;小型块级问题可用 `+replace-slide` 修正,页面级/素材保留问题继续用 `+replace-pages`
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
```
### jq 命令模板(编辑已有 PPT 时使用)
无用户导入材料的新建 PPT `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
新建 PPT 推荐`+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
```bash
# 追加到末尾
@@ -275,7 +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) | 在原演示文稿内重建一个或多个页面:先创建新页到旧页前,再删除旧页;适合导入模板二创、页面级重写和素材保留,不新建链接 |
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
没有 Shortcut 覆盖时使用原生 API。高频资源`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
@@ -288,14 +281,13 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **用户材料默认作为二创底稿**:用户提供 PDF/PPTX/slides 材料并要求生成、改写、二创、压缩页数或保留材料风格/资产时,必须先导入或回读为 Slides默认 `target_xml_presentation_id` 等于导入或已有材料的 `xml_presentation_id`,在该 presentation 内继续创作。“只作为模板/视觉线索”只表示不复制原文案,不表示可以跳过导入或新建脱离材料的 deck
2. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
3. **创建流程**:仅在没有用户提供可导入材料、用户明确要求新建 deck或导入失败/`xml_presentations.get` 无法回读时,才使用 `slides +create` 新建目标 deck页数多、内容不可用、只参考风格、布局复杂或 PDF 是正文资料都不是新建理由
4. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
5. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
6. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
7. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
8. **编辑已有页面优先原链接更新**:页面级重写、导入模板二创、布局/素材保留优先用 `+replace-pages`;修改单个 shape/img 才用 `+replace-slide``block_replace` / `block_insert`)。不要用 `slides +create` 新建整份 PPT不要手动编排 `slide.create` + `slide.delete` 来替代已有 shortcut
9. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果 `file_token` 来自同一个 `xml_presentation_id` 的旧页,可以在 `+replace-pages` 的新页 XML 中直接复用;如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
3. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
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`),不要整页重建;已有 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>` 输出不一致,以后两者为准。

View File

@@ -10,25 +10,9 @@
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan for genuinely missing new assets.
- If the user provides PPTX/PDF/slides material and it has been imported/read back, its existing slide assets are not "missing assets." Record them in `source_asset_inventory` / `rewrite_contract` in `planning-layer.md` and reuse the original XML or file token in the replacement page.
- `fallback_if_missing` must not replace an existing source `<img>`, `<table>`, `<chart>`, `<whiteboard>`, or locked motif. It only applies when the page needs a new asset that is absent from the source deck.
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
- Do not leave blank image boxes in final XML. If the asset is missing, render the fallback visual.
## Imported Slide Assets
For imported or existing Slides rewrites, first inventory the source XML before writing new page XML. This inventory is a lock list, not a suggestion list:
- Preserve `<style>` when it carries the template background, gradient, image fill, or visual tone.
- Preserve `<img src="...">` when the image is part of the template, brand, product screenshot, chart export, decorative background, or layout.
- Preserve `<chart>` / `<table>` when the user asked to keep data visuals or when they anchor the page design; update labels/data only if requested.
- Preserve `<whiteboard>` position when it represents a diagram to keep, but remember readback XML may not include its inner SVG/Mermaid content.
- Preserve recurring shape motifs such as side bars, section bands, numbered badges, separators, or card containers.
When using `+replace-pages`, a reused `<img src>` from the same `xml_presentation_id` can be copied directly into the new `<slide>` content. Do not re-upload it and do not replace it with an external URL.
Use `asset_need` for new desired assets. Use `source_asset_inventory` and `rewrite_contract.must_reuse` for old assets that must survive the rewrite. If an old asset should be removed, list that exact block in `rewrite_contract.discarded_blocks` with `type`, `id`, and reason; do not remove whole pages of assets with a broad "old graphics do not match" explanation.
## JSON Shape
Use an object for one planned asset, or an array when a page genuinely needs multiple assets. Keep each item compact.
@@ -133,9 +117,8 @@ Business comparison page:
When generating XML:
1. If an asset exists in the imported/current presentation and the page rewrite stays in that same `xml_presentation_id`, reuse the original XML block or file token in the planned visual region.
2. Generate from source outward: copy source `<style>` and locked assets first, then replace placeholder text, then add new XML-native visuals.
3. If no source or new real asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
4. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
5. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
6. After creation or replacement, fetch the presentation and verify asset pages are not blank, locked source assets are still present, and each planned fallback is visible only when no source or real asset was available.
1. If an asset exists and the workflow supports it, place it in the planned visual region.
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.

View File

@@ -1,51 +1,21 @@
# 编辑已有 PPT读-改-写闭环
页面级重写、导入模板二创、布局/素材保留优先走 **[`+replace-pages`](lark-slides-replace-pages.md)**,保持原 presentation 链接不变。只有很小的局部编辑才**[`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),并且必须先读原页拿最新 `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)。
## 决策树replace-pages vs replace-slide
## 决策树:block_replace vs block_insert
| 需求 | 推荐 action | 理由 |
|------|------------|------|
| 导入 PPTX/PDF 后二次创作、保留模板、背景、图片、图表或页面视觉语言 | `+replace-pages` | 生成完整 `<slide>`,可直接复用旧页 `<style>``<img src>` file token、`<chart>``<table>`、关键 motif避开 block 级 patch 易失败点 |
| 单页也要重排版式或替换大部分页面内容 | `+replace-pages`pages 数组传 1 项) | 仓库代码命令名是复数;支持 1 个 item按 create-before-delete-old 执行 |
| 多页版式重建、整页坐标重排 | `+replace-pages` | 原 presentation 内批量 create-before/delete-old不生成新 Slides 链接 |
| 已知某块的 `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`。这不是"微调",是块级重写。
## 导入 PPTX/PDF 后的保模板流程
1.`drive +import --type slides` 导入 PPTX/PDFPPTX 必须导入PDF 只有明显不是演示稿/模板时才跳过。
2.`xml_presentations.get` 回读导入后的 XML保存到 `.lark-slides/plan/<id>/source.xml`
3. 为每页列 `source_asset_inventory``<style>``<img src>``<chart>``<table>``<whiteboard>`、关键 shape/motif 和 bbox。源素材默认是 locked assets。
4.`slide_plan.json` 里设置 `target_xml_presentation_id`,每页记录 `source_slide_id``rewrite_mode: "preserve_template"``rewrite_contract`
5. 生成完整新 `<slide>`:先复制源页 `<style>` 和 locked assets再替换文案、调整布局、删除逐块列明的模板占位文字最后补充新业务图形。
6. 组装 `pages.json`,每项包含旧页 `slide_id` 和完整新页 `content`
7. 先跑 `+replace-pages --validate-only` 或 dry-run再执行 `+replace-pages`
8. 回读全文 XML核对 locked assets、图片 token、图表、表格、背景和页数如果账号具备截图能力可额外截图检查视觉效果。
不要从空 `<slide>` 重画后再按感觉补素材。模板二创不是"复用 slide_id 的新 deck",而是在源页 XML 之上做保留式改写。
`rewrite_contract.discarded_blocks` 必须逐块列出 `type``id` 和原因。`discarded_blocks.type = "all"` 默认禁止;只有用户明确要求不保留模板素材或只参考风格重做时,才允许 `rewrite_mode: "style_reference_only"`,并在 plan 中记录用户原话或等价证据。
`+replace-slide` 不作为导入模板二创主路径。它依赖当前页 `block_id`、单根 XML 片段和后端 block replace 约束,出现 3350001 时需要重新读页和修片段;页面级二创通常用 `+replace-pages` 更稳。
最小 `pages.json` 形态:
```json
[
{
"slide_id": "old_slide_id",
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style>...</style><data>...</data></slide>"
}
]
```
## 小型块级编辑闭环
## 最小读-改-写闭环
```bash
PID="xml_presentation_id_here"

View File

@@ -1,11 +1,9 @@
# slides +replace-pages面级重建)
# slides +replace-pages多页整页重建)
批量替换已有演示文稿里的一个或多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合导入 PPTX/PDF 后二次创作、保留模板素材、版式大改、坐标重排、整页视觉重建;单个文本框、图片或 shape 的小型局部编辑才考虑 [`+replace-slide`](lark-slides-replace-slide.md)。
批量替换已有演示文稿里的多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合多页版式大改、坐标重排、整页视觉重建;单个文本框、图片或 shape 的局部编辑仍优先用 [`+replace-slide`](lark-slides-replace-slide.md)。
> 重要这是多步编排不是后端原子事务。CLI 对每页执行“先创建新页到旧页前,再删除旧页”;创建失败时旧页会保留。删除失败时可能出现新旧页同时存在,需要按返回结果继续处理。
> 命令名以仓库代码为准:当前 shortcut 是 `slides +replace-pages`(复数)。即使只替换一页,也传一个包含 1 个 item 的 `pages` 数组。
## 命令
```bash
@@ -47,26 +45,6 @@ lark-cli slides +replace-pages \
- 同一批次不能重复 `slide_id`
- CLI 不会回读整份 presentation如果 `slide_id` 已失效create/delete 阶段会返回对应错误。
## 保留导入模板素材
导入 PPTX/PDF 或改写已有 Slides 时,先用 `xml_presentations.get` 保存当前 XML再为每页盘点素材。源素材默认锁定保留不是可随意替换的装饰
- `<style>`:页面背景、渐变、底色或图片底纹。
- `<img src="...">`:同一个 `xml_presentation_id` 内的 file token 可直接复制到新页 XML。
- `<chart>` / `<table>`:优先保留原结构;只在用户要求时改数据或标签。
- `<whiteboard>`:可保留外层位置;注意回读 XML 可能不包含内部 SVG/Mermaid。
- 关键 shape/motif侧边栏、分节条、卡片底、编号徽章、分割线等模板视觉语言。
`slide_plan.json` 中把这些事实写入 `source_asset_inventory`,并在每页 `rewrite_contract.must_reuse` 中绑定要保留的 locked assets。生成 replacement `content` 时,顺序必须是:
1. 先复制源页 `<style>` 和 locked assets。
2. 再替换模板占位文案和必要布局。
3. 最后补充新业务图形。
不要从空 `<slide>` 重画后再按感觉补素材。不要把导入底稿当成普通参考后重新 `slides +create` 一份脱离素材的新 deck。
`discarded_blocks` 只能逐块删除源元素,必须写 `type``id` 和原因。`discarded_blocks.type = "all"` 默认非法;只有用户明确要求"不保留模板素材"或"只参考风格重做"时,才允许 `rewrite_mode: "style_reference_only"`
## Dry Run
```bash
@@ -112,7 +90,6 @@ lark-cli slides +replace-pages --as user \
## 使用建议
1. 大幅改写前先 `xml_presentations.get` 保存当前 XML并记录要替换页面的 `slide_id`
2. 导入模板二创时,先在 plan 中记录每页要复用的背景、图片 token、图表、表格和 motif再生成完整新页 XML
3. 生成只含 `slide_id` 和完整 `<slide>` content 的 `pages.json` 后先跑 `--dry-run``--validate-only`
4. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。
5. 替换后再回读全文 XML确认页序、背景、图片、图表、表格、locked motif 和文本没有破损;如果当前账号具备截图能力,可额外截图检查视觉效果。
2. 生成只含 `slide_id``pages.json` 后先跑 `--dry-run``--validate-only`
3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换
4. 替换后再回读全文 XML 并截图检查,确认页序、视觉和文本没有破损。

View File

@@ -2,9 +2,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
对指定 slide 做块级替换或插入。适合小型局部编辑:`slide_id` 不变、页序不动、只影响被指定的块。
导入 PPTX/PDF 后二次创作、模板素材保留、整页布局重建不要默认用本 shortcut优先用 [`+replace-pages`](lark-slides-replace-pages.md) 生成完整新页 XML并在同一个 presentation 内复用旧页背景、图片 file token、图表、表格和 motif。
对指定 slide 做块级替换或插入。编辑已有 PPT 的主路径——`slide_id` 不变、页序不动、只影响被指定的块。
相比直接调 `xml_presentation.slide.replace`,这个 shortcut 的四个额外价值:

View File

@@ -7,34 +7,16 @@
## Required Flow
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
2. 如果用户给了 PPTX/PDF/slides 材料,先导入或回读为 SlidesPPTX 必须导入PDF 只有在明显是长文档/资料而不是演示稿、模板或视觉底稿时才跳过导入
3. 如果没有用户提供的模板材料、且适合内置模板,再用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息
4. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`
5. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`
6. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
7. 读取 `xml-schema-quick-ref.md``visual-planning.md``asset-planning.md`
8. 按 plan、visual planning 和 asset planning 规则逐页生成 XML`layout_type``visual_focus``text_density` 转成具体页面几何和文本量约束;只有缺失的新素材才转成可执行兜底视觉,源页素材不算缺失素材
9. 创建或替换后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
2. 如果适合模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息
3. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`
4. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`
5. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
6. 读取 `xml-schema-quick-ref.md``visual-planning.md``asset-planning.md`
7. 按 plan、visual planning 和 asset planning 规则逐页生成 XML`layout_type``visual_focus``text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉
8. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系
模板不能代替 plan。模板搜索和摘要只能影响 `theme_style`、页面流、布局选择和局部布局骨架;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
## Imported Deck Preservation
当用户提供 PPTX/PDF/slides 作为模板、底稿、版式参考或二创对象时plan 必须把导入后的 presentation 当成目标,而不是把它当成普通参考图。
流程:
1. 先用 `drive +import --type slides` 导入本地 PPTX/PDF或用 `xml_presentations.get` 回读已有 Slides。
2. 保存导入/回读 XML 到 plan 目录,记录 `target_xml_presentation_id``revision_id`、每页 `slide_id`
3. 做每页 `source_asset_inventory``<style>` 背景、`<img src="...">` file token、`<chart>``<table>``<whiteboard>`、关键 shape/motif 和它们的 bbox。这个清单是事实层来自 `source.xml`,不要用模型偏好改写事实。
4. 写 plan 时为每个目标页绑定 `source_slide_id`,并写 `rewrite_contract`。默认 `reuse_policy``preserve_by_default`:源页素材默认锁定保留。
5. 生成完整 `<slide>` XML 时,先复制源页 `<style>` 和 locked assets再替换模板占位文案最后补充新业务图形。
6. 默认用 `slides +replace-pages` 执行页面级替换。仓库代码中的命令名是复数 `+replace-pages`;即使只替换一页,也传一个包含 1 个 item 的 `pages` 数组。
`+replace-slide` 只适合小型块级编辑:改一个标题、插入一个图、替换一个已知 block。导入模板二创不要默认走它因为它依赖最新 `block_id` 和局部 XML 片段,更容易触发 3350001。
禁止把模板二创理解成"按模板风格重画"。`discarded_blocks` 必须逐块列出 `type``id` 和具体原因;`discarded_blocks.type = "all"` 默认非法。只有用户明确说"不要保留模板素材"、"只参考风格重做"等同义要求时,才允许 `rewrite_mode: "style_reference_only"`,并必须在 plan 中记录 `user_intent_evidence`
## Plan Path
Use a separate plan directory per deck or task so multiple presentations in the same workspace cannot overwrite each other.
@@ -74,27 +56,6 @@ Exception:
```json
{
"presentation_goal": "Explain the proposal and secure approval for the next phase.",
"target_xml_presentation_id": "optional existing/imported presentation id for in-place rewrite",
"source_deck": {
"source_type": "pptx|pdf|slides|none",
"source_path_or_url": "optional original user material",
"source_xml_path": ".lark-slides/plan/example/source.xml",
"source_slide_count": 12
},
"source_asset_inventory": {
"generated_from": ".lark-slides/plan/example/source.xml",
"default_policy": "preserve_by_default",
"pages": [
{
"source_slide_id": "old1",
"locked_assets": [
{"type": "style", "id": "slide-style", "reason": "template background"},
{"type": "img", "id": "bPk", "src": "file_token", "reason": "template hero visual"},
{"type": "shape", "id": "bQr", "role": "motif", "reason": "layout structure"}
]
}
]
},
"audience": "Product and engineering leaders who know the domain but need a concise decision narrative.",
"theme_style": "Clean business style, light background, restrained blue accent, strong visual hierarchy.",
"visual_system": {
@@ -122,15 +83,6 @@ Exception:
{
"page": 1,
"title": "Proposal Title",
"source_slide_id": "optional original slide id when rewriting an imported/existing deck",
"rewrite_mode": "preserve_template",
"rewrite_contract": {
"reuse_policy": "preserve_by_default",
"must_reuse": ["style:slide-style", "img:bPk", "shape:bQr"],
"discarded_blocks": [
{"type": "shape", "id": "bAb", "reason": "template placeholder text"}
]
},
"key_message": "The initiative is ready for a focused pilot.",
"layout_type": "title-cover",
"visual_focus": "Large title area with one concise supporting statement.",
@@ -152,9 +104,6 @@ Exception:
Top-level fields:
- `presentation_goal`: what the whole deck is trying to achieve.
- `target_xml_presentation_id`: required when rewriting an imported or existing deck in place.
- `source_deck`: required when user-provided PPTX/PDF/slides material was imported or read back.
- `source_asset_inventory`: required when `source_deck.source_type` is `pptx`, `pdf`, or `slides`; record source-page assets before planning new page XML.
- `audience`: target readers or listeners and their assumed background.
- `theme_style`: visual tone, palette direction, and professional style.
- `visual_system`: deck-level visual rules that must stay stable across pages, including background strategy, recurring motif, and color roles.
@@ -166,9 +115,6 @@ Each slide must include:
- `page`: 1-based page number.
- `title`: slide title.
- `source_slide_id`: required when this page rewrites an imported/existing page.
- `rewrite_mode`: `preserve_template` by default for imported/existing deck rewrites; use `style_reference_only` only when the user explicitly asked not to preserve template assets.
- `rewrite_contract`: required when `source_slide_id` is present; list locked source assets to reuse and any individually discarded source blocks.
- `key_message`: the one idea this page must land.
- `layout_type`: planned page structure.
- `visual_focus`: dominant visual object or region.
@@ -258,8 +204,6 @@ Before writing each slide XML, map the plan fields to concrete decisions:
- `visual_focus` determines the largest visual region or emphasized object.
- `text_density` caps visible text volume.
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
- `source_asset_inventory` determines which source XML blocks are locked by default. Copy source `<style>`, `<img src>` file tokens, charts, tables, whiteboards, and key motif shapes before generating new business visuals.
- `rewrite_contract.discarded_blocks` only removes individually named source blocks. Do not use `type: "all"` unless `rewrite_mode` is `style_reference_only` and `user_intent_evidence` records the user's explicit instruction.
After creating the PPT, fetch the presentation and verify:
@@ -273,4 +217,3 @@ After creating the PPT, fetch the presentation and verify:
- The actual backgrounds match `visual_system.background_strategy`; any dark, image-led, or emphasis page has an intentional relationship to the rest of the deck.
- Text boxes respect `typography_constraints`; long labels, captions, footer text, and conclusion bars are not squeezed into boxes that are too short for the intended line count.
- If real assets are used, the final XML contains renderable asset tokens or supported local placeholders for creation, not http URLs, stale local paths, or blank image boxes.
- For imported/template rewrites, replacement/readback XML still contains locked source assets from `source_asset_inventory`, especially background style, image tokens, charts, tables, whiteboards, and recurring motif blocks. If a locked source asset disappears, the plan must contain a block-level discard reason.

View File

@@ -21,7 +21,7 @@
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
7. 小型局部问题用 `+replace-slide` 块级修正;整页结构要改、导入模板二创或需要保留页面素材时,用 `+replace-pages`,不要手写 `slide.delete` + `slide.create` 编排
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页
## Symptom Fixes

View File

@@ -14,8 +14,7 @@
6. 检查页面不是全部退化为标题加 bullet list。
7. 检查视觉层级:标题、主视觉、支撑信息三者可区分。
8. 检查明显溢出和布局风险:重叠、越界、底部拥挤、长文本框。
9. 如果是导入 PPTX/PDF/slides 后二创,必须做 XML 级素材核对:`source_asset_inventory` / `rewrite_contract.must_reuse` 中的 locked assets 是否仍在 replacement 或回读 XML 中
10. 在最终回复中给出简短验证记录。
9. 在最终回复中给出简短验证记录
回读命令:
@@ -62,17 +61,6 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- `visual_focus` 是页面中最醒目或最大的信息区域之一。
- `text_density` 影响了文本量,没有用长 bullet 框替代规划。
- `asset_need` 有真实素材时已放入正确区域;没有真实素材时,`fallback_if_missing` 已用 XML 形状、线条、标签、表格或图表兜底。
- 导入/模板二创任务中,`source_asset_inventory` 里的 `<style>``<img src>``<chart>``<table>``<whiteboard>` 或 motif shape 已在最终 XML 中保留,除非 `rewrite_contract.discarded_blocks` 逐块标记了 `type``id` 和原因。
## Source Asset Preservation
模板二创默认 `reuse_policy: "preserve_by_default"`。验证时不要只看新页是否有视觉元素,还要核对源素材是否被保留:
- 源页 locked assets 必须出现在 `pages.json` replacement XML 或最终回读 XML 中。
- 如果源页存在 `<img>``<table>``<chart>``<whiteboard>` 或关键 motifreplacement 里对应类型数量明显归零,且 plan 没有逐块 discard视为失败。
- `discarded_blocks.type = "all"` 默认不是有效说明。只有 `rewrite_mode: "style_reference_only"` 且 plan 记录了用户明确要求不保留模板素材时,才可接受。
- `fallback_if_missing` 不能解释源素材消失;它只适用于源页没有可用素材时的新资产兜底。
- 截图检查是可选增强能力,不是默认必需项;没有截图能力时,至少完成 XML 级素材核对。
如果用户指定了关键页例如“架构解释”“Self-Attention 机制解释”“对比或演进视角”“总结页”,最终验证记录必须逐项说明这些页已存在。
@@ -84,8 +72,6 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- 关键文本没有出现在回读 XML 中。
- 图片仍是 `@./path`,或 `<img src>` 是 http(s) 外链。
- 页面依赖的图片区域为空,且没有 fallback visual。
- 导入模板的背景、核心图片、图表、表格或品牌/版式 motif 在最终 XML 中消失,且 plan 没有逐块说明丢弃原因。
- 源页有 `<img>` / `<table>` 等素材replacement 或最终回读 XML 中对应类型数量归零,且没有 `style_reference_only` 用户意图证据。
- 返回 XML 缺页、页序明显错误,或某页内容被 shell 截断。
- 大量形状坐标完全相同,导致主体内容重叠。
- 渐变背景回退成空白或白底,导致文字不可读。
@@ -118,8 +104,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- 回读:已执行 xml_presentations.get实际页数 N / 预期 N。
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
- 素材:已核对 source_asset_inventory / rewrite_contract.must_reuse导入底稿的背景、图片 token、图表/表格或 motif 已按 plan 保留;被删除素材均有逐块 discarded reason。
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。
```
不要声称完成了人工视觉验收,除非确实打开或获取了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。截图能力可能不可用,不要把截图作为默认交付门槛。
不要声称完成了人工视觉验收,除非确实打开或获取了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。

View File

@@ -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)

View File

@@ -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) — 认证和全局参数

View File

@@ -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) — 认证和全局参数

View File

@@ -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) — 认证和全局参数

View File

@@ -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-idsminute-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 +transcriptlark-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)

View 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 获取纪要文档 Tokennote_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`。

View File

@@ -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) — 认证和全局参数

View File

@@ -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) — 认证和全局参数

View File

@@ -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) -- 认证和全局参数

View File

@@ -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`
## 三域关联总览

View File

@@ -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渲染 & 写入**

Some files were not shown because too many files have changed in this diff Show More