feat: add meeting message shortcut

This commit is contained in:
houzhicong
2026-06-23 19:14:41 +08:00
parent cb54bea00d
commit 2ba0e5802b
6 changed files with 381 additions and 7 deletions

View File

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

View File

@@ -838,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
for _, shortcut := range got {
commands = append(commands, shortcut.Command)
}
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events", "+meeting-message-send"}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
meetingMessageTypeText = "text"
meetingMessageTypeReaction = "reaction"
)
var supportedVCMeetingReactionKeys = map[string]struct{}{
"VC_CanNotSee": {},
"VC_NoSound": {},
"VC_LooksGood": {},
"VC_SoundsClear": {},
}
// VCMeetingMessageSend sends an in-meeting text message or feedback reaction.
var VCMeetingMessageSend = common.Shortcut{
Service: "vc",
Command: "+meeting-message-send",
Description: "Send an in-meeting text message or feedback reaction",
Risk: "write",
Scopes: []string{"vc:meeting.message:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to send into"},
{Name: "msg-type", Desc: "message type: text or reaction"},
{Name: "text", Desc: "text content when --msg-type text"},
{Name: "emoji-type", Desc: "reaction key when --msg-type reaction"},
{Name: "uuid", Desc: "optional idempotency key"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil {
return err
}
_, err := resolveMeetingMessageType(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(buildMeetingMessageSendPath(runtime.Str("meeting-id"))).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(http.MethodPost, buildMeetingMessageSendPath(runtime.Str("meeting-id")), nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintln(w, "Meeting message sent.")
if msgType := common.GetString(data, "msg_type"); msgType != "" {
fmt.Fprintf(w, " Type: %s\n", msgType)
} else if msgType, _ := body["msg_type"].(string); msgType != "" {
fmt.Fprintf(w, " Type: %s\n", msgType)
}
if uuid := common.GetString(data, "uuid"); uuid != "" {
fmt.Fprintf(w, " UUID: %s\n", uuid)
}
})
return nil
},
}
func buildMeetingMessageSendPath(meetingID string) string {
return fmt.Sprintf("/open-apis/vc/v1/meetings/%s/messages", strings.TrimSpace(meetingID))
}
func buildMeetingMessageSendBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
msgType, err := resolveMeetingMessageType(runtime)
if err != nil {
return nil, err
}
body := map[string]interface{}{
"msg_type": msgType,
}
switch msgType {
case meetingMessageTypeText:
body["text"] = strings.TrimSpace(runtime.Str("text"))
case meetingMessageTypeReaction:
body["emoji_type"] = strings.TrimSpace(runtime.Str("emoji-type"))
}
if uuid := strings.TrimSpace(runtime.Str("uuid")); uuid != "" {
body["uuid"] = uuid
}
return body, nil
}
func resolveMeetingMessageType(runtime *common.RuntimeContext) (string, error) {
msgType := strings.ToLower(strings.TrimSpace(runtime.Str("msg-type")))
text := strings.TrimSpace(runtime.Str("text"))
emojiType := strings.TrimSpace(runtime.Str("emoji-type"))
if msgType == "" {
switch {
case text != "" && emojiType == "":
msgType = meetingMessageTypeText
case text == "" && emojiType != "":
msgType = meetingMessageTypeReaction
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type is required when both --text and --emoji-type are empty or both are set").WithParam("--msg-type")
}
}
switch msgType {
case meetingMessageTypeText:
if text == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text is required when --msg-type text").WithParam("--text")
}
case meetingMessageTypeReaction:
if emojiType == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type is required when --msg-type reaction").WithParam("--emoji-type")
}
if _, ok := supportedVCMeetingReactionKeys[emojiType]; !ok {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type must be one of VC_CanNotSee, VC_NoSound, VC_LooksGood, VC_SoundsClear").WithParam("--emoji-type")
}
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type must be text or reaction").WithParam("--msg-type")
}
return msgType, nil
}

View File

