mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce0b68dc0e | ||
|
|
cc16c4d2d7 | ||
|
|
1ee7f22ee5 | ||
|
|
b612dde19e | ||
|
|
4181174352 | ||
|
|
1180baac61 | ||
|
|
db1a3fc0a6 | ||
|
|
7c6abb3834 |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -2,6 +2,23 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.29] - 2026-05-12
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Add agent meeting join, leave, and events shortcuts (#824)
|
||||
- **mail**: Add unknown-flag fuzzy match for `lark-cli mail` commands (#806)
|
||||
- **whiteboard**: Pin `whiteboard-cli` to `v0.2.11` in `lark-whiteboard` skill (#850)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Silence misleading "skills not installed" startup notice (#801)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Refine data analysis SOP wording (#784, #849)
|
||||
- Update README capability descriptions (#793)
|
||||
|
||||
## [v1.0.28] - 2026-05-11
|
||||
|
||||
### Features
|
||||
@@ -659,6 +676,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
|
||||
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
|
||||
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27
|
||||
[v1.0.26]: https://github.com/larksuite/cli/releases/tag/v1.0.26
|
||||
|
||||
@@ -24,7 +24,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
|
||||
| Category | Capabilities |
|
||||
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
|
||||
| 📅 Calendar | View, create and update events, invite attendees, find meeting rooms, RSVP to invitations, check free/busy & time suggestions |
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
@@ -36,7 +36,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes artifacts and recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||
@@ -136,7 +136,7 @@ lark-cli auth status
|
||||
| Skill | Description |
|
||||
| ------------------------------- |----------------------------------------------------------------------------------------------------------------|
|
||||
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `lark-calendar` | Calendar events (create/update), agenda view, free/busy queries, time suggestions, room finding, RSVP replies |
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
@@ -151,7 +151,7 @@ lark-cli auth status
|
||||
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
|
||||
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
|
||||
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters); upload audio/video to create minutes, download media |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-attendance` | Query personal attendance check-in records |
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
| 类别 | 能力 |
|
||||
| ------------- |--------------------------------------------|
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 📅 日历 | 查看、创建和更新日程,邀请参会人、查找会议室、回复日程邀请、查询忙闲与时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
@@ -36,7 +36,7 @@
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要产物与会议录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
@@ -137,7 +137,7 @@ lark-cli auth status
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- |-------------------------------------------|
|
||||
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-calendar` | 日历日程(创建/更新)、议程查看、忙闲查询、时间建议、会议室查找、回复邀请 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
@@ -152,7 +152,7 @@ lark-cli auth status
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节),上传音视频生成妙记,下载音视频文件 |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-attendance` | 查询个人考勤打卡记录 |
|
||||
|
||||
@@ -504,10 +504,12 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart verifies that when no skills stamp exists,
|
||||
// the composed PendingNotice provider includes a "skills" key with an
|
||||
// empty Current and the cold-start message.
|
||||
func TestSetupNotices_ColdStart(t *testing.T) {
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
|
||||
// produces no skills key in the composed notice. Users who installed
|
||||
// skills via `npx skills add` (no stamp) must not see the misleading
|
||||
// "not installed" notice — only `lark-cli update` users opt into the
|
||||
// drift tracker.
|
||||
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -530,17 +532,10 @@ func TestSetupNotices_ColdStart(t *testing.T) {
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
t.Fatal("GetNotice() = nil, want non-nil for cold start")
|
||||
return // expected — no pending notices at all
|
||||
}
|
||||
skills, ok := notice["skills"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.skills missing, got %+v", notice)
|
||||
}
|
||||
if skills["current"] != "" || skills["target"] != "1.0.21" {
|
||||
t.Errorf("notice.skills = %+v, want {current:\"\", target:\"1.0.21\"}", skills)
|
||||
}
|
||||
if msg, _ := skills["message"].(string); msg != "lark-cli skills not installed, run: lark-cli update" {
|
||||
t.Errorf("notice.skills.message = %q, want cold-start message", msg)
|
||||
if _, ok := notice["skills"]; ok {
|
||||
t.Errorf("notice.skills present in cold-start state, want absent: %+v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
package skillscheck
|
||||
|
||||
// Init runs the synchronous skills version check. Stores a StaleNotice
|
||||
// when the local stamp does not match currentVersion. Safe to call
|
||||
// from cmd/root.go before rootCmd.Execute(); zero network, zero
|
||||
// subprocess — only a local stamp file read.
|
||||
// when the local stamp records a version that does not match
|
||||
// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute();
|
||||
// zero network, zero subprocess — only a local stamp file read.
|
||||
//
|
||||
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
|
||||
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
|
||||
@@ -15,10 +15,12 @@ package skillscheck
|
||||
// - shouldSkip rule met
|
||||
// - ReadStamp returns an I/O error other than ENOENT
|
||||
// - Stamp matches currentVersion (in-sync)
|
||||
// - Stamp is missing (cold start) — only users who ran `lark-cli update`
|
||||
// opt into drift tracking; npx-only installs are intentionally silent.
|
||||
func Init(currentVersion string) {
|
||||
// Clear any stale notice from a prior call so early returns below
|
||||
// (skip rules / read errors / in-sync) leave pending == nil instead
|
||||
// of preserving a stale value from a previous Init invocation.
|
||||
// (skip rules / read errors / cold start / in-sync) leave pending == nil
|
||||
// instead of preserving a stale value from a previous Init invocation.
|
||||
SetPending(nil)
|
||||
if shouldSkip(currentVersion) {
|
||||
return
|
||||
@@ -28,11 +30,19 @@ func Init(currentVersion string) {
|
||||
// Fail closed — don't nag for a transient FS problem.
|
||||
return
|
||||
}
|
||||
if stamp == "" {
|
||||
// Cold start: the stamp is written exclusively by `lark-cli update`
|
||||
// (runSkillsAndStamp). Users who installed skills via
|
||||
// `npx skills add larksuite/cli -g` have no stamp yet — they must
|
||||
// not be nagged with "skills not installed", since the on-disk
|
||||
// skills directory may already be fully populated.
|
||||
return
|
||||
}
|
||||
if stamp == currentVersion {
|
||||
return
|
||||
}
|
||||
SetPending(&StaleNotice{
|
||||
Current: stamp, // "" when never synced
|
||||
Current: stamp, // guaranteed non-empty under the new contract
|
||||
Target: currentVersion,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,17 +29,13 @@ func TestInit_InSync_NoNotice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_ColdStart_NoticeWithEmptyCurrent(t *testing.T) {
|
||||
func TestInit_ColdStart_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
Init("1.0.21")
|
||||
got := GetPending()
|
||||
if got == nil {
|
||||
t.Fatal("GetPending() = nil, want non-nil for cold start")
|
||||
}
|
||||
if got.Current != "" || got.Target != "1.0.21" {
|
||||
t.Errorf("notice = %+v, want {Current:\"\", Target:\"1.0.21\"}", got)
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (cold start is silent)", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,20 +15,20 @@ import (
|
||||
|
||||
// StaleNotice signals that the locally synced skills version does not
|
||||
// match the running binary. Current is the last successfully synced
|
||||
// version (or "" when never synced); Target is the running binary
|
||||
// version. Mirrors internal/update.UpdateInfo's pending-notice pattern.
|
||||
// version (always non-empty — Init no longer emits a notice on cold
|
||||
// start). Target is the running binary version. Mirrors
|
||||
// internal/update.UpdateInfo's pending-notice pattern.
|
||||
type StaleNotice struct {
|
||||
Current string `json:"current"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
// Message returns a single-line, AI-agent-parseable description of the
|
||||
// gap plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
|
||||
// in style ("..., run: lark-cli update" suffix).
|
||||
// drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
|
||||
// in style ("..., run: lark-cli update" suffix). Current is guaranteed
|
||||
// non-empty because Init only emits a StaleNotice for the drift case
|
||||
// (stamp present and != binary version).
|
||||
func (s *StaleNotice) Message() string {
|
||||
if s.Current == "" {
|
||||
return "lark-cli skills not installed, run: lark-cli update"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",
|
||||
s.Current, s.Target,
|
||||
|
||||
@@ -14,11 +14,6 @@ func TestStaleNotice_Message(t *testing.T) {
|
||||
n StaleNotice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"cold_start",
|
||||
StaleNotice{Current: "", Target: "1.0.21"},
|
||||
"lark-cli skills not installed, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
"drift",
|
||||
StaleNotice{Current: "1.0.20", Target: "1.0.21"},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.29",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
288
shortcuts/mail/flag_suggest.go
Normal file
288
shortcuts/mail/flag_suggest.go
Normal file
@@ -0,0 +1,288 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// flagName is a package-private snapshot of a pflag.Flag's identity.
|
||||
type flagName struct {
|
||||
long, short string
|
||||
hidden bool
|
||||
}
|
||||
|
||||
// Candidate is a single suggested flag returned to the user when an
|
||||
// unknown flag is detected. It is serialised into the ErrorEnvelope's
|
||||
// error.detail.candidates[] array.
|
||||
type Candidate struct {
|
||||
// Flag is the long-form spelling of the suggested flag, e.g. "--to".
|
||||
Flag string `json:"flag"`
|
||||
// Shorthand is the single-character shorthand (without the leading
|
||||
// dash) when the suggested flag has one; empty otherwise.
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
// Distance is the Levenshtein edit distance to the unknown token.
|
||||
// Zero indicates a bidirectional prefix hit (Reason == "prefix").
|
||||
Distance int `json:"distance"`
|
||||
// Reason explains how the candidate was matched: "prefix" for
|
||||
// bidirectional prefix hits, "edit_distance" for fuzzy matches.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// maxCandidates caps the number of suggestions returned per error so
|
||||
// the JSON envelope stays compact and the user-visible hint remains
|
||||
// scannable.
|
||||
const maxCandidates = 5
|
||||
|
||||
// InstallOnMail attaches the unknown-flag fuzzy-match hook on the mail
|
||||
// service cobra parent command. It is invoked exactly once from
|
||||
// shortcuts/register.go inside the `service == "mail"` branch.
|
||||
//
|
||||
// Cobra's FlagErrorFunc walks up the parent chain looking for the nearest
|
||||
// non-nil hook, so every mail subcommand inherits this behaviour without
|
||||
// any per-shortcut wiring.
|
||||
func InstallOnMail(svc *cobra.Command) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
svc.SetFlagErrorFunc(flagSuggestErrorFunc)
|
||||
}
|
||||
|
||||
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a
|
||||
// structured *output.ExitError carrying candidate suggestions. Any other
|
||||
// error is passed through unchanged so cobra's existing handling kicks in.
|
||||
func flagSuggestErrorFunc(c *cobra.Command, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
token, isShorthand, ok := parseUnknownToken(err.Error())
|
||||
if !ok {
|
||||
// Non unknown-flag errors (e.g. "required flag(s) ... not set")
|
||||
// pass through to cmd/root.go::handleRootError's fallback path.
|
||||
return err
|
||||
}
|
||||
names := collectFlags(c)
|
||||
var matches []Candidate
|
||||
if isShorthand {
|
||||
matches = suggestShorthand(token, names)
|
||||
} else {
|
||||
matches = suggest(token, names)
|
||||
}
|
||||
// Normalise to a non-nil slice so the JSON envelope always emits
|
||||
// `candidates: []` instead of `null`, keeping the wire shape stable
|
||||
// for downstream parsers regardless of command-state.
|
||||
if matches == nil {
|
||||
matches = []Candidate{}
|
||||
}
|
||||
hint := buildHint(c, matches)
|
||||
detail := map[string]any{
|
||||
"unknown": rawUnknownToken(token, isShorthand),
|
||||
"command_path": c.CommandPath(),
|
||||
"candidates": matches,
|
||||
}
|
||||
// Code is ExitAPI (=1), matching cobra's default unknown-flag exit
|
||||
// code. The structured type discrimination lives in error.type.
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: err.Error(),
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parseUnknownToken extracts the offending flag name from a pflag error
|
||||
// string. Recognised forms:
|
||||
//
|
||||
// - "unknown flag: --tos"
|
||||
// - "unknown flag: --bogus=val"
|
||||
// - "unknown shorthand flag: 'X' in -Xyz"
|
||||
//
|
||||
// Anything else returns (_, _, false) so the caller can pass the error
|
||||
// through unchanged.
|
||||
func parseUnknownToken(errMsg string) (token string, isShorthand bool, ok bool) {
|
||||
const longPrefix = "unknown flag: --"
|
||||
const shortPrefix = "unknown shorthand flag: '"
|
||||
switch {
|
||||
case strings.HasPrefix(errMsg, longPrefix):
|
||||
rest := errMsg[len(longPrefix):]
|
||||
if eq := strings.IndexAny(rest, "= \t"); eq >= 0 {
|
||||
rest = rest[:eq]
|
||||
}
|
||||
return rest, false, rest != ""
|
||||
case strings.HasPrefix(errMsg, shortPrefix):
|
||||
rest := errMsg[len(shortPrefix):]
|
||||
end := strings.IndexByte(rest, '\'')
|
||||
if end <= 0 {
|
||||
return "", false, false
|
||||
}
|
||||
return rest[:end], true, true
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
// rawUnknownToken re-attaches the leading dash(es) to a bare token so the
|
||||
// JSON envelope echoes the user-visible spelling.
|
||||
func rawUnknownToken(token string, isShorthand bool) string {
|
||||
if isShorthand {
|
||||
return "-" + token
|
||||
}
|
||||
return "--" + token
|
||||
}
|
||||
|
||||
// collectFlags snapshots the merged local + persistent + inherited flag
|
||||
// set of cmd. The hidden bit is preserved on each entry; the suggest
|
||||
// helpers apply the actual filter so the slice stays reusable.
|
||||
func collectFlags(cmd *cobra.Command) []flagName {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
var out []flagName
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
out = append(out, flagName{long: f.Name, short: f.Shorthand, hidden: f.Hidden})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// suggest produces top-N long-flag candidates for an unknown token, using
|
||||
// bidirectional prefix matching first and Levenshtein distance for the
|
||||
// remainder. Hidden flags and empty long names are skipped. Results are
|
||||
// stably sorted by (Distance asc, Flag asc) and capped at maxCandidates.
|
||||
func suggest(unknown string, names []flagName) []Candidate {
|
||||
if unknown == "" || len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
threshold := levThreshold(unknown)
|
||||
out := make([]Candidate, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
|
||||
// Priority 1: bidirectional prefix match.
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(n.long, unknown) || strings.HasPrefix(unknown, n.long) {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
seen[n.long] = struct{}{}
|
||||
}
|
||||
}
|
||||
// Priority 2: Levenshtein distance, skipping already-matched names.
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n.long]; ok {
|
||||
continue
|
||||
}
|
||||
if d := levenshtein(unknown, n.long); d <= threshold {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: d, Reason: "edit_distance"})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Distance != out[j].Distance {
|
||||
return out[i].Distance < out[j].Distance
|
||||
}
|
||||
return out[i].Flag < out[j].Flag
|
||||
})
|
||||
if len(out) > maxCandidates {
|
||||
out = out[:maxCandidates]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// suggestShorthand produces candidates for an unknown single-character
|
||||
// shorthand. It first looks for exact f.Shorthand matches; if there are
|
||||
// none, it falls back to long names that begin with the same character.
|
||||
// Levenshtein is deliberately not used here since single-char edit
|
||||
// distance would match almost every flag.
|
||||
func suggestShorthand(c string, names []flagName) []Candidate {
|
||||
if c == "" || len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Candidate, 0)
|
||||
for _, n := range names {
|
||||
if n.hidden {
|
||||
continue
|
||||
}
|
||||
if n.short == c {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(n.long, c) {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Flag < out[j].Flag })
|
||||
if len(out) > maxCandidates {
|
||||
out = out[:maxCandidates]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildHint returns a one-line hint suitable for the ErrorEnvelope.
|
||||
// When at least one candidate exists, the top hit is named; otherwise
|
||||
// the user is directed to --help.
|
||||
func buildHint(c *cobra.Command, matches []Candidate) string {
|
||||
if len(matches) == 0 {
|
||||
return fmt.Sprintf("Run `%s --help` to view available flags", c.CommandPath())
|
||||
}
|
||||
return fmt.Sprintf("Did you mean: %s ?", matches[0].Flag)
|
||||
}
|
||||
|
||||
// levThreshold returns the maximum acceptable Levenshtein distance for a
|
||||
// token of the given length, clamped to [1, 4].
|
||||
func levThreshold(s string) int {
|
||||
t := len(s)/3 + 1
|
||||
if t < 1 {
|
||||
return 1
|
||||
}
|
||||
if t > 4 {
|
||||
return 4
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// levenshtein computes the standard Levenshtein edit distance between
|
||||
// two ASCII strings using a 2-row dynamic-programming table.
|
||||
func levenshtein(a, b string) int {
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
prev := make([]int, lb+1)
|
||||
curr := make([]int, lb+1)
|
||||
for j := 0; j <= lb; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(curr[j-1]+1, prev[j]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[lb]
|
||||
}
|
||||
352
shortcuts/mail/flag_suggest_test.go
Normal file
352
shortcuts/mail/flag_suggest_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// --- suggest (long-flag) ---
|
||||
|
||||
func TestSuggest_Prefix(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "to", short: "t"},
|
||||
{long: "cc"},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggest("tos", names)
|
||||
require.NotEmpty(t, got)
|
||||
// "tos" has --to as a prefix → bidirectional prefix hit, Distance=0.
|
||||
assert.Equal(t, "--to", got[0].Flag)
|
||||
assert.Equal(t, 0, got[0].Distance)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
}
|
||||
|
||||
func TestSuggest_Levenshtein(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "subject"},
|
||||
{long: "body"},
|
||||
{long: "to"},
|
||||
}
|
||||
// Distance 1 from "subject".
|
||||
got := suggest("subjec", names)
|
||||
require.NotEmpty(t, got)
|
||||
// "subjec" is prefix of "subject" → bidirectional prefix.
|
||||
assert.Equal(t, "--subject", got[0].Flag)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
|
||||
// True edit-distance: "subjeect" is not a prefix either way of "subject".
|
||||
got = suggest("subjeect", names)
|
||||
require.NotEmpty(t, got)
|
||||
assert.Equal(t, "--subject", got[0].Flag)
|
||||
assert.Equal(t, "edit_distance", got[0].Reason)
|
||||
assert.GreaterOrEqual(t, got[0].Distance, 1)
|
||||
}
|
||||
|
||||
func TestSuggest_HiddenSkipped(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "internal-debug", hidden: true},
|
||||
{long: "interactive"},
|
||||
}
|
||||
got := suggest("internal", names)
|
||||
for _, c := range got {
|
||||
assert.NotEqual(t, "--internal-debug", c.Flag, "hidden flag must not appear in suggestions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggest_TopNAndStableSort(t *testing.T) {
|
||||
// 6 names all within threshold and at the same distance (1) from the
|
||||
// unknown token so that the lexicographic tiebreak and maxCandidates
|
||||
// cap are both exercised. (Earlier the names were 3-distance from
|
||||
// "zzz" which is above the threshold of 2 — suggest returned empty
|
||||
// and the assertions trivially passed.)
|
||||
names := []flagName{
|
||||
{long: "aaab"},
|
||||
{long: "aaac"},
|
||||
{long: "aaad"},
|
||||
{long: "aaae"},
|
||||
{long: "aaaf"},
|
||||
{long: "aaag"},
|
||||
}
|
||||
got := suggest("aaaa", names)
|
||||
require.Len(t, got, maxCandidates, "must cap at maxCandidates")
|
||||
// All distances equal → lex ordering by Flag asc, top 5 alphabetically.
|
||||
wantFlags := []string{"--aaab", "--aaac", "--aaad", "--aaae", "--aaaf"}
|
||||
gotFlags := []string{got[0].Flag, got[1].Flag, got[2].Flag, got[3].Flag, got[4].Flag}
|
||||
assert.Equal(t, wantFlags, gotFlags, "tiebreak must order by Flag asc")
|
||||
}
|
||||
|
||||
// --- suggestShorthand ---
|
||||
|
||||
func TestSuggestShorthand_Exact(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "to", short: "t"},
|
||||
{long: "cc", short: "c"},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggestShorthand("t", names)
|
||||
require.NotEmpty(t, got)
|
||||
assert.Equal(t, "--to", got[0].Flag)
|
||||
assert.Equal(t, "t", got[0].Shorthand)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_PrefixFallback(t *testing.T) {
|
||||
// No short matches "x"; fall back to long names starting with "x".
|
||||
names := []flagName{
|
||||
{long: "xargs"},
|
||||
{long: "xterm"},
|
||||
{long: "yargs"},
|
||||
}
|
||||
got := suggestShorthand("x", names)
|
||||
require.NotEmpty(t, got)
|
||||
flags := make([]string, 0, len(got))
|
||||
for _, c := range got {
|
||||
flags = append(flags, c.Flag)
|
||||
}
|
||||
assert.Contains(t, flags, "--xargs")
|
||||
assert.Contains(t, flags, "--xterm")
|
||||
assert.NotContains(t, flags, "--yargs")
|
||||
}
|
||||
|
||||
// --- parseUnknownToken ---
|
||||
|
||||
func TestParseUnknownToken_Long(t *testing.T) {
|
||||
tok, isShort, ok := parseUnknownToken("unknown flag: --tos")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "tos", tok)
|
||||
|
||||
tok, isShort, ok = parseUnknownToken("unknown flag: --bogus=val")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "bogus", tok, "must strip =value tail")
|
||||
|
||||
tok, _, ok = parseUnknownToken("unknown flag: --bogus value")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "bogus", tok, "must strip whitespace tail")
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_Shorthand(t *testing.T) {
|
||||
tok, isShort, ok := parseUnknownToken("unknown shorthand flag: 'X' in -X")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, isShort)
|
||||
assert.Equal(t, "X", tok)
|
||||
|
||||
tok, isShort, ok = parseUnknownToken("unknown shorthand flag: 'q' in -qrs")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, isShort)
|
||||
assert.Equal(t, "q", tok)
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_NotMatch(t *testing.T) {
|
||||
cases := []string{
|
||||
`required flag(s) "to" not set`,
|
||||
"some unrelated error",
|
||||
"",
|
||||
"unknown command \"foo\" for \"mail\"",
|
||||
}
|
||||
for _, in := range cases {
|
||||
tok, isShort, ok := parseUnknownToken(in)
|
||||
assert.False(t, ok, "input %q must not match", in)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "", tok)
|
||||
}
|
||||
}
|
||||
|
||||
// --- flagSuggestErrorFunc ---
|
||||
|
||||
// newFakeMailCmd builds a cobra command tree resembling the mail parent
|
||||
// with a handful of flags exercised by the hook tests.
|
||||
func newFakeMailCmd() *cobra.Command {
|
||||
c := &cobra.Command{Use: "mail"}
|
||||
c.Flags().String("to", "", "recipients")
|
||||
c.Flags().String("cc", "", "cc recipients")
|
||||
c.Flags().String("subject", "", "subject")
|
||||
c.Flags().StringP("body", "b", "", "body")
|
||||
return c
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsExitError(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
|
||||
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr), "expected *output.ExitError, got %T", got)
|
||||
require.NotNil(t, exitErr.Detail)
|
||||
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
|
||||
assert.Equal(t, "unknown flag: --tos", exitErr.Detail.Message)
|
||||
assert.Contains(t, exitErr.Detail.Hint, "--to")
|
||||
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
require.True(t, ok, "Detail.Detail should be map[string]any")
|
||||
assert.Equal(t, "--tos", detail["unknown"])
|
||||
assert.Equal(t, cmd.CommandPath(), detail["command_path"])
|
||||
|
||||
cands, ok := detail["candidates"].([]Candidate)
|
||||
require.True(t, ok, "candidates should be []Candidate")
|
||||
require.NotEmpty(t, cands)
|
||||
|
||||
var foundTo bool
|
||||
for _, c := range cands {
|
||||
if c.Flag == "--to" {
|
||||
foundTo = true
|
||||
assert.Equal(t, "prefix", c.Reason)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundTo, "expected --to in candidates")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
in := errors.New(`required flag(s) "to" not set`)
|
||||
got := flagSuggestErrorFunc(cmd, in)
|
||||
// Identity passthrough: same error pointer.
|
||||
assert.Same(t, in, got, "non-unknown-flag errors must be returned unchanged")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_ExitCodeIsOne(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
// Hard contract — both compile-time and runtime guards:
|
||||
assert.Equal(t, output.ExitAPI, exitErr.Code, "unknown_flag must use ExitAPI, not ExitValidation")
|
||||
assert.Equal(t, 1, output.ExitAPI, "ExitAPI constant must remain 1")
|
||||
}
|
||||
|
||||
// --- edge-case coverage ---
|
||||
|
||||
func TestInstallOnMail_NilIsNoop(t *testing.T) {
|
||||
// Must not panic; the nil-guard is the contract.
|
||||
InstallOnMail(nil)
|
||||
}
|
||||
|
||||
func TestInstallOnMail_InstallsHook(t *testing.T) {
|
||||
c := newFakeMailCmd()
|
||||
InstallOnMail(c)
|
||||
require.NotNil(t, c.FlagErrorFunc())
|
||||
got := c.FlagErrorFunc()(c, errors.New("unknown flag: --tos"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr), "installed hook must produce *output.ExitError")
|
||||
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
assert.NoError(t, flagSuggestErrorFunc(cmd, nil))
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_LongUnknown_StripsValueTail(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos=alice@example.com"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
assert.Equal(t, "--tos", detail["unknown"], "value tail must be stripped before echoing")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_ShorthandUnknown(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown shorthand flag: 'b' in -bXY"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
assert.Equal(t, "-b", detail["unknown"])
|
||||
cands, ok := detail["candidates"].([]Candidate)
|
||||
require.True(t, ok)
|
||||
// newFakeMailCmd has --body/-b; exact shorthand hit expected.
|
||||
require.NotEmpty(t, cands)
|
||||
assert.Equal(t, "--body", cands[0].Flag)
|
||||
assert.Equal(t, "b", cands[0].Shorthand)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_CandidatesAlwaysArray(t *testing.T) {
|
||||
// A cobra command with no flags forces collectFlags → empty names →
|
||||
// suggest → nil. The envelope must still expose candidates as a
|
||||
// non-nil []Candidate so the JSON wire shape is "candidates: []"
|
||||
// rather than "candidates: null".
|
||||
bare := &cobra.Command{Use: "mail"}
|
||||
got := flagSuggestErrorFunc(bare, errors.New("unknown flag: --bogus"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
cands, ok := detail["candidates"].([]Candidate)
|
||||
require.True(t, ok, "candidates must be []Candidate even when empty")
|
||||
assert.NotNil(t, cands, "candidates must be non-nil empty slice, not nil")
|
||||
assert.Empty(t, cands)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NoCandidatesUsesHelpHint(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
// Token with no plausible neighbor in {to, cc, subject, body}.
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --zzzzzzz"))
|
||||
var exitErr *output.ExitError
|
||||
require.True(t, errors.As(got, &exitErr))
|
||||
assert.Contains(t, exitErr.Detail.Hint, "--help")
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_EmptyAndMalformed(t *testing.T) {
|
||||
// Long form with empty token after the prefix.
|
||||
_, _, ok := parseUnknownToken("unknown flag: --")
|
||||
assert.False(t, ok, "empty long token must not match")
|
||||
|
||||
// Shorthand with no closing quote.
|
||||
_, _, ok = parseUnknownToken("unknown shorthand flag: 'q")
|
||||
assert.False(t, ok, "shorthand without closing quote must not match")
|
||||
|
||||
// Shorthand with empty char between quotes.
|
||||
_, _, ok = parseUnknownToken("unknown shorthand flag: '' in -")
|
||||
assert.False(t, ok, "empty shorthand token must not match")
|
||||
}
|
||||
|
||||
func TestSuggest_EmptyInputs(t *testing.T) {
|
||||
assert.Nil(t, suggest("", []flagName{{long: "to"}}))
|
||||
assert.Nil(t, suggest("foo", nil))
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_EmptyInputs(t *testing.T) {
|
||||
assert.Nil(t, suggestShorthand("", []flagName{{long: "to", short: "t"}}))
|
||||
assert.Nil(t, suggestShorthand("x", nil))
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_HiddenSkipped(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "secret", short: "s", hidden: true},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggestShorthand("s", names)
|
||||
for _, c := range got {
|
||||
assert.NotEqual(t, "--secret", c.Flag, "hidden shorthand must not be suggested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectFlags_NilSafe(t *testing.T) {
|
||||
assert.Nil(t, collectFlags(nil))
|
||||
}
|
||||
|
||||
func TestLevThreshold_Clamp(t *testing.T) {
|
||||
// len 0 → 0/3+1 = 1
|
||||
assert.Equal(t, 1, levThreshold(""))
|
||||
// len 3 → 2
|
||||
assert.Equal(t, 2, levThreshold("abc"))
|
||||
// Long token caps at 4.
|
||||
assert.Equal(t, 4, levThreshold("aaaaaaaaaaaaaaaaaaaa"))
|
||||
}
|
||||
|
||||
func TestLevenshtein_EmptyAndIdentical(t *testing.T) {
|
||||
assert.Equal(t, 0, levenshtein("", ""))
|
||||
assert.Equal(t, 3, levenshtein("", "abc"))
|
||||
assert.Equal(t, 3, levenshtein("abc", ""))
|
||||
assert.Equal(t, 0, levenshtein("abc", "abc"))
|
||||
assert.Equal(t, 1, levenshtein("abc", "abd"))
|
||||
}
|
||||
@@ -99,5 +99,8 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
for _, shortcut := range shortcuts {
|
||||
shortcut.MountWithContext(ctx, svc, f)
|
||||
}
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package shortcuts
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -305,6 +307,65 @@ func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterShortcutsInstallsMailFlagSuggestHook is the end-to-end
|
||||
// wiring guard for the mail unknown-flag fuzzy-match feature: it ensures
|
||||
// the `if service == "mail" { mail.InstallOnMail(svc) }` branch in
|
||||
// RegisterShortcutsWithContext is actually exercised, so a future refactor
|
||||
// that drops the branch (or breaks the import) will fail this test rather
|
||||
// than silently regressing the structured-error contract.
|
||||
func TestRegisterShortcutsInstallsMailFlagSuggestHook(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
mailCmd, _, err := program.Find([]string{"mail"})
|
||||
if err != nil {
|
||||
t.Fatalf("find mail command: %v", err)
|
||||
}
|
||||
if mailCmd == nil || mailCmd.Name() != "mail" {
|
||||
t.Fatalf("mail command not mounted: %#v", mailCmd)
|
||||
}
|
||||
|
||||
// The FlagErrorFunc lookup walks up to the nearest non-nil hook, so
|
||||
// invoking it on the mail parent (or any of its children) must yield
|
||||
// a structured *output.ExitError with type "unknown_flag".
|
||||
got := mailCmd.FlagErrorFunc()(mailCmd, errors.New("unknown flag: --bogus"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T (%v)", got, got)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Fatalf("expected Detail.Type=unknown_flag, got %#v", exitErr.Detail)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected Code=ExitAPI(%d), got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterShortcutsLeavesNonMailFlagErrorUntouched confirms the
|
||||
// install is scoped: a non-mail service must keep the default cobra
|
||||
// pass-through behaviour, otherwise an accidental fall-through in
|
||||
// register.go would silently change every domain's error envelope.
|
||||
func TestRegisterShortcutsLeavesNonMailFlagErrorUntouched(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
baseCmd, _, err := program.Find([]string{"base"})
|
||||
if err != nil {
|
||||
t.Fatalf("find base command: %v", err)
|
||||
}
|
||||
in := errors.New("unknown flag: --bogus")
|
||||
got := baseCmd.FlagErrorFunc()(baseCmd, in)
|
||||
// Default cobra hook is identity — anything else means the mail hook
|
||||
// leaked across domains.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(got, &exitErr) {
|
||||
t.Fatalf("base service unexpectedly produced *output.ExitError: %#v", exitErr)
|
||||
}
|
||||
if got != in {
|
||||
t.Fatalf("base service should pass through original error pointer, got %T (%v)", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
output := os.Getenv("SHORTCUTS_OUTPUT")
|
||||
if output == "" {
|
||||
|
||||
@@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut {
|
||||
VCSearch,
|
||||
VCNotes,
|
||||
VCRecording,
|
||||
VCMeetingJoin,
|
||||
VCMeetingLeave,
|
||||
VCMeetingEvents,
|
||||
}
|
||||
}
|
||||
|
||||
984
shortcuts/vc/vc_meeting_events.go
Normal file
984
shortcuts/vc/vc_meeting_events.go
Normal file
@@ -0,0 +1,984 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
vcMeetingEventsAPIPath = "/open-apis/vc/v1/bots/events"
|
||||
defaultVCMeetingEventsSize = 20
|
||||
minVCMeetingEventsPageSize = 20
|
||||
maxVCMeetingEventsPageSize = 100
|
||||
maxVCMeetingEventsPages = 200
|
||||
)
|
||||
|
||||
var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
|
||||
// toUnixSeconds converts a supported CLI time input into a Unix seconds string.
|
||||
func toUnixSeconds(input string, hint ...string) (string, error) {
|
||||
ts, err := common.ParseTime(input, hint...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := strconv.ParseInt(ts, 10, 64); err != nil {
|
||||
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
|
||||
}
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// VCMeetingEvents lists bot meeting events for a meeting.
|
||||
var VCMeetingEvents = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-events",
|
||||
Description: "List bot meeting events by meeting ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-id", Required: true, Desc: "meeting ID to query"},
|
||||
{Name: "start", Desc: "time lower bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"},
|
||||
{Name: "end", Desc: "time upper bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"},
|
||||
{Name: "page-token", Desc: "page token for the next page"},
|
||||
{Name: "page-size", Default: "20", Desc: "page size, 20-100 (default 20)"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all available pages"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := meetingEventsPageSize(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseMeetingEventsTimeRange(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
startTime, endTime, err := parseMeetingEventsTimeRange(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params, err := buildMeetingEventsParams(runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
dryRun := common.NewDryRunAPI()
|
||||
if runtime.Bool("page-all") {
|
||||
dryRun = dryRun.Desc("Auto-paginates through all available pages")
|
||||
}
|
||||
dryRun = dryRun.GET(vcMeetingEventsAPIPath)
|
||||
if flat := flattenQueryParams(params); len(flat) > 0 {
|
||||
dryRun.Params(flat)
|
||||
}
|
||||
return dryRun
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
startTime, endTime, err := parseMeetingEventsTimeRange(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, events, hasMore, pageToken, err := fetchMeetingEvents(ctx, runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events = compactMeetingEvents(events)
|
||||
outData := map[string]interface{}{
|
||||
"events": events,
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
|
||||
timeline := buildMeetingEventTimeline(events)
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
|
||||
if len(timeline.entries) == 0 {
|
||||
fmt.Fprintln(w, "No meeting events.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderMeetingEventsPretty(timeline))
|
||||
})
|
||||
if runtime.Format == "pretty" && pageToken != "" {
|
||||
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
|
||||
if hasMore {
|
||||
fmt.Fprintln(runtime.IO().Out, "more available")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
|
||||
if runtime.Bool("page-all") {
|
||||
return maxVCMeetingEventsPageSize, nil
|
||||
}
|
||||
pageSizeStr := strings.TrimSpace(runtime.Str("page-size"))
|
||||
if pageSizeStr == "" {
|
||||
return defaultVCMeetingEventsSize, nil
|
||||
}
|
||||
pageSize, err := strconv.Atoi(pageSizeStr)
|
||||
if err != nil {
|
||||
return 0, common.FlagErrorf("invalid --page-size %q: must be an integer", pageSizeStr)
|
||||
}
|
||||
if pageSize < minVCMeetingEventsPageSize {
|
||||
return minVCMeetingEventsPageSize, nil
|
||||
}
|
||||
if pageSize > maxVCMeetingEventsPageSize {
|
||||
return maxVCMeetingEventsPageSize, nil
|
||||
}
|
||||
return pageSize, nil
|
||||
}
|
||||
|
||||
func meetingEventsPaginationConfig(runtime *common.RuntimeContext) (bool, int) {
|
||||
if !runtime.Bool("page-all") {
|
||||
return false, 0
|
||||
}
|
||||
return true, maxVCMeetingEventsPages
|
||||
}
|
||||
|
||||
func validateMeetingEventsMeetingID(meetingID string) error {
|
||||
meetingID = strings.TrimSpace(meetingID)
|
||||
if meetingID == "" {
|
||||
return common.FlagErrorf("--meeting-id is required")
|
||||
}
|
||||
value, err := strconv.ParseInt(meetingID, 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return common.FlagErrorf("--meeting-id must be a positive integer, got %q", meetingID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseMeetingEventsTimeRange validates --start/--end and returns Unix seconds strings.
|
||||
func parseMeetingEventsTimeRange(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 := toUnixSeconds(start)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--start: %v", err)
|
||||
}
|
||||
startTime = parsed
|
||||
}
|
||||
if end != "" {
|
||||
parsed, err := toUnixSeconds(end, "end")
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--end: %v", err)
|
||||
}
|
||||
endTime = parsed
|
||||
}
|
||||
if startTime != "" && endTime != "" {
|
||||
startValue, _ := strconv.ParseInt(startTime, 10, 64)
|
||||
endValue, _ := strconv.ParseInt(endTime, 10, 64)
|
||||
if startValue > endValue {
|
||||
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
|
||||
}
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
|
||||
func buildMeetingEventsParams(runtime *common.RuntimeContext, startTime, endTime string) (larkcore.QueryParams, error) {
|
||||
pageSize, err := meetingEventsPageSize(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := make(larkcore.QueryParams)
|
||||
params.Set("meeting_id", strings.TrimSpace(runtime.Str("meeting-id")))
|
||||
params.Set("page_size", strconv.Itoa(pageSize))
|
||||
if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" {
|
||||
params.Set("page_token", pageToken)
|
||||
}
|
||||
if startTime != "" {
|
||||
params.Set("start_time", startTime)
|
||||
}
|
||||
if endTime != "" {
|
||||
params.Set("end_time", endTime)
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func fetchMeetingEvents(ctx context.Context, runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, []interface{}, bool, string, error) {
|
||||
params, err := buildMeetingEventsParams(runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, nil, false, "", err
|
||||
}
|
||||
autoPaginate, pageLimit := meetingEventsPaginationConfig(runtime)
|
||||
if !autoPaginate {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, nil, false, "", err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
events := common.GetSlice(data, "events")
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
pageToken, _ := data["page_token"].(string)
|
||||
return data, events, hasMore, pageToken, nil
|
||||
}
|
||||
|
||||
var (
|
||||
allEvents []interface{}
|
||||
lastData map[string]interface{}
|
||||
lastPageToken string
|
||||
lastHasMore bool
|
||||
)
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, nil, false, "", err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
lastData = data
|
||||
events := common.GetSlice(data, "events")
|
||||
allEvents = append(allEvents, events...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
params.Set("page_token", lastPageToken)
|
||||
}
|
||||
if lastData == nil {
|
||||
lastData = map[string]interface{}{}
|
||||
}
|
||||
lastData["events"] = allEvents
|
||||
lastData["has_more"] = lastHasMore
|
||||
lastData["page_token"] = lastPageToken
|
||||
return lastData, allEvents, lastHasMore, lastPageToken, nil
|
||||
}
|
||||
|
||||
func flattenQueryParams(params larkcore.QueryParams) map[string]interface{} {
|
||||
if len(params) == 0 {
|
||||
return nil
|
||||
}
|
||||
flat := make(map[string]interface{}, len(params))
|
||||
for key, values := range params {
|
||||
switch len(values) {
|
||||
case 0:
|
||||
continue
|
||||
case 1:
|
||||
flat[key] = values[0]
|
||||
default:
|
||||
copied := make([]string, len(values))
|
||||
copy(copied, values)
|
||||
flat[key] = copied
|
||||
}
|
||||
}
|
||||
return flat
|
||||
}
|
||||
|
||||
func compactMeetingEvents(events []interface{}) []interface{} {
|
||||
compacted := make([]interface{}, 0, len(events))
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
if payload := common.GetMap(event, "payload"); payload != nil {
|
||||
event["payload"] = compactMeetingPayload(payload)
|
||||
}
|
||||
compacted = append(compacted, event)
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
func compactMeetingPayload(payload map[string]interface{}) map[string]interface{} {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
compacted := make(map[string]interface{}, len(payload))
|
||||
for key, value := range payload {
|
||||
if items, ok := value.([]interface{}); ok && len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
compacted[key] = value
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
type meetingTimeline struct {
|
||||
topic string
|
||||
startTime time.Time
|
||||
hasStart bool
|
||||
endTime time.Time
|
||||
hasEnd bool
|
||||
entries []meetingTimelineEntry
|
||||
}
|
||||
|
||||
type meetingTimelineEntry struct {
|
||||
when time.Time
|
||||
hasWhen bool
|
||||
sequence int
|
||||
group int
|
||||
subject string
|
||||
description string
|
||||
details []string
|
||||
}
|
||||
|
||||
func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||
timeline := meetingTimeline{}
|
||||
var sequence int
|
||||
var group int
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
payload := common.GetMap(event, "payload")
|
||||
if payload == nil {
|
||||
continue
|
||||
}
|
||||
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
|
||||
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
|
||||
}
|
||||
for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) {
|
||||
timeline.entries = append(timeline.entries, entry)
|
||||
}
|
||||
group++
|
||||
}
|
||||
sort.SliceStable(timeline.entries, func(i, j int) bool {
|
||||
left := timeline.entries[i]
|
||||
right := timeline.entries[j]
|
||||
switch {
|
||||
case left.hasWhen && right.hasWhen:
|
||||
if left.when.Equal(right.when) {
|
||||
return left.sequence < right.sequence
|
||||
}
|
||||
return left.when.Before(right.when)
|
||||
case left.hasWhen:
|
||||
return true
|
||||
case right.hasWhen:
|
||||
return false
|
||||
default:
|
||||
return left.sequence < right.sequence
|
||||
}
|
||||
})
|
||||
return timeline
|
||||
}
|
||||
|
||||
func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) {
|
||||
if timeline == nil || meeting == nil {
|
||||
return
|
||||
}
|
||||
if timeline.topic == "" {
|
||||
timeline.topic = common.GetString(meeting, "topic")
|
||||
}
|
||||
if !timeline.hasStart {
|
||||
if parsed, ok := parseFlexibleTime(common.GetString(meeting, "start_time")); ok {
|
||||
timeline.startTime = parsed
|
||||
timeline.hasStart = true
|
||||
}
|
||||
}
|
||||
if !timeline.hasEnd {
|
||||
if parsed, ok := parseFlexibleTime(common.GetString(meeting, "end_time")); ok {
|
||||
timeline.endTime = parsed
|
||||
timeline.hasEnd = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry {
|
||||
payload := common.GetMap(event, "payload")
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
eventType := meetingEventType(event)
|
||||
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
|
||||
switch eventType {
|
||||
case "participant_joined":
|
||||
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
case "participant_left":
|
||||
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
case "transcript_received":
|
||||
return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
case "chat_received":
|
||||
return chatEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
case "magic_share_started":
|
||||
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
case "magic_share_ended":
|
||||
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
default:
|
||||
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "participant_joined_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "加入了会议", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "join_time"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "participant"))
|
||||
if subject == "" {
|
||||
subject = "未知参会人"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "participant_left_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "离开了会议", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "leave_time"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "participant"))
|
||||
if subject == "" {
|
||||
subject = "未知参会人"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "transcript_received_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "产生了转写", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "start_time_ms"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "speaker"))
|
||||
if subject == "" {
|
||||
subject = "未知发言人"
|
||||
}
|
||||
text := strings.TrimSpace(common.GetString(item, "text"))
|
||||
description := "产生了转写"
|
||||
if text != "" {
|
||||
description = text
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "chat_received_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "发送了消息", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "send_time"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
|
||||
if subject == "" {
|
||||
subject = "未知发送者"
|
||||
}
|
||||
typeLabel := chatMessageTypeLabel(item)
|
||||
description := strings.TrimSpace(common.GetString(item, "content"))
|
||||
if description == "" {
|
||||
description = fmt.Sprintf("[%s] 发送了消息", typeLabel)
|
||||
} else {
|
||||
description = fmt.Sprintf("[%s] %s", typeLabel, description)
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "magic_share_started_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "开始共享内容", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "time"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
|
||||
if subject == "" {
|
||||
subject = "未知用户"
|
||||
}
|
||||
title := strings.TrimSpace(common.GetString(common.GetMap(item, "share_doc"), "title"))
|
||||
url := strings.TrimSpace(common.GetString(common.GetMap(item, "share_doc"), "url"))
|
||||
description := "开始共享内容"
|
||||
if title != "" {
|
||||
description = fmt.Sprintf("开始共享「%s」", title)
|
||||
}
|
||||
var details []string
|
||||
if url != "" {
|
||||
details = append(details, "URL: "+url)
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "magic_share_ended_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "结束共享", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
when, ok := parseFlexibleTime(common.GetString(item, "time"))
|
||||
if !ok {
|
||||
when, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
subject := meetingEventUserWithID(common.GetMap(item, "operator"))
|
||||
if subject == "" {
|
||||
subject = "未知用户"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry {
|
||||
entry := meetingTimelineEntry{
|
||||
when: when,
|
||||
hasWhen: hasWhen,
|
||||
sequence: *sequence,
|
||||
group: group,
|
||||
subject: subject,
|
||||
description: description,
|
||||
details: details,
|
||||
}
|
||||
*sequence = *sequence + 1
|
||||
return entry
|
||||
}
|
||||
|
||||
func parseFlexibleTime(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if ts, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
switch {
|
||||
case ts > 1_000_000_000_000:
|
||||
return time.UnixMilli(ts), true
|
||||
case ts > 0:
|
||||
return time.Unix(ts, 0), true
|
||||
}
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func renderMeetingEventsPretty(timeline meetingTimeline) string {
|
||||
var b strings.Builder
|
||||
if timeline.topic != "" {
|
||||
fmt.Fprintf(&b, "会议主题:%s\n", escapePrettyText(timeline.topic))
|
||||
}
|
||||
if timeline.hasStart || timeline.hasEnd {
|
||||
fmt.Fprintf(&b, "会议时间:%s\n", escapePrettyText(formatMeetingWindow(timeline.startTime, timeline.hasStart, timeline.endTime, timeline.hasEnd)))
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, entry := range timeline.entries {
|
||||
fmt.Fprintf(&b, "[%s] ", formatTimelineOffset(entry.when, entry.hasWhen, timeline.startTime, timeline.hasStart))
|
||||
if entry.subject != "" {
|
||||
if entry.description == "" {
|
||||
fmt.Fprintln(&b, escapePrettyText(entry.subject))
|
||||
for _, detail := range entry.details {
|
||||
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if needsColon(entry.description) {
|
||||
fmt.Fprintf(&b, "%s: %s\n", escapePrettyText(entry.subject), escapePrettyText(entry.description))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%s %s\n", escapePrettyText(entry.subject), escapePrettyText(entry.description))
|
||||
}
|
||||
for _, detail := range entry.details {
|
||||
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(&b, escapePrettyText(entry.description))
|
||||
for _, detail := range entry.details {
|
||||
fmt.Fprintf(&b, " %s\n", escapePrettyText(detail))
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapePrettyText(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
if unicode.IsControl(r) {
|
||||
fmt.Fprintf(&b, "\\u%04X", r)
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatMeetingWindow(start time.Time, hasStart bool, end time.Time, hasEnd bool) string {
|
||||
switch {
|
||||
case hasStart && hasEnd:
|
||||
if !end.After(start) {
|
||||
return fmt.Sprintf("%s(进行中)", start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
return fmt.Sprintf("%s - %s", start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"), end.In(meetingDisplayLocation).Format("2006-01-02 15:04:05"))
|
||||
case hasStart:
|
||||
return start.In(meetingDisplayLocation).Format("2006-01-02 15:04:05")
|
||||
case hasEnd:
|
||||
return end.In(meetingDisplayLocation).Format("2006-01-02 15:04:05")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func formatTimelineOffset(when time.Time, hasWhen bool, meetingStart time.Time, hasMeetingStart bool) string {
|
||||
if hasWhen && hasMeetingStart {
|
||||
diff := when.Sub(meetingStart)
|
||||
if diff < 0 {
|
||||
diff = 0
|
||||
}
|
||||
totalSeconds := int(diff.Seconds())
|
||||
hours := totalSeconds / 3600
|
||||
minutes := (totalSeconds % 3600) / 60
|
||||
seconds := totalSeconds % 60
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
if hasWhen {
|
||||
return when.In(meetingDisplayLocation).Format("15:04:05")
|
||||
}
|
||||
return "??:??:??"
|
||||
}
|
||||
|
||||
func needsColon(description string) bool {
|
||||
switch description {
|
||||
case "发送了消息", "产生了转写":
|
||||
return false
|
||||
default:
|
||||
return !strings.HasPrefix(description, "加入了") &&
|
||||
!strings.HasPrefix(description, "离开了") &&
|
||||
!strings.HasPrefix(description, "被移出") &&
|
||||
!strings.HasPrefix(description, "会议结束") &&
|
||||
!strings.HasPrefix(description, "开始共享") &&
|
||||
!strings.HasPrefix(description, "结束共享")
|
||||
}
|
||||
}
|
||||
|
||||
func leaveAction(item map[string]interface{}) string {
|
||||
switch int(common.GetFloat(item, "leave_reason")) {
|
||||
case 2:
|
||||
return "因会议结束离开了会议"
|
||||
case 3:
|
||||
return "被移出了会议"
|
||||
default:
|
||||
return "离开了会议"
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventUserWithID(user map[string]interface{}) string {
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
userID := common.GetString(user, "id")
|
||||
userName := common.GetString(user, "user_name")
|
||||
switch {
|
||||
case userName != "" && userID != "":
|
||||
return fmt.Sprintf("%s(%s)", userName, userID)
|
||||
case userName != "":
|
||||
return userName
|
||||
case userID != "":
|
||||
return userID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventType(event map[string]interface{}) string {
|
||||
if eventType := common.GetString(event, "event_type"); eventType != "" {
|
||||
return eventType
|
||||
}
|
||||
return common.GetString(common.GetMap(event, "payload"), "activity_event_type")
|
||||
}
|
||||
|
||||
func meetingEventSummary(event map[string]interface{}) string {
|
||||
payload := common.GetMap(event, "payload")
|
||||
eventType := meetingEventType(event)
|
||||
switch eventType {
|
||||
case "participant_joined":
|
||||
return participantJoinedSummary(payload)
|
||||
case "participant_left":
|
||||
return participantLeftSummary(payload)
|
||||
case "transcript_received":
|
||||
return transcriptReceivedSummary(payload)
|
||||
case "chat_received":
|
||||
return chatReceivedSummary(payload)
|
||||
case "magic_share_started":
|
||||
return magicShareStartedSummary(payload)
|
||||
case "magic_share_ended":
|
||||
return magicShareEndedSummary(payload)
|
||||
default:
|
||||
return fallbackMeetingEventSummary(payload, eventType)
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "participant_joined_items")
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return "participant joined"
|
||||
case 1:
|
||||
user := common.GetMap(firstSliceMap(payload, "participant_joined_items"), "participant")
|
||||
if label := meetingEventUserLabel(user); label != "" {
|
||||
return fmt.Sprintf("participant %s joined", label)
|
||||
}
|
||||
return "participant joined"
|
||||
default:
|
||||
return fmt.Sprintf("%d participants joined", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func participantLeftSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "participant_left_items")
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return "participant left"
|
||||
case 1:
|
||||
user := common.GetMap(firstSliceMap(payload, "participant_left_items"), "participant")
|
||||
if label := meetingEventUserLabel(user); label != "" {
|
||||
return fmt.Sprintf("participant %s left", label)
|
||||
}
|
||||
return "participant left"
|
||||
default:
|
||||
return fmt.Sprintf("%d participants left", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func transcriptReceivedSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "transcript_received_items")
|
||||
if len(items) > 1 {
|
||||
return fmt.Sprintf("%d transcript items", len(items))
|
||||
}
|
||||
item := firstSliceMap(payload, "transcript_received_items")
|
||||
text := common.GetString(item, "text")
|
||||
speaker := meetingEventUserLabel(common.GetMap(item, "speaker"))
|
||||
switch {
|
||||
case speaker != "" && text != "":
|
||||
return fmt.Sprintf("speaker %s: %s", speaker, text)
|
||||
case speaker != "":
|
||||
return fmt.Sprintf("speaker %s transcript received", speaker)
|
||||
case text != "":
|
||||
return fmt.Sprintf("transcript: %s", text)
|
||||
default:
|
||||
return "transcript received"
|
||||
}
|
||||
}
|
||||
|
||||
func chatReceivedSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "chat_received_items")
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return "chat received"
|
||||
case 1:
|
||||
item := firstSliceMap(payload, "chat_received_items")
|
||||
content := common.GetString(item, "content")
|
||||
operator := meetingEventUserDisplayName(common.GetMap(item, "operator"))
|
||||
switch {
|
||||
case operator != "" && content != "":
|
||||
return fmt.Sprintf("%s: %s", operator, content)
|
||||
case operator != "":
|
||||
return fmt.Sprintf("message by %s", operator)
|
||||
case content != "":
|
||||
return fmt.Sprintf("message: %s", content)
|
||||
default:
|
||||
return "chat received"
|
||||
}
|
||||
default:
|
||||
count, operator := summarizeChatOperators(items)
|
||||
switch {
|
||||
case count == 1 && operator != "":
|
||||
return fmt.Sprintf("%d messages by %s", len(items), operator)
|
||||
case count > 1:
|
||||
return fmt.Sprintf("%d messages by %d users", len(items), count)
|
||||
default:
|
||||
return fmt.Sprintf("%d messages", len(items))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func magicShareStartedSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "magic_share_started_items")
|
||||
if len(items) > 1 {
|
||||
return fmt.Sprintf("%d share start events", len(items))
|
||||
}
|
||||
item := firstSliceMap(payload, "magic_share_started_items")
|
||||
shareID := common.GetString(item, "share_id")
|
||||
title := common.GetString(common.GetMap(item, "share_doc"), "title")
|
||||
switch {
|
||||
case shareID != "" && title != "":
|
||||
return fmt.Sprintf("share %s started: %s", shareID, title)
|
||||
case shareID != "":
|
||||
return fmt.Sprintf("share %s started", shareID)
|
||||
case title != "":
|
||||
return fmt.Sprintf("share started: %s", title)
|
||||
default:
|
||||
return "share started"
|
||||
}
|
||||
}
|
||||
|
||||
func magicShareEndedSummary(payload map[string]interface{}) string {
|
||||
items := common.GetSlice(payload, "magic_share_ended_items")
|
||||
if len(items) > 1 {
|
||||
return fmt.Sprintf("%d share end events", len(items))
|
||||
}
|
||||
item := firstSliceMap(payload, "magic_share_ended_items")
|
||||
if shareID := common.GetString(item, "share_id"); shareID != "" {
|
||||
return fmt.Sprintf("share %s ended", shareID)
|
||||
}
|
||||
return "share ended"
|
||||
}
|
||||
|
||||
func fallbackMeetingEventSummary(payload map[string]interface{}, eventType string) string {
|
||||
meeting := common.GetMap(payload, "meeting")
|
||||
if topic := common.GetString(meeting, "topic"); topic != "" {
|
||||
if eventType != "" {
|
||||
return fmt.Sprintf("%s: %s", eventType, topic)
|
||||
}
|
||||
return topic
|
||||
}
|
||||
if eventType != "" {
|
||||
return eventType
|
||||
}
|
||||
return "meeting event"
|
||||
}
|
||||
|
||||
func firstSliceMap(payload map[string]interface{}, key string) map[string]interface{} {
|
||||
items := common.GetSlice(payload, key)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
first, _ := items[0].(map[string]interface{})
|
||||
return first
|
||||
}
|
||||
|
||||
func meetingEventUserLabel(user map[string]interface{}) string {
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
userID := common.GetString(user, "id")
|
||||
userName := common.GetString(user, "user_name")
|
||||
switch {
|
||||
case userID != "" && userName != "":
|
||||
return fmt.Sprintf("%s (%s)", userID, userName)
|
||||
case userID != "":
|
||||
return userID
|
||||
case userName != "":
|
||||
return userName
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventUserDisplayName(user map[string]interface{}) string {
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
if userName := common.GetString(user, "user_name"); userName != "" {
|
||||
return userName
|
||||
}
|
||||
return common.GetString(user, "id")
|
||||
}
|
||||
|
||||
func chatMessageTypeLabel(item map[string]interface{}) string {
|
||||
code := int(common.GetFloat(item, "message_type"))
|
||||
switch code {
|
||||
case 1:
|
||||
return "text"
|
||||
case 2:
|
||||
return "system"
|
||||
case 3:
|
||||
return "reaction"
|
||||
case 4:
|
||||
return "encrypted"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeChatOperators(items []interface{}) (int, string) {
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
operator := meetingEventUserDisplayName(common.GetMap(item, "operator"))
|
||||
if operator == "" {
|
||||
continue
|
||||
}
|
||||
seen[operator] = struct{}{}
|
||||
}
|
||||
if len(seen) != 1 {
|
||||
return len(seen), ""
|
||||
}
|
||||
for operator := range seen {
|
||||
return 1, operator
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
931
shortcuts/vc/vc_meeting_events_test.go
Normal file
931
shortcuts/vc/vc_meeting_events_test.go
Normal file
@@ -0,0 +1,931 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
shortcuts/vc/vc_meeting_join.go
Normal file
94
shortcuts/vc/vc_meeting_join.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var meetingNumberRe = regexp.MustCompile(`^\d{9}$`)
|
||||
|
||||
// validMeetingNumber checks whether s is a valid 9-digit meeting number.
|
||||
func validMeetingNumber(s string) bool {
|
||||
return meetingNumberRe.MatchString(s)
|
||||
}
|
||||
|
||||
// VCMeetingJoin joins a meeting by meeting number via /vc/v1/bots/join.
|
||||
var VCMeetingJoin = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-join",
|
||||
Description: "Join a meeting by meeting number (bot join)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"vc:meeting.bot.join:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-number", Required: true, Desc: "meeting number to join"},
|
||||
{Name: "password", Desc: "meeting password (if required)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
mn := strings.TrimSpace(runtime.Str("meeting-number"))
|
||||
if !validMeetingNumber(mn) {
|
||||
return common.FlagErrorf("--meeting-number must be exactly 9 digits, got %q", mn)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildMeetingJoinBody(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/vc/v1/bots/join").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildMeetingJoinBody(runtime)
|
||||
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/join", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
meeting, _ := data["meeting"].(map[string]interface{})
|
||||
if meeting == nil {
|
||||
fmt.Fprintln(w, "Joined meeting (no meeting info returned).")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Joined meeting successfully.\n")
|
||||
if id := common.GetString(meeting, "id"); id != "" {
|
||||
fmt.Fprintf(w, " Meeting ID: %s\n", id)
|
||||
}
|
||||
if no := common.GetString(meeting, "meeting_no"); no != "" {
|
||||
fmt.Fprintf(w, " Meeting No: %s\n", no)
|
||||
}
|
||||
if topic := common.GetString(meeting, "topic"); topic != "" {
|
||||
fmt.Fprintf(w, " Topic: %s\n", topic)
|
||||
}
|
||||
if startTime := common.GetString(meeting, "start_time"); startTime != "" {
|
||||
fmt.Fprintf(w, " Start Time: %s\n", startTime)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildMeetingJoinBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
meetingNo := strings.TrimSpace(runtime.Str("meeting-number"))
|
||||
body := map[string]interface{}{
|
||||
"join_type": 1,
|
||||
"join_identify": map[string]interface{}{
|
||||
"meeting_no": meetingNo,
|
||||
},
|
||||
}
|
||||
if pw := strings.TrimSpace(runtime.Str("password")); pw != "" {
|
||||
body["password"] = pw
|
||||
}
|
||||
return body
|
||||
}
|
||||
57
shortcuts/vc/vc_meeting_leave.go
Normal file
57
shortcuts/vc/vc_meeting_leave.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// VCMeetingLeave leaves a meeting via /vc/v1/bots/leave.
|
||||
var VCMeetingLeave = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-leave",
|
||||
Description: "Leave a meeting by meeting ID",
|
||||
Risk: "write",
|
||||
Scopes: []string{"vc:meeting.bot.join:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-id", Required: true, Desc: "meeting ID to leave"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("meeting-id")) == "" {
|
||||
return common.FlagErrorf("--meeting-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/vc/v1/bots/leave").
|
||||
Body(map[string]interface{}{
|
||||
"meeting_id": strings.TrimSpace(runtime.Str("meeting-id")),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
meetingID := strings.TrimSpace(runtime.Str("meeting-id"))
|
||||
body := map[string]interface{}{
|
||||
"meeting_id": meetingID,
|
||||
}
|
||||
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/leave", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Left meeting %s successfully.\n", meetingID)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
536
shortcuts/vc/vc_meeting_test.go
Normal file
536
shortcuts/vc/vc_meeting_test.go
Normal file
@@ -0,0 +1,536 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: pure functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidMeetingNumber(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"9 digits", "123456789", true},
|
||||
{"9 digits leading zero", "012345678", true},
|
||||
{"empty", "", false},
|
||||
{"8 digits", "12345678", false},
|
||||
{"10 digits", "1234567890", false},
|
||||
{"with space", "12345 678", false},
|
||||
{"letters mixed", "12345678a", false},
|
||||
{"pure letters", "abcdefghi", false},
|
||||
{"with dash", "123-456-789", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := validMeetingNumber(tt.in); got != tt.want {
|
||||
t.Errorf("validMeetingNumber(%q) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingJoinBody_WithoutPassword(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-number", "", "")
|
||||
cmd.Flags().String("password", "", "")
|
||||
_ = cmd.Flags().Set("meeting-number", "123456789")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
body := buildMeetingJoinBody(runtime)
|
||||
|
||||
if body["join_type"] != 1 {
|
||||
t.Errorf("join_type = %v, want 1", body["join_type"])
|
||||
}
|
||||
ji, ok := body["join_identify"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("join_identify missing or wrong type: %v", body["join_identify"])
|
||||
}
|
||||
if ji["meeting_no"] != "123456789" {
|
||||
t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"])
|
||||
}
|
||||
if _, exists := body["password"]; exists {
|
||||
t.Errorf("password should be omitted when empty, got %v", body["password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingJoinBody_WithPassword(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-number", "", "")
|
||||
cmd.Flags().String("password", "", "")
|
||||
_ = cmd.Flags().Set("meeting-number", "123456789")
|
||||
_ = cmd.Flags().Set("password", "secret")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
body := buildMeetingJoinBody(runtime)
|
||||
|
||||
if body["password"] != "secret" {
|
||||
t.Errorf("password = %v, want secret", body["password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingJoinBody_TrimsWhitespace(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-number", "", "")
|
||||
cmd.Flags().String("password", "", "")
|
||||
_ = cmd.Flags().Set("meeting-number", " 123456789 ")
|
||||
_ = cmd.Flags().Set("password", " pw ")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
body := buildMeetingJoinBody(runtime)
|
||||
|
||||
ji, _ := body["join_identify"].(map[string]interface{})
|
||||
if ji["meeting_no"] != "123456789" {
|
||||
t.Errorf("meeting_no should be trimmed, got %q", ji["meeting_no"])
|
||||
}
|
||||
if body["password"] != "pw" {
|
||||
t.Errorf("password should be trimmed, got %q", body["password"])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validate tests: VCMeetingJoin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingJoin_Validate_MissingNumber(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
// cobra MarkFlagRequired should reject missing --meeting-number
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{"+meeting-join", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --meeting-number is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "meeting-number") {
|
||||
t.Errorf("error should mention meeting-number, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Validate_InvalidFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
num string
|
||||
}{
|
||||
{"too short", "12345678"},
|
||||
{"too long", "1234567890"},
|
||||
{"with letters", "12345abcd"},
|
||||
{"empty after trim", " "},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-number", "", "")
|
||||
cmd.Flags().String("password", "", "")
|
||||
_ = cmd.Flags().Set("meeting-number", tt.num)
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := VCMeetingJoin.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %q", tt.num)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "9 digits") {
|
||||
t.Errorf("error should mention '9 digits', got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Validate_Valid(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-number", "", "")
|
||||
cmd.Flags().String("password", "", "")
|
||||
_ = cmd.Flags().Set("meeting-number", "123456789")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
if err := VCMeetingJoin.Validate(context.Background(), runtime); err != nil {
|
||||
t.Errorf("unexpected validation error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DryRun tests: VCMeetingJoin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingJoin_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "123456789", "--password", "pw123",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/vc/v1/bots/join") {
|
||||
t.Errorf("dry-run should include API path, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "123456789") {
|
||||
t.Errorf("dry-run should include meeting number, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "pw123") {
|
||||
t.Errorf("dry-run should include password, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests: VCMeetingJoin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingJoin_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/join",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "69999999",
|
||||
"meeting_no": "123456789",
|
||||
"topic": "Weekly Sync",
|
||||
"start_time": "1700000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "123456789",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// verify captured request body
|
||||
if len(stub.CapturedBody) == 0 {
|
||||
t.Fatal("expected request body to be captured")
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
|
||||
t.Fatalf("failed to parse request body: %v", err)
|
||||
}
|
||||
if req["join_type"].(float64) != 1 {
|
||||
t.Errorf("join_type = %v, want 1", req["join_type"])
|
||||
}
|
||||
ji, _ := req["join_identify"].(map[string]interface{})
|
||||
if ji["meeting_no"] != "123456789" {
|
||||
t.Errorf("meeting_no = %v, want 123456789", ji["meeting_no"])
|
||||
}
|
||||
if _, exists := ji["password"]; exists {
|
||||
t.Errorf("password should be omitted when not provided, got %v", ji["password"])
|
||||
}
|
||||
|
||||
// verify response envelope carries meeting info under data.meeting
|
||||
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)
|
||||
meeting, _ := data["meeting"].(map[string]any)
|
||||
if meeting["id"] != "69999999" {
|
||||
t.Errorf("meeting.id = %v, want 69999999 (envelope: %s)", meeting["id"], stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Execute_WithPassword_CapturesBody(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/join",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "987654321", "--password", "s3cret",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
|
||||
t.Fatalf("failed to parse request body: %v", err)
|
||||
}
|
||||
ji, _ := req["join_identify"].(map[string]interface{})
|
||||
if req["password"] != "s3cret" {
|
||||
t.Errorf("password = %v, want s3cret", req["password"])
|
||||
}
|
||||
if ji["meeting_no"] != "987654321" {
|
||||
t.Errorf("meeting_no = %v, want 987654321", ji["meeting_no"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Execute_PrettyOutput(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/join",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "69999999",
|
||||
"meeting_no": "123456789",
|
||||
"topic": "Weekly Sync",
|
||||
"start_time": "1700000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "123456789",
|
||||
"--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"Joined meeting successfully", "69999999", "123456789", "Weekly Sync", "1700000000"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing %q, got: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Execute_PrettyOutput_NoMeetingInfo(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/join",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "123456789",
|
||||
"--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "no meeting info returned") {
|
||||
t.Errorf("pretty output should fall back to 'no meeting info' notice, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingLeave_Execute_PrettyOutput(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/leave",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{
|
||||
"+meeting-leave", "--meeting-id", "69999999",
|
||||
"--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "Left meeting 69999999 successfully") {
|
||||
t.Errorf("pretty output should confirm leave, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingJoin_Execute_APIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/join",
|
||||
Body: map[string]interface{}{"code": 190001, "msg": "invalid meeting number"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingJoin, []string{
|
||||
"+meeting-join", "--meeting-number", "123456789",
|
||||
"--as", "user",
|
||||
}, f, &bytes.Buffer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid meeting number") {
|
||||
t.Errorf("error should surface API message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validate tests: VCMeetingLeave
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingLeave_Validate_MissingID(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{"+meeting-leave", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --meeting-id is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "meeting-id") {
|
||||
t.Errorf("error should mention meeting-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingLeave_Validate_WhitespaceOnly(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-id", "", "")
|
||||
_ = cmd.Flags().Set("meeting-id", " ")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := VCMeetingLeave.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for whitespace-only meeting-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "meeting-id") {
|
||||
t.Errorf("error should mention meeting-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingLeave_Validate_Valid(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-id", "", "")
|
||||
_ = cmd.Flags().Set("meeting-id", "69999999")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
if err := VCMeetingLeave.Validate(context.Background(), runtime); err != nil {
|
||||
t.Errorf("unexpected validation error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DryRun tests: VCMeetingLeave
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingLeave_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{
|
||||
"+meeting-leave", "--meeting-id", "69999999",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/vc/v1/bots/leave") {
|
||||
t.Errorf("dry-run should include API path, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "69999999") {
|
||||
t.Errorf("dry-run should include meeting-id, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests: VCMeetingLeave
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMeetingLeave_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/leave",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{
|
||||
"+meeting-leave", "--meeting-id", "69999999",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// verify captured request body
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
|
||||
t.Fatalf("failed to parse request body: %v", err)
|
||||
}
|
||||
if req["meeting_id"] != "69999999" {
|
||||
t.Errorf("meeting_id = %v, want 69999999", req["meeting_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingLeave_Execute_TrimsMeetingID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/leave",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{
|
||||
"+meeting-leave", "--meeting-id", " 69999999 ",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
|
||||
t.Fatalf("failed to parse request body: %v", err)
|
||||
}
|
||||
if req["meeting_id"] != "69999999" {
|
||||
t.Errorf("meeting_id should be trimmed, got %q", req["meeting_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingLeave_Execute_APIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/bots/leave",
|
||||
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingLeave, []string{
|
||||
"+meeting-leave", "--meeting-id", "69999999", "--as", "user",
|
||||
}, f, &bytes.Buffer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no permission") {
|
||||
t.Errorf("error should surface API message, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ metadata:
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
|
||||
> **执行前必做:** 执行任何 `base` 命令前,必须先阅读对应命令的 reference 文档,再调用命令。
|
||||
> **查询类任务必做:** 涉及筛选、排序、Top/Bottom N、聚合、多表关联、查询后写入或判断全局结论时,必须先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),再选择 `record / view / data-query` 路径。
|
||||
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;如需先解析 Wiki 链接,可先调用 `lark-cli wiki ...`。
|
||||
> **分流规则:** 如果用户要“把本地文件导入成 Base / 多维表格 / bitable”,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
|
||||
|
||||
@@ -104,7 +105,7 @@ metadata:
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 记录读取统一先读 read SOP guide:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query`;`+record-get` 支持重复 `--record-id` 或 `--json` 读取多条记录 |
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 记录读取统一先读 data analysis SOP:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query`;`+record-get` 支持重复 `--record-id` 或 `--json` 读取多条记录 |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
|
||||
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token` 从 `+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403) |
|
||||
@@ -120,7 +121,7 @@ metadata:
|
||||
|------|------------------|----------------|----------|
|
||||
| `+view-list / +view-get` | 列出视图,或获取视图详情 | [`lark-base-view-list.md`](references/lark-base-view-list.md)、[`lark-base-view-get.md`](references/lark-base-view-get.md) | `+view-list` 只能串行执行;`+view-get` 适合查看已有视图配置 |
|
||||
| `+view-create / +view-delete / +view-rename` | 创建、删除或重命名视图 | [`lark-base-view-create.md`](references/lark-base-view-create.md)、[`lark-base-view-delete.md`](references/lark-base-view-delete.md)、[`lark-base-view-rename.md`](references/lark-base-view-rename.md) | 创建前先确认表和视图类型;删除前先确认目标;用户已明确新名字时可直接重命名 |
|
||||
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
|
||||
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
|
||||
| `+view-get-sort / +view-set-sort` | 读取或配置排序 | [`lark-base-view-get-sort.md`](references/lark-base-view-get-sort.md)、[`lark-base-view-set-sort.md`](references/lark-base-view-set-sort.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-group / +view-set-group` | 读取或配置分组 | [`lark-base-view-get-group.md`](references/lark-base-view-get-group.md)、[`lark-base-view-set-group.md`](references/lark-base-view-set-group.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-visible-fields / +view-set-visible-fields` | 读取或配置视图可见字段 | [`lark-base-view-get-visible-fields.md`](references/lark-base-view-get-visible-fields.md)、[`lark-base-view-set-visible-fields.md`](references/lark-base-view-set-visible-fields.md) | 用于控制视图中的字段顺序与可见性;字段名必须来自真实结构 |
|
||||
@@ -228,14 +229,24 @@ metadata:
|
||||
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
|
||||
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create`、`+table-create` 或 `+record-upsert` |
|
||||
|
||||
### 3.3 表名、字段名与表达式引用
|
||||
### 3.3 查询执行契约
|
||||
|
||||
涉及查询、统计或判断结论时,先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),并遵守以下高优先级规则:
|
||||
|
||||
1. `+record-list` 默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实,不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。
|
||||
2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询服务中执行;不要先拉明细到本地上下文再手工筛选排序。
|
||||
3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。
|
||||
4. 多表查询必须先确认关系字段和连接键;link 单元格里的 `record_id` 是关系键,不是用户可读答案。
|
||||
5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。
|
||||
|
||||
### 3.4 表名、字段名与表达式引用
|
||||
|
||||
1. 表名、字段名必须精确匹配真实返回,来源应是 `+table-list / +table-get / +field-list`。
|
||||
2. 不要凭自然语言猜名称,不要自行改写用户口述中的表名、字段名。
|
||||
3. `formula / lookup / data-query / workflow` 中出现的名称同样必须精确匹配;表达式引用、where 条件、DSL 字段名、workflow 配置都遵守同一规则。
|
||||
4. 跨表场景必须额外读取目标表结构,不能只看当前表。
|
||||
|
||||
### 3.4 Token 与链接
|
||||
### 3.5 Token 与链接
|
||||
|
||||
这是高优先级章节。只要用户输入里出现链接、token,或报错涉及 `baseToken` / `wiki_token` / `obj_token`,都应优先回到这里检查。
|
||||
|
||||
@@ -254,7 +265,7 @@ metadata:
|
||||
| `slides` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
| `mindnote` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
|
||||
### 3.5 身份选择与权限降级策略
|
||||
### 3.6 身份选择与权限降级策略
|
||||
|
||||
多维表格通常属于用户的个人或团队资源。**默认应优先使用 `--as user`(用户身份)执行所有 Base 操作**,始终显式指定身份。
|
||||
|
||||
@@ -282,10 +293,11 @@ lark-cli auth login --domain base
|
||||
|
||||
1. 先判断任务属于哪个模块,选对命令族。
|
||||
2. 如果用户给了链接,先解析 token,不要把 wiki token、完整 URL 或其他对象 ID 误当成 `base_token`。
|
||||
3. 先拿结构,再写命令,避免猜表名、字段名、表达式引用。
|
||||
4. 定位到命令后,先读对应 reference,再执行命令。
|
||||
5. 执行命令,并按返回结果判断下一步。
|
||||
6. 回复时返回关键结果和后续可继续操作的信息,方便 agent 链式执行下一步。
|
||||
3. 如果是查询类任务,先判断问题范围,阅读 data analysis SOP,再决定使用 `record / view / data-query`。
|
||||
4. 先拿结构,再写命令,避免猜表名、字段名、表达式引用。
|
||||
5. 定位到命令后,先读对应 reference,再执行命令。
|
||||
6. 执行命令,并按返回结果判断下一步。
|
||||
7. 回复时返回关键结果和后续可继续操作的信息,方便 agent 链式执行下一步。
|
||||
|
||||
### 4.2 不可违反规则
|
||||
|
||||
@@ -297,11 +309,12 @@ lark-cli auth login --domain base
|
||||
6. 只写可写字段;系统字段、附件字段、`formula`、`lookup` 默认不作为普通记录写入目标。
|
||||
7. 聚合分析与取数分流;统计走 `+data-query`,关键词检索走 `+record-search`,明细走 `+record-list / +record-get`。
|
||||
8. 筛选查询按视图能力执行;先用 `+view-set-filter` 配置筛选,再结合 `+record-list` 读取。
|
||||
9. Base 场景不要改走裸 API,不要切去 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
10. 统一使用 `--base-token`。
|
||||
11. workflow 场景先读 schema,不要凭自然语言猜 `type`。
|
||||
12. dashboard 场景先读 guide;提到图表、看板、block 就先进入 dashboard 模块。
|
||||
13. formula / lookup 场景先读 guide;没读 guide 前不要直接创建或更新。
|
||||
9. 全局查询不得基于默认分页、小 `--limit` 或未证明全量的本地 `jq` 结果下结论。
|
||||
10. Base 场景不要改走裸 API,不要切去 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
11. 统一使用 `--base-token`。
|
||||
12. workflow 场景先读 schema,不要凭自然语言猜 `type`。
|
||||
13. dashboard 场景先读 guide;提到图表、看板、block 就先进入 dashboard 模块。
|
||||
14. formula / lookup 场景先读 guide;没读 guide 前不要直接创建或更新。
|
||||
|
||||
### 4.3 并发、分页与批量限制
|
||||
|
||||
|
||||
88
skills/lark-base/references/lark-base-data-analysis-sop.md
Normal file
88
skills/lark-base/references/lark-base-data-analysis-sop.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Base data analysis SOP
|
||||
|
||||
Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、排序、Top/Bottom N、聚合统计、分组聚合、多表关联、临时分析和查询后写入前的目标定位。
|
||||
|
||||
具体命令参数不要在本文猜;需要时跳到对应 reference:
|
||||
|
||||
- `+data-query`: [lark-base-data-query.md](lark-base-data-query.md)
|
||||
- 视图筛选/排序/投影: [lark-base-view-set-filter.md](lark-base-view-set-filter.md), [lark-base-view-set-sort.md](lark-base-view-set-sort.md), [lark-base-view-set-visible-fields.md](lark-base-view-set-visible-fields.md)
|
||||
- 记录读取: [lark-base-record.md](lark-base-record.md)
|
||||
|
||||
## 0. Hard Rules
|
||||
|
||||
- 全局问题不能用默认 `+record-list --limit N` 片面地回答。
|
||||
- `jq` / shell / 本地代码是在个人电脑或当前运行环境中处理已返回数据,只适合小范围结果;超过 200 行默认不推荐本地统计、排序或求极值,应改用 Base 云端查询服务的 filter/sort/aggregate。
|
||||
- “最高、最低、最新、最早、Top、Bottom、总数、全部、异常、最大、最小、最多、最少、优先级最高”等全局语义,必须在 Base 云端查询服务中完成筛选、排序或聚合。
|
||||
- `+record-search` 用于关键词检索字段的展示文本;可搜多类字段,但匹配的是文本表示(如人员命中 name),不要用它替代金额、状态、日期、空值等结构化条件。
|
||||
- 不要依赖已有视图,除非用户明确指定该视图,或你已读取并验证其 filter/sort/projection 符合当前问题。
|
||||
- 交付输出必须使用用户可读的真实字段值;内部 ID、`record_id`、关联记录 ID、open_id、编码字段只可作为连接键或定位键,不能替代最终输出,除非用户明确要求输出这些键值。
|
||||
- 每次读取必须做最小投影,并包含后续解释、回查或写入需要的业务 key。
|
||||
|
||||
## 1. Intent -> Tool Path
|
||||
|
||||
| 用户意图 | 首选路径 | 关键规则 |
|
||||
| --- | --- | --- |
|
||||
| 看几条、预览、示例 | `+record-list --limit N` | 保持局部语义;不要推广为全局结论 |
|
||||
| 已知 `record_id` | `+record-get` | 直接读取;不要 search/list 反查 |
|
||||
| 明确关键词 | `+record-search` | 按字段展示文本命中;使用 `search_fields` 限定匹配范围、`select_fields` 投影降低返回内容 token 量;不要把文本检索当作结构化关联解析 |
|
||||
| 按条件找明细记录 | 先创建临时视图设置筛选和可见字段,再用 `+record-list --view-id` 读取 | 条件字段来自 `+field-list`;不要先读全表再本地过滤 |
|
||||
| 排序 / TopN 原始记录 | 临时视图 filter/sort/projection -> `+record-list --view-id --limit N` | 最高/最新降序,最低/最早升序 |
|
||||
| 聚合 / 分组 / 分组排序 | `+data-query` | 使用 filters/dimensions/measures/sort/limit |
|
||||
| 聚合后输出实体字段 | `+data-query` 得到业务 key -> record 路径回查明细 | `+data-query` 不返回原始记录或 link 明细;聚合结果中的 key 需要再解析成用户要求字段 |
|
||||
| 多表 / 多跳关联 | 以候选数最小的事实表为驱动表,沿业务 key 或 link `record_id` 逐跳回查 | 读出 link 单元格里的关联 `record_id` 后,到被关联表批量 `+record-get` 展示字段,并在回答用结果中合并展示 |
|
||||
| 查询后写入 / 视图化 | 先用本 SOP 得到可复核的目标记录 id 集合 | 再进入记录写入或视图配置;高价值可复用查询优先沉淀为持久视图 |
|
||||
|
||||
## 2. Execution Patterns
|
||||
|
||||
### 2.1 结构化明细与 TopN
|
||||
|
||||
使用视图路径:
|
||||
|
||||
1. `+field-list` 确认筛选字段、排序字段、展示字段、业务 key。
|
||||
2. `+view-create` 创建 grid 视图。
|
||||
3. 设置 filter/sort/visible fields。
|
||||
4. `+record-list --view-id <view_id> --limit <N>` 读取结果。
|
||||
|
||||
不要从未筛选、未排序的全表输出中手动挑选。一次性查询可用临时视图;如果这个筛选/排序结果对用户后续查看有价值,应保留为持久视图,不要删除,并告知用户视图名称和用途。视图参数细节见 view-set references。
|
||||
|
||||
### 2.2 聚合分析与 TopN
|
||||
|
||||
使用 `+data-query`:
|
||||
|
||||
- 让 Base 云端查询服务完成 filters、dimensions、measures、sort、pagination.limit。
|
||||
- `pagination.limit` 是 Base 云端查询服务中的聚合结果限制,不是本地分页扫描。
|
||||
- 需要输出明细或用户可读字段时,先拿业务 key,再用 record 路径精确回查。
|
||||
- 字段类型、日期 value、DSL shape 以 [lark-base-data-query.md](lark-base-data-query.md) 为准。
|
||||
|
||||
### 2.3 关系查询与回查
|
||||
|
||||
- link 单元格通常是关联表 `record_id` 数组,不是用户可读内容,只是连接键。
|
||||
- 先用 `+field-list` 确认 link 字段的 `link_table`、业务唯一键和展示字段。
|
||||
- 从驱动表拿到候选记录后,用关联 `record_id` 到关联表 `+record-get` 批量读取记录内容。
|
||||
- 多跳关系逐跳建立 `record_id/key -> 用户可读字段` 映射;最终用户可读的信息。
|
||||
|
||||
禁止:
|
||||
|
||||
- 把 link `record_id` 当最终输出。
|
||||
- 用 `+record-search` 搜 link `record_id`。
|
||||
- 基于 ID、自增编号、link 值做语义猜测;禁止依赖字段先验、样本记忆补全交付输出。
|
||||
|
||||
## 3. Range & Pagination Contract
|
||||
|
||||
- `+record-list` 默认页、固定 `--limit`、本地 `jq`、shell 管道、手工浏览输出,都只覆盖已读取范围;超过 200 行不要把本地处理当作推荐路径。
|
||||
- `has_more=true`、存在下一页 offset/page token、或返回行数等于 page size,都表示可能还有未读取数据。
|
||||
- 对全局问题,只有 Base 云端查询服务已经通过 filter/sort/aggregate 收敛目标范围,或 `data-query` 已在云端完成聚合、排序和限制时,才可以用有限返回形成结论。
|
||||
- 必须全量导出时,按 CLI 分页语义串行翻页;不要并发调用 `+record-list`。
|
||||
|
||||
## 4. Final Answer Check
|
||||
|
||||
形成交付输出前必须能确认:
|
||||
|
||||
- 问题范围是局部样例、单点定位、全局明细、聚合分析、多表关联,还是查询后写入。
|
||||
- 筛选、排序、聚合是否发生在 Base 云端查询服务中,而不是本地 `jq` / shell 中。
|
||||
- 如果使用 `jq` / shell,本地输入是否是 200 行以内的小范围结果;超过 200 行是否已改用 Base 云端查询服务查询。
|
||||
- 如果使用 `+record-list`,是否处理了 `has_more`,且投影包含业务 key 和解释字段。
|
||||
- 如果涉及关系查询,是否按 `record_id` 或业务 key 精确回查,交付输出是否来自关联表真实字段。
|
||||
- 交付输出能追溯到表、字段、筛选条件、排序/聚合条件和连接键。
|
||||
|
||||
任一项无法确认时,继续查询或明确说明只能得到局部结论。
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
对多维表格数据进行聚合查询(分组、过滤、排序、聚合计算),基于以下语法的 JSON DSL:
|
||||
|
||||
查询类任务还必须先遵守 [`lark-base-data-analysis-sop.md`](lark-base-data-analysis-sop.md)。`+data-query` 适合让筛选、分组、聚合、排序和 TopN 在 Base 云端查询服务中执行;不要用默认分页的 `+record-list` 或本地 `jq` 替代聚合查询。
|
||||
|
||||
## 限制
|
||||
|
||||
- **权限要求**(按文档类型分流):
|
||||
@@ -51,6 +53,23 @@ lark-cli base +data-query \
|
||||
"measures": [{"field_name": "金额", "aggregation": "sum", "alias": "total"}],
|
||||
"shaper": {"format": "flat"}
|
||||
}'
|
||||
|
||||
# 聚合后如需读取明细,先让 data-query 返回可回查的业务 key
|
||||
lark-cli base +data-query \
|
||||
--base-token MAGObxxxxx \
|
||||
--dsl '{
|
||||
"datasource": {"type": "table", "table": {"tableId": "tblxxxxxxxx"}},
|
||||
"dimensions": [{"field_name": "业务编号", "alias": "biz_key"}],
|
||||
"measures": [{"field_name": "指标值", "aggregation": "max", "alias": "max_value"}],
|
||||
"filters": {
|
||||
"type": 1,
|
||||
"conjunction": "and",
|
||||
"conditions": [{"field_name": "状态", "operator": "is", "value": ["有效"]}]
|
||||
},
|
||||
"sort": [{"field_name": "max_value", "order": "desc"}],
|
||||
"pagination": {"limit": 10},
|
||||
"shaper": {"format": "flat"}
|
||||
}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -397,6 +416,19 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|
||||
- 每个 value 是 CellValue 对象,实际值在 `value` 字段中,如 `{"value": "北京"}` 或 `{"value": 12345.00}`
|
||||
- 失败时结果在 `data.error` 中,包含具体错误码和信息
|
||||
|
||||
## 与记录读取组合
|
||||
|
||||
`+data-query` 不返回原始记录或 link 字段明细。需要输出聚合结果对应的原始记录字段、展示值或关联表字段时,按以下方式组合:
|
||||
|
||||
1. 用 `+data-query` 在 Base 云端查询服务中完成全局筛选、分组、聚合、排序和 TopN,得到业务 key、分组值或候选范围。
|
||||
2. 如果已经拿到候选记录的 `record_id`,用 `+record-get` 读取明细字段。
|
||||
3. 如果拿到的是结构化业务 key(例如编号、状态、日期、金额等),优先创建临时视图做精确过滤后再 `+record-list --view-id` 读取;不要用 `+record-search` 代替结构化条件。
|
||||
4. 只有候选条件本身是文本展示值关键词时,才使用 `+record-search`,并用 `search_fields` 限定范围、`select_fields` 做投影。
|
||||
5. 若候选记录包含 link 字段,提取关联 `record_id` 后到关联表用 `+record-get` 批量读取展示字段。
|
||||
6. 最终回答业务字段,不要把内部 `record_id` 当作用户可读答案。
|
||||
|
||||
不要把 `data-query pagination.limit` 理解为分页扫描;它只限制 Base 云端查询服务返回的聚合结果行数,不支持 offset。需要全量明细导出时回到 data analysis SOP 的 record 分页规则。
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ **必须先查表结构**:DSL 的 `field_name` 必须与表中字段名称精确匹配(区分大小写),不能凭猜测构造。先用 `lark-cli base +field-list --base-token <base_token> --table-id <table_id>` 获取真实字段名
|
||||
@@ -408,10 +440,12 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|
||||
- ⚠️ **数据表标识 `tableId` vs `tableName`**:datasource 中可以用 `tableId`(如 `tblXXX`)或 `tableName`(数据表的用户自定义显示名称),二选一,不要混用
|
||||
- ⚠️ **`pagination.limit` 最大 5000**:超过会报错,且不支持 offset,只支持 limit
|
||||
- ⚠️ **所有 alias 必须全局唯一**:dimensions 和 measures 之间的 alias 也不能重名
|
||||
- ⚠️ **不要用本地分页结果替代 data-query**:凡是全局计数、分组、聚合、排序 TopN,优先让 `+data-query` 在 Base 云端查询服务中执行;默认页 `+record-list` 后本地统计只能得到已读取范围内的结果
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
- [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) — 查询范围、选路、下推、分页、record 明细回查和关系查询 SOP
|
||||
- [lark-base-cell-value.md](lark-base-cell-value.md) — CellValue 格式规范
|
||||
- [lark-base-shortcut-field-properties.md](lark-base-shortcut-field-properties.md) — shortcut 字段类型与 JSON 结构
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# base record read SOP
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和全局参数。
|
||||
|
||||
记录读取由 6 个功能组合完成:选路、字段投影、视图预处理、分页与范围、返回结构解释、link 关联读取。
|
||||
|
||||
## 1. 读取选路
|
||||
|
||||
| 场景 | 使用方式 | 规则 |
|
||||
|------|------|------|
|
||||
| 已知 `record_id` | `+record-get` | 只读单条记录,不要用 search/list 反查。 |
|
||||
| 明确关键词检索 | `+record-search` | 只用于文本关键词检索;金额、状态、日期等结构化条件不要用 search。 |
|
||||
| 普通明细读取 / 导出 / 查看前 N 条 | `+record-list` | 优先加 `--view-id` 时只读该视图可见记录与可见字段;或者加 `--field-id` 手动裁剪字段;不传 `--view-id` 时会读取全表。 |
|
||||
| 明确筛选 / 排序 / Top N / Bottom N 且需要原始记录或 `record_id` | 创建带 filter + sort 的临时视图 + `+record-list --view-id` | 让视图完成 filter/sort projection,LLM 不擅长手工筛选排序,建议用视图完成。 |
|
||||
| 统计 / 聚合结果且不需要 `record_id` | 转到 [`lark-base-data-query.md`](lark-base-data-query.md) | `data-query` 是特殊分析 DSL,不是记录读取工具。 |
|
||||
|
||||
## 2. 字段投影
|
||||
|
||||
- `FieldListFirst`: 不清楚字段结构时先 `+field-list`,确认筛选字段、排序字段、展示字段、关联字段、业务唯一键字段。
|
||||
- `UseRealField`: 字段名和字段 ID 必须来自 `+field-list` 返回,不要凭自然语言猜字段名。
|
||||
- `MinimalProjection`: 每次读取只返回本次任务需要的字段;`+record-list` 用重复 `--field-id`,视图读取用 `+view-set-visible-fields`。
|
||||
- `FieldScopePriority`: 返回字段优先级为显式投影字段(`+record-list --field-id` / `record-search select_fields`) > 视图可见字段 > 全表字段;需要稳定列范围时必须显式投影。
|
||||
- `LongFieldAvoidance`: 默认不要读取 `trace`、`raw`、长文本、附件等高噪声字段,除非任务明确需要。
|
||||
- `BusinessKey`: 后续要定位、更新或解释记录时,投影中必须包含可识别业务字段,例如订单号、日报ID、姓名、编号。
|
||||
|
||||
## 3. 视图预处理
|
||||
|
||||
适用于结构化筛选、排序、最高/最低、倒数、Top/Bottom N、按条件找记录等场景。
|
||||
|
||||
1. `+field-list` 获取字段 ID、字段名和字段类型。
|
||||
2. `+view-create` 创建临时 `grid` 视图,名称带任务语义,例如 `tmp_query_销售额升序`。
|
||||
3. `+view-set-filter` 设置筛选条件;空值是否参与必须按用户语义判断。
|
||||
4. `+view-set-sort` 设置排序条件;最高/最新用降序,最低/最早/倒数用升序。
|
||||
5. `+view-set-visible-fields` 设置投影字段,只保留业务键、排序字段、筛选解释字段、需要展示或二跳的字段。
|
||||
6. `+record-list --view-id <view_id> --limit <N>` 读取结果;不要再从未排序全表输出中手动挑选。
|
||||
|
||||
## 4. 分页与范围
|
||||
|
||||
- `ViewScope`: URL 带 `view_id` 时先判断用户是否要求“该视图下”;全表问题不要误用 URL 视图范围,应该根据需求创建合适的临时视图完成查询任务。
|
||||
- `ViewIdScope`: `+record-list --view-id` 是作用域参数;仅用于用户指定的视图,或本次任务主动创建的临时筛选 / 排序 / 投影视图。
|
||||
- `NeedAllPages`: 用户要求全部、导出、统计、最高/最低且未用视图/limit 限定时,必须检查 `has_more` 并串行翻页。
|
||||
- `LimitWhenScoped`: 用户只要示例、前 N 条、Top/Bottom N,使用 `--limit` 控制结果规模。
|
||||
- `NoConcurrentList`: `+record-list` 禁止并发调用;分页和多表读取必须串行。
|
||||
- `DataQueryScope`: `data-query` 的筛选 DSL 与视图筛选不是同一套语法;不要混用。
|
||||
|
||||
## 5. 返回结构解释
|
||||
|
||||
- `ColumnMapping`: `fields` / `field_id_list` 定义 `data` 每列含义;解释记录前先建立列到字段名的映射。
|
||||
- `RowMapping`: `record_id_list[i]` 与 `data[i]` 是同一行;需要后续定位、更新或关联时,按下标整理成 `record_id + 字段名:值` 的小表。
|
||||
- `BusinessMatch`: 后续引用目标记录时按业务字段匹配,不靠肉眼数行号。
|
||||
- `FieldType`: 按字段类型解释值;数字、货币、日期、人员、formula、lookup、attachment、link 不要当普通文本处理。
|
||||
- `EmptyValue`: 空值参与筛选或排序前必须明确语义;不要默认把空值当 `0`、空字符串或有效状态。
|
||||
- `AnswerCheck`: 最终回答前复核答案记录来自读取结果、筛选排序已应用、字段含义和 record_id 映射无误。
|
||||
|
||||
## 6. link 关联字段读取
|
||||
|
||||
link 字段是关联单元格;读取结果通常是关联表的 `record_id` 数组,不是用户可读名称。
|
||||
|
||||
| 步骤 | 做法 |
|
||||
|------|------|
|
||||
| 识别 link 字段 | 用 `+field-list` 查看字段类型为 `link`,并读取 `link_table` 确认关联目标表。 |
|
||||
| 读取当前表 | 在当前表 `+record-list` / `+record-get` 中保留 link 字段和业务键字段。 |
|
||||
| 解析单元格值 | link 单元格通常形如 `[{"id":"rec..."}]`;提取其中每个 `id` 作为关联表 `record_id`。 |
|
||||
| 读取关联表 | 到 `link_table` 使用 `+record-get --record-id <rec...>` 或裁剪后的 `+record-list` 读取显示字段。 |
|
||||
| 建立映射 | 形成 `关联record_id -> 显示字段值` 映射,再回填当前表结果。 |
|
||||
| 多值处理 | 多个关联值保持原顺序;可去重批量读取,但回答时按原单元格顺序输出。 |
|
||||
|
||||
禁止事项:
|
||||
|
||||
- 不要把 link 单元格里的 `record_id` 当作最终答案。
|
||||
- 不要用 `+record-search` 搜索 link `record_id` 来查关联记录。
|
||||
- 不要凭关联 `record_id` 猜名称、负责人、门店等显示值。
|
||||
- 不要只看当前表字段名推断关联表结构;跨表读取前必须拿关联表字段结构。
|
||||
|
||||
## 7. 命令 help
|
||||
|
||||
- `HelpFirst`: 参数、示例、JSON shape 和取值约束以 `lark-cli base +record-get --help`、`+record-search --help`、`+record-list --help` 为准。
|
||||
- `RecordSearchJson`: 构造 `+record-search --json` 前先看 `+record-search --help`,确认 `keyword/search_fields/select_fields/view_id/offset/limit` 的结构和约束。
|
||||
- `RecordListProjection`: 构造 `+record-list` 前先看 `+record-list --help`,确认 `--field-id`、`--view-id`、`--offset`、`--limit` 的语义。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-view-set-filter.md](lark-base-view-set-filter.md)
|
||||
- [lark-base-view-set-sort.md](lark-base-view-set-sort.md)
|
||||
- [lark-base-view-set-visible-fields.md](lark-base-view-set-visible-fields.md)
|
||||
- [lark-base-data-query.md](lark-base-data-query.md)
|
||||
@@ -8,7 +8,7 @@ record 相关命令索引。
|
||||
|
||||
| 文档 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| [lark-base-record-read-sop.md](lark-base-record-read-sop.md) | `+record-get` / `+record-search` / `+record-list` | 记录读取统一选路、筛选排序投影 SOP |
|
||||
| [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) | `+record-get` / `+record-search` / `+record-list` / `+data-query` / 视图筛选排序 | 数据查询与分析统一选路、筛选排序投影、聚合后回查明细 SOP |
|
||||
| [lark-base-record-upsert.md](lark-base-record-upsert.md) | `+record-upsert` | 创建或更新记录 |
|
||||
| [lark-base-record-batch-create.md](lark-base-record-batch-create.md) | `+record-batch-create` | 按 `fields/rows` 批量创建记录 |
|
||||
| [lark-base-record-batch-update.md](lark-base-record-batch-update.md) | `+record-batch-update` | 批量更新记录 |
|
||||
@@ -19,7 +19,7 @@ record 相关命令索引。
|
||||
|
||||
## 说明
|
||||
|
||||
- 读取记录前优先阅读 [lark-base-record-read-sop.md](lark-base-record-read-sop.md),它合并了 `+record-get` / `+record-search` / `+record-list` 的选路和 SOP。
|
||||
- 读取记录前优先阅读 [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md),它合并了 `record / view / data-query` 的选路、分页、投影、聚合后回查明细和 link 关联读取。
|
||||
- 聚合页只保留目录职责;写入、删除、历史等命令的详细说明请进入对应单命令文档。
|
||||
- 所有 `+xxx-list` 调用都必须串行执行;若要批量跑多个 list 请求,只能串行执行。
|
||||
- `+record-list` 支持重复传参 `--field-id` 做字段筛选。
|
||||
|
||||
121
skills/lark-vc-agent/SKILL.md
Normal file
121
skills/lark-vc-agent/SKILL.md
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: lark-vc-agent
|
||||
version: 1.0.0
|
||||
description: "飞书视频会议:让机器人代当前用户加入/离开正在进行的会议,并读取会议期间的实时事件(参会人加入与离开、发言、聊天、屏幕共享等)。1. 用户提供 9 位会议号、要求代为入会或离会时使用 +meeting-join / +meeting-leave——会真实产生入会/离会记录。2. 会议进行中用户想知道“谁加入了”“谁离开了”“谁在发言”“有人共享屏幕吗”等会中动态时,机器人入会后用 +meeting-events 读取事件时间线。3. 典型场景:参会机器人、会中助手、代为旁听、代为参会。前提:机器人只能读到它自己参会过且仍在进行中的会议的事件;查询已结束会议的参会名单、纪要或逐字稿请使用 lark-vc 技能。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli vc --help"
|
||||
---
|
||||
|
||||
# vc-agent (v1)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取以下两份 skill 文档:**
|
||||
|
||||
- [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、身份切换、权限处理
|
||||
- [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) — 视频会议的核心概念(Meeting / Note / Minutes 等),本 skill 直接复用,不再重复定义
|
||||
|
||||
## 内测提示
|
||||
|
||||
- 当前功能正在内测中,仅少数用户可用。忽略 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 中的权限申请流程,以下方的指引为准。
|
||||
- 如果 `lark-cli` 提示 `missing required scope(s)` / `permission_violations`,不要申请对应权限,而是提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`。
|
||||
- 如果 `lark-cli` 返回 `error.code=20017` / `ErrNotInGray`,提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`。
|
||||
|
||||
## 定位
|
||||
|
||||
本 skill 与 [`lark-vc`](../lark-vc/SKILL.md) 并列:
|
||||
|
||||
- **`lark-vc`** **负责"会后查询"**:搜索历史会议、参会人快照、纪要/逐字稿/录制
|
||||
- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 机器人离会
|
||||
|
||||
按此分工路由,避免两个 skill 语义混淆。
|
||||
|
||||
| 用户意图示例 | 应路由到 |
|
||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| "帮我入会 123456789"、"代我参会"、"让机器人进会旁听" | **本 skill** `+meeting-join` |
|
||||
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"(**进行中会议**,且**机器人已入会**) | **本 skill** `+meeting-events` |
|
||||
| "退出会议"、"让机器人离开" | **本 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)(发群) |
|
||||
|
||||
## 核心场景
|
||||
|
||||
### 1. 加入正在进行的会议(写操作)
|
||||
|
||||
1. 只有用户明确表达"让 Agent **真实入会**"(参会机器人、会中助手、代为旁听、代参会)时才用 `+meeting-join`。只是查数据不要入会。
|
||||
2. `+meeting-join --meeting-number` 只接受 **9 位纯数字**会议号,不是会议链接整串、也不是 `meeting_id`。
|
||||
3. 返回体中的 `meeting.id` **必须立刻记录**——后续 `+meeting-events` / `+meeting-leave` 都靠它,**不能用 9 位会议号替代**。
|
||||
4. 入会对所有参会人可见,执行前核实 9 位会议号来源,避免误入错会。
|
||||
5. 仅支持 `user` 身份,需提前 `lark-cli auth login`。
|
||||
6. 若入会失败,优先查看 `+meeting-join` reference 的错误排查段落,重点确认会议号、密码、会议状态、等候室 / 审批以及会议是否禁止当前身份加入。
|
||||
|
||||
### 2. 感知会中事件(读操作)
|
||||
|
||||
1. 用户要看"会议里正在发生什么"(参会人加入/离开、聊天、转写、屏幕共享)时,用 `+meeting-events`。
|
||||
2. 输入是 **`meeting_id`**(长数字 ID),不是 9 位会议号。
|
||||
3. Bot 必须**真实参会过**(先 `+meeting-join`),否则事件流通常不可见。具体的状态边界、结束后宽限窗口与错误码(如 `10005 / 20001 / 20002`)请查看 `+meeting-events` reference。
|
||||
4. **不能做会后复盘**,**不能替代参会人快照查询**。如果会议已结束:
|
||||
- 想拿纪要文档或逐字稿文档 token:用 `lark-cli vc +notes --meeting-ids <meeting.id>`
|
||||
- 想拿 AI 产物(summary / todos / chapters)或导出逐字稿文件:先用 `lark-cli vc +recording --meeting-ids <meeting.id>` 拿 `minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`
|
||||
- 想看参会人快照:用 `vc meeting get --with-participants`(见 [`lark-vc`](../lark-vc/SKILL.md))
|
||||
5. **默认必须使用** **`--page-all`**,除非用户明确要求“只查一页”,或确实需要控制返回体大小。
|
||||
6. 输出格式默认优先 `--format pretty`(时间线更易读);只有在需要完整保留原始消息流与结构化字段时,才使用 `--format json`。
|
||||
7. **必须识别分页信号**:只要响应里出现 `has_more=true`、pretty 里的 `more available`,或返回了非空 `page_token`,就不能把当前结果当作完整事件流;默认应继续分页,或明确告诉用户当前只是部分结果。
|
||||
8. 保留响应里的 `page_token`,下次增量拉取直接续,不要从头再拉。
|
||||
9. **只要你是基于** **`+meeting-events`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
|
||||
|
||||
### 3. 离开会议(写操作)
|
||||
|
||||
1. 任务完成、或用户要求结束时,用 `+meeting-leave --meeting-id <从 +meeting-join 拿到的 meeting.id>`。
|
||||
2. `--meeting-id` **必须**是 `+meeting-join` 返回的长数字 `meeting.id`,**不接受 9 位会议号**。
|
||||
3. 离会**立即生效**,机器人从会议的参会人列表中消失,对其他参会人可见;若需要重新入会,再跑一次 `+meeting-join` 即可(非真正"不可逆")。
|
||||
4. 仅支持 `user` 身份。
|
||||
|
||||
### 4. Agent 参会最小闭环示范
|
||||
|
||||
```bash
|
||||
# 1. 入会,捕获 meeting.id
|
||||
JOIN=$(lark-cli vc +meeting-join --meeting-number 123456789 --format json)
|
||||
MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
|
||||
|
||||
# 2. 会中轮询事件
|
||||
# 默认用 --page-all 拉全当前可见事件;下次增量优先复用 page_token
|
||||
# 典型间隔 10-30 秒
|
||||
lark-cli vc +meeting-events --meeting-id "$MID" --page-all --format pretty
|
||||
|
||||
# 3. 任务完成或用户要求结束时离会
|
||||
lark-cli vc +meeting-leave --meeting-id "$MID"
|
||||
|
||||
# 4. 会后可选:取纪要 / 逐字稿(跨到 lark-vc)
|
||||
lark-cli vc +notes --meeting-ids "$MID"
|
||||
```
|
||||
|
||||
## Shortcuts
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
|
||||
|
||||
| Shortcut | 类型 | 说明 |
|
||||
| --------------------------------------------------------------- | -- | -------------------------------------------------------------------------- |
|
||||
| [`+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-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-leave` 前**必须**阅读 [references/lark-vc-agent-meeting-leave.md](references/lark-vc-agent-meeting-leave.md),了解 `meeting_id` 的来源与写操作可见性。
|
||||
|
||||
## 权限表
|
||||
|
||||
| Shortcut | 所需 scope |
|
||||
| ----------------- | ------------------------------ |
|
||||
| `+meeting-join` | `vc:meeting.bot.join:write` |
|
||||
| `+meeting-events` | `vc:meeting.meetingevent:read` |
|
||||
| `+meeting-leave` | `vc:meeting.bot.join:write` |
|
||||
|
||||
## 延伸
|
||||
|
||||
- 查已结束会议、参会人快照、搜索历史会议 → [`lark-vc`](../lark-vc/SKILL.md)
|
||||
- 会议纪要、逐字稿 → [`lark-vc`](../lark-vc/SKILL.md) 的 `+notes`
|
||||
- 妙记产物(AI 总结 / 转写 / 章节)→ [`lark-minutes`](../lark-minutes/SKILL.md)
|
||||
- 会后把产物发到群 / 私聊 → [`lark-im`](../lark-im/SKILL.md)
|
||||
- 认证、身份切换、scope 管理 → [`lark-shared`](../lark-shared/SKILL.md)
|
||||
247
skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md
Normal file
247
skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md
Normal file
@@ -0,0 +1,247 @@
|
||||
|
||||
# vc +meeting-events
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查询当前 bot 在一场正在进行的视频会议中收到的会中事件列表。该命令是**读操作**。对进行中会议,要求 bot 当前仍在会中;对已结束会议,存在一个**结束后 5 分钟内的宽限窗口**,只要 bot 曾经在这场会里出现过,仍可继续拉取事件。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-events`(调用 `GET /open-apis/vc/v1/bots/events`)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 默认用法:全量拉取当前可见事件
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty
|
||||
|
||||
# 指定时间范围,并拉全该时间窗内当前可见事件
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --page-all --format pretty
|
||||
|
||||
# 基于上一次保存的 page_token 继续查新增事件
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-token <last_page_token> --page-all --format pretty
|
||||
|
||||
# 调试或控制返回体大小时,显式只查一页
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json
|
||||
|
||||
# 预览 API 调用(不实际请求)
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--meeting-id <id>` | 是 | 会议 ID(长数字 ID,不是 9 位会议号) |
|
||||
| `--start <time>` | 否 | 起始时间,支持 ISO 8601 / `YYYY-MM-DD` / Unix 秒 |
|
||||
| `--end <time>` | 否 | 结束时间,支持 ISO 8601 / `YYYY-MM-DD` / Unix 秒 |
|
||||
| `--page-token <token>` | 否 | 从指定分页游标继续拉取下一页 |
|
||||
| `--page-size <n>` | 否 | 单页模式每页大小。CLI 会自动夹紧到 `20-100`;传 `--page-all` 时固定使用 `100` |
|
||||
| `--page-all` | 否 | 自动分页,直到没有更多页面为止(内部有安全上限) |
|
||||
| `--format <fmt>` | 否 | 输出格式:json (CLI 默认) / pretty(本 skill 推荐默认) / table / ndjson / csv |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 输入必须是 meeting_id,不是 9 位会议号
|
||||
|
||||
`--meeting-id` 必须是会议的长数字 ID。它通常来自:
|
||||
- `+meeting-join` 返回体中的 `meeting.id`
|
||||
- `+search` 结果中的 `id`
|
||||
|
||||
**不要**把 9 位会议号(`--meeting-number`)传给这个命令。
|
||||
|
||||
### 2. 仅支持 user 身份
|
||||
|
||||
该命令仅支持 `user` 身份。
|
||||
|
||||
### 3. bot 必须在会中,或在会议结束后的 5 分钟宽限窗口内曾经在会中
|
||||
|
||||
这是查询“bot 在会中观察到的事件”的接口。若 bot 已离会、未入会、或会议已经无法再判断 bot 身份,后端通常会报:
|
||||
- `bot is not in meeting, no permission`
|
||||
|
||||
因此,最稳妥的调用顺序通常是:
|
||||
|
||||
```bash
|
||||
# 先入会
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 记录返回的 meeting.id
|
||||
|
||||
# 再查询事件
|
||||
lark-cli vc +meeting-events --meeting-id <meeting.id>
|
||||
```
|
||||
|
||||
更精确地说,后端当前的判断规则是:
|
||||
|
||||
- **会议进行中**:要求 bot **当前仍在会中**
|
||||
- **会议已结束后的 5 分钟内**:只要 bot **曾经在这场会中出现过**,仍可拉取事件
|
||||
- **会议结束超过 5 分钟**:按会议结束处理,通常不再返回事件流
|
||||
- **bot 从未真实入会过**:即使会议仍在进行或刚结束,也会返回 `10005 bot is not in meeting`
|
||||
|
||||
### 4. 自动分页规则
|
||||
|
||||
- **先分清两层默认值**:
|
||||
- shortcut 本身:不传 `--page-all` 时,只查 1 页。
|
||||
- 本 skill 的默认策略:除非用户明确要求只看一页,或你确实需要控制返回体大小,否则默认**必须主动带 `--page-all`**,把当前可见事件尽量一次拉全。
|
||||
- 传 `--page-all`:开启自动分页,直到没有更多页面为止。
|
||||
- `--page-all` 时,CLI 固定使用最大 `page_size=100`。
|
||||
|
||||
执行准则:
|
||||
|
||||
- **默认命令模板**:`lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty`
|
||||
- 如果你发现自己执行成了不带 `--page-all` 的单页查询,而响应里又出现 `has_more=true` / `more available` / 非空 `page_token`,应立刻意识到这只是部分结果。
|
||||
- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --meeting-id <meeting.id> --page-token <returned_page_token> --page-all --format pretty`
|
||||
- 只有在用户明确要求“就看第一页”“先不要翻页”时,才不要默认带 `--page-all`
|
||||
- 只要你是基于 `+meeting-events` 来回答一场**正在进行中的会议内容**,就不能直接复用上一次查询结果。无论用户是在问“现在是谁在说话”“刚刚发生了什么”“最新事件有哪些”,还是让你“总结一下这个会议讲什么”,都必须先重新执行一次 `+meeting-events`,确认拿到的是最新事件流,再回答用户。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
|
||||
|
||||
### 5. pretty / json 输出差异
|
||||
|
||||
- `--format pretty`:输出会议主题、会议时间和逐条时间线,适合快速理解“发生了什么”,也是本 skill 的默认推荐格式。
|
||||
- `--format json`:保留完整原始 `events[]` 结构——参会人 open_id、聊天原文、share_doc、分页字段都在原始响应里,适合提取字段、联动其他命令或做进一步程序处理。
|
||||
|
||||
**选型原则**:只要目标是告诉用户“发生了什么”,默认就用 `--page-all --format pretty`;只有在需要完整原始消息流和结构化字段时,才改用 `json`。
|
||||
|
||||
> **注意**:pretty 输出中的正文文本会做单行转义,真实换行会显示为 `\n`,避免打乱时间线布局。
|
||||
|
||||
### 6. 内容理解模式:共享文档不能只看标题
|
||||
|
||||
当用户意图是:
|
||||
|
||||
- “总结这个会议”
|
||||
- “这个会议讲了什么”
|
||||
- “有哪些结论 / 待办 / 关键讨论”
|
||||
- “共享文档里在讲什么”
|
||||
|
||||
不要只基于事件时间线直接回答。此时 `+meeting-events` 只是**线索发现器**,不是最终信息源。
|
||||
|
||||
执行准则:
|
||||
|
||||
- 这类问题默认先用 `lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format json` 拉取最新事件流。
|
||||
- 如果事件中出现共享文档线索,例如:
|
||||
- `magic_share_started`
|
||||
- `share_doc.title`
|
||||
- `share_doc.url`
|
||||
- 必须继续读取共享文档内容,再生成总结,不能只根据“开始共享了某文档”这条事件和文档标题来概括会议内容。
|
||||
- 若存在多个共享文档,优先读取**最近一次共享**的文档。
|
||||
- 若文档读取失败,必须明确说明“以下总结仅基于会中事件流,未成功读取共享文档内容”。
|
||||
|
||||
### 7. 关于 `page_token` 的返回与续拉
|
||||
|
||||
- 不管这次是只查 1 页,还是通过 `--page-all` 已经把当前可见事件都拿完,都应把最后拿到的 `page_token` 一并保留下来并返回给用户。
|
||||
- 只要响应里出现 `has_more=true`、pretty 里出现 `more available`,或返回了非空 `page_token`,就必须先判断当前结果是否完整;默认情况下,这意味着你还需要继续分页。
|
||||
- 如果没有使用 `--page-all`,但出现了上述分页信号,默认应继续用返回的 `page_token` 拉下一页,而不是直接结束。只有在用户明确不要继续翻页时,才可以停止并明确说明当前结果不完整。
|
||||
- 下次继续“查新增事件”时,应优先复用上一次保存的 `page_token`,而不是从头全量再拉一次。
|
||||
- 只有在用户明确要求“从头回放全部事件”时,才忽略历史 `page_token`,重新从第一页开始。
|
||||
- 但如果用户要你回答的是**当前这场会正在讲什么**,而不是“上一次之后新增了什么”,也要先做一次新的事件查询,再决定是否需要基于旧 `page_token` 继续补拉。
|
||||
|
||||
## 返回结构
|
||||
|
||||
常见顶层字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `events` | 事件列表 |
|
||||
| `has_more` | 是否还有下一页 |
|
||||
| `page_token` | 下一页游标 |
|
||||
|
||||
事件 `event_type` 常见类型:
|
||||
|
||||
| event_type | 含义 |
|
||||
|-----------|------|
|
||||
| `participant_joined` | 有参会人加入会议 |
|
||||
| `participant_left` | 有参会人离开会议 |
|
||||
| `chat_received` | 收到会中聊天消息 |
|
||||
| `transcript_received` | 收到转写文本 |
|
||||
| `magic_share_started` | 开始共享内容 / 文档 |
|
||||
| `magic_share_ended` | 结束共享 |
|
||||
|
||||
## pretty 输出示例
|
||||
|
||||
```text
|
||||
会议主题:张三的视频会议
|
||||
会议时间:2026-04-17 15:28:52(进行中)
|
||||
|
||||
[00:00:33] 明日之虾BOE(ou_xxx) 加入了会议
|
||||
[00:00:41] 张三(ou_xxx): [text] 6666
|
||||
[00:00:44] 张三(ou_xxx) 开始共享《智能纪要:飞书20251022-140223 2026年3月9日》
|
||||
URL: https://...
|
||||
[00:01:32] 张三(ou_xxx): [reaction] JIAYI
|
||||
```
|
||||
|
||||
## 如何获取输入参数
|
||||
|
||||
| 输入参数 | 获取方式 |
|
||||
|---------|---------|
|
||||
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+search` 结果中的 `id` |
|
||||
| `start` / `end` | 用户给出的时间范围;如未给出则默认取全量可见事件 |
|
||||
| `page-token` | 上一页或上一次查询结果中保存的 `page_token`;建议持久化保存,便于下次继续拉取新增事件 |
|
||||
|
||||
## Agent 组合场景
|
||||
|
||||
### 场景 1:入会后查看会中发生了什么
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入会议,记录返回的 meeting.id
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 第 2 步:查询事件流
|
||||
lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty
|
||||
```
|
||||
|
||||
### 场景 2:过滤某段时间内的事件
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-events \
|
||||
--meeting-id <meeting.id> \
|
||||
--start 2026-04-17T15:00:00+08:00 \
|
||||
--end 2026-04-17T16:00:00+08:00 \
|
||||
--page-all \
|
||||
--format pretty
|
||||
```
|
||||
|
||||
### 场景 3:基于上一次的 `page_token` 继续查新增事件
|
||||
|
||||
```bash
|
||||
# 上一次查询结束后,保留最后返回的 page_token
|
||||
# 这次直接从该游标继续拉新增事件
|
||||
lark-cli vc +meeting-events \
|
||||
--meeting-id <meeting.id> \
|
||||
--page-token <last_page_token> \
|
||||
--page-all \
|
||||
--format pretty
|
||||
```
|
||||
|
||||
适用规则:
|
||||
|
||||
- 当用户说“继续看新事件”“看上次之后新增了什么”时,优先使用上一次保存的 `page_token`。
|
||||
- 如果这次返回里仍有 `has_more=true`、pretty 里出现 `more available`,或又返回了新的 `page_token`,说明新增事件还没拉完,应继续分页,而不是把当前页误当成完整增量结果。
|
||||
- 只有在用户明确要求“从头回放全部事件”时,才忽略已有 `page_token`,重新从第一页开始。
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入长数字 `meeting.id` |
|
||||
| `10005 bot is not in meeting` | bot 从未真实入会该会议;或会议已结束但 bot 从未在会中出现过 | 先 `+meeting-join --meeting-number <9位号>` 真实入会再查;如果会议已经结束且当时 bot 没进过会,本接口也拉不到数据。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`**(不依赖 bot 身份参会) |
|
||||
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。若要拿纪要文档或逐字稿 token,用 `lark-cli vc +notes --meeting-ids <meeting.id>`;若要拿 AI 产物(summary / todos / chapters)或导出逐字稿文件,先用 `lark-cli vc +recording --meeting-ids <meeting.id>` 拿 `minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
|
||||
| `20002 meeting not exist` | `meeting_id` 错误,或会议实例当前已不可获取(常见于把 9 位会议号当 meeting_id 传) | 确认传入的是长数字 `meeting_id`,不是 9 位会议号 |
|
||||
| `HTTP 404` / `HTTP 500` | 服务端当前无法找到或处理该会议实例 | 换一个正在进行且 bot 可见的 meeting_id,或排查后端问题 |
|
||||
|
||||
## 提示
|
||||
|
||||
- 这是**会中事件流**查询,不适合拿来搜历史会议记录;搜历史会议请用 `+search`。
|
||||
- 如果会议已经结束,不要卡在 `+meeting-events`:
|
||||
- 想拿纪要文档或逐字稿 token:用 `lark-cli vc +notes --meeting-ids <meeting.id>`
|
||||
- 想拿 AI 产物(summary / todos / chapters)或导出逐字稿文件:先用 `lark-cli vc +recording --meeting-ids <meeting.id>` 拿 `minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`
|
||||
- 事件列表是否完整,取决于 bot 何时入会、何时离会,以及后端当前可见的会中事件范围。对于已结束会议,通常只在**结束后 5 分钟内**、且 bot **曾经在会中**时还能继续拉到事件。
|
||||
- 查询"谁参加过某会议"请用 `vc meeting get --params '{"meeting_id":"<id>","with_participants":true}'`——这是参会人**快照** API,不依赖 bot 是否参会,对已结束会议也可查;**不要** 用 `+meeting-events` 做参会人查询。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 先真实入会
|
||||
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 完成任务后离会
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
|
||||
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill)
|
||||
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
133
skills/lark-vc-agent/references/lark-vc-agent-meeting-join.md
Normal file
133
skills/lark-vc-agent/references/lark-vc-agent-meeting-join.md
Normal file
@@ -0,0 +1,133 @@
|
||||
|
||||
# vc +meeting-join
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
通过 9 位会议号加入一场正在进行的视频会议(bot join)。这是一次**写操作**,会实际让当前身份加入会议。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-join`(调用 `POST /open-apis/vc/v1/bots/join`)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 仅指定会议号(无密码)
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 指定会议号 + 密码
|
||||
lark-cli vc +meeting-join --meeting-number 123456789 --password 8888
|
||||
|
||||
# 输出格式
|
||||
lark-cli vc +meeting-join --meeting-number 123456789 --format json
|
||||
|
||||
# 预览 API 调用(不实际加入会议)
|
||||
lark-cli vc +meeting-join --meeting-number 123456789 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--meeting-number <no>` | 是 | 会议号,必须为 **9 位纯数字** |
|
||||
| `--password <pw>` | 否 | 会议密码,仅在该会议设置了入会密码时传入 |
|
||||
| `--format <fmt>` | 否 | 输出格式:json (默认) / pretty / table / ndjson / csv |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 仅支持 user 身份
|
||||
|
||||
该命令仅支持 `user` 身份。
|
||||
|
||||
### 2. 会议号格式严格校验
|
||||
|
||||
`--meeting-number` 必须是 9 位纯数字,否则本地校验直接报错:
|
||||
`--meeting-number must be exactly 9 digits`。
|
||||
|
||||
常见错误来源:
|
||||
- 把会议链接整条粘进来(应仅取尾部的 9 位数字)
|
||||
- 把 `meeting_id`(长数字 ID)当成会议号传入(两者不是同一个东西)
|
||||
|
||||
### 3. 会议必须已开始且允许入会
|
||||
|
||||
- 会议必须处于**进行中**状态,bot 无法加入尚未开始或已结束的会议。
|
||||
- 若会议设置了**等候室 / 入会审批**,bot 可能需要主持人放行后才真正入会。
|
||||
- 若返回 `HTTP 403: no permission`(错误码 `121003`),不要只理解成“账号没权限”。这类报错更常见的原因是:会议参数或会控配置当前不满足入会条件,例如会议号填错、密码未传或错误、会议尚未开始、等候室 / 入会审批未放行、会议禁止外部/特定身份加入等。应先确认这些配置项,再重试。
|
||||
|
||||
### 4. 机器人入会后对其他参会人可见
|
||||
|
||||
这是一次真实入会操作,机器人会立即出现在参会人列表中,其他参会人可见,并产生会议日志。误入错会的社交成本高于技术成本——执行前优先确认 9 位会议号的来源(用户输入 / 会议链接末尾),不要臆造。参数格式有疑问时可用 `--dry-run` 预览请求体。
|
||||
|
||||
## 输出结果
|
||||
|
||||
接口返回会议基本信息,字段视具体响应而定,常见字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `meeting.id` | 会议 ID(可后续传给 `+meeting-leave --meeting-id`) |
|
||||
| `meeting.meeting_no` | 会议号(与入参一致) |
|
||||
| `meeting.topic` | 会议主题 |
|
||||
| `meeting.start_time` | 会议开始时间 |
|
||||
|
||||
> **重要**:拿到 `meeting.id` 后务必保留,退出会议(`+meeting-leave`)需要使用它,而不是会议号。
|
||||
|
||||
## 如何获取输入参数
|
||||
|
||||
| 输入参数 | 获取方式 |
|
||||
|---------|---------|
|
||||
| `meeting-number` | 会议号由主持人分享;也可从会议链接尾部解析 9 位数字 |
|
||||
| `password` | 若会议设置了入会密码,由主持人提供 |
|
||||
|
||||
## Agent 组合场景
|
||||
|
||||
### 场景 1:加入会议 → 离开会议(最小闭环)
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入会议,记录返回的 meeting.id
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 第 2 步:完成任务后,使用上一步返回的 meeting.id 离开会议
|
||||
lark-cli vc +meeting-leave --meeting-id <meeting.id>
|
||||
```
|
||||
|
||||
### 场景 2:加入会议 → 会后拉取纪要 / 录制
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入并参会
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 第 2 步:离会
|
||||
lark-cli vc +meeting-leave --meeting-id <meeting.id>
|
||||
|
||||
# 第 3 步:会议结束后,查询录制(拿到 minute_token)
|
||||
lark-cli vc +recording --meeting-ids <meeting.id>
|
||||
|
||||
# 第 4 步:查询会议纪要(总结 / 待办 / 章节 / 逐字稿)
|
||||
lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
```
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| `--meeting-number must be exactly 9 digits` | 会议号不是 9 位纯数字 | 检查是否误传了会议链接或 meeting_id |
|
||||
| 会议密码错误 | `--password` 错误或未提供 | 向主持人确认会议密码 |
|
||||
| 会议不存在 / 已结束 | 会议号错误或会议未进行中 | 确认会议正在进行中 |
|
||||
| `HTTP 403: no permission` / `121003` | 入会前置条件不满足,通常不是单纯 scope 问题 | 依次确认:1)会议允许智能体加入;2)会议号正确;3)如有密码,已正确传入 `--password`;4)会议已开始;5)等候室 / 入会审批已放行;6)会议未禁止当前身份加入(如限制外部、限制 bot、仅特定成员可入会);确认后重试 |
|
||||
| 入会被拒绝 | 等候室 / 入会审批 / 限制外部入会 | 联系主持人放行或调整会议设置 |
|
||||
|
||||
## 提示
|
||||
|
||||
- 仅在 Agent 需要**真实加入**会议(例如参会机器人、会中助手)时使用;只拉取会议数据不需要入会。
|
||||
- 入会会让机器人立即出现在参会列表;若要回退,直接 `+meeting-leave` 即可。参数格式不确定时可选 `--dry-run` 预览,但不是必经步骤。
|
||||
- 执行成功后,立即记录返回的 `meeting.id`,用于后续 `+meeting-leave` / `+meeting-events`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 对应的离会命令
|
||||
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议记录
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
|
||||
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill)
|
||||
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
111
skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md
Normal file
111
skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md
Normal file
@@ -0,0 +1,111 @@
|
||||
|
||||
# vc +meeting-leave
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
通过 `meeting_id` 离开当前身份所在的视频会议(bot leave)。这是一次**写操作**,会实际把当前身份从会议中移出。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-leave`(调用 `POST /open-apis/vc/v1/bots/leave`)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 通过 meeting_id 离会
|
||||
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28
|
||||
|
||||
# 输出格式
|
||||
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --format json
|
||||
|
||||
# 预览 API 调用(不实际离会)
|
||||
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--meeting-id <id>` | 是 | 会议 ID(**不是 9 位会议号**) |
|
||||
| `--format <fmt>` | 否 | 输出格式:json (默认) / pretty / table / ndjson / csv |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 入参是 meeting_id,不是会议号
|
||||
|
||||
`--meeting-id` 必须是会议的长数字 ID,通常由 `+meeting-join` 返回体中的 `meeting.id` 提供,也可从 `+search` 结果中的 `id` 字段获取。**传 9 位会议号会失败**。
|
||||
|
||||
### 2. 仅支持 user 身份
|
||||
|
||||
该命令仅支持 `user` 身份。只能让当前身份自己离会,无法强制移出其他参会人。
|
||||
|
||||
### 3. 当前身份必须在会议中
|
||||
|
||||
必须先通过 `+meeting-join` 或其他方式在该会议中,否则接口会报错。
|
||||
|
||||
### 4. 离会立即生效,对其他参会人可见
|
||||
|
||||
机器人会立刻从参会列表消失;若会议启用了录制/纪要,bot 的参会时段到此截止。确认任务完成再调用;如需要重新入会,再跑 `+meeting-join` 即可(非真正"不可逆")。
|
||||
|
||||
## 输出结果
|
||||
|
||||
接口成功返回时,默认输出:`Left meeting <meeting-id> successfully.`。
|
||||
`--format json` 返回 API 原始响应体。
|
||||
|
||||
## 如何获取输入参数
|
||||
|
||||
| 输入参数 | 获取方式 |
|
||||
|---------|---------|
|
||||
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+search` 结果中的 `id` 字段 |
|
||||
|
||||
## Agent 组合场景
|
||||
|
||||
### 场景 1:加入 → 完成任务 → 离开(最小闭环)
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入会议,记录 meeting.id
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 第 2 步:在会中完成任务(如监听发言、记录信息等)
|
||||
# ...
|
||||
|
||||
# 第 3 步:使用上一步记录的 meeting.id 离会
|
||||
lark-cli vc +meeting-leave --meeting-id <meeting.id>
|
||||
```
|
||||
|
||||
### 场景 2:会后补拉产物
|
||||
|
||||
```bash
|
||||
# 第 1 步:离会后会议仍在进行或已结束
|
||||
lark-cli vc +meeting-leave --meeting-id <meeting.id>
|
||||
|
||||
# 第 2 步:会议结束后查询录制
|
||||
lark-cli vc +recording --meeting-ids <meeting.id>
|
||||
|
||||
# 第 3 步:查询会议纪要
|
||||
lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
```
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入从 `+meeting-join` 得到的 `meeting.id` |
|
||||
| `meeting not found` / `invalid meeting_id` | 误传了 9 位会议号 | 必须使用 `meeting.id`,不是会议号 |
|
||||
| `not in meeting` | 当前身份并不在该会议中 | 确认先 `+meeting-join` 成功 |
|
||||
|
||||
## 提示
|
||||
|
||||
- 离会会让机器人从参会列表消失,对其他参会人可见;若需要重新入会直接再 `+meeting-join`,不是真正的"不可逆"。参数格式不确定时可选 `--dry-run` 预览。
|
||||
- 与 `+meeting-join` 成对使用:能 join 的身份才能 leave。
|
||||
- `meeting_id` 必须来自 `+meeting-join` 的返回值,不要用 9 位会议号。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 对应的入会命令
|
||||
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要
|
||||
- [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill)
|
||||
- [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-vc
|
||||
version: 1.0.0
|
||||
description: "飞书视频会议:查询会议记录、获取会议纪要产物(总结、待办、章节、逐字稿)。1. 查询已经结束的会议数量或详情时使用本技能(如历史日期| 昨天 | 上周 | 今天已经开过的会议等场景),查询未开始的会议日程使用 lark-calendar 技能。2. 支持通过关键词、时间范围、组织者、参与者、会议室等筛选条件搜索会议记录。3. 获取或整理会议纪要时使用本技能。"
|
||||
description: "飞书视频会议:搜索历史会议、查询会议纪要产物(总结、待办、章节、逐字稿)、查询会议参会人快照。1. 查询已经结束的会议数量或详情时使用本技能(如历史日期|昨天|上周|今天已经开过的会议等场景),查询未开始的会议日程使用 lark-calendar 技能。2. 支持通过关键词、时间范围、组织者、参与者、会议室等筛选条件搜索会议。3. 获取或整理会议纪要、逐字稿、录制产物时使用本技能。4. 查询“谁参加过某会议”“参会人列表”等参会人快照信息用 vc meeting get --with-participants(任意时点可查,含已结束会议)。注意:**Agent 真实入会/离会、感知正在进行中会议的实时事件**请使用 lark-vc-agent 技能,本技能不覆盖写操作和会中事件流。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -14,8 +14,7 @@ metadata:
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting\_id 标识。
|
||||
- **会议记录(Meeting Record)**:视频会议结束后生成的记录,支持通过关键词、时间段、参会人、组织者、会议室等筛选条件搜索会议室。
|
||||
- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting\_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。
|
||||
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结、待办、章节)和逐字稿文档。
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写和会议纪要,通过 minute\_token 标识。
|
||||
- **纪要文档(MainDoc)**:AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`。
|
||||
@@ -67,6 +66,23 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
|
||||
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
|
||||
```
|
||||
|
||||
### 4. 查询参会人快照(读操作)
|
||||
|
||||
用户问"谁参加过这场会议""这个会议有哪些参会人""某某参会了吗"等**参会人快照**类问题时,使用 **`vc meeting get --with-participants`**:这是参会人服务端快照 API,不依赖 bot 身份参会,**已结束会议也可查**:
|
||||
|
||||
```bash
|
||||
lark-cli vc meeting get --params '{"meeting_id":"<meeting_id>","with_participants":true}'
|
||||
```
|
||||
|
||||
选型判断表:
|
||||
|
||||
| 用户意图 | 推荐命令 | 所在 skill |
|
||||
|---------|---------|--------|
|
||||
| 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill |
|
||||
| 已结束会议的发言内容 | `vc +notes` 取 `verbatim_doc_token` 再 `docs +fetch` | 本 skill |
|
||||
| **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `vc +meeting-events` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
|
||||
| **Agent 真实入会 / 离会** | `vc +meeting-join` / `vc +meeting-leave` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
|
||||
|
||||
## 资源关系
|
||||
|
||||
```
|
||||
@@ -109,6 +125,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
|
||||
- 使用 `+notes` 命令时,必须阅读 [references/lark-vc-notes.md](references/lark-vc-notes.md),了解查询参数、产物类型和返回值结构。
|
||||
- 使用 `+recording` 命令时,必须阅读 [references/lark-vc-recording.md](references/lark-vc-recording.md),了解查询参数和返回值结构。
|
||||
|
||||
> **Agent 参会相关命令已独立**:`+meeting-join` / `+meeting-leave` / `+meeting-events` 请使用 [`lark-vc-agent`](../lark-vc-agent/SKILL.md) 技能。
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
@@ -146,3 +164,5 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa
|
||||
| `+recording --calendar-event-ids` | `vc:record:readonly`、`calendar:calendar:read`、`calendar:calendar.event:read` |
|
||||
| `+search` | `vc:meeting.search:read` |
|
||||
| `meeting.get` | `vc:meeting.meetingevent:read` |
|
||||
|
||||
> Agent 参会相关 scope(`vc:meeting.bot.join:write` / `vc:meeting.meetingevent:read`)见 [`lark-vc-agent`](../lark-vc-agent/SKILL.md)。
|
||||
|
||||
@@ -109,7 +109,7 @@ lark-cli vc +notes --minute-tokens <minute_token>
|
||||
|
||||
```bash
|
||||
# 第 1 步:搜索历史会议,拿到 meeting_ids
|
||||
lark-cli vc +search --query "周会" --start yesterday
|
||||
lark-cli vc +search --query "周会" --start 2026-03-10
|
||||
|
||||
# 第 2 步:使用上一步返回的 meeting_ids 查询录制,拿到 minute_tokens
|
||||
lark-cli vc +recording --meeting-ids <ids>
|
||||
|
||||
@@ -88,7 +88,17 @@ lark-cli vc +search --query "周会" --format json
|
||||
|
||||
当返回 `has_more=true` 时,使用响应中的 `page_token` 配合 `--page-token` 获取下一页结果。
|
||||
|
||||
### 5. 日期型 `--end` 包含当天整天
|
||||
### 5. 机器人可同时加入多个会议
|
||||
|
||||
机器人支持同时加入多个正在进行中的会议;加入新会议前,不需要先退出已经在会中的其他会议。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 不要假设 bot 一次只能在一个会议中
|
||||
- 如果用户要求 bot 再加入另一场会,可以直接继续执行对应的入会命令
|
||||
- 只有在用户明确要求结束某一场会中的 bot 参会时,才调用对应的离会命令
|
||||
|
||||
### 6. 日期型 `--end` 包含当天整天
|
||||
|
||||
当 `--end` 传入的是仅日期格式(如 `2026-03-10`)时,CLI 会将它解释为当天 `23:59:59`,而不是当天 `00:00:00`。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ metadata:
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 运行 `lark-cli --version`,确认可用,无需询问用户。
|
||||
> - 运行 `npx -y @larksuite/whiteboard-cli@^0.2.10 -v`,确认可用,无需询问用户。
|
||||
> - 运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 -v`,确认可用,无需询问用户。
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
@@ -124,7 +124,7 @@ diagram.png ← 渲染结果
|
||||
|
||||
```bash
|
||||
# 第一步:dry-run 探测
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i <产物文件> --to openapi --format json \
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
|
||||
| lark-cli whiteboard +update \
|
||||
--whiteboard-token <Token> \
|
||||
--source - --input_format raw \
|
||||
@@ -132,7 +132,7 @@ npx -y @larksuite/whiteboard-cli@^0.2.10 -i <产物文件> --to openapi --format
|
||||
--overwrite --dry-run --as user
|
||||
|
||||
# 第二步:确认后执行
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i <产物文件> --to openapi --format json \
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
|
||||
| lark-cli whiteboard +update \
|
||||
--whiteboard-token <Token> \
|
||||
--source - --input_format raw \
|
||||
|
||||
@@ -4,20 +4,24 @@
|
||||
|
||||
## 概述
|
||||
|
||||
画板 DSL 支持 `type: 'image'` 节点,但图片不能直接使用 URL,必须先上传到飞书获取 **media token**,然后在 DSL 中引用。
|
||||
画板 DSL 支持 `type: 'image'` 节点,但图片不能直接使用 URL 或其他域的 token,**必须先上传到目标画板获取 `whiteboard` 域 media token**,然后在 DSL 中引用。
|
||||
|
||||
**关键约束**:
|
||||
- 图片 token 必须通过 `docs +media-upload --parent-type whiteboard` 上传获取
|
||||
- 图片必须上传到**目标画板**(`--parent-node` 设为目标画板 token),跨画板的 token 不可用
|
||||
- `drive +upload` 获取的 Drive file token **不能**用于画板图片节点
|
||||
**核心规则**:不管图片从哪来(本地文件、URL、文档中的 `docx_image` token、其他域的 Drive token),都必须通过 `docs +media-upload --parent-type whiteboard --parent-node <目标画板token>` 上传,拿到画板专属的 media token 后才能在 DSL 中使用。直接使用非 `whiteboard` 域的 token 会导致画板 API 报 500(错误码 2891001)或图片在文档中消失。
|
||||
|
||||
## Step 0:图片准备流程
|
||||
|
||||
### 1. 下载图片
|
||||
### 1. 获取图片到本地
|
||||
|
||||
用 `curl` 下载图片到本地。**必须使用能根据关键词返回相关图片的图片源**。
|
||||
根据图片来源选择对应方式:
|
||||
|
||||
**推荐图片源**:
|
||||
| 图片来源 | 获取方式 |
|
||||
|---------|---------|
|
||||
| 本地文件 | 直接使用 |
|
||||
| 网络 URL | `curl -L -o photo.jpg "<URL>"` |
|
||||
| 文档中的图片 token | `lark-cli docs +media-download --token <token> --output ./photo.png` |
|
||||
| 其他域的 Drive token | `lark-cli docs +media-download --token <token> --output ./photo.png` |
|
||||
|
||||
**图片源选择(需要搜索图片时)**:
|
||||
|
||||
| 图片源类型 | 说明 |
|
||||
|-------|------|
|
||||
@@ -29,11 +33,6 @@
|
||||
- **关键词搜索**:支持按关键词搜索并返回相关图片,确保图片内容与主题匹配
|
||||
- **内容丰富**:图库图片种类多、数量大,能覆盖常见主题(宠物、美食、景点、产品等)
|
||||
|
||||
```bash
|
||||
curl -L -o photo1.jpg "<图片URL>"
|
||||
curl -L -o photo2.jpg "<图片URL>"
|
||||
```
|
||||
|
||||
**严禁使用随机占位图服务**:某些图库仅提供随机占位图,URL 中的关键词参数不会影响返回的图片内容,下载的图片与主题完全无关。
|
||||
|
||||
### 2. 校验图片
|
||||
@@ -74,7 +73,8 @@ lark-cli docs +media-upload --file ./photo3.jpg --parent-type whiteboard --paren
|
||||
|
||||
| 错误现象 | 原因 | 解决 |
|
||||
|---------|------|------|
|
||||
| 画板 API 返回 500(2891001) | 使用了 Drive file token 而非 media token | 改用 `docs +media-upload --parent-type whiteboard` |
|
||||
| 画板 API 返回 500(2891001) | 使用了非 `whiteboard` 域 token(如 `docx_image`、Drive file token) | 下载图片后用 `docs +media-upload --parent-type whiteboard` 重新上传 |
|
||||
| 画板 API 返回 500 | 图片上传到了其他画板 | 重新上传到目标画板 |
|
||||
| 画板在文档中图片消失 | 图片 token 的资源域与画板不匹配 | 确保图片通过 `--parent-type whiteboard --parent-node <画板token>` 上传 |
|
||||
| 图片裂开/无法显示 | token 无效或已过期 | 重新上传获取新 token |
|
||||
| 图片内容与主题无关 | 使用了随机占位图服务 | 改用免费版权图库服务 |
|
||||
|
||||
@@ -74,7 +74,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
|
||||
|
||||
```bash
|
||||
# 使用 whiteboard-cli 生成 OpenAPI 格式并通过管道传递
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i <产物文件> --to openapi --format json \
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
|
||||
| lark-cli whiteboard +update \
|
||||
--whiteboard-token <画板Token> \
|
||||
--source - --input_format raw \
|
||||
@@ -88,7 +88,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
|
||||
|
||||
```bash
|
||||
# 生成 OpenAPI 格式到文件
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i <DSL 文件> --to openapi --format json -o ./temp.json
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <DSL 文件> --to openapi --format json -o ./temp.json
|
||||
|
||||
# 从文件读取并更新
|
||||
lark-cli whiteboard +update \
|
||||
|
||||
@@ -336,7 +336,7 @@ DSL 的语法是严格白名单,不能写原生 CSS 属性(不支持 `alignS
|
||||
先出骨架图导出坐标,再基于坐标补充连线和注解:
|
||||
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i skeleton.json -o step1.png -l coords.json
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i skeleton.json -o step1.png -l coords.json
|
||||
```
|
||||
|
||||
`coords.json` 包含每个带 id 节点的精确坐标(absX, absY, width, height)。
|
||||
|
||||
@@ -272,14 +272,14 @@ SVG 通过 `image/svg+xml` Blob 加载到画布,**不在 HTML DOM 中**,因
|
||||
x?: number; y?: number;
|
||||
width?: WBSizeValue; // 默认 48
|
||||
height?: WBSizeValue; // 默认 48,保持正方形
|
||||
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.10 --icons 输出中选取
|
||||
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.11 --icons 输出中选取
|
||||
color?: string; // 可选颜色覆盖,hex 格式如 '#FF6600'
|
||||
}
|
||||
```
|
||||
|
||||
**获取可用图标**:规划好内容和布局后,运行以下命令查看所有可用图标名,从中选取:
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 --icons
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 --icons
|
||||
```
|
||||
|
||||
用法:
|
||||
|
||||
@@ -13,7 +13,7 @@ Step 1: 路由 & 读取知识
|
||||
Step 2: 生成完整 DSL(含颜色)
|
||||
- 按 content.md 规划信息量和分组
|
||||
- 按 layout.md 选择布局模式和间距
|
||||
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.10 --icons` 查看可用图标
|
||||
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 --icons` 查看可用图标
|
||||
- 按 style.md 上色(用户没指定时用默认经典色板)
|
||||
- 按 schema.md 语法输出完整 JSON
|
||||
- 连线参考 connectors.md,排版参考 typography.md
|
||||
@@ -25,12 +25,12 @@ Step 2: 生成完整 DSL(含颜色)
|
||||
|
||||
Step 3: 渲染 & 审查 → 交付
|
||||
- 渲染前自查(见下方检查清单)
|
||||
- 渲染 PNG(仅用于预览验证,不是最终产物):npx -y @larksuite/whiteboard-cli@^0.2.10 -i diagram.json -o diagram.png
|
||||
- 渲染 PNG(仅用于预览验证,不是最终产物):npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json -o diagram.png
|
||||
- 检查:信息完整?布局合理?配色协调?文字无截断?连线无交叉?
|
||||
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
|
||||
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
|
||||
- 写入画板:用 whiteboard-cli 将 diagram.json 转换为 OpenAPI 格式并 pipe 给 +update:
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i diagram.json --to openapi --format json \
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json --to openapi --format json \
|
||||
| lark-cli whiteboard +update --whiteboard-token <board_token> \
|
||||
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
|
||||
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)
|
||||
|
||||
@@ -16,10 +16,10 @@ Step 3: 渲染验证 & 写入画板 & 交付
|
||||
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
|
||||
2. 保存为 diagram.mmd
|
||||
3. 渲染(仅用于预览验证,PNG 不是最终产物):
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i diagram.mmd -o diagram.png
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd -o diagram.png
|
||||
4. 审查 PNG,有问题修改后重新渲染(最多 2 轮)
|
||||
5. 写入画板:用 whiteboard-cli 将 diagram.mmd 转换为 OpenAPI 格式并 pipe 给 +update:
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.10 -i diagram.mmd --to openapi --format json \
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd --to openapi --format json \
|
||||
| lark-cli whiteboard +update --whiteboard-token <board_token> \
|
||||
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
|
||||
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
```
|
||||
建目录 ./diagrams/YYYY-MM-DDTHHMMSS/ (例:./diagrams/2026-04-15T143022/)
|
||||
写文件 <dir>/diagram.svg
|
||||
渲染 npx -y @larksuite/whiteboard-cli@^0.2.10 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
|
||||
检查 npx -y @larksuite/whiteboard-cli@^0.2.10 -i <dir>/diagram.svg -f svg --check
|
||||
导出 npx -y @larksuite/whiteboard-cli@^0.2.10 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
|
||||
渲染 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
|
||||
检查 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --check
|
||||
导出 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
|
||||
```
|
||||
|
||||
`npx -y @larksuite/whiteboard-cli@^0.2.10 --check` 检测 `text-overflow` 和 `node-overlap`, 并结合视觉效果(查看 PNG)进行调整
|
||||
`npx -y @larksuite/whiteboard-cli@^0.2.11 --check` 检测 `text-overflow` 和 `node-overlap`, 并结合视觉效果(查看 PNG)进行调整
|
||||
|
||||
## 画板怎么处理 SVG
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.10` 渲染
|
||||
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
|
||||
- **绝对定位手写**:简单柱状图(≤ 5 个柱)可手写坐标
|
||||
|
||||
## Layout 规则
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.10` 渲染
|
||||
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.10` 渲染
|
||||
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.10` 渲染
|
||||
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.10` 渲染
|
||||
- **脚本生成坐标**(推荐):Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
|
||||
- 不适合手动心算坐标
|
||||
|
||||
## Layout 规则
|
||||
|
||||
Reference in New Issue
Block a user