Skip to content

Commit e4cb586

Browse files
committed
feat: split note domain
Add note shortcuts for note detail and unified transcript retrieval, route vc note detail parsing through the note domain, and update note/vc/minutes skill guidance for normal versus unified transcript handling. Includes dry-run E2E coverage for the new note shortcuts and documents the remaining live E2E fixture gap.
1 parent 154ecdb commit e4cb586

27 files changed

Lines changed: 1845 additions & 196 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ tests/mail/reports/
3535
# Generated / test artifacts
3636
.hammer/
3737
.lark-slides/
38+
/notes/
39+
/minutes/
3840
internal/registry/meta_data.json
3941
cmd/api/download.bin
4042
app.log

cmd/auth/login_messages.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
128128
// (not backed by from_meta service specs). Descriptions are now centralized in
129129
// service_descriptions.json.
130130
func getShortcutOnlyDomainNames() []string {
131-
return []string{"base", "contact", "docs", "markdown", "apps"}
131+
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
132132
}

cmd/auth/login_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"errors"
1010
"io"
1111
"net/http"
12+
"slices"
1213
"sort"
1314
"strings"
1415
"testing"
@@ -214,6 +215,12 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
214215
}
215216
}
216217

218+
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
219+
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
220+
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
221+
}
222+
}
223+
217224
func TestCollectScopesForDomains(t *testing.T) {
218225
projects := registry.ListFromMetaProjects()
219226
if len(projects) == 0 {

internal/registry/service_descriptions.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
4848
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
4949
},
50+
"note": {
51+
"en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" },
52+
"zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" }
53+
},
5054
"sheets": {
5155
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
5256
"zh": { "title": "电子表格", "description": "电子表格操作" }

lint/errscontract/rule_no_legacy_common_helper_call.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ var migratedCommonHelperPaths = []string{
2424
"shortcuts/doc/",
2525
"shortcuts/drive/",
2626
"shortcuts/event/",
27+
"shortcuts/im/",
2728
"shortcuts/mail/",
2829
"shortcuts/markdown/",
2930
"shortcuts/minutes/",
31+
"shortcuts/note/",
3032
"shortcuts/okr/",
3133
"shortcuts/sheets/",
3234
"shortcuts/slides/",

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,18 @@ var migratedEnvelopePaths = []string{
2525
"shortcuts/doc/",
2626
"shortcuts/drive/",
2727
"shortcuts/event/",
28+
"shortcuts/im/",
2829
"shortcuts/mail/",
2930
"shortcuts/markdown/",
3031
"shortcuts/minutes/",
32+
"shortcuts/note/",
3133
"shortcuts/okr/",
3234
"shortcuts/sheets/",
3335
"shortcuts/slides/",
3436
"shortcuts/task/",
3537
"shortcuts/vc/",
3638
"shortcuts/whiteboard/",
3739
"shortcuts/wiki/",
38-
"shortcuts/im/",
3940
}
4041

4142
// legacyOutputImportPath is the import path of the package that declares the

lint/errscontract/rules_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
953953
paths := []string{
954954
"shortcuts/doc/docs_fetch_v2.go",
955955
"shortcuts/drive/drive_search.go",
956+
"shortcuts/im/im_messages_send.go",
956957
"shortcuts/mail/mail_send.go",
957958
"shortcuts/markdown/markdown_fetch.go",
958959
"shortcuts/okr/okr_progress_create.go",
@@ -988,6 +989,18 @@ common.` + helper + `()
988989
}
989990
}
990991

992+
func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) {
993+
commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths))
994+
for _, path := range migratedCommonHelperPaths {
995+
commonPaths[path] = struct{}{}
996+
}
997+
for _, path := range migratedEnvelopePaths {
998+
if _, ok := commonPaths[path]; !ok {
999+
t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path)
1000+
}
1001+
}
1002+
}
1003+
9911004
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
9921005
src := `package calendar
9931006

shortcuts/note/note.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
// Package note owns the Note domain: querying note detail and the unified
5+
// transcript by a known note_id. The vc domain locates a
6+
// note_id from meeting context and delegates note-detail parsing here, so the
7+
// parsing logic lives in exactly one place.
8+
package note
9+
10+
import (
11+
"context"
12+
"encoding/json"
13+
"fmt"
14+
"net/http"
15+
"strconv"
16+
"strings"
17+
18+
"github.com/larksuite/cli/errs"
19+
"github.com/larksuite/cli/internal/validate"
20+
"github.com/larksuite/cli/shortcuts/common"
21+
)
22+
23+
// NoNoteReadPermissionCode is returned when the caller lacks read permission
24+
// for the requested note.
25+
const NoNoteReadPermissionCode = 121005
26+
27+
// artifact_type enum from the note detail API.
28+
const (
29+
artifactTypeMainDoc = 1 // main note document
30+
artifactTypeVerbatim = 2 // verbatim transcript
31+
)
32+
33+
// note_display_type enum (i32) from the note detail API. Surfaced to callers as
34+
// a stable string so Agents route on a name, not a magic number.
35+
const (
36+
displayTypeNormal = 1
37+
displayTypeUnified = 2
38+
)
39+
40+
// Detail is the parsed note detail shared by `note +detail` and `vc +notes`.
41+
type Detail struct {
42+
NoteID string
43+
CreatorID string
44+
CreateTime string
45+
DisplayType string // unknown | normal | unified
46+
NoteDocToken string
47+
VerbatimDocToken string
48+
SharedDocTokens []string
49+
}
50+
51+
// FetchDetail queries GET /open-apis/vc/v1/notes/{note_id} and parses the note
52+
// object. API errors are returned as typed errs.* values so callers can enrich
53+
// user guidance without downgrading the envelope.
54+
func FetchDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) (*Detail, error) {
55+
data, err := runtime.DoAPIJSONTyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
56+
if err != nil {
57+
return nil, err
58+
}
59+
noteObj, _ := data["note"].(map[string]any)
60+
if noteObj == nil {
61+
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "note detail is empty")
62+
}
63+
noteDoc, verbatimDoc := extractArtifactTokens(common.GetSlice(noteObj, "artifacts"))
64+
return &Detail{
65+
NoteID: noteID,
66+
CreatorID: common.GetString(noteObj, "creator_id"),
67+
CreateTime: common.FormatTime(noteObj["create_time"]),
68+
DisplayType: displayTypeString(displayTypeValue(noteObj)),
69+
NoteDocToken: noteDoc,
70+
VerbatimDocToken: verbatimDoc,
71+
SharedDocTokens: extractDocTokens(common.GetSlice(noteObj, "references")),
72+
}, nil
73+
}
74+
75+
// ToMap renders the detail as the field map consumed by `vc +notes`, keeping
76+
// the historical key set (shared_doc_tokens omitted when empty) and adding the
77+
// note_id / note_display_type fields.
78+
func (d *Detail) ToMap() map[string]any {
79+
m := map[string]any{
80+
"note_id": d.NoteID,
81+
"note_display_type": d.DisplayType,
82+
"creator_id": d.CreatorID,
83+
"create_time": d.CreateTime,
84+
"note_doc_token": d.NoteDocToken,
85+
"verbatim_doc_token": d.VerbatimDocToken,
86+
}
87+
if len(d.SharedDocTokens) > 0 {
88+
m["shared_doc_tokens"] = d.SharedDocTokens
89+
}
90+
return m
91+
}
92+
93+
// displayTypeValue reads the display-type field, tolerating either the
94+
// documented note_display_type key or a bare display_type fallback.
95+
func displayTypeValue(note map[string]any) any {
96+
if v, ok := note["note_display_type"]; ok {
97+
return v
98+
}
99+
return note["display_type"]
100+
}
101+
102+
func displayTypeString(v any) string {
103+
switch parseLooseInt(v) {
104+
case displayTypeNormal:
105+
return "normal"
106+
case displayTypeUnified:
107+
return "unified"
108+
default:
109+
return "unknown"
110+
}
111+
}
112+
113+
// extractArtifactTokens picks main-doc and verbatim-doc tokens from artifacts.
114+
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
115+
for _, a := range artifacts {
116+
artifact, _ := a.(map[string]any)
117+
if artifact == nil {
118+
continue
119+
}
120+
docToken, _ := artifact["doc_token"].(string)
121+
switch parseLooseInt(artifact["artifact_type"]) {
122+
case artifactTypeMainDoc:
123+
noteDoc = docToken
124+
case artifactTypeVerbatim:
125+
verbatimDoc = docToken
126+
}
127+
}
128+
return
129+
}
130+
131+
// extractDocTokens collects doc_token values from a list of reference objects.
132+
func extractDocTokens(refs []any) []string {
133+
var tokens []string
134+
for _, s := range refs {
135+
source, _ := s.(map[string]any)
136+
if source == nil {
137+
continue
138+
}
139+
if docToken, _ := source["doc_token"].(string); docToken != "" {
140+
tokens = append(tokens, docToken)
141+
}
142+
}
143+
return tokens
144+
}
145+
146+
// parseLooseInt extracts an int from the varying JSON number representations
147+
// DoAPIJSON may yield (json.Number, float64, or int).
148+
func parseLooseInt(v any) int {
149+
switch n := v.(type) {
150+
case json.Number:
151+
i, _ := n.Int64()
152+
return int(i)
153+
case float64:
154+
// Reject fractional values: truncating 1.9 to 1 would silently coerce
155+
// a malformed enum into a valid one.
156+
if n != float64(int64(n)) {
157+
return 0
158+
}
159+
return int(n)
160+
case int:
161+
return n
162+
default:
163+
return 0
164+
}
165+
}
166+
167+
// parseLooseCursorID extracts a positive cursor as a string. String cursors are
168+
// preferred because large JSON numbers lose precision when decoded into any.
169+
func parseLooseCursorID(v any) (string, bool) {
170+
switch n := v.(type) {
171+
case string:
172+
s := strings.TrimSpace(n)
173+
if s == "" || s == "0" {
174+
return "", false
175+
}
176+
return s, true
177+
case json.Number:
178+
i, err := n.Int64()
179+
if err != nil || i <= 0 {
180+
return "", false
181+
}
182+
return strconv.FormatInt(i, 10), true
183+
case float64:
184+
// encoding/json decodes numbers in map[string]any as float64. Accept
185+
// only values that can round-trip safely as an integer cursor.
186+
const maxSafeJSONInteger = 1<<53 - 1
187+
if n <= 0 || n != float64(int64(n)) || n > maxSafeJSONInteger {
188+
return "", false
189+
}
190+
return strconv.FormatInt(int64(n), 10), true
191+
case int64:
192+
if n <= 0 {
193+
return "", false
194+
}
195+
return strconv.FormatInt(n, 10), true
196+
case int:
197+
if n <= 0 {
198+
return "", false
199+
}
200+
return strconv.Itoa(n), true
201+
default:
202+
return "", false
203+
}
204+
}

shortcuts/note/note_detail.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
//
4+
// note +detail — get note metadata and document tokens by a known note_id.
5+
6+
package note
7+
8+
import (
9+
"context"
10+
"errors"
11+
"fmt"
12+
"strings"
13+
14+
"github.com/larksuite/cli/errs"
15+
"github.com/larksuite/cli/internal/validate"
16+
"github.com/larksuite/cli/shortcuts/common"
17+
)
18+
19+
// NoteDetail queries note metadata, display type and document tokens by note_id.
20+
var NoteDetail = common.Shortcut{
21+
Service: "note",
22+
Command: "+detail",
23+
Description: "Get note detail (display type, document tokens) by note_id",
24+
Risk: "read",
25+
Scopes: []string{"vc:note:read"},
26+
AuthTypes: []string{"user"},
27+
Flags: []common.Flag{
28+
{Name: "note-id", Desc: "note ID", Required: true},
29+
},
30+
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
31+
noteID := strings.TrimSpace(runtime.Str("note-id"))
32+
if noteID == "" {
33+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id")
34+
}
35+
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
36+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err)
37+
}
38+
return nil
39+
},
40+
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
41+
noteID := strings.TrimSpace(runtime.Str("note-id"))
42+
return common.NewDryRunAPI().
43+
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)))
44+
},
45+
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
46+
noteID := strings.TrimSpace(runtime.Str("note-id"))
47+
detail, err := FetchDetail(ctx, runtime, noteID)
48+
if err != nil {
49+
return mapNoteError(err)
50+
}
51+
runtime.OutFormat(map[string]any{"note": detail.ToMap()}, nil, nil)
52+
return nil
53+
},
54+
}
55+
56+
// mapNoteError surfaces the no-permission case explicitly and passes through
57+
// any other typed API error unchanged.
58+
func mapNoteError(err error) error {
59+
if problem, ok := errs.ProblemOf(err); ok && problem.Code == NoNoteReadPermissionCode {
60+
message := strings.TrimSpace(problem.Message)
61+
if message == "" {
62+
message = "no read permission for this note"
63+
} else if !strings.Contains(message, "no read permission for this note") {
64+
message = fmt.Sprintf("no read permission for this note: %s", message)
65+
}
66+
var permErr *errs.PermissionError
67+
if errors.As(err, &permErr) {
68+
mapped := *permErr
69+
mapped.Problem.Message = message
70+
if mapped.Problem.Hint == "" {
71+
mapped.Problem.Hint = "Ask the note owner to grant read permission, then retry"
72+
}
73+
mapped.Cause = err
74+
return &mapped
75+
}
76+
mappedProblem := *problem
77+
mappedProblem.Category = errs.CategoryAuthorization
78+
mappedProblem.Subtype = errs.SubtypePermissionDenied
79+
mappedProblem.Message = message
80+
if mappedProblem.Hint == "" {
81+
mappedProblem.Hint = "Ask the note owner to grant read permission, then retry"
82+
}
83+
return &errs.PermissionError{Problem: mappedProblem, Cause: err}
84+
}
85+
return err
86+
}

0 commit comments

Comments
 (0)