Compare commits

...

1 Commits

Author SHA1 Message Date
houzhicong
8470e92427 feat: add active meeting list shortcut 2026-06-09 19:28:34 +08:00
6 changed files with 337 additions and 0 deletions

View File

@@ -14,5 +14,6 @@ func Shortcuts() []common.Shortcut {
VCMeetingJoin,
VCMeetingLeave,
VCMeetingEvents,
VCMeetingListActive,
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const vcActiveMeetingsAPIPath = "/open-apis/vc/v1/bots/active-meetings"
// VCMeetingListActive lists the caller's ongoing meetings.
// As user (UAT): returns the user's own ongoing meetings.
// As bot (TAT): returns the target user's ongoing meetings that the bot is also in.
var VCMeetingListActive = common.Shortcut{
Service: "vc",
Command: "+meeting-list-active",
Description: "List ongoing meetings for the current identity (UAT: self; TAT: target user with bot in meeting)",
Risk: "read",
Scopes: []string{"vc:meeting.active:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "user-id", Desc: "target user open_id (ou_...), required when --as bot"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
userID := strings.TrimSpace(runtime.Str("user-id"))
if runtime.IsBot() {
if userID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id is required when --as bot").WithParam("--user-id")
}
if !strings.HasPrefix(userID, "ou_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id must be an open_id (ou_...), got %q", userID).WithParam("--user-id")
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dryRun := common.NewDryRunAPI().GET(vcActiveMeetingsAPIPath)
if params := buildActiveMeetingsParams(runtime); len(params) > 0 {
dryRun.Params(params)
}
return dryRun
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params := buildActiveMeetingsParams(runtime)
data, err := runtime.CallAPITyped(http.MethodGet, vcActiveMeetingsAPIPath, params, nil)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
meetings := common.GetSlice(data, "meetings")
runtime.OutFormat(data, &output.Meta{Count: len(meetings)}, func(w io.Writer) {
if len(meetings) == 0 {
fmt.Fprintln(w, "No ongoing meetings.")
return
}
for _, raw := range meetings {
m, _ := raw.(map[string]interface{})
if m == nil {
continue
}
title := common.GetString(m, "meeting_title")
if title == "" {
title = "(no title)"
}
fmt.Fprintf(w, "%s\n", title)
if id := common.GetString(m, "meeting_id"); id != "" {
fmt.Fprintf(w, " Meeting ID: %s\n", id)
}
if no := common.GetString(m, "meeting_no"); no != "" {
fmt.Fprintf(w, " Meeting No: %s\n", no)
}
}
})
return nil
},
}
func buildActiveMeetingsParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if runtime.IsBot() {
if userID := strings.TrimSpace(runtime.Str("user-id")); userID != "" {
params["user_id"] = userID
}
}
return params
}

View File

