From ff32adf8a131090272c602400f261feb8ff1a001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=B3=E5=B3=B0=E5=A8=9F?= Date: Sat, 9 May 2026 12:05:25 +0800 Subject: [PATCH] feat(im): add --chat-mode topic to +chat-create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds --chat-mode group|topic so users and AI agents can create 话题群 (topic chats) directly via the CLI. Default remains group; chat_mode is now always emitted in the POST /open-apis/im/v1/chats request body. Without this, "create a topic chat" requests would silently fall back to a normal conversation group. - shortcuts/im/im_chat_create.go: new --chat-mode flag (Enum, default group); buildCreateChatBody always emits chat_mode and defensively falls back to "group" when the value is empty (validateEnumFlags skips empty strings, so an explicit `--chat-mode ""` would otherwise reach the wire with unspecified server semantics); Description updated for AI-agent discoverability. - shortcuts/im/builders_test.go: extended TestBuildCreateChatBody; added TestBuildCreateChatBody_TopicMode and TestBuildCreateChatBody_EmptyChatModeFallsBack; updated TestShortcutDryRunShapes/ImChatCreate to register and assert chat_mode. - skills/lark-im/SKILL.md: added 创建群聊或话题群 to trigger keywords; +chat-create row references --chat-mode. - skills/lark-im/references/lark-im-chat-create.md: extended intro paragraph; added topic-chat example; added --chat-mode parameter row; added disambiguation block clarifying 话题群 (chat_mode=topic) vs 普通群+话题消息模式 (group_message_type=thread). - CHANGELOG.md: [Unreleased] Features entry. Change-Id: I79385e2e8606f84e3f27de240d1b41037bf51261 --- shortcuts/im/builders_test.go | 55 ++++++++++++++++++- shortcuts/im/im_chat_create.go | 16 +++++- skills/lark-im/SKILL.md | 4 +- .../lark-im/references/lark-im-chat-create.md | 10 +++- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go index d44fa0750..e7be9cdf0 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -15,6 +15,7 @@ import ( "github.com/spf13/cobra" ) +// mustMarshalDryRun marshals v to a JSON string, calling t.Fatalf on error. func mustMarshalDryRun(t *testing.T, v interface{}) string { t.Helper() @@ -25,6 +26,9 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string { return string(b) } +// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra +// command whose flags are populated from the provided string and bool maps, +// for unit-testing shortcut bodies, validators, and dry-run shapes. func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext { t.Helper() @@ -55,6 +59,9 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag return &common.RuntimeContext{Cmd: cmd} } +// newMessagesSearchTestRuntimeContext is the messages-search variant of +// newTestRuntimeContext: registers the search-specific --page-size flag +// before applying caller-provided values. func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext { t.Helper() @@ -86,6 +93,8 @@ func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]st return &common.RuntimeContext{Cmd: cmd} } +// TestBuildCreateChatBody verifies the request body assembled when every +// flag is populated, including the default chat_mode="group". func TestBuildCreateChatBody(t *testing.T) { runtime := newTestRuntimeContext(t, map[string]string{ "type": "public", @@ -94,11 +103,13 @@ func TestBuildCreateChatBody(t *testing.T) { "users": "ou_1, ou_2", "bots": "cli_1, cli_2", "owner": "ou_owner", + "chat-mode": "group", }, nil) got := buildCreateChatBody(runtime) want := map[string]interface{}{ "chat_type": "public", + "chat_mode": "group", "name": "Team Chat", "description": "daily sync", "user_id_list": []string{ @@ -116,6 +127,43 @@ func TestBuildCreateChatBody(t *testing.T) { } } +// TestBuildCreateChatBody_TopicMode verifies that --chat-mode topic produces +// chat_mode="topic" in the request body, the topic-chat creation path. +func TestBuildCreateChatBody_TopicMode(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "type": "public", + "name": "Topic Group", + "chat-mode": "topic", + }, nil) + + got := buildCreateChatBody(runtime) + want := map[string]interface{}{ + "chat_type": "public", + "chat_mode": "topic", + "name": "Topic Group", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildCreateChatBody() = %#v, want %#v", got, want) + } +} + +// TestBuildCreateChatBody_EmptyChatModeFallsBack pins the defensive fallback: +// explicit `--chat-mode ""` slips past validateEnumFlags (which skips empty +// values), but buildCreateChatBody must still emit chat_mode="group" rather +// than an empty string with unspecified server semantics. +func TestBuildCreateChatBody_EmptyChatModeFallsBack(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "type": "public", + "name": "Fallback Test", + "chat-mode": "", + }, nil) + + got := buildCreateChatBody(runtime) + if got["chat_mode"] != "group" { + t.Fatalf("buildCreateChatBody() chat_mode = %#v, want \"group\"", got["chat_mode"]) + } +} + // TestSplitMembers verifies the delegation wrapper; core logic is tested in TestSplitCSV. [#17] func TestSplitMembers(t *testing.T) { got := common.SplitCSV(" ou_1, ,ou_2 ,, ou_3 ") @@ -591,10 +639,12 @@ func TestMessagesSearchPaginationConfig(t *testing.T) { }) } +// TestShortcutDryRunShapes verifies that each shortcut's DryRun function +// produces the expected API path, query parameters, and request body. func TestShortcutDryRunShapes(t *testing.T) { t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) { cmd := &cobra.Command{Use: "test"} - for _, name := range []string{"type", "name", "users", "owner"} { + for _, name := range []string{"type", "name", "users", "owner", "chat-mode"} { cmd.Flags().String(name, "", "") } cmd.Flags().Bool("set-bot-manager", false, "") @@ -604,9 +654,10 @@ func TestShortcutDryRunShapes(t *testing.T) { _ = cmd.Flags().Set("users", "ou_1,ou_2") _ = cmd.Flags().Set("owner", "ou_owner") _ = cmd.Flags().Set("set-bot-manager", "true") + _ = cmd.Flags().Set("chat-mode", "group") runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, "bot") got := mustMarshalDryRun(t, ImChatCreate.DryRun(context.Background(), runtime)) - if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) { + if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) || !strings.Contains(got, `"chat_mode":"group"`) { t.Fatalf("ImChatCreate.DryRun() = %s", got) } }) diff --git a/shortcuts/im/im_chat_create.go b/shortcuts/im/im_chat_create.go index 75f1d70c2..c2d88e5f0 100644 --- a/shortcuts/im/im_chat_create.go +++ b/shortcuts/im/im_chat_create.go @@ -16,10 +16,14 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) +// ImChatCreate is the +chat-create shortcut: creates a group chat or topic +// chat via POST /open-apis/im/v1/chats. Supports user and bot identities; +// --chat-mode selects group (default) or topic; --type selects private +// (default) or public; --users/--bots invite members at creation. var ImChatCreate = common.Shortcut{ Service: "im", Command: "+chat-create", - Description: "Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager", + Description: "Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager", Risk: "write", UserScopes: []string{"im:chat:create_by_user"}, BotScopes: []string{"im:chat:create"}, @@ -32,6 +36,7 @@ var ImChatCreate = common.Shortcut{ {Name: "bots", Desc: "comma-separated bot app IDs (cli_xxx) to invite, max 5"}, {Name: "owner", Desc: "owner open_id (ou_xxx); defaults to bot (--as bot) or authorized user (--as user)"}, {Name: "type", Default: "private", Desc: "chat type", Enum: []string{"private", "public"}}, + {Name: "chat-mode", Default: "group", Desc: "group mode (\"topic\" creates a topic chat; differs from a normal group in topic-message mode)", Enum: []string{"group", "topic"}}, {Name: "set-bot-manager", Type: "bool", Desc: "set the bot that creates this chat as manager (bot identity only)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -141,9 +146,18 @@ var ImChatCreate = common.Shortcut{ }, } +// buildCreateChatBody assembles the POST /open-apis/im/v1/chats request +// body. chat_mode is always emitted; an empty value (which can slip past +// validateEnumFlags, since that helper skips empty strings) is pinned to +// "group" so the wire never carries an unspecified chat_mode value. func buildCreateChatBody(runtime *common.RuntimeContext) map[string]interface{} { + chatMode := runtime.Str("chat-mode") + if chatMode == "" { + chatMode = "group" + } body := map[string]interface{}{ "chat_type": runtime.Str("type"), + "chat_mode": chatMode, } if name := runtime.Str("name"); name != "" { body["name"] = name diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 4e9d12547..1666f527d 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-im version: 1.0.0 -description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、管理标记数据时使用。" +description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据时使用。" metadata: requires: bins: ["lark-cli"] @@ -68,7 +68,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。 | Shortcut | 说明 | |----------|------| -| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager | +| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager | | [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination | | [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by `--query` keyword and/or `--member-ids`; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, and pagination | | [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description | diff --git a/skills/lark-im/references/lark-im-chat-create.md b/skills/lark-im/references/lark-im-chat-create.md index 2a9fda262..76716f769 100644 --- a/skills/lark-im/references/lark-im-chat-create.md +++ b/skills/lark-im/references/lark-im-chat-create.md @@ -2,7 +2,7 @@ > **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules. -Create a group chat. Supports both user identity (`--as user`) and bot identity (`--as bot`). You can specify the group name, description, members (users/bots), owner, and chat type (private/public). +Create a group chat. Supports both user identity (`--as user`) and bot identity (`--as bot`). You can specify the group name, description, members (users/bots), owner, chat type (private/public), and group mode. Set `--chat-mode topic` to create a topic chat. This skill maps to the shortcut: `lark-cli im +chat-create` (internally calls `POST /open-apis/im/v1/chats`). @@ -18,6 +18,9 @@ lark-cli im +chat-create --name "My Group" # Create a public group (name is required and must be at least 2 characters) lark-cli im +chat-create --name "Public Group" --type public +# Create a topic chat +lark-cli im +chat-create --name "Topic Group" --chat-mode topic + # Specify the group owner lark-cli im +chat-create --name "My Group" --owner ou_xxx @@ -55,12 +58,15 @@ lark-cli im +chat-create --name "My Group" --dry-run | `--users ` | No | Up to 50, format `ou_xxx` | Comma-separated user open_ids | | `--bots ` | No | Up to 5, format `cli_xxx` | Comma-separated bot app IDs | | `--owner ` | No | Format `ou_xxx` | Owner open_id (defaults to the bot when using `--as bot`, or the authorized user when using `--as user`) | -| `--type ` | No | `private` (default) or `public` | Group type | +| `--type ` | No | `private` (default) or `public` | Group type. Default to `private`; pass `public` only when the user explicitly asks for a discoverable/public group. | +| `--chat-mode ` | No | `group` (default) or `topic` | Group mode; `topic` creates a topic chat (not the same as `group_message_type=thread`). When the user asks for a topic chat, pass `topic` explicitly — do not rely on the default. | | `--set-bot-manager` | No | - | Set the creating bot as a group manager (only effective with `--as bot`) | | `--format json` | No | - | Output as JSON | | `--as ` | No | `bot` or `user` | Identity type | | `--dry-run` | No | - | Preview the request without executing it | +> **`--chat-mode topic` vs "normal group with topic-message mode"**: `--chat-mode topic` here creates a 话题群 — the entire group is a topic chat. This is different from "normal group (`chat_mode=group`) + topic-message mode (`group_message_type=thread`)". This CLI exposes only `chat_mode`; `group_message_type` is intentionally not surfaced. + ## AI Usage Guidance ### When using `--as bot`