@@ -0,0 +1,125 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
func newMeetingMessageSendRuntime() *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
cmd.Flags().String("msg-type", "", "")
cmd.Flags().String("text", "", "")
cmd.Flags().String("emoji-type", "", "")
cmd.Flags().String("uuid", "", "")
return common.TestNewRuntimeContext(cmd, defaultConfig())
}
func mustSetMeetingMessageSendFlag(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 TestMeetingMessageSendBuildBody_Text(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "text", " hello ")
mustSetMeetingMessageSendFlag(t, runtime, "uuid", " cid-1 ")
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
}
if body["msg_type"] != meetingMessageTypeText {
t.Fatalf("msg_type = %v, want text", body["msg_type"])
}
if body["text"] != "hello" {
t.Fatalf("text = %v, want hello", body["text"])
}
if body["uuid"] != "cid-1" {
t.Fatalf("uuid = %v, want cid-1", body["uuid"])
}
}
func TestMeetingMessageSendBuildBody_Reaction(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "VC_LooksGood")
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
}
if body["msg_type"] != meetingMessageTypeReaction {
t.Fatalf("msg_type = %v, want reaction", body["msg_type"])
}
if body["emoji_type"] != "VC_LooksGood" {
t.Fatalf("emoji_type = %v, want VC_LooksGood", body["emoji_type"])
}
if _, ok := body["text"]; ok {
t.Fatalf("text should be omitted for reaction, got %#v", body["text"])
}
}
func TestMeetingMessageSendValidateRejectsMeetingNumber(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "123456789")
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "9-digit meeting number") {
t.Fatalf("error = %v, want 9-digit meeting number hint", err)
}
}
func TestMeetingMessageSendValidateRejectsUnknownReaction(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "VC_CanNotSee") {
t.Fatalf("error = %v, want supported key hint", err)
}
}
func TestMeetingMessageSendDryRun_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingMessageSend, []string{
"+meeting-message-send", "--dry-run", "--as", "user",
"--meeting-id", "7651377260537433044",
"--text", "hello",
"--uuid", "cid-1",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/vc/v1/meetings/7651377260537433044/messages",
"\"msg_type\": \"text\"",
"\"text\": \"hello\"",
"\"uuid\": \"cid-1\"",
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q: %s", want, out)
}
}
}

View File