@@ -0,0 +1,175 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// Unit tests: validation + param assembly (UAT/TAT identity split)
// ---------------------------------------------------------------------------
func newActiveMeetingsCmd() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("user-id", "", "")
return cmd
}
func TestMeetingListActive_Validate_BotRequiresUserID(t *testing.T) {
cmd := newActiveMeetingsCmd()
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
if err := VCMeetingListActive.Validate(context.Background(), runtime); err == nil {
t.Fatal("expected error: --user-id required when --as bot")
}
}
func TestMeetingListActive_Validate_BotRejectsNonOpenID(t *testing.T) {
cmd := newActiveMeetingsCmd()
_ = cmd.Flags().Set("user-id", "123456")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
if err := VCMeetingListActive.Validate(context.Background(), runtime); err == nil {
t.Fatal("expected error: --user-id must be an open_id")
}
}
func TestMeetingListActive_Validate_UserNoUserID_OK(t *testing.T) {
cmd := newActiveMeetingsCmd()
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsUser)
if err := VCMeetingListActive.Validate(context.Background(), runtime); err != nil {
t.Fatalf("unexpected error for UAT without user-id: %v", err)
}
}
func TestBuildActiveMeetingsParams_UAT_OmitsUserID(t *testing.T) {
cmd := newActiveMeetingsCmd()
_ = cmd.Flags().Set("user-id", "ou_ignored")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsUser)
params := buildActiveMeetingsParams(runtime)
if _, exists := params["user_id"]; exists {
t.Errorf("UAT must not send user_id, got %v", params["user_id"])
}
}
func TestBuildActiveMeetingsParams_TAT_SendsUserID(t *testing.T) {
cmd := newActiveMeetingsCmd()
_ = cmd.Flags().Set("user-id", "ou_target")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
params := buildActiveMeetingsParams(runtime)
if params["user_id"] != "ou_target" {
t.Errorf("TAT user_id = %v, want ou_target", params["user_id"])
}
}
// ---------------------------------------------------------------------------
// Execute tests: final observable (no / single / multi meeting semantics)
// ---------------------------------------------------------------------------
func TestMeetingListActive_Execute_UAT_Multi(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/active-meetings",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"meetings": []interface{}{
map[string]interface{}{"meeting_no": "111", "meeting_id": "9001", "meeting_title": "Standup"},
map[string]interface{}{"meeting_no": "222", "meeting_id": "9002", "meeting_title": "Review"},
},
},
},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--as", "user", "--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse stdout: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 2 {
t.Fatalf("meetings count = %d, want 2 (envelope: %s)", len(meetings), stdout.String())
}
first, _ := meetings[0].(map[string]any)
if first["meeting_id"] != "9001" || first["meeting_title"] != "Standup" {
t.Errorf("unexpected first meeting: %v", first)
}
}
func TestMeetingListActive_Execute_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/active-meetings",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"meetings": []interface{}{}},
},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--as", "user", "--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse stdout: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 0 {
t.Errorf("meetings count = %d, want 0", len(meetings))
}
}
func TestMeetingListActive_Execute_TAT_SendsUserIDParam(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
var capturedUserID string
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/active-meetings",
OnMatch: func(req *http.Request) {
capturedUserID = req.URL.Query().Get("user_id")
},
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"meetings": []interface{}{
map[string]interface{}{"meeting_no": "333", "meeting_id": "9003", "meeting_title": "Bot Meeting"},
},
},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--as", "bot", "--user-id", "ou_target", "--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedUserID != "ou_target" {
t.Errorf("captured user_id query = %q, want ou_target", capturedUserID)
}
}

View File

