Files
larksuite-cli/shortcuts/vc/vc_search.go

273 lines
8.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
defaultVCSearchPageSize = 15
maxVCSearchPageSize = 30
maxVCSearchQueryLen = 50
)
// toRFC3339 parses a time string via ParseTime (unix timestamp) and formats it as RFC3339.
func toRFC3339(input string, hint ...string) (string, error) {
ts, err := common.ParseTime(input, hint...)
if err != nil {
return "", err
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
}
return time.Unix(sec, 0).Format(time.RFC3339), nil
}
// parseTimeRange validates --start/--end and returns RFC3339 formatted strings.
func parseTimeRange(runtime *common.RuntimeContext) (string, string, error) {
start := strings.TrimSpace(runtime.Str("start"))
end := strings.TrimSpace(runtime.Str("end"))
if start == "" && end == "" {
return "", "", nil
}
var startTime, endTime string
if start != "" {
parsed, err := toRFC3339(start)
if err != nil {
return "", "", output.ErrValidation("--start: %v", err)
}
startTime = parsed
}
if end != "" {
parsed, err := toRFC3339(end, "end")
if err != nil {
return "", "", output.ErrValidation("--end: %v", err)
}
endTime = parsed
}
// validate start <= end
if startTime != "" && endTime != "" {
st, _ := time.Parse(time.RFC3339, startTime)
et, _ := time.Parse(time.RFC3339, endTime)
if st.After(et) {
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
}
}
return startTime, endTime, nil
}
// uniqueIDs deduplicates a string slice while preserving order.
func uniqueIDs(ids []string) []string {
if len(ids) == 0 {
return nil
}
seen := make(map[string]struct{}, len(ids))
var out []string
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
// buildTimeFilter returns a time range filter if start or end is non-empty.
func buildTimeFilter(startTime, endTime string) map[string]interface{} {
if startTime == "" && endTime == "" {
return nil
}
timeRange := map[string]interface{}{}
if startTime != "" {
timeRange["start_time"] = startTime
}
if endTime != "" {
timeRange["end_time"] = endTime
}
return timeRange
}
// buildMeetingFilter assembles the meeting_filter object from flags and time range.
// Note: the API expects time range under "start_time" key in meeting_filter.
func buildMeetingFilter(participants, organizers, rooms []string, timeRange map[string]interface{}) map[string]interface{} {
filter := map[string]interface{}{}
if timeRange != nil {
filter["start_time"] = timeRange
}
if len(participants) > 0 {
filter["participant_ids"] = participants
}
if len(organizers) > 0 {
filter["organizer_ids"] = organizers
}
if len(rooms) > 0 {
filter["open_room_ids"] = rooms
}
if len(filter) == 0 {
return nil
}
return filter
}
// buildSearchBody builds the request body for meeting search API.
func buildSearchBody(runtime *common.RuntimeContext, startTime, endTime string) map[string]interface{} {
body := map[string]interface{}{}
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
body["query"] = q
}
participants := uniqueIDs(common.SplitCSV(runtime.Str("participant-ids")))
organizers := common.SplitCSV(runtime.Str("organizer-ids"))
rooms := common.SplitCSV(runtime.Str("room-ids"))
if filter := buildMeetingFilter(participants, organizers, rooms, buildTimeFilter(startTime, endTime)); filter != nil {
body["meeting_filter"] = filter
}
return body
}
func buildSearchParams(runtime *common.RuntimeContext) larkcore.QueryParams {
params := larkcore.QueryParams{}
pageToken := strings.TrimSpace(runtime.Str("page-token"))
pageSize, _ := strconv.Atoi(strings.TrimSpace(runtime.Str("page-size")))
if pageSize <= 0 {
pageSize = defaultVCSearchPageSize
}
params["page_size"] = []string{strconv.Itoa(pageSize)}
if pageToken != "" {
params["page_token"] = []string{pageToken}
}
return params
}
func meetingSearchDisplayInfo(item map[string]interface{}) string {
if displayInfo := common.GetString(item, "display_info"); displayInfo != "" {
return displayInfo
}
return ""
}
func meetingSearchDescription(item map[string]interface{}) string {
if meta, ok := item["meta_data"].(map[string]interface{}); ok {
if desc := common.GetString(meta, "description"); desc != "" {
return desc
}
}
return ""
}
// VCSearch searches historical meeting records with filters.
var VCSearch = common.Shortcut{
Service: "vc",
Command: "+search",
Description: "Search meeting records by keyword, time range, participant, organizer, or meeting room (requires at least one filter)",
Risk: "read",
Scopes: []string{"vc:meeting.search:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "start", Desc: "start time (ISO 8601 or YYYY-MM-DD, e.g. 2026-03-24T00:00+08:00)"},
{Name: "end", Desc: "end time (ISO 8601 or YYYY-MM-DD, e.g. 2026-03-25)"},
{Name: "organizer-ids", Desc: "organizer open_id list, comma-separated"},
{Name: "participant-ids", Desc: "participant open_id list, comma-separated"},
{Name: "room-ids", Desc: "room_id list, comma-separated"},
{Name: "page-token", Desc: "page token for next page"},
{Name: "page-size", Default: "15", Desc: "page size, 1-30 (default 15)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, _, err := parseTimeRange(runtime); err != nil {
return err
}
if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxVCSearchQueryLen {
return output.ErrValidation("--query: length must be between 1 and 50 characters")
}
if _, err := common.ValidatePageSize(runtime, "page-size", defaultVCSearchPageSize, 1, maxVCSearchPageSize); err != nil {
return err
}
for _, flag := range []string{"query", "start", "end", "organizer-ids", "participant-ids", "room-ids"} {
if strings.TrimSpace(runtime.Str(flag)) != "" {
return nil
}
}
return common.FlagErrorf("specify at least one of --query, --start, --end, --organizer-ids, --participant-ids, or --room-ids")
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
startTime, endTime, err := parseTimeRange(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildSearchParams(runtime)
dryRunParams := map[string]interface{}{}
for key, values := range params {
if len(values) == 1 {
dryRunParams[key] = values[0]
} else if len(values) > 1 {
vs := make([]string, len(values))
copy(vs, values)
dryRunParams[key] = vs
}
}
dryRun := common.NewDryRunAPI().
POST("/open-apis/vc/v1/meetings/search")
if len(dryRunParams) > 0 {
dryRun.Params(dryRunParams)
}
return dryRun.Body(buildSearchBody(runtime, startTime, endTime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
startTime, endTime, err := parseTimeRange(runtime)
if err != nil {
return err
}
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/meetings/search", buildSearchParams(runtime), buildSearchBody(runtime, startTime, endTime))
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
items := common.GetSlice(data, "items")
outData := map[string]interface{}{
"items": items,
"total": data["total"],
"has_more": data["has_more"],
"page_token": data["page_token"],
}
hasMore, _ := data["has_more"].(bool)
runtime.OutFormat(outData, &output.Meta{Count: len(items)}, func(w io.Writer) {
if len(items) == 0 {
fmt.Fprintln(w, "No meetings.")
return
}
var rows []map[string]interface{}
common.EachMap(items, func(item map[string]interface{}) {
rows = append(rows, map[string]interface{}{
"id": fmt.Sprintf("%v", item["id"]),
"display_info": common.TruncateStr(meetingSearchDisplayInfo(item), 40),
"meta_data": common.TruncateStr(meetingSearchDescription(item), 80),
})
})
output.PrintTable(w, rows)
})
// 非 json 格式下追加分页提示json 格式已包含 has_more/page_token 字段)
if hasMore && runtime.Format != "json" && runtime.Format != "" {
pt, _ := data["page_token"].(string)
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pt)
}
return nil
},
}