@@ -1,7 +1,7 @@
---
name: lark-vc-agent
version: 1.0.0
description: "飞书视频会议会中能力:用于让应用机器人真实加入或离开正在进行的会议,并读取当前身份可见的会中事件,如参会人加入/离开、发言、聊天、屏幕共享。适用于用户询问正在开的会议发生了什么、谁在发言、是否共享内容,或需要发现当前可读的进行中会议 ID。不负责已结束会议搜索、参会人快照、纪要、逐字稿或录制查询这些使用 lark-vc 技能。"
description: "飞书视频会议会中能力:用于让应用机器人真实加入或离开正在进行的会议,并读取当前身份可见的会中事件、发送会中文本消息或反馈表情。适用于用户询问正在开的会议发生了什么、谁在发言、是否共享内容,或需要发现当前可读的进行中会议 ID。不负责已结束会议搜索、参会人快照、纪要、逐字稿或录制查询这些使用 lark-vc 技能。"
metadata:
requires:
bins: ["lark-cli"]
@@ -26,7 +26,7 @@ metadata:
本 skill 与 [`lark-vc`](../lark-vc/SKILL.md) 并列:
- **`lark-vc`** **负责"会后查询"**:搜索历史会议、参会人快照、纪要/逐字稿/录制
- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 机器人离会
- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 发送会中文本或反馈 / 机器人离会
按此分工路由,避免两个 skill 语义混淆。
@@ -35,6 +35,7 @@ metadata:
| "帮我入会 123456789"、"代我参会"、"让机器人进会旁听" | **本 skill** `+meeting-join` |
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"**进行中会议** | **本 skill** `+meeting-events` |
| "我/某个用户现在在哪个会里"、"给我找当前可拉事件的 meeting_id" | **本 skill** `+meeting-list-active` |
| "在会里发一句 xx"、"提示大家 xx"、"反馈听不到/看不到/声音清楚/效果不错"**进行中会议** | **本 skill** `+meeting-message-send` |
| "退出会议"、"让机器人离开" | **本 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) 发群 |
@@ -49,7 +50,7 @@ metadata:
| 查询目标用户且应用机器人也在会中的会议 | `--as bot --user-id <user_open_id>` | `--user-id` 必须是 `ou_...`;拿到的 `meeting_id` 后续继续用 `--as bot` 读事件 |
| 用户明确要求应用机器人入会/旁听/代参会 | `--as bot` | 这是写操作,会真实产生入会记录;返回的 `meeting.id` 后续继续用 `--as bot` |
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份,除非用户明确要求切换场景(例如从“仅查询我当前会”改成“让应用机器人入会旁听”)。
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` / `+meeting-message-send` 就沿用哪种身份,除非用户明确要求切换场景(例如从“仅查询我当前会”改成“让应用机器人入会旁听”)。
## 核心场景
@@ -79,14 +80,31 @@ metadata:
10. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择。
11. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`
### 3. 离开会议(写操作)
### 3. 发送会中文本或反馈表情(写操作)
1. 用户明确要求在当前进行中的会议里发送提示、说明、反馈或表情时,用 `+meeting-message-send`
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。
3. 身份必须延续:`meeting_id` 来自用户身份发现,就继续 `--as user`;来自应用身份发现或应用机器人入会,就继续 `--as bot`
4. 文本消息使用 `--text`;反馈表情使用 `--emoji-type`。不要把自然语言硬编码转换成 key但可以基于用户语义选择最匹配的已支持 key。
5. 当前支持的会中特定反馈 key`VC_CanNotSee``VC_NoSound``VC_LooksGood``VC_SoundsClear`
6. 该命令只暴露会中文本和会中反馈表情,不作为“发送绑定群消息”的默认能力;如果用户明确要发群聊,请路由到 [`lark-im`](../lark-im/SKILL.md)。
7. 若使用应用身份发送,应用机器人必须在会中;若使用用户身份发送,当前用户必须正在该会议中。权限错误时按“应用身份权限配置检查”或“用户身份被拒绝时”处理。
示例:
```bash
lark-cli vc +meeting-message-send --as user --meeting-id <meeting_id> --text "稍等,我在看文档"
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type VC_NoSound
```
### 4. 离开会议(写操作)
1. 只有用户明确要求机器人退出 / 离开 / 结束参会时,才用应用身份执行 `+meeting-leave --as bot --meeting-id <长数字 meeting_id>`;不应因任务完成而执行离会。
2. `--meeting-id` **必须**是长数字会议 ID通常来自 `+meeting-join` 返回的 `meeting.id`,也可以来自应用身份 `+meeting-list-active` 返回的 `meeting_id`。如果来自 list-active必须确认应用机器人当前就在该会中。**不接受 9 位会议号**
3. 离会**立即生效**,机器人从会议的参会人列表中消失,对其他参会人可见;若需要重新入会,再跑一次 `+meeting-join` 即可(非真正"不可逆")。
4. 使用与入会或 active meeting 发现相同的应用身份离会。
### 4. 获取当前可用的进行中会议 ID读操作
### 5. 获取当前可用的进行中会议 ID读操作
1. `+meeting-list-active` 用来发现当前进行中的会议,并拿到后续 `+meeting-events` 需要的长数字 `meeting_id`
2. 用户身份:`lark-cli vc +meeting-list-active --as user --format json`,用于发现当前登录用户正在参加的会议;后续 `+meeting-events` 继续 `--as user`
@@ -95,7 +113,7 @@ metadata:
5. 如果返回多个会议,不要自动任选一个;按 `meeting_title` / `meeting_no` / `meeting_id` 展示候选,等待用户明确选择后再调用 `+meeting-events`
6. 如果用户给了 9 位会议号,先在 active meeting 结果中按 `meeting_no` 匹配。匹配失败时,不要自动入会;只有用户明确要求应用机器人真实入会时,才询问或执行 `+meeting-join`
### 5. Agent 参会示范
### 6. Agent 参会示范
```bash
# 1. 入会,捕获 meeting.id
@@ -136,11 +154,13 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number |
| [`+meeting-list-active`](references/lark-vc-agent-meeting-list-active.md) | 读 | List active meetings and discover meeting_id for event reads |
| [`+meeting-events`](references/lark-vc-agent-meeting-events.md) | 读 | List meeting events visible to the app agent (participant joined/left, transcript, chat, share) |
| [`+meeting-message-send`](references/lark-vc-agent-meeting-message-send.md) | 写 | Send an in-meeting text message or feedback reaction |
| [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md) | 写 | Leave a meeting by meeting\_id |
- [`+meeting-join`](references/lark-vc-agent-meeting-join.md):入参格式、写操作可见性风险、入会失败排查。
- [`+meeting-list-active`](references/lark-vc-agent-meeting-list-active.md):用户身份和应用身份的不同返回范围。
- [`+meeting-events`](references/lark-vc-agent-meeting-events.md)`meeting_id` 来源、身份延续、分页和错误码10005 / 20001 / 20002
- [`+meeting-message-send`](references/lark-vc-agent-meeting-message-send.md):会中文本、会中反馈表情、身份延续和写操作风险。
- [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md)`meeting_id` 的来源与写操作可见性。
## 应用身份权限配置检查

View File

@@ -0,0 +1,84 @@
# vc +meeting-message-send
发送会中文本消息或会中特定反馈表情。
本 skill 对应 shortcut`lark-cli vc +meeting-message-send`(调用 `POST /open-apis/vc/v1/meetings/{meeting_id}/messages`)。
## 适用场景
- 用户要求“在会里发一句话”“提示大家”“给当前会议发消息”。
- 用户要求表达会中反馈,例如“听不到”“看不到”“声音清楚”“效果不错”。
- 只用于正在进行中的会议;已结束会议不支持。
## 身份规则
`meeting_id` 从哪种身份路径拿到,发送消息时就沿用哪种身份:
| meeting_id 来源 | 发送时身份 |
| --- | --- |
| `+meeting-list-active --as user` | `+meeting-message-send --as user` |
| `+meeting-list-active --as bot --user-id <user_open_id>` | `+meeting-message-send --as bot` |
| `+meeting-join --as bot` 返回的 `meeting.id` | `+meeting-message-send --as bot` |
不要把用户身份发现的 `meeting_id` 改用应用身份发送,也不要把应用身份发现的 `meeting_id` 改用用户身份发送,除非用户明确要求切换。
## 参数
| 参数 | 说明 |
| --- | --- |
| `--meeting-id` | 必填,长数字 `meeting_id`,不是 9 位会议号 |
| `--msg-type` | 可选,`text``reaction`;只传 `--text` 或只传 `--emoji-type` 时可自动推断 |
| `--text` | 文本消息内容 |
| `--emoji-type` | 会中反馈表情 key |
| `--uuid` | 可选,幂等 key不传则服务端生成 |
## 文本消息
```bash
lark-cli vc +meeting-message-send --as user --meeting-id <meeting_id> --text "稍等,我在看文档"
```
文本消息会出现在会议内的文本互动区。不要把它当成绑定群消息发送能力;如果用户明确要求发到群聊,路由到 `lark-im`
## 反馈表情
当前支持的会中特定反馈 key
| 用户表达 | 推荐 key |
| --- | --- |
| 听不到、没声音 | `VC_NoSound` |
| 看不到、画面有问题 | `VC_CanNotSee` |
| 声音清楚 | `VC_SoundsClear` |
| 效果不错、看起来可以 | `VC_LooksGood` |
```bash
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type VC_NoSound
```
不要维护自己的自然语言到 key 的硬编码表;根据用户当前语义选择最匹配的 key。如果没有匹配项先向用户确认不要发送普通 IM 表情 key。
## 9 位会议号处理
如果用户给的是 9 位会议号并要求发送会中消息:
1. 先按当前身份执行 `+meeting-list-active`
2. 在返回结果中按 `meeting_no` 匹配该 9 位会议号。
3. 匹配到唯一会议后取长数字 `meeting_id`
4. 用发现该会议时的同一身份执行 `+meeting-message-send`
匹配失败时不要自动入会。只有用户明确要求“让应用机器人入会/旁听/代参会”时,才改用 `+meeting-join`
## 权限和前置条件
- 用户身份:当前用户必须正在该会议中。
- 应用身份:应用机器人必须正在该会议中。
- 会议需要开启会中智能体/Agent 能力开关。
- 需要 `vc:meeting.message:write` 权限;应用身份还需要应用已安装、数据范围已配置。
应用身份权限错误时,不要引导用户反复 `auth login`。按主 skill 的“应用身份权限配置检查”处理。
## 相关
- [lark-vc-agent-meeting-list-active](lark-vc-agent-meeting-list-active.md) — 发现当前进行中会议 ID
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 读取会中事件
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 应用机器人入会