Skip to content
Merged
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
56 changes: 56 additions & 0 deletions shortcuts/im/builders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,25 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})

t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
}, map[string]bool{
"exclude-muted": true,
})
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
// Filter is client-side; --exclude-muted must NOT mutate request body or auto-inject search_types.
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) {
t.Fatalf("ImChatSearch.DryRun() missing endpoint: %s", got)
}
if strings.Contains(got, `"exclude_muted"`) || strings.Contains(got, `"exclude-muted"`) {
t.Fatalf("--exclude-muted leaked into request: %s", got)
}
if strings.Contains(got, `"search_types"`) {
t.Fatalf("search_types must not be auto-injected by --exclude-muted: %s", got)
}
})

t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "incident",
Expand Down Expand Up @@ -809,6 +828,20 @@ func TestShortcutDryRunShapes(t *testing.T) {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
})

t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
t.Fatalf("ImChatList.DryRun() = %s", got)
}
if !strings.Contains(got, `"sort_type":"ByCreateTimeAsc"`) {
t.Fatalf("ImChatList.DryRun() missing sort_type: %s", got)
}
})
}

func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
Expand All @@ -823,3 +856,26 @@ func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
}

func TestDetectAllNonMemberPreSkip(t *testing.T) {
cases := []struct {
name string
searchTypes string
want string
}{
{"empty", "", ""},
{"only public_not_joined", "public_not_joined", SkipReasonAllNonMember},
{"public_not_joined with whitespace", " public_not_joined ", SkipReasonAllNonMember},
{"private only", "private", ""},
{"mixed includes public_not_joined", "public_not_joined,private", ""},
{"all four types", "private,public_joined,external,public_not_joined", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := detectAllNonMemberPreSkip(c.searchTypes)
if got != c.want {
t.Fatalf("detectAllNonMemberPreSkip(%q) = %q, want %q", c.searchTypes, got, c.want)
}
})
}
}
1 change: 1 addition & 0 deletions shortcuts/im/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,7 @@ func TestShortcuts(t *testing.T) {

want := []string{
"+chat-create",
"+chat-list",
"+chat-messages-list",
"+chat-search",
"+chat-update",
Expand Down
156 changes: 156 additions & 0 deletions shortcuts/im/im_chat_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package im

import (
"context"
"fmt"
"io"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)

// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
const imChatListPath = "/open-apis/im/v1/chats"

// ImChatList is the +chat-list shortcut: wraps GET /open-apis/im/v1/chats to
// list groups the current user/bot is a member of. Supports sort order,
// pagination, and (user identity only) muted-chat filtering via --exclude-muted.
var ImChatList = common.Shortcut{
Service: "im",
Command: "+chat-list",
Description: "List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only)",
Risk: "read",
Scopes: []string{"im:chat:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
// DryRun previews the GET /open-apis/im/v1/chats request without executing.
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(imChatListPath).
Params(buildChatListParams(runtime))
},
// Validate enforces flag preconditions; only --page-size has bounds (1-100).
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if n := runtime.Int("page-size"); n < 1 || n > 100 {
return output.ErrValidation("--page-size must be an integer between 1 and 100")
}
return nil
},
// Execute fetches one page of chats, optionally applies --exclude-muted
// via MaybeApplyMuteFilter, and renders the result. outData["filter"] is
// populated only when --exclude-muted is set (backward compatible).
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params := buildChatListParams(runtime)
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
if err != nil {
return err

Check warning on line 56 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L52-L56

Added lines #L52 - L56 were not covered by tests
}

rawItems, _ := resData["items"].([]interface{})
hasMore, pageToken := common.PaginationMeta(resData)

Check warning on line 60 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L59-L60

Added lines #L59 - L60 were not covered by tests

var items []map[string]interface{}
for _, raw := range rawItems {
item, _ := raw.(map[string]interface{})
if item == nil {
continue

Check warning on line 66 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L62-L66

Added lines #L62 - L66 were not covered by tests
}
items = append(items, item)

Check warning on line 68 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L68

Added line #L68 was not covered by tests
}

mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
ExcludeMuted: runtime.Bool("exclude-muted"),
IsBot: runtime.IsBot(),
Chats: items,
ChatIDKey: "chat_id",
HasMore: hasMore,
})
if err != nil {
return err

Check warning on line 79 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L71-L79

Added lines #L71 - L79 were not covered by tests
}
items = mfOut.Chats

Check warning on line 81 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L81

Added line #L81 was not covered by tests

outData := map[string]interface{}{
"chats": items,
"has_more": hasMore,
"page_token": pageToken,

Check warning on line 86 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L83-L86

Added lines #L83 - L86 were not covered by tests
}
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)

Check warning on line 89 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L88-L89

Added lines #L88 - L89 were not covered by tests
}

runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(items) == 0 {
fmt.Fprintln(w, "No chats found.")
if mfOut.Meta.Hint != "" {
fmt.Fprintln(w, mfOut.Meta.Hint)

Check warning on line 96 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L92-L96

Added lines #L92 - L96 were not covered by tests
}
return

Check warning on line 98 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L98

Added line #L98 was not covered by tests
}
rows := make([]map[string]interface{}, 0, len(items))
for _, m := range items {
row := map[string]interface{}{
"chat_id": m["chat_id"],
"name": m["name"],

Check warning on line 104 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L100-L104

Added lines #L100 - L104 were not covered by tests
}
if desc, _ := m["description"].(string); desc != "" {
row["description"] = desc

Check warning on line 107 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L106-L107

Added lines #L106 - L107 were not covered by tests
}
if ownerID, _ := m["owner_id"].(string); ownerID != "" {
row["owner_id"] = ownerID

Check warning on line 110 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L109-L110

Added lines #L109 - L110 were not covered by tests
}
if external, ok := m["external"].(bool); ok {
row["external"] = external

Check warning on line 113 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L112-L113

Added lines #L112 - L113 were not covered by tests
}
if status, _ := m["chat_status"].(string); status != "" {
row["chat_status"] = status

Check warning on line 116 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L115-L116

Added lines #L115 - L116 were not covered by tests
}
rows = append(rows, row)

Check warning on line 118 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L118

Added line #L118 was not covered by tests
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d chat(s) listed", len(rows))
if hasMore {
fmt.Fprint(w, " (more available, use --page-token to fetch next page")
if pageToken != "" {
fmt.Fprintf(w, ", page_token: %s", pageToken)

Check warning on line 125 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L120-L125

Added lines #L120 - L125 were not covered by tests
}
fmt.Fprint(w, ")")

Check warning on line 127 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L127

Added line #L127 was not covered by tests
}
fmt.Fprintln(w)
if mfOut.Meta.Hint != "" {
fmt.Fprintln(w, mfOut.Meta.Hint)

Check warning on line 131 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L129-L131

Added lines #L129 - L131 were not covered by tests
}
})
return nil

Check warning on line 134 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L134

Added line #L134 was not covered by tests
},
}

// buildChatListParams builds the query parameters for the GET /im/v1/chats
// call from the runtime flag values. user_id_type and sort_type are always
// present (their flag defaults are non-empty); page_token is omitted when
// empty; page_size falls back to the API default of 20 when not provided.
func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"),
"sort_type": runtime.Str("sort-type"),
}
if n := runtime.Int("page-size"); n > 0 {
params["page_size"] = n
} else {
params["page_size"] = 20
}
if pt := runtime.Str("page-token"); pt != "" {
params["page_token"] = pt
}
return params
}
128 changes: 128 additions & 0 deletions shortcuts/im/im_chat_list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package im

import (
"context"
"strings"
"testing"

"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)

// newChatListTestRuntimeContext mirrors newMessagesSearchTestRuntimeContext —
// it registers page-size as Int (the existing newTestRuntimeContext registers
// it as String, which would short-circuit our buildChatListParams logic).
func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
cmd.Flags().String(name, "", "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range stringFlags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}

func TestBuildChatListParams_Defaults(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt)
if got["user_id_type"] != "open_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
if got["sort_type"] != "ByCreateTimeAsc" {
t.Fatalf("sort_type = %v", got["sort_type"])
}
if got["page_size"] != 20 {
t.Fatalf("page_size = %v, want 20", got["page_size"])
}
if _, present := got["page_token"]; present {
t.Fatalf("page_token should be omitted when empty")
}
}

func TestBuildChatListParams_Overrides(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "user_id",
"sort-type": "ByActiveTimeDesc",
"page-size": "50",
"page-token": "tok_xyz",
}, nil)
got := buildChatListParams(rt)
if got["user_id_type"] != "user_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
if got["sort_type"] != "ByActiveTimeDesc" {
t.Fatalf("sort_type = %v", got["sort_type"])
}
if got["page_size"] != 50 {
t.Fatalf("page_size = %v, want 50", got["page_size"])
}
if got["page_token"] != "tok_xyz" {
t.Fatalf("page_token = %v", got["page_token"])
}
}

func TestImChatList_Validate_PageSizeBounds(t *testing.T) {
cases := []struct {
name string
pageSize string
wantErr bool
}{
{"zero rejected", "0", true},
{"negative rejected", "-1", true},
{"one ok", "1", false},
{"hundred ok", "100", false},
{"oneoone rejected", "101", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{"page-size": c.pageSize}, nil)
err := ImChatList.Validate(context.Background(), rt)
if (err != nil) != c.wantErr {
t.Fatalf("Validate() err = %v, wantErr=%v", err, c.wantErr)
}
})
}
}

func TestImChatList_DryRun_IncludesEndpoint(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByActiveTimeDesc",
"page-size": "30",
}, nil)
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
t.Fatalf("DryRun missing endpoint: %s", got)
}
if !strings.Contains(got, `"sort_type":"ByActiveTimeDesc"`) {
t.Fatalf("DryRun missing sort_type: %s", got)
}
if !strings.Contains(got, `"page_size":30`) {
t.Fatalf("DryRun missing page_size: %s", got)
}
}
Loading
Loading