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
22 changes: 22 additions & 0 deletions shortcuts/im/builders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions shortcuts/im/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,7 @@ func TestShortcuts(t *testing.T) {

want := []string{
"+chat-create",
"+chat-disband",
"+chat-list",
"+chat-messages-list",
"+chat-search",
Expand Down
55 changes: 55 additions & 0 deletions shortcuts/im/im_chat_disband.go
Original file line number Diff line number Diff line change
@@ -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
},
}
65 changes: 65 additions & 0 deletions shortcuts/im/im_chat_disband_test.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions shortcuts/im/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ImChatCreate,
ImChatDisband,
ImChatList,
ImChatMessageList,
ImChatSearch,
Expand Down
4 changes: 3 additions & 1 deletion skills/lark-im/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [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) |
Expand All @@ -99,6 +100,7 @@ lark-cli im <resource> <method> [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`.
Expand Down Expand Up @@ -146,6 +148,7 @@ lark-cli im <resource> <method> [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` |
Expand All @@ -169,4 +172,3 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `pins.create` | `im:message.pins:write_only` |
| `pins.delete` | `im:message.pins:write_only` |
| `pins.list` | `im:message.pins:read` |

44 changes: 44 additions & 0 deletions skills/lark-im/references/lark-im-chat-disband.md
Original file line number Diff line number Diff line change
@@ -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
```
44 changes: 44 additions & 0 deletions tests/cli_e2e/im/chat_disband_dryrun_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
51 changes: 51 additions & 0 deletions tests/cli_e2e/im/chat_disband_workflow_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 4 additions & 3 deletions tests/cli_e2e/im/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down
Loading