@@ -34,6 +34,7 @@ metadata:
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| "帮我入会 123456789"、"代我参会"、"让机器人进会旁听" | **本 skill** `+meeting-join` |
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"**进行中会议**,且**机器人已入会** | **本 skill** `+meeting-events` |
| "我现在在哪些会里"、"这个用户正在开的会有哪些"、"机器人和某用户共同在哪些会"**当前在会列表** | **本 skill** `+meeting-list-active` |
| "退出会议"、"让机器人离开" | **本 skill** `+meeting-leave` |
| "昨天那场会有谁参加过"、"搜昨天的会"、"查纪要/逐字稿/录制" | [`lark-vc`](../lark-vc/SKILL.md) |
| "帮我参会,结束后把纪要发到群" 等跨阶段场景 | 按序编排:本 skill入会 → 读事件)→ 会议结束后用 [`lark-vc`](../lark-vc/SKILL.md) / [`lark-minutes`](../lark-minutes/SKILL.md) 拉纪要 → [`lark-im`](../lark-im/SKILL.md) 发群 |
@@ -97,10 +98,12 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
| --------------------------------------------------------------- | -- | -------------------------------------------------------------------------- |
| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number |
| [`+meeting-events`](references/lark-vc-agent-meeting-events.md) | 读 | List bot meeting events (participant joined/left, transcript, chat, share) |
| [`+meeting-list-active`](references/lark-vc-agent-meeting-list-active.md) | 读 | List ongoing meetings for current identity (UAT: self; TAT: target user + bot in meeting) |
| [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md) | 写 | Leave a meeting by meeting\_id |
- 使用 `+meeting-join` 前**必须**阅读 [references/lark-vc-agent-meeting-join.md](references/lark-vc-agent-meeting-join.md),了解入参格式与写操作可见性风险。
- 使用 `+meeting-events` 前**必须**阅读 [references/lark-vc-agent-meeting-events.md](references/lark-vc-agent-meeting-events.md),了解 `meeting_id` 来源、分页、错误码10005 / 20001 / 20002与 "bot 仍在会中" 硬约束。
- 使用 `+meeting-list-active` 前**必须**阅读 [references/lark-vc-agent-meeting-list-active.md](references/lark-vc-agent-meeting-list-active.md),了解 UAT/TAT 两种身份的语义差异:`--as user` 查自己当前在会列表(无需 `--user-id``--as bot` 查目标用户(`--user-id ou_...`)与 bot 共同在会的会议;返回 `meeting_no` / `meeting_id` / `meeting_title`,覆盖无/单/多会议语义,可把 `meeting_id` 直接喂给 `+meeting-events`
- 使用 `+meeting-leave` 前**必须**阅读 [references/lark-vc-agent-meeting-leave.md](references/lark-vc-agent-meeting-leave.md),了解 `meeting_id` 的来源与写操作可见性。
## 权限表
@@ -109,6 +112,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
| ----------------- | ------------------------------ |
| `+meeting-join` | `vc:meeting.bot.join:write` |
| `+meeting-events` | `vc:meeting.meetingevent:read` |
| `+meeting-list-active` | `vc:meeting.active:read` |
| `+meeting-leave` | `vc:meeting.bot.join:write` |
## 延伸

View File

@@ -0,0 +1,58 @@
# vc +meeting-list-active
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查询「当前正在进行中Ongoing的会议列表」。该命令是**读操作**,按发起方身份返回不同语义:
- **UAT`--as user`**:以**当前用户**身份,查询用户**自己**当前正在参加的会议列表。无需 `--user-id`
- **TAT`--as bot`**:以**应用/机器人**身份,按目标用户 `--user-id`open_id, `ou_...`)查询该用户当前在会列表,并**只保留 bot 也在其中**的会议(用户与 bot 的共同在会列表)。
返回每条会议的 `meeting_no` / `meeting_id` / `meeting_title`,覆盖**无 / 单 / 多** 会议三种语义。返回的 `meeting_id` 可直接喂给 [`+meeting-events`](lark-vc-agent-meeting-events.md)。
本 skill 对应 shortcut`lark-cli vc +meeting-list-active`(调用 `GET /open-apis/vc/v1/bots/active-meetings`)。
## 命令
```bash
# UAT查我自己当前在会列表
lark-cli vc +meeting-list-active --as user --format pretty
# TAT查目标用户与 bot 共同在会的会议
lark-cli vc +meeting-list-active --as bot --user-id ou_xxxxxxxxxxxxxxxx --format json
# 预览 API 调用(不实际请求)
lark-cli vc +meeting-list-active --as bot --user-id ou_xxxxxxxxxxxxxxxx --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--as <user\|bot>` | 否 | 发起身份。`user`=UAT查自己`bot`=TAT查目标用户与 bot 共同在会)。默认取配置的 default-as |
| `--user-id <open_id>` | TAT 必填 | 目标用户 open_id`ou_...`)。仅 `--as bot` 时使用且必填;`--as user` 时忽略 |
| `--format <fmt>` | 否 | 输出格式json (CLI 默认) / pretty本 skill 推荐) / table / ndjson / csv |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 核心约束
### 1. 数据为后台聚合级,非毫秒实时
在会列表来自 admin meeting_listOngoing数据源。它匹配「IM 聊天后定位用户当前在哪些会」这类场景,但不保证毫秒级实时;刚入会/刚离会可能有秒级延迟。
### 2. UAT 与 TAT 身份语义不同
- UAT 返回的是**用户自己**的在会列表,按操作者身份天然鉴权,无需管理员权限。
- TAT 在用户在会列表基础上**额外过滤**「bot 是否也在这场会中」,因此结果是用户与 bot 的**交集**。若 bot 未入任何会TAT 通常返回空列表。
### 3. 返回语义:无 / 单 / 多
- **无**`meetings` 为空数组——用户当前不在任何进行中会议TAT 下为无共同在会)。
- **单**:返回单条,可直接用其 `meeting_id`
- **多**:返回全部,由调用方提示用户选择。
## 权限
| Shortcut | 所需 scope |
|----------|-----------|
| `+meeting-list-active` | `vc:meeting.active:read` |

View File

@@ -135,6 +135,7 @@ lark-cli vc meeting get --params '{"meeting_id":"<meeting_id>","with_participant
| 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill |
| 已结束会议的发言内容 | `vc +notes``verbatim_doc_token``docs +fetch --api-version v2` | 本 skill |
| **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `vc +meeting-events` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
| **当前在会列表**(我正在哪些会 / 用户与 bot 共同在会)| `vc +meeting-list-active` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
| **Agent 真实入会 / 离会** | `vc +meeting-join` / `vc +meeting-leave` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
## 资源关系