Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to this project will be documented in this file.

## [Unreleased]

### Features

- **im**: Support `--chat-mode topic` in `+chat-create` to create topic chats

## [v1.0.26] - 2026-05-08

### Features
Expand Down
55 changes: 53 additions & 2 deletions shortcuts/im/builders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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",
Expand All @@ -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{
Expand All @@ -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 ")
Expand Down Expand Up @@ -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, "")
Expand All @@ -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)
}
})
Expand Down
16 changes: 15 additions & 1 deletion shortcuts/im/im_chat_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions skills/lark-im/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: lark-im
version: 1.0.0
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员时使用。"
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、创建群聊或话题群时使用。"
metadata:
requires:
bins: ["lark-cli"]
Expand Down Expand Up @@ -56,7 +56,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [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` or `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 keyword and/or member open_ids (e.g. look up chat_id by group name); user/bot; supports member/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 |
Expand Down
8 changes: 7 additions & 1 deletion skills/lark-im/references/lark-im-chat-create.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand All @@ -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; use --type public if discoverability is required
lark-cli im +chat-create --name "Topic Group" --chat-mode topic --type public

# Specify the group owner
lark-cli im +chat-create --name "My Group" --owner ou_xxx

Expand Down Expand Up @@ -56,11 +59,14 @@ lark-cli im +chat-create --name "My Group" --dry-run
| `--bots <ids>` | No | Up to 5, format `cli_xxx` | Comma-separated bot app IDs |
| `--owner <open_id>` | No | Format `ou_xxx` | Owner open_id (defaults to the bot when using `--as bot`, or the authorized user when using `--as user`) |
| `--type <type>` | No | `private` (default) or `public` | Group type |
| `--chat-mode <mode>` | No | `group` (default) or `topic` | Group mode; `topic` creates a topic chat (not the same as `group_message_type=thread`) |
| `--set-bot-manager` | No | - | Set the creating bot as a group manager (only effective with `--as bot`) |
| `--format json` | No | - | Output as JSON |
| `--as <identity>` | 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`
Expand Down
Loading