Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-slides/
/notes/
/minutes/
internal/registry/meta_data.json
cmd/api/download.bin
app.log
Expand Down
2 changes: 1 addition & 1 deletion cmd/auth/login_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps"}
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
}
7 changes: 7 additions & 0 deletions cmd/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"errors"
"io"
"net/http"
"slices"
"sort"
"strings"
"testing"
Expand Down Expand Up @@ -214,6 +215,12 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
}
}

func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}

func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
Expand Down
4 changes: 4 additions & 0 deletions internal/registry/service_descriptions.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
},
"note": {
"en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" },
"zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" }
},
"sheets": {
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
"zh": { "title": "电子表格", "description": "电子表格操作" }
Expand Down
2 changes: 2 additions & 0 deletions lint/errscontract/rule_no_legacy_common_helper_call.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ var migratedCommonHelperPaths = []string{
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/im/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/note/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
Expand Down
3 changes: 2 additions & 1 deletion lint/errscontract/rule_no_legacy_envelope_literal.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,18 @@ var migratedEnvelopePaths = []string{
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/im/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/note/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
"shortcuts/im/",
}

// legacyOutputImportPath is the import path of the package that declares the
Expand Down
13 changes: 13 additions & 0 deletions lint/errscontract/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
paths := []string{
"shortcuts/doc/docs_fetch_v2.go",
"shortcuts/drive/drive_search.go",
"shortcuts/im/im_messages_send.go",
"shortcuts/mail/mail_send.go",
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_progress_create.go",
Expand Down Expand Up @@ -988,6 +989,18 @@ common.` + helper + `()
}
}

func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) {
commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths))
for _, path := range migratedCommonHelperPaths {
commonPaths[path] = struct{}{}
}
for _, path := range migratedEnvelopePaths {
if _, ok := commonPaths[path]; !ok {
t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path)
}
}
}

