From 36fd5934d141dd6fc53f0ef5abf7352ec6496fa7 Mon Sep 17 00:00:00 2001 From: David-Buxy <239541535+David-Buxy@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:03:01 +0800 Subject: [PATCH] feat: add IM chat disband shortcut --- shortcuts/im/builders_test.go | 22 +++++++ shortcuts/im/helpers_test.go | 1 + shortcuts/im/im_chat_disband.go | 55 ++++++++++++++++ shortcuts/im/im_chat_disband_test.go | 65 +++++++++++++++++++ shortcuts/im/shortcuts.go | 1 + skills/lark-im/SKILL.md | 4 +- .../references/lark-im-chat-disband.md | 44 +++++++++++++ tests/cli_e2e/im/chat_disband_dryrun_test.go | 44 +++++++++++++ .../cli_e2e/im/chat_disband_workflow_test.go | 51 +++++++++++++++ tests/cli_e2e/im/helpers_test.go | 7 +- 10 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 shortcuts/im/im_chat_disband.go create mode 100644 shortcuts/im/im_chat_disband_test.go create mode 100644 skills/lark-im/references/lark-im-chat-disband.md create mode 100644 tests/cli_e2e/im/chat_disband_dryrun_test.go create mode 100644 tests/cli_e2e/im/chat_disband_workflow_test.go diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go index c32bd6a32..6ca594963 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -338,6 +338,16 @@ func TestShortcutValidateBranches(t *testing.T) { } }) + t.Run("ImChatDisband invalid chat id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "bad_chat", + }, nil) + err := ImChatDisband.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid chat ID format") { + t.Fatalf("ImChatDisband.Validate() error = %v", err) + } + }) + t.Run("ImChatUpdate description too long", func(t *testing.T) { runtime := newTestRuntimeContext(t, map[string]string{ "chat-id": "oc_123", @@ -717,6 +727,18 @@ func TestShortcutDryRunShapes(t *testing.T) { } }) + t.Run("ImChatDisband dry run resolves path", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + }, nil) + got := mustMarshalDryRun(t, ImChatDisband.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/chats/oc_123"`) || + !strings.Contains(got, `"method":"DELETE"`) || + !strings.Contains(got, "high-risk") { + t.Fatalf("ImChatDisband.DryRun() = %s", got) + } + }) + t.Run("ImMessagesSend dry run resolves open_id target", func(t *testing.T) { runtime := newTestRuntimeContext(t, map[string]string{ "user-id": "ou_123", diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 114ae524f..06b24a7e5 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -707,6 +707,7 @@ func TestShortcuts(t *testing.T) { want := []string{ "+chat-create", + "+chat-disband", "+chat-list", "+chat-messages-list", "+chat-search", diff --git a/shortcuts/im/im_chat_disband.go b/shortcuts/im/im_chat_disband.go new file mode 100644 index 000000000..bf2347ef5 --- /dev/null +++ b/shortcuts/im/im_chat_disband.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var ImChatDisband = common.Shortcut{ + Service: "im", + Command: "+chat-disband", + Description: "Disband a group chat; user/bot; high-risk, permanently dissolves the chat", + Risk: "high-risk-write", + Scopes: []string{"im:chat:delete"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "chat-id", Desc: "group chat ID to disband (oc_xxx)", Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + chatID := runtime.Str("chat-id") + return common.NewDryRunAPI(). + DELETE("/open-apis/im/v1/chats/:chat_id"). + Set("chat_id", chatID). + Desc("high-risk: disbands the entire group chat; use --yes to execute") + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := common.ValidateChatID(runtime.Str("chat-id")) + return err + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + chatID := runtime.Str("chat-id") + if _, err := runtime.DoAPIJSON(http.MethodDelete, + fmt.Sprintf("/open-apis/im/v1/chats/%s", validate.EncodePathSegment(chatID)), + nil, + nil); err != nil { + return err + } + + runtime.OutFormat(map[string]interface{}{ + "chat_id": chatID, + "disbanded": true, + }, nil, func(w io.Writer) { + fmt.Fprintf(w, "Group disbanded successfully (chat_id: %s)\n", chatID) + }) + return nil + }, +} diff --git a/shortcuts/im/im_chat_disband_test.go b/shortcuts/im/im_chat_disband_test.go new file mode 100644 index 000000000..5726178d5 --- /dev/null +++ b/shortcuts/im/im_chat_disband_test.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestImChatDisbandExecute_DeletesChat(t *testing.T) { + ctx := context.Background() + cmd := newChatDisbandTestCommand(t, map[string]string{ + "chat-id": "oc_123", + }) + cfg := &core.CliConfig{AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu} + f, _, _, reg := cmdutil.TestFactory(t, cfg) + stub := &httpmock.Stub{ + Method: http.MethodDelete, + URL: "/open-apis/im/v1/chats/oc_123", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + rt := common.TestNewRuntimeContextForAPI(ctx, cmd, cfg, f, core.AsBot) + if err := ImChatDisband.Execute(ctx, rt); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if len(stub.CapturedBody) != 0 { + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("request body invalid JSON: %v\n%s", err, string(stub.CapturedBody)) + } + if len(body) != 0 { + t.Fatalf("request body = %#v, want empty", body) + } + } +} + +func newChatDisbandTestCommand(t *testing.T, flags map[string]string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("chat-id", "", "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, value := range flags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + return cmd +} diff --git a/shortcuts/im/shortcuts.go b/shortcuts/im/shortcuts.go index 3c8aadfbe..e40961c3b 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common" func Shortcuts() []common.Shortcut { return []common.Shortcut{ ImChatCreate, + ImChatDisband, ImChatList, ImChatMessageList, ImChatSearch, diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 7ec3b7118..9015f5ef7 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -73,6 +73,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。 | Shortcut | 说明 | |----------|------| | [`+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-disband`](references/lark-im-chat-disband.md) | Disband a group chat; user/bot; high-risk operation, requires `--yes` | | [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) | | [`+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, pagination, and --exclude-muted (user identity only) | @@ -99,6 +100,7 @@ lark-cli im [flags] # 调用 API ### chats - `create` — 创建群。Identity: `bot` only (`tenant_access_token`). + - Chat disband is exposed through shortcut `+chat-disband`; the current OpenAPI registry does not expose native `chats.delete` yet. - `get` — 获取群信息。Identity: supports `user` and `bot`; the caller must be in the target chat to get full details, and must belong to the same tenant for internal chats. - `link` — 获取群分享链接。Identity: supports `user` and `bot`; the caller must be in the target chat, must be an owner or admin when chat sharing is restricted to owners/admins, and must belong to the same tenant for internal chats. - `update` — 更新群信息。Identity: supports `user` and `bot`. @@ -146,6 +148,7 @@ lark-cli im [flags] # 调用 API | 方法 | 所需 scope | |------|-----------| | `chats.create` | `im:chat:create` | +| `+chat-disband` | `im:chat:delete` | | `chats.get` | `im:chat:read` | | `chats.link` | `im:chat:read` | | `chats.update` | `im:chat:update` | @@ -169,4 +172,3 @@ lark-cli im [flags] # 调用 API | `pins.create` | `im:message.pins:write_only` | | `pins.delete` | `im:message.pins:write_only` | | `pins.list` | `im:message.pins:read` | - diff --git a/skills/lark-im/references/lark-im-chat-disband.md b/skills/lark-im/references/lark-im-chat-disband.md new file mode 100644 index 000000000..0f63d0845 --- /dev/null +++ b/skills/lark-im/references/lark-im-chat-disband.md @@ -0,0 +1,44 @@ +# lark-cli im +chat-disband + +Disband a group chat. + +This is a high-risk shortcut for `DELETE /open-apis/im/v1/chats/{chat_id}`. It permanently dissolves the target group chat, so real execution requires `--yes`. + +Use this for explicit group lifecycle operations or for cleaning up temporary E2E chats created by `lark-cli`. + +## Limits + +- Only group chat IDs (`oc_xxx`) are accepted. +- Disbanding a group is irreversible. +- Bot calls require the app/bot to have permission to disband the target group, such as being the group owner or having the platform-required group operation permission. +- User calls require the user identity to have permission to disband the group, typically the group owner. +- Tenant and group-type restrictions still apply. + +## Examples + +Preview the request: + +```bash +lark-cli im +chat-disband \ + --chat-id oc_xxx \ + --as bot \ + --dry-run +``` + +Execute the disband operation: + +```bash +lark-cli im +chat-disband \ + --chat-id oc_xxx \ + --as bot \ + --yes +``` + +User identity: + +```bash +lark-cli im +chat-disband \ + --chat-id oc_xxx \ + --as user \ + --yes +``` diff --git a/tests/cli_e2e/im/chat_disband_dryrun_test.go b/tests/cli_e2e/im/chat_disband_dryrun_test.go new file mode 100644 index 000000000..c272be5c3 --- /dev/null +++ b/tests/cli_e2e/im/chat_disband_dryrun_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" +) + +func TestIM_ChatDisbandDryRun(t *testing.T) { + setIMDisbandDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+chat-disband", + "--chat-id", "oc_dryrun", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + if !strings.Contains(result.Stdout, "/open-apis/im/v1/chats/oc_dryrun") || + !strings.Contains(result.Stdout, `"method": "DELETE"`) || + !strings.Contains(result.Stdout, "high-risk") { + t.Fatalf("dry-run output missing chat disband request shape:\n%s", result.Stdout) + } +} + +func setIMDisbandDryRunConfigEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +} diff --git a/tests/cli_e2e/im/chat_disband_workflow_test.go b/tests/cli_e2e/im/chat_disband_workflow_test.go new file mode 100644 index 000000000..673127632 --- /dev/null +++ b/tests/cli_e2e/im/chat_disband_workflow_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestIM_ChatDisbandWorkflowAsBot(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + chatName := "lark-cli-e2e-im-disband-" + suffix + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+chat-create", + "--name", chatName, + "--type", "private", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + chatID := gjson.Get(result.Stdout, "data.chat_id").String() + require.NotEmpty(t, chatID, "chat_id should not be empty") + + disbandResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"im", "+chat-disband", "--chat-id", chatID}, + DefaultAs: "bot", + Yes: true, + }) + require.NoError(t, err) + if disbandResult.ExitCode != 0 && strings.Contains(disbandResult.Stderr, "required scope") { + t.Skipf("skip chat disband workflow because the E2E app lacks im:chat:delete: %s", disbandResult.Stderr) + } + disbandResult.AssertExitCode(t, 0) + disbandResult.AssertStdoutStatus(t, true) + require.Equal(t, chatID, gjson.Get(disbandResult.Stdout, "data.chat_id").String(), "stdout:\n%s", disbandResult.Stdout) + require.True(t, gjson.Get(disbandResult.Stdout, "data.disbanded").Bool(), "stdout:\n%s", disbandResult.Stdout) +} diff --git a/tests/cli_e2e/im/helpers_test.go b/tests/cli_e2e/im/helpers_test.go index ac509bec8..fb70c3c9e 100644 --- a/tests/cli_e2e/im/helpers_test.go +++ b/tests/cli_e2e/im/helpers_test.go @@ -14,7 +14,8 @@ import ( // createChat creates a private chat with the given name and returns the chatID. // The chat will be automatically cleaned up via parentT.Cleanup(). -// Note: Chat deletion is not available via lark-cli im command. +// Note: +chat-disband requires im:chat:delete, which is not guaranteed for all +// IM E2E credentials, so shared helpers avoid global chat cleanup side effects. func createChat(t *testing.T, parentT *testing.T, ctx context.Context, name string) string { t.Helper() return createChatAs(t, parentT, ctx, name, "bot") @@ -38,8 +39,8 @@ func createChatAs(t *testing.T, parentT *testing.T, ctx context.Context, name st require.NotEmpty(t, chatID, "chat_id should not be empty") parentT.Cleanup(func() { - // No IM chat delete command is currently available in lark-cli, - // so created chats are intentionally left in the test account. + // Intentionally left blank. Tests that specifically cover chat disband + // own their cleanup path and required scope. }) return chatID