mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
3 Commits
chore/spli
...
v1.0.30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a18504b1f9 | ||
|
|
5e0ac02f08 | ||
|
|
b0c9a4d74e |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.30] - 2026-05-13
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add `--chat-mode topic` to `+chat-create` (#790)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Support comma-separated `--scope` in `auth login` (#764)
|
||||
- **auth**: Clarify URL handling in auth messages and docs (#856)
|
||||
- **bind**: Accept `~/` paths in OpenClaw secret references (#839)
|
||||
|
||||
### Tests
|
||||
|
||||
- **update**: Isolate stamp writes from real `~/.lark-cli/skills.stamp` (#858)
|
||||
|
||||
## [v1.0.29] - 2026-05-12
|
||||
|
||||
### Features
|
||||
@@ -676,6 +692,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
|
||||
[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
|
||||
|
||||
@@ -62,7 +62,7 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated)")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
available := sortedKnownDomains()
|
||||
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
|
||||
@@ -185,7 +185,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
finalScope := opts.Scope
|
||||
// Normalize --scope so users can pass either OAuth-standard space-separated
|
||||
// values or the more natural comma-separated list. RFC 6749 §3.3 mandates
|
||||
// space-delimited scopes in the wire request, so the device authorization
|
||||
// endpoint rejects raw "a,b" strings as a single malformed scope.
|
||||
finalScope := normalizeScopeInput(opts.Scope)
|
||||
|
||||
// Resolve scopes from domain/permission filters
|
||||
if len(selectedDomains) > 0 || opts.Recommend {
|
||||
@@ -532,6 +536,40 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeScopeInput accepts a user-supplied --scope value that may use
|
||||
// commas, spaces, tabs, or newlines (or any mix) as separators and returns the
|
||||
// canonical OAuth 2.0 wire form: a single space-joined string with empties
|
||||
// trimmed and duplicates removed (first occurrence wins; order preserved).
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read"
|
||||
// "a, b , c" -> "a b c"
|
||||
// "a b a" -> "a b"
|
||||
// "" -> ""
|
||||
func normalizeScopeInput(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// Treat both commas and any whitespace as separators.
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if _, ok := seen[f]; ok {
|
||||
continue
|
||||
}
|
||||
seen[f] = struct{}{}
|
||||
out = append(out, f)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// suggestDomain finds the best "did you mean" match for an unknown domain.
|
||||
func suggestDomain(input string, known map[string]bool) string {
|
||||
// Check common cases: prefix match or input is a substring
|
||||
|
||||
@@ -70,6 +70,32 @@ func TestSuggestDomain_ExactMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeScopeInput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"single", "vc:note:read", "vc:note:read"},
|
||||
{"comma", "vc:note:read,vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"space", "vc:note:read vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"comma_and_spaces", "vc:note:read, vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"mixed_separators", "a, b\tc\nd e", "a b c d e"},
|
||||
{"trim_and_dedup", " a , b , a ", "a b"},
|
||||
{"trailing_separators", "a,b,,", "a b"},
|
||||
{"only_separators", " , , ", ""},
|
||||
{"tab_separated", "im:message:send\toffline_access", "im:message:send offline_access"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := normalizeScopeInput(tc.in); got != tc.want {
|
||||
t.Errorf("normalizeScopeInput(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
// Empty AuthTypes defaults to ["user"]
|
||||
sc := common.Shortcut{AuthTypes: nil}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.29",
|
||||
"version": "1.0.30",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +<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|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 |
|
||||
|
||||
@@ -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 <ids>` | No | Up to 50, format `ou_xxx` | Comma-separated user open_ids |
|
||||
| `--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 |
|
||||
| `--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 <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 <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`
|
||||
|
||||
Reference in New Issue
Block a user