func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
src := `package calendar

Expand Down
204 changes: 204 additions & 0 deletions shortcuts/note/note.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

// Package note owns the Note domain: querying note detail and the unified
// transcript by a known note_id. The vc domain locates a
// note_id from meeting context and delegates note-detail parsing here, so the
// parsing logic lives in exactly one place.
package note

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)

// NoNoteReadPermissionCode is returned when the caller lacks read permission
// for the requested note.
const NoNoteReadPermissionCode = 121005

// artifact_type enum from the note detail API.
const (
artifactTypeMainDoc = 1 // main note document
artifactTypeVerbatim = 2 // verbatim transcript
)

// note_display_type enum (i32) from the note detail API. Surfaced to callers as
// a stable string so Agents route on a name, not a magic number.
const (
displayTypeNormal = 1
displayTypeUnified = 2
)

// Detail is the parsed note detail shared by `note +detail` and `vc +notes`.
type Detail struct {
NoteID string
CreatorID string
CreateTime string
DisplayType string // unknown | normal | unified
NoteDocToken string
VerbatimDocToken string
SharedDocTokens []string
}

// FetchDetail queries GET /open-apis/vc/v1/notes/{note_id} and parses the note
// object. API errors are returned as typed errs.* values so callers can enrich
// user guidance without downgrading the envelope.
func FetchDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) (*Detail, error) {
data, err := runtime.DoAPIJSONTyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
if err != nil {
return nil, err

Check warning on line 57 in shortcuts/note/note.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note.go#L57

Added line #L57 was not covered by tests
}
noteObj, _ := data["note"].(map[string]any)
if noteObj == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "note detail is empty")

Check warning on line 61 in shortcuts/note/note.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note.go#L61

Added line #L61 was not covered by tests
}
noteDoc, verbatimDoc := extractArtifactTokens(common.GetSlice(noteObj, "artifacts"))
return &Detail{
NoteID: noteID,
CreatorID: common.GetString(noteObj, "creator_id"),
CreateTime: common.FormatTime(noteObj["create_time"]),
DisplayType: displayTypeString(displayTypeValue(noteObj)),
NoteDocToken: noteDoc,
VerbatimDocToken: verbatimDoc,
SharedDocTokens: extractDocTokens(common.GetSlice(noteObj, "references")),
}, nil
}

// ToMap renders the detail as the field map consumed by `vc +notes`, keeping
// the historical key set (shared_doc_tokens omitted when empty) and adding the
// note_id / note_display_type fields.
func (d *Detail) ToMap() map[string]any {
m := map[string]any{
"note_id": d.NoteID,
"note_display_type": d.DisplayType,
"creator_id": d.CreatorID,
"create_time": d.CreateTime,
"note_doc_token": d.NoteDocToken,
"verbatim_doc_token": d.VerbatimDocToken,
}
if len(d.SharedDocTokens) > 0 {
m["shared_doc_tokens"] = d.SharedDocTokens
}
return m
}

// displayTypeValue reads the display-type field, tolerating either the
// documented note_display_type key or a bare display_type fallback.
func displayTypeValue(note map[string]any) any {
if v, ok := note["note_display_type"]; ok {
return v
}
return note["display_type"]

Check warning on line 99 in shortcuts/note/note.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note.go#L99

Added line #L99 was not covered by tests
}

func displayTypeString(v any) string {
switch parseLooseInt(v) {
case displayTypeNormal:
return "normal"
case displayTypeUnified:
return "unified"
default:
return "unknown"

Check warning on line 109 in shortcuts/note/note.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note.go#L108-L109

Added lines #L108 - L109 were not covered by tests
}
}

// extractArtifactTokens picks main-doc and verbatim-doc tokens from artifacts.
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
for _, a := range artifacts {
artifact, _ := a.(map[string]any)
if artifact == nil {
continue
}
docToken, _ := artifact["doc_token"].(string)
switch parseLooseInt(artifact["artifact_type"]) {
case artifactTypeMainDoc:
noteDoc = docToken
case artifactTypeVerbatim:
verbatimDoc = docToken
}
}
return
}

// extractDocTokens collects doc_token values from a list of reference objects.
func extractDocTokens(refs []any) []string {
var tokens []string
for _, s := range refs {
source, _ := s.(map[string]any)
if source == nil {
continue
}
if docToken, _ := source["doc_token"].(string); docToken != "" {
tokens = append(tokens, docToken)
}
}
return tokens
}

// parseLooseInt extracts an int from the varying JSON number representations
// DoAPIJSON may yield (json.Number, float64, or int).
func parseLooseInt(v any) int {
switch n := v.(type) {
case json.Number:
i, _ := n.Int64()
return int(i)
case float64:
// Reject fractional values: truncating 1.9 to 1 would silently coerce
// a malformed enum into a valid one.
if n != float64(int64(n)) {
return 0
}
return int(n)
case int:
Comment thread
Ren1104 marked this conversation as resolved.
return n

Check warning on line 161 in shortcuts/note/note.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note.go#L160-L161

Added lines #L160 - L161 were not covered by tests
default:
return 0
}
}

// parseLooseCursorID extracts a positive cursor as a string. String cursors are
// preferred because large JSON numbers lose precision when decoded into any.
func parseLooseCursorID(v any) (string, bool) {
switch n := v.(type) {
case string:
s := strings.TrimSpace(n)
if s == "" || s == "0" {
return "", false
}
return s, true
case json.Number:
i, err := n.Int64()
if err != nil || i <= 0 {
return "", false

Check warning on line 180 in shortcuts/note/note.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note.go#L180

Added line #L180 was not covered by tests
}
return strconv.FormatInt(i, 10), true
case float64:
// encoding/json decodes numbers in map[string]any as float64. Accept
// only values that can round-trip safely as an integer cursor.
const maxSafeJSONInteger = 1<<53 - 1
if n <= 0 || n != float64(int64(n)) || n > maxSafeJSONInteger {
return "", false
}
return strconv.FormatInt(int64(n), 10), true
case int64:
if n <= 0 {
return "", false

Check warning on line 193 in shortcuts/note/note.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note.go#L191-L193

Added lines #L191 - L193 were not covered by tests
}
return strconv.FormatInt(n, 10), true

Check warning on line 195 in shortcuts/note/note.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note.go#L195

Added line #L195 was not covered by tests
case int:
if n <= 0 {
return "", false
}
return strconv.Itoa(n), true

Check warning on line 200 in shortcuts/note/note.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note.go#L200

Added line #L200 was not covered by tests
default:
return "", false
}
}
86 changes: 86 additions & 0 deletions shortcuts/note/note_detail.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// note +detail — get note metadata and document tokens by a known note_id.

package note

import (
"context"
"errors"
"fmt"
"strings"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)

// NoteDetail queries note metadata, display type and document tokens by note_id.
var NoteDetail = common.Shortcut{
Service: "note",
Command: "+detail",
Description: "Get note detail (display type, document tokens) by note_id",
Risk: "read",
Scopes: []string{"vc:note:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "note-id", Desc: "note ID", Required: true},
},
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
noteID := strings.TrimSpace(runtime.Str("note-id"))
if noteID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id")

Check warning on line 33 in shortcuts/note/note_detail.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note_detail.go#L30-L33

Added lines #L30 - L33 were not covered by tests
}
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err)

Check warning on line 36 in shortcuts/note/note_detail.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note_detail.go#L35-L36

Added lines #L35 - L36 were not covered by tests
}
return nil

Check warning on line 38 in shortcuts/note/note_detail.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note_detail.go#L38

Added line #L38 was not covered by tests
},
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
noteID := strings.TrimSpace(runtime.Str("note-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
noteID := strings.TrimSpace(runtime.Str("note-id"))
detail, err := FetchDetail(ctx, runtime, noteID)
if err != nil {
return mapNoteError(err)

Check warning on line 49 in shortcuts/note/note_detail.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note_detail.go#L40-L49

Added lines #L40 - L49 were not covered by tests
}
runtime.OutFormat(map[string]any{"note": detail.ToMap()}, nil, nil)
return nil

Check warning on line 52 in shortcuts/note/note_detail.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note_detail.go#L51-L52

Added lines #L51 - L52 were not covered by tests
},
}

// mapNoteError surfaces the no-permission case explicitly and passes through
// any other typed API error unchanged.
func mapNoteError(err error) error {
if problem, ok := errs.ProblemOf(err); ok && problem.Code == NoNoteReadPermissionCode {
message := strings.TrimSpace(problem.Message)
if message == "" {
message = "no read permission for this note"

Check warning on line 62 in shortcuts/note/note_detail.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/note/note_detail.go#L62

Added line #L62 was not covered by tests
} else if !strings.Contains(message, "no read permission for this note") {
message = fmt.Sprintf("no read permission for this note: %s", message)
}
var permErr *errs.PermissionError
if errors.As(err, &permErr) {
mapped := *permErr
mapped.Problem.Message = message
if mapped.Problem.Hint == "" {
mapped.Problem.Hint = "Ask the note owner to grant read permission, then retry"
}
mapped.Cause = err
return &mapped
}
mappedProblem := *problem
mappedProblem.Category = errs.CategoryAuthorization
mappedProblem.Subtype = errs.SubtypePermissionDenied
mappedProblem.Message = message
if mappedProblem.Hint == "" {
mappedProblem.Hint = "Ask the note owner to grant read permission, then retry"
}
return &errs.PermissionError{Problem: mappedProblem, Cause: err}
Comment thread
Ren1104 marked this conversation as resolved.
}
return err
}
Loading
Loading