mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* feat: split note domain * fix: address note transcript review comments * fix: stabilize empty note detail detection
210 lines
6.0 KiB
Go
210 lines
6.0 KiB
Go
// 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"
|
|
"errors"
|
|
"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
|
|
|
|
// ErrEmptyDetail identifies note detail responses that do not contain a note
|
|
// object. Callers should use errors.Is instead of matching the display message.
|
|
var ErrEmptyDetail = errors.New("note detail is empty")
|
|
|
|
// 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
|
|
}
|
|
noteObj, _ := data["note"].(map[string]any)
|
|
if noteObj == nil {
|
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "note detail is empty").WithCause(ErrEmptyDetail)
|
|
}
|
|
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"]
|
|
}
|
|
|
|
func displayTypeString(v any) string {
|
|
switch parseLooseInt(v) {
|
|
case displayTypeNormal:
|
|
return "normal"
|
|
case displayTypeUnified:
|
|
return "unified"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// 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:
|
|
return n
|
|
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
|
|
}
|
|
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
|
|
}
|
|
return strconv.FormatInt(n, 10), true
|
|
case int:
|
|
if n <= 0 {
|
|
return "", false
|
|
}
|
|
return strconv.Itoa(n), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|