mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
db1a3fc0a6
* feat(vc): agent join meeting basic shortcuts structure Change-Id: Ic5d64067eb48670fa6636841cd00cbfa9b0bf3e7 * docs: add skill references for vc +meeting-join and +meeting-leave * feat(vc): add meeting events shortcut Add vc +meeting-events for bot meeting activity queries with page-all pagination support and tested pretty/json output. * feat(vc): refine meeting events pagination and output * test: add unit tests for vc +meeting-join and +meeting-leave shortcuts * feat(vc): improve meeting events pretty timeline * feat(vc): refine meeting events pretty output * docs(skill): add vc meeting events shortcut guide * docs(skill): clarify vc meeting events output guidance * docs: clarify participant-snapshot vs meeting-events routing * refactor: split lark-vc-agent from lark-vc * docs: drop nonexistent workflow skill reference and fix identity * docs: fix cross-links in lark-vc-agent references after split * fix(vc): send meeting join password at top level * docs: rewrite lark-vc-agent description in user-facing language * docs: tighten lark-vc-agent description to descriptive neutral tone * fix: use Chinese quotes in vc/vc-agent description YAML frontmatter * docs: downgrade dry-run from mandatory to optional for vc-agent writes * docs: clarify pretty vs json format choice by processing depth * docs: systematic review of lark-vc-agent SKILL for clarity and precision * feat(vc): print meeting event page token in pretty output * docs(skill): refine vc agent meeting guidance * revert: restore CRITICAL banner in lark-vc-agent to match repo convention * docs: replace inaccurate no-replay warning with real social-cost risk * docs: tighten meeting-join risk warning to single sentence * docs: tighten vc-agent references - remove redundancy and fix vague wording * Revert "docs: tighten vc-agent references - remove redundancy and fix vague wording" This reverts commit 9845fc40622c65b0811da1c9ae4902434377f33e. * docs(skill): refine vc meeting events paging guidance * fix(vc): keep meeting event count aligned with events list * docs(skill): tighten vc agent meeting events workflow * refactor(vc): simplify meeting events pagination * docs(skill): tighten vc agent meeting guidance * docs(skill): require reading shared docs for meeting summaries * chore(env): switch default feishu endpoints to pre * fix(env): use feishu accounts host * docs(vc): use explicit date in recording example * revert(env): remove default ppe request header * chore(env): switch default feishu endpoints to pre * docs(skill): guide users to early-bird group on agent meeting gray miss Teach the lark-vc-agent skill to recognize OAPI's new gray-miss signal for the three agent meeting commands (`+meeting-join`, `+meeting-leave`, `+meeting-events`) and route the user to the early-bird group instead of treating it as a permission error. When CLI stderr JSON returns `error.code=20017 / ErrNotInGray`, the agent renders the fixed early-bird invite link `https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`. The user manual is intentionally not surfaced yet. Scope-related errors still follow the existing `auth login --scope` flow with no early-bird copy mixed in. lark-shared and other skills are not touched, so the guidance stays scoped to the agent meeting commands only. * chore(env): switch endpoints to boe for agent meeting gray testing * chore(vc-agent): update gray guide and boe endpoints * docs(vc-agent): refine gray guidance flow * docs(vc-agent): centralize gray guidance * fix(ci): stabilize vc output and skill frontmatter * fix(vc): address review feedback --------- Co-authored-by: zhaolei.vc <zhaolei.vc@bytedance.com> Co-authored-by: renaocheng <renaocheng@bytedance.com>
932 lines
27 KiB
Go
932 lines
27 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package vc
|
||
|
||
import (
|
||
"context"
|
||
"reflect"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/spf13/cobra"
|
||
|
||
"github.com/larksuite/cli/internal/cmdutil"
|
||
"github.com/larksuite/cli/internal/httpmock"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||
)
|
||
|
||
func newMeetingEventsRuntime() *common.RuntimeContext {
|
||
cmd := &cobra.Command{Use: "test"}
|
||
cmd.Flags().String("meeting-id", "", "")
|
||
cmd.Flags().String("start", "", "")
|
||
cmd.Flags().String("end", "", "")
|
||
cmd.Flags().String("page-token", "", "")
|
||
cmd.Flags().String("page-size", "", "")
|
||
cmd.Flags().Bool("page-all", false, "")
|
||
return common.TestNewRuntimeContext(cmd, defaultConfig())
|
||
}
|
||
|
||
func mustSetMeetingEventsFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
|
||
t.Helper()
|
||
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
|
||
t.Fatalf("Flags().Set(%q, %q) error = %v", name, value, err)
|
||
}
|
||
}
|
||
|
||
func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *httpmock.Stub {
|
||
return &httpmock.Stub{
|
||
Method: "GET",
|
||
URL: vcMeetingEventsAPIPath,
|
||
Body: map[string]interface{}{
|
||
"code": 0,
|
||
"msg": "ok",
|
||
"data": map[string]interface{}{
|
||
"total": len(events),
|
||
"has_more": hasMore,
|
||
"page_token": pageToken,
|
||
"events": events,
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func participantJoinedEvent() map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"event_id": "event-1",
|
||
"event_type": "participant_joined",
|
||
"event_time": "2026-04-17T08:00:00Z",
|
||
"payload": map[string]interface{}{
|
||
"activity_event_type": "participant_joined",
|
||
"meeting": map[string]interface{}{
|
||
"id": "7628568141510692381",
|
||
"topic": "项目例会",
|
||
"meeting_no": "724939760",
|
||
"start_time": "1776407700",
|
||
"end_time": "1776411300",
|
||
},
|
||
"participant_joined_items": []interface{}{
|
||
map[string]interface{}{
|
||
"participant": map[string]interface{}{
|
||
"id": "bot_001",
|
||
"user_name": "Demo Bot",
|
||
},
|
||
"join_time": "2026-04-17T08:00:00Z",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func participantJoinedEventOngoing() map[string]interface{} {
|
||
event := participantJoinedEvent()
|
||
payload := common.GetMap(event, "payload")
|
||
meeting := common.GetMap(payload, "meeting")
|
||
meeting["start_time"] = "1776410100"
|
||
meeting["end_time"] = "1776410100"
|
||
return event
|
||
}
|
||
|
||
func chatReceivedEvent() map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"event_id": "event-2",
|
||
"event_type": "chat_received",
|
||
"event_time": "2026-04-17T08:05:00Z",
|
||
"payload": map[string]interface{}{
|
||
"activity_event_type": "chat_received",
|
||
"meeting": map[string]interface{}{
|
||
"id": "7628568141510692381",
|
||
"topic": "项目例会",
|
||
"meeting_no": "724939760",
|
||
"start_time": "1776407700",
|
||
"end_time": "1776411300",
|
||
},
|
||
"participant_joined_items": []interface{}{},
|
||
"participant_left_items": []interface{}{},
|
||
"transcript_received_items": []interface{}{},
|
||
"magic_share_started_items": []interface{}{},
|
||
"magic_share_ended_items": []interface{}{},
|
||
"chat_received_items": []interface{}{
|
||
map[string]interface{}{
|
||
"content": "hello",
|
||
"message_type": 3,
|
||
"operator": map[string]interface{}{
|
||
"id": "u1",
|
||
"user_name": "Alice",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func multiChatReceivedEvent() map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"event_id": "event-3",
|
||
"event_type": "chat_received",
|
||
"event_time": "2026-04-17T08:06:00Z",
|
||
"payload": map[string]interface{}{
|
||
"activity_event_type": "chat_received",
|
||
"meeting": map[string]interface{}{
|
||
"id": "7628568141510692381",
|
||
"topic": "项目例会",
|
||
"meeting_no": "724939760",
|
||
"start_time": "1776407700",
|
||
"end_time": "1776411300",
|
||
},
|
||
"chat_received_items": []interface{}{
|
||
map[string]interface{}{
|
||
"content": "第一条\n第二行",
|
||
"message_type": 3,
|
||
"send_time": "1776408061000",
|
||
"operator": map[string]interface{}{
|
||
"id": "u1",
|
||
"user_name": "Alice",
|
||
},
|
||
},
|
||
map[string]interface{}{
|
||
"content": "第二条",
|
||
"message_type": 3,
|
||
"send_time": "1776408062000",
|
||
"operator": map[string]interface{}{
|
||
"id": "u1",
|
||
"user_name": "Alice",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func magicShareStartedEvent() map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"event_id": "event-4",
|
||
"event_type": "magic_share_started",
|
||
"event_time": "2026-04-17T08:07:00Z",
|
||
"payload": map[string]interface{}{
|
||
"activity_event_type": "magic_share_started",
|
||
"meeting": map[string]interface{}{
|
||
"id": "7628568141510692381",
|
||
"topic": "项目例会",
|
||
"meeting_no": "724939760",
|
||
"start_time": "1776407700",
|
||
"end_time": "1776411300",
|
||
},
|
||
"magic_share_started_items": []interface{}{
|
||
map[string]interface{}{
|
||
"time": "1776408123000",
|
||
"operator": map[string]interface{}{
|
||
"id": "u2",
|
||
"user_name": "Bob",
|
||
},
|
||
"share_doc": map[string]interface{}{
|
||
"title": "共享文档",
|
||
"url": "https://example.com/doc",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func TestChatReceivedSummary_MultipleItems(t *testing.T) {
|
||
payload := map[string]interface{}{
|
||
"chat_received_items": []interface{}{
|
||
map[string]interface{}{"content": "hello"},
|
||
map[string]interface{}{"content": "world"},
|
||
},
|
||
}
|
||
|
||
got := chatReceivedSummary(payload)
|
||
if got != "2 messages" {
|
||
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "2 messages")
|
||
}
|
||
}
|
||
|
||
func TestChatReceivedSummary_MultipleItemsSameOperator(t *testing.T) {
|
||
payload := map[string]interface{}{
|
||
"chat_received_items": []interface{}{
|
||
map[string]interface{}{"content": "hello", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
|
||
map[string]interface{}{"content": "world", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
|
||
},
|
||
}
|
||
|
||
got := chatReceivedSummary(payload)
|
||
if got != "2 messages by Alice" {
|
||
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "2 messages by Alice")
|
||
}
|
||
}
|
||
|
||
func TestChatReceivedSummary_MultipleItemsMultipleOperators(t *testing.T) {
|
||
payload := map[string]interface{}{
|
||
"chat_received_items": []interface{}{
|
||
map[string]interface{}{"content": "hello", "operator": map[string]interface{}{"id": "u1", "user_name": "Alice"}},
|
||
map[string]interface{}{"content": "world", "operator": map[string]interface{}{"id": "u2", "user_name": "Bob"}},
|
||
map[string]interface{}{"content": "again", "operator": map[string]interface{}{"id": "u3", "user_name": "Carol"}},
|
||
},
|
||
}
|
||
|
||
got := chatReceivedSummary(payload)
|
||
if got != "3 messages by 3 users" {
|
||
t.Fatalf("chatReceivedSummary() = %q, want %q", got, "3 messages by 3 users")
|
||
}
|
||
}
|
||
|
||
func TestParticipantJoinedSummary_MultipleItems(t *testing.T) {
|
||
payload := map[string]interface{}{
|
||
"participant_joined_items": []interface{}{
|
||
map[string]interface{}{"participant": map[string]interface{}{"id": "u1", "user_name": "User 1"}},
|
||
map[string]interface{}{"participant": map[string]interface{}{"id": "u2", "user_name": "User 2"}},
|
||
},
|
||
}
|
||
|
||
got := participantJoinedSummary(payload)
|
||
if got != "2 participants joined" {
|
||
t.Fatalf("participantJoinedSummary() = %q, want %q", got, "2 participants joined")
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_Validation_InvalidMeetingID(t *testing.T) {
|
||
runtime := newMeetingEventsRuntime()
|
||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "not-a-number")
|
||
|
||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||
if err == nil {
|
||
t.Fatal("expected validation error for invalid meeting ID")
|
||
}
|
||
if !strings.Contains(err.Error(), "positive integer") {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_Validation_InvalidTimeRange(t *testing.T) {
|
||
runtime := newMeetingEventsRuntime()
|
||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||
mustSetMeetingEventsFlag(t, runtime, "start", "200")
|
||
mustSetMeetingEventsFlag(t, runtime, "end", "100")
|
||
|
||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||
if err == nil {
|
||
t.Fatal("expected validation error for invalid time range")
|
||
}
|
||
if !strings.Contains(err.Error(), "after --end") {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_Validation_PageSizeBelowMinDoesNotError(t *testing.T) {
|
||
runtime := newMeetingEventsRuntime()
|
||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
|
||
|
||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||
if err != nil {
|
||
t.Fatalf("expected no validation error for page-size clamp, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_Validation_PageAllIgnoresInvalidPageSize(t *testing.T) {
|
||
runtime := newMeetingEventsRuntime()
|
||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||
mustSetMeetingEventsFlag(t, runtime, "page-all", "true")
|
||
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
|
||
|
||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||
if err != nil {
|
||
t.Fatalf("expected no validation error when page-all ignores page-size, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_Validation_InvalidPageSizeReturnsFlagError(t *testing.T) {
|
||
runtime := newMeetingEventsRuntime()
|
||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||
mustSetMeetingEventsFlag(t, runtime, "page-size", "foo")
|
||
|
||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||
if err == nil {
|
||
t.Fatal("expected validation error for non-integer page-size")
|
||
}
|
||
if !strings.Contains(err.Error(), "invalid --page-size") {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestBuildMeetingEventsParams(t *testing.T) {
|
||
runtime := newMeetingEventsRuntime()
|
||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||
mustSetMeetingEventsFlag(t, runtime, "page-size", "40")
|
||
mustSetMeetingEventsFlag(t, runtime, "page-token", "1710000000000000000")
|
||
|
||
params, err := buildMeetingEventsParams(runtime, "1710000000", "1710003600")
|
||
if err != nil {
|
||
t.Fatalf("buildMeetingEventsParams() error = %v", err)
|
||
}
|
||
if got := params["meeting_id"][0]; got != "7628568141510692381" {
|
||
t.Fatalf("meeting_id = %q, want %q", got, "7628568141510692381")
|
||
}
|
||
if got := params["page_size"][0]; got != "40" {
|
||
t.Fatalf("page_size = %q, want %q", got, "40")
|
||
}
|
||
if got := params["page_token"][0]; got != "1710000000000000000" {
|
||
t.Fatalf("page_token = %q, want %q", got, "1710000000000000000")
|
||
}
|
||
if got := params["start_time"][0]; got != "1710000000" {
|
||
t.Fatalf("start_time = %q, want %q", got, "1710000000")
|
||
}
|
||
if got := params["end_time"][0]; got != "1710003600" {
|
||
t.Fatalf("end_time = %q, want %q", got, "1710003600")
|
||
}
|
||
}
|
||
|
||
func TestBuildMeetingEventsParams_PageSizeBelowMinClampsToMin(t *testing.T) {
|
||
runtime := newMeetingEventsRuntime()
|
||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||
mustSetMeetingEventsFlag(t, runtime, "page-size", "10")
|
||
|
||
params, err := buildMeetingEventsParams(runtime, "", "")
|
||
if err != nil {
|
||
t.Fatalf("buildMeetingEventsParams() error = %v", err)
|
||
}
|
||
if got := params["page_size"][0]; got != "20" {
|
||
t.Fatalf("page_size = %q, want %q when below min", got, "20")
|
||
}
|
||
}
|
||
|
||
func TestBuildMeetingEventsParams_PageSizeAboveMaxClampsToMax(t *testing.T) {
|
||
runtime := newMeetingEventsRuntime()
|
||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||
mustSetMeetingEventsFlag(t, runtime, "page-size", "999")
|
||
|
||
params, err := buildMeetingEventsParams(runtime, "", "")
|
||
if err != nil {
|
||
t.Fatalf("buildMeetingEventsParams() error = %v", err)
|
||
}
|
||
if got := params["page_size"][0]; got != "100" {
|
||
t.Fatalf("page_size = %q, want %q when above max", got, "100")
|
||
}
|
||
}
|
||
|
||
func TestBuildMeetingEventsParams_PageAllUsesMaxPageSize(t *testing.T) {
|
||
runtime := newMeetingEventsRuntime()
|
||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||
mustSetMeetingEventsFlag(t, runtime, "page-all", "true")
|
||
mustSetMeetingEventsFlag(t, runtime, "page-size", "50")
|
||
|
||
params, err := buildMeetingEventsParams(runtime, "", "")
|
||
if err != nil {
|
||
t.Fatalf("buildMeetingEventsParams() error = %v", err)
|
||
}
|
||
if got := params["page_size"][0]; got != "100" {
|
||
t.Fatalf("page_size = %q, want %q when page-all is set", got, "100")
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_DryRun(t *testing.T) {
|
||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||
"+meeting-events",
|
||
"--meeting-id", "7628568141510692381",
|
||
"--page-token", "1710000000000000000",
|
||
"--page-size", "40",
|
||
"--start", "1710000000",
|
||
"--end", "1710003600",
|
||
"--dry-run",
|
||
"--as", "user",
|
||
}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
|
||
out := stdout.String()
|
||
for _, want := range []string{
|
||
vcMeetingEventsAPIPath,
|
||
`"meeting_id": "7628568141510692381"`,
|
||
`"page_token": "1710000000000000000"`,
|
||
`"page_size": "40"`,
|
||
`"start_time": "1710000000"`,
|
||
`"end_time": "1710003600"`,
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Fatalf("dry-run output missing %q: %s", want, out)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) {
|
||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||
"+meeting-events",
|
||
"--meeting-id", "7628568141510692381",
|
||
"--page-all",
|
||
"--dry-run",
|
||
"--as", "user",
|
||
}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
|
||
if !strings.Contains(stdout.String(), "Auto-paginates through all available pages") {
|
||
t.Fatalf("dry-run output missing auto-pagination description: %s", stdout.String())
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2"))
|
||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||
|
||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||
"+meeting-events",
|
||
"--meeting-id", "7628568141510692381",
|
||
"--format", "json",
|
||
"--page-all",
|
||
"--as", "user",
|
||
}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
reg.Verify(t)
|
||
|
||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||
out = strings.ReplaceAll(out, "\n", "")
|
||
if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 {
|
||
t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String())
|
||
}
|
||
if !strings.Contains(out, `"has_more":false`) {
|
||
t.Fatalf("expected final has_more=false: %s", stdout.String())
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
|
||
|
||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||
"+meeting-events",
|
||
"--meeting-id", "7628568141510692381",
|
||
"--format", "json",
|
||
"--as", "user",
|
||
}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
reg.Verify(t)
|
||
|
||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||
out = strings.ReplaceAll(out, "\n", "")
|
||
for _, want := range []string{
|
||
`"event_type":"participant_joined"`,
|
||
`"has_more":true`,
|
||
`"page_token":"1710000000000000000"`,
|
||
`"events":[`,
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
|
||
|
||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||
"+meeting-events",
|
||
"--meeting-id", "7628568141510692381",
|
||
"--format", "json",
|
||
"--as", "user",
|
||
}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
reg.Verify(t)
|
||
|
||
out := stdout.String()
|
||
for _, unwanted := range []string{
|
||
`"participant_joined_items": []`,
|
||
`"participant_left_items": []`,
|
||
`"transcript_received_items": []`,
|
||
`"magic_share_started_items": []`,
|
||
`"magic_share_ended_items": []`,
|
||
} {
|
||
if strings.Contains(out, unwanted) {
|
||
t.Fatalf("json output should not contain %q: %s", unwanted, out)
|
||
}
|
||
}
|
||
if !strings.Contains(out, `"message_type": 3`) {
|
||
t.Fatalf("json output should keep numeric fields: %s", out)
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
|
||
|
||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||
"+meeting-events",
|
||
"--meeting-id", "7628568141510692381",
|
||
"--format", "pretty",
|
||
"--as", "user",
|
||
}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
reg.Verify(t)
|
||
|
||
out := stdout.String()
|
||
for _, want := range []string{
|
||
"会议主题:项目例会",
|
||
"会议时间:2026-04-17 15:15:00(进行中)",
|
||
"Demo Bot(bot_001) 加入了会议",
|
||
"Alice(u1): [reaction] 第一条\\n第二行",
|
||
"Alice(u1): [reaction] 第二条",
|
||
"Bob(u2) 开始共享「共享文档」",
|
||
"URL: https://example.com/doc",
|
||
"page_token: 1710000000000000000",
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Fatalf("pretty output missing %q: %s", want, out)
|
||
}
|
||
}
|
||
if strings.Contains(out, "第二条\n\n[") {
|
||
t.Fatalf("pretty output should not insert blank lines between event entries: %s", out)
|
||
}
|
||
if !strings.Contains(out, "第二条\n[") {
|
||
t.Fatalf("pretty output should keep event entries contiguous: %s", out)
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
|
||
|
||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||
"+meeting-events",
|
||
"--meeting-id", "7628568141510692381",
|
||
"--format", "pretty",
|
||
"--as", "user",
|
||
}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
reg.Verify(t)
|
||
|
||
out := stdout.String()
|
||
if !strings.Contains(out, "page_token: pt_last") {
|
||
t.Fatalf("pretty output should print page_token even when has_more is false: %s", out)
|
||
}
|
||
if strings.Contains(out, "more available") {
|
||
t.Fatalf("pretty output should not print more-available hint when has_more is false: %s", out)
|
||
}
|
||
}
|
||
|
||
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
|
||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||
reg.Register(meetingEventsStub(nil, false, ""))
|
||
|
||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||
"+meeting-events",
|
||
"--meeting-id", "7628568141510692381",
|
||
"--format", "pretty",
|
||
"--as", "user",
|
||
}, f, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
reg.Verify(t)
|
||
|
||
if !strings.Contains(stdout.String(), "No meeting events.") {
|
||
t.Fatalf("unexpected output: %s", stdout.String())
|
||
}
|
||
}
|
||
|
||
func TestParseFlexibleTime(t *testing.T) {
|
||
t.Run("unix seconds", func(t *testing.T) {
|
||
got, ok := parseFlexibleTime("1776410100")
|
||
if !ok {
|
||
t.Fatal("parseFlexibleTime() ok = false, want true")
|
||
}
|
||
if want := time.Unix(1776410100, 0); !got.Equal(want) {
|
||
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
|
||
}
|
||
})
|
||
|
||
t.Run("unix millis", func(t *testing.T) {
|
||
got, ok := parseFlexibleTime("1776408061000")
|
||
if !ok {
|
||
t.Fatal("parseFlexibleTime() ok = false, want true")
|
||
}
|
||
if want := time.UnixMilli(1776408061000); !got.Equal(want) {
|
||
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
|
||
}
|
||
})
|
||
|
||
t.Run("rfc3339", func(t *testing.T) {
|
||
got, ok := parseFlexibleTime("2026-04-17T08:00:00Z")
|
||
if !ok {
|
||
t.Fatal("parseFlexibleTime() ok = false, want true")
|
||
}
|
||
if want, _ := time.Parse(time.RFC3339, "2026-04-17T08:00:00Z"); !got.Equal(want) {
|
||
t.Fatalf("parseFlexibleTime() = %v, want %v", got, want)
|
||
}
|
||
})
|
||
|
||
t.Run("invalid", func(t *testing.T) {
|
||
if _, ok := parseFlexibleTime("not-a-time"); ok {
|
||
t.Fatal("parseFlexibleTime() ok = true, want false")
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestFormatMeetingWindow(t *testing.T) {
|
||
start := time.Unix(1776410100, 0)
|
||
end := time.Unix(1776413700, 0)
|
||
|
||
tests := []struct {
|
||
name string
|
||
start time.Time
|
||
hasStart bool
|
||
end time.Time
|
||
hasEnd bool
|
||
want string
|
||
}{
|
||
{
|
||
name: "ongoing",
|
||
start: start,
|
||
hasStart: true,
|
||
end: start,
|
||
hasEnd: true,
|
||
want: "2026-04-17 15:15:00(进行中)",
|
||
},
|
||
{
|
||
name: "finished range",
|
||
start: start,
|
||
hasStart: true,
|
||
end: end,
|
||
hasEnd: true,
|
||
want: "2026-04-17 15:15:00 - 2026-04-17 16:15:00",
|
||
},
|
||
{
|
||
name: "only start",
|
||
start: start,
|
||
hasStart: true,
|
||
want: "2026-04-17 15:15:00",
|
||
},
|
||
{
|
||
name: "only end",
|
||
end: end,
|
||
hasEnd: true,
|
||
want: "2026-04-17 16:15:00",
|
||
},
|
||
{
|
||
name: "empty",
|
||
want: "",
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := formatMeetingWindow(tt.start, tt.hasStart, tt.end, tt.hasEnd); got != tt.want {
|
||
t.Fatalf("formatMeetingWindow() = %q, want %q", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestFormatTimelineOffset(t *testing.T) {
|
||
start := time.Unix(1776410100, 0)
|
||
later := start.Add(90 * time.Second)
|
||
earlier := start.Add(-5 * time.Minute)
|
||
|
||
tests := []struct {
|
||
name string
|
||
when time.Time
|
||
hasWhen bool
|
||
meetingStart time.Time
|
||
hasMeetingStart bool
|
||
want string
|
||
}{
|
||
{
|
||
name: "with meeting start",
|
||
when: later,
|
||
hasWhen: true,
|
||
meetingStart: start,
|
||
hasMeetingStart: true,
|
||
want: "00:01:30",
|
||
},
|
||
{
|
||
name: "negative diff clamps to zero",
|
||
when: earlier,
|
||
hasWhen: true,
|
||
meetingStart: start,
|
||
hasMeetingStart: true,
|
||
want: "00:00:00",
|
||
},
|
||
{
|
||
name: "without meeting start uses wall clock",
|
||
when: later,
|
||
hasWhen: true,
|
||
want: "15:16:30",
|
||
},
|
||
{
|
||
name: "missing when",
|
||
want: "??:??:??",
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := formatTimelineOffset(tt.when, tt.hasWhen, tt.meetingStart, tt.hasMeetingStart); got != tt.want {
|
||
t.Fatalf("formatTimelineOffset() = %q, want %q", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestFlattenQueryParams(t *testing.T) {
|
||
params := larkcore.QueryParams{
|
||
"one": []string{"1"},
|
||
"many": []string{"2", "3"},
|
||
"empty": []string{},
|
||
}
|
||
|
||
got := flattenQueryParams(params)
|
||
want := map[string]interface{}{
|
||
"one": "1",
|
||
"many": []string{"2", "3"},
|
||
}
|
||
if !reflect.DeepEqual(got, want) {
|
||
t.Fatalf("flattenQueryParams() = %#v, want %#v", got, want)
|
||
}
|
||
}
|
||
|
||
func TestCompactMeetingPayload_DropsOnlyEmptySlices(t *testing.T) {
|
||
got := compactMeetingPayload(map[string]interface{}{
|
||
"empty_items": []interface{}{},
|
||
"items": []interface{}{"x"},
|
||
"zero": 0,
|
||
"text": "ok",
|
||
})
|
||
|
||
if _, ok := got["empty_items"]; ok {
|
||
t.Fatalf("compactMeetingPayload() should drop empty_items: %#v", got)
|
||
}
|
||
if !reflect.DeepEqual(got["items"], []interface{}{"x"}) {
|
||
t.Fatalf("compactMeetingPayload() items = %#v, want %#v", got["items"], []interface{}{"x"})
|
||
}
|
||
if got["zero"] != 0 || got["text"] != "ok" {
|
||
t.Fatalf("compactMeetingPayload() preserved fields mismatch: %#v", got)
|
||
}
|
||
}
|
||
|
||
func TestCompactMeetingEvents_IgnoresNonMapsAndCompactsPayload(t *testing.T) {
|
||
got := compactMeetingEvents([]interface{}{
|
||
"skip-me",
|
||
map[string]interface{}{
|
||
"event_type": "chat_received",
|
||
"payload": map[string]interface{}{
|
||
"chat_received_items": []interface{}{"x"},
|
||
"empty_items": []interface{}{},
|
||
},
|
||
},
|
||
})
|
||
|
||
if len(got) != 1 {
|
||
t.Fatalf("len(compactMeetingEvents()) = %d, want 1", len(got))
|
||
}
|
||
event, _ := got[0].(map[string]interface{})
|
||
payload := common.GetMap(event, "payload")
|
||
if _, ok := payload["empty_items"]; ok {
|
||
t.Fatalf("compactMeetingEvents() should prune empty payload slices: %#v", payload)
|
||
}
|
||
}
|
||
|
||
func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
|
||
got := Shortcuts()
|
||
var commands []string
|
||
for _, shortcut := range got {
|
||
commands = append(commands, shortcut.Command)
|
||
}
|
||
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-events"}
|
||
if !reflect.DeepEqual(commands, want) {
|
||
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
|
||
}
|
||
}
|
||
|
||
func TestLeaveAction(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
item map[string]interface{}
|
||
want string
|
||
}{
|
||
{name: "meeting ended", item: map[string]interface{}{"leave_reason": 2}, want: "因会议结束离开了会议"},
|
||
{name: "kicked", item: map[string]interface{}{"leave_reason": 3}, want: "被移出了会议"},
|
||
{name: "default", item: map[string]interface{}{"leave_reason": 1}, want: "离开了会议"},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := leaveAction(tt.item); got != tt.want {
|
||
t.Fatalf("leaveAction() = %q, want %q", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestMeetingEventUserWithID(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
user map[string]interface{}
|
||
want string
|
||
}{
|
||
{name: "nil", want: ""},
|
||
{name: "name and id", user: map[string]interface{}{"user_name": "Alice", "id": "u1"}, want: "Alice(u1)"},
|
||
{name: "name only", user: map[string]interface{}{"user_name": "Alice"}, want: "Alice"},
|
||
{name: "id only", user: map[string]interface{}{"id": "u1"}, want: "u1"},
|
||
{name: "empty", user: map[string]interface{}{}, want: ""},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := meetingEventUserWithID(tt.user); got != tt.want {
|
||
t.Fatalf("meetingEventUserWithID() = %q, want %q", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestMeetingEventSummary(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
event map[string]interface{}
|
||
want string
|
||
}{
|
||
{
|
||
name: "participant joined count",
|
||
event: map[string]interface{}{
|
||
"event_type": "participant_joined",
|
||
"payload": map[string]interface{}{
|
||
"participant_joined_items": []interface{}{
|
||
map[string]interface{}{},
|
||
map[string]interface{}{},
|
||
},
|
||
},
|
||
},
|
||
want: "2 participants joined",
|
||
},
|
||
{
|
||
name: "participant left with label",
|
||
event: map[string]interface{}{
|
||
"event_type": "participant_left",
|
||
"payload": map[string]interface{}{
|
||
"participant_left_items": []interface{}{
|
||
map[string]interface{}{"participant": map[string]interface{}{"user_name": "Bob", "id": "u2"}},
|
||
},
|
||
},
|
||
},
|
||
want: "participant u2 (Bob) left",
|
||
},
|
||
{
|
||
name: "fallback unknown event",
|
||
event: map[string]interface{}{
|
||
"event_type": "mystery_event",
|
||
"payload": map[string]interface{}{},
|
||
},
|
||
want: "mystery_event",
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := meetingEventSummary(tt.event); got != tt.want {
|
||
t.Fatalf("meetingEventSummary() = %q, want %q", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestEscapePrettyText(t *testing.T) {
|
||
got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07)))
|
||
want := `line1\nline2\t\r\u0007`
|
||
if got != want {
|
||
t.Fatalf("escapePrettyText() = %q, want %q", got, want)
|
||
}
|
||
}
|
||
|
||
func TestNeedsColon(t *testing.T) {
|
||
tests := []struct {
|
||
description string
|
||
want bool
|
||
}{
|
||
{description: "发送了消息", want: false},
|
||
{description: "加入了会议", want: false},
|
||
{description: "离开了会议", want: false},
|
||
{description: "开始共享「文档」", want: false},
|
||
{description: "[text] hello", want: true},
|
||
}
|
||
for _, tt := range tests {
|
||
if got := needsColon(tt.description); got != tt.want {
|
||
t.Fatalf("needsColon(%q) = %v, want %v", tt.description, got, tt.want)
|
||
}
|
||
}
|
||
}
|