Skip to content

Commit 7c64e63

Browse files
authored
feat(note): clarify note ownership with dedicated detail and transcript flows (#1435)
* feat: split note domain * fix: address note transcript review comments * fix: stabilize empty note detail detection
1 parent 8e60f01 commit 7c64e63

26 files changed

Lines changed: 1738 additions & 196 deletions

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
@@ -25,9 +25,11 @@ var migratedCommonHelperPaths = []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/",

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,18 @@ var migratedEnvelopePaths = []string{
2626
"shortcuts/doc/",
2727
"shortcuts/drive/",
2828
"shortcuts/event/",
29+
"shortcuts/im/",
2930
"shortcuts/mail/",
3031
"shortcuts/markdown/",
3132
"shortcuts/minutes/",
33+
"shortcuts/note/",
3234
"shortcuts/okr/",
3335
"shortcuts/sheets/",
3436
"shortcuts/slides/",
3537
"shortcuts/task/",
3638
"shortcuts/vc/",
3739
"shortcuts/whiteboard/",
3840
"shortcuts/wiki/",
39-
"shortcuts/im/",
4041
}
4142

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

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)