feat(im/chat-list): support --types flag for listing p2p single chats (#1077)

Add a new --types flag (string_slice; values from {group, p2p}) to
+chat-list, backed by the new GET /open-apis/im/v1/chats `types` query
parameter. Accepts CSV (--types group,p2p) and repeated-flag forms
(--types group --types p2p).

Defaults to groups-only (backward compatible). Under user identity,
p2p single chats appear with chat_mode="p2p" plus p2p_target_type /
p2p_target_id fields. Under bot identity:

  - --types=p2p alone is rejected at validation
  - --types=p2p,group is silently downgraded to types=group (no runtime
    notice; skill docs document this contract)

Updates Shortcut.Description, lark-im SKILL.md (frontmatter trigger
+ shortcut table row), and the chat-list reference doc with command
examples, the new parameter, output field documentation, and a
dedicated "Bot identity and p2p" section.

Change-Id: I637ce23b3c6ce4ec350f0ac26dbac8120761bb71
This commit is contained in:
shifengjuan-dev
2026-05-29 15:29:37 +08:00
committed by GitHub
parent 0a2c3202cb
commit 365e0a2880
4 changed files with 685 additions and 19 deletions

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -15,13 +16,31 @@ import (
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
const imChatListPath = "/open-apis/im/v1/chats"
// bot_strip_p2p is the request-level adjustment notice emitted when bot
// identity receives a mixed --types containing "p2p": the p2p value is
// removed from the outgoing query (which the API would otherwise reject)
// and the caller is informed via a stderr warning + a structured entry
// in outData["notices"]. This is a notice, not a filter — it lives in a
// separate slot from outData["filter"] so the two never collide.
const (
botStripP2pCode = "bot_strip_p2p"
botStripP2pMessage = "To protect user privacy, bot identity cannot list p2p chats; --types=p2p,group was sent as types=group. Use --as user to include p2p."
)
// writeBotStripP2pWarning prints the bot_strip_p2p adjustment to stderr in
// the repo's standard "warning: <code>: <message>" form (matches the format
// used in shortcuts/common/runner.go's unknown-format fallback).
func writeBotStripP2pWarning(errOut io.Writer) {
fmt.Fprintf(errOut, "warning: %s: %s\n", botStripP2pCode, botStripP2pMessage)
}
// 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)",
Description: "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)",
Risk: "read",
Scopes: []string{"im:chat:read"},
AuthTypes: []string{"user", "bot"},
@@ -29,28 +48,53 @@ var ImChatList = common.Shortcut{
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: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
{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.
// When bot identity strips p2p from --types, emits the same stderr warning
// Execute would emit, so DryRun output truthfully reflects what the API
// will receive (matches the shortcuts/drive/drive_search.go pattern of
// echoing request-level adjustments in both DryRun and Execute).
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
effective, stripped, _ := resolveTypes(runtime) // Validate has already guaranteed err == nil
if stripped {
writeBotStripP2pWarning(runtime.IO().ErrOut)
}
return common.NewDryRunAPI().
GET(imChatListPath).
Params(buildChatListParams(runtime))
Params(buildChatListParams(runtime, effective))
},
// Validate enforces flag preconditions; only --page-size has bounds (1-100).
// Validate enforces flag preconditions: page-size bounds, --types element
// enum, and the bot + single-p2p rejection (mixed types degrade in Execute).
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")
}
parts, err := normalizeTypes(runtime.StrSlice("types"))
if err != nil {
return err
}
if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() {
return output.ErrValidation(
`--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`)
}
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).
// outData["notices"] is populated only when bot identity strips p2p from
// --types — a request-level adjustment that lives in its own slot so it
// never collides with the row-level mute filter.
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params := buildChatListParams(runtime)
effective, stripped, _ := resolveTypes(runtime) // Validate guarantees err == nil
if stripped {
writeBotStripP2pWarning(runtime.IO().ErrOut)
}
params := buildChatListParams(runtime, effective)
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
if err != nil {
return err
@@ -88,6 +132,11 @@ var ImChatList = common.Shortcut{
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
}
if stripped {
outData["notices"] = []map[string]interface{}{
{"code": botStripP2pCode, "message": botStripP2pMessage},
}
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(items) == 0 {
@@ -115,6 +164,17 @@ var ImChatList = common.Shortcut{
if status, _ := m["chat_status"].(string); status != "" {
row["chat_status"] = status
}
if chatMode, _ := m["chat_mode"].(string); chatMode != "" {
row["chat_mode"] = chatMode
if chatMode == "p2p" {
if pt, _ := m["p2p_target_type"].(string); pt != "" {
row["p2p_target_type"] = pt
}
if pid, _ := m["p2p_target_id"].(string); pid != "" {
row["p2p_target_id"] = pid
}
}
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
@@ -135,11 +195,76 @@ var ImChatList = common.Shortcut{
},
}
// 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{} {
// normalizeTypes validates and normalizes the --types slice already parsed by cobra.
// cobra's StringSlice handles the CSV split automatically — both --types=p2p,group
// and repeated --types p2p --types group arrive here as a 2-element []string,
// so this function never re-splits on commas.
// Returns the normalized (lowercased, deduped, in input order) parts on success.
// Empty raw input is a no-op (returns nil, nil).
// Returns ErrValidation when any element is empty or outside {"p2p", "group"}.
func normalizeTypes(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, p := range raw {
p = strings.TrimSpace(strings.ToLower(p))
if p == "" {
return nil, output.ErrValidation("--types must contain at least one of p2p, group")
}
if p != "p2p" && p != "group" {
return nil, output.ErrValidation("--types contains invalid value %q: expected one of p2p, group", p)
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out, nil
}
// resolveTypes layers bot identity downgrade on top of normalizeTypes.
// Under bot identity, "p2p" is stripped from the parts and the caller is
// informed (DryRun / Execute emit a stderr warning; Execute additionally
// writes a structured entry under outData["notices"]).
// Validate has already rejected "bot + parts == ['p2p']" cases, so kept is
// never empty here.
//
// Returns (effective CSV, stripped, err):
// - effective: comma-joined types to send as the API query param
// - stripped: true iff bot identity removed "p2p" from a mixed --types value
// - err: forwarded from normalizeTypes
func resolveTypes(runtime *common.RuntimeContext) (string, bool, error) {
parts, err := normalizeTypes(runtime.StrSlice("types"))
if err != nil {
return "", false, err
}
if !runtime.IsBot() {
return strings.Join(parts, ","), false, nil
}
// Bot identity: strip "p2p" so the API call succeeds with just groups.
// Validate has already rejected the "bot + only p2p" case, so kept is never empty here.
// Allocate a fresh slice (rather than aliasing parts[:0]) — parts has at most 2
// elements so the cost is negligible, and avoiding shared backing storage removes
// a class of "two slices, same array" surprises if a future caller keeps parts.
stripped := false
kept := make([]string, 0, len(parts))
for _, p := range parts {
if p == "p2p" {
stripped = true
continue
}
kept = append(kept, p)
}
return strings.Join(kept, ","), stripped, nil
}
// buildChatListParams builds the query parameters. effectiveTypes is the
// CSV string already normalized + bot-stripped by resolveTypes; pass "" to
// omit the types query param entirely (backward compatible default).
func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string) map[string]interface{} {
params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"),
"sort_type": runtime.Str("sort-type"),
@@ -152,5 +277,8 @@ func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{}
if pt := runtime.Str("page-token"); pt != "" {
params["page_token"] = pt
}
if effectiveTypes != "" {
params["types"] = effectiveTypes
}
return params
}

View File

@@ -4,26 +4,41 @@
package im
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"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).
// newChatListTestRuntimeContext registers flags and returns a user-identity runtime context.
func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
return newChatListTestRuntimeContextWithIdentity(t, stringFlags, boolFlags, core.AsUser)
}
// newChatListTestRuntimeContextWithIdentity is the identity-aware variant.
func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool, as core.Identity) *common.RuntimeContext {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
cmd.Flags().String(name, "", "")
if name == "types" {
cmd.Flags().StringSlice(name, nil, "")
} else {
cmd.Flags().String(name, "", "")
}
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
@@ -37,11 +52,22 @@ func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string,
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
if err := cmd.Flags().Set(name, strconv.FormatBool(val)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
rt := common.TestNewRuntimeContextWithIdentity(cmd, nil, as)
// Attach a minimal Factory with IOStreams so DryRun / Execute paths that
// emit stderr warnings (e.g. bot_strip_p2p) don't panic on runtime.IO().
// Stays pure-logic — no HTTP client, no httpmock; integration tests use
// newBotShortcutRuntime / newUserShortcutRuntime for that.
rt.Factory = &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
},
}
return rt
}
func TestBuildChatListParams_Defaults(t *testing.T) {
@@ -49,7 +75,7 @@ func TestBuildChatListParams_Defaults(t *testing.T) {
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt)
got := buildChatListParams(rt, "")
if got["user_id_type"] != "open_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
@@ -62,6 +88,9 @@ func TestBuildChatListParams_Defaults(t *testing.T) {
if _, present := got["page_token"]; present {
t.Fatalf("page_token should be omitted when empty")
}
if _, present := got["types"]; present {
t.Fatalf("types should be omitted when --types is empty")
}
}
func TestBuildChatListParams_Overrides(t *testing.T) {
@@ -71,7 +100,7 @@ func TestBuildChatListParams_Overrides(t *testing.T) {
"page-size": "50",
"page-token": "tok_xyz",
}, nil)
got := buildChatListParams(rt)
got := buildChatListParams(rt, "")
if got["user_id_type"] != "user_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
@@ -126,3 +155,459 @@ func TestImChatList_DryRun_IncludesEndpoint(t *testing.T) {
t.Fatalf("DryRun missing page_size: %s", got)
}
}
func TestNormalizeTypes(t *testing.T) {
cases := []struct {
name string
raw []string
want []string
wantErr string // substring match
}{
{"empty returns nil no error", nil, nil, ""},
{"single p2p", []string{"p2p"}, []string{"p2p"}, ""},
{"single group", []string{"group"}, []string{"group"}, ""},
{"p2p,group preserves order", []string{"p2p", "group"}, []string{"p2p", "group"}, ""},
{"group,p2p preserves order", []string{"group", "p2p"}, []string{"group", "p2p"}, ""},
{"trim whitespace", []string{" p2p ", " group "}, []string{"p2p", "group"}, ""},
{"lowercase", []string{"P2P", "GROUP"}, []string{"p2p", "group"}, ""},
{"dedupe", []string{"p2p", "p2p"}, []string{"p2p"}, ""},
{"empty element rejected", []string{""}, nil, "must contain at least one of p2p, group"},
{"invalid element rejected", []string{"private"}, nil, `expected one of p2p, group`},
{"mixed invalid rejected", []string{"p2p", "private"}, nil, `expected one of p2p, group`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := normalizeTypes(c.raw)
if c.wantErr != "" {
if err == nil {
t.Fatalf("normalizeTypes(%v) err = nil; want substring %q", c.raw, c.wantErr)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("normalizeTypes(%v) err = %v; want substring %q", c.raw, err, c.wantErr)
}
return
}
if err != nil {
t.Fatalf("normalizeTypes(%v) unexpected err = %v", c.raw, err)
}
if len(got) != len(c.want) {
t.Fatalf("normalizeTypes(%v) = %v; want %v", c.raw, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("normalizeTypes(%v)[%d] = %q; want %q", c.raw, i, got[i], c.want[i])
}
}
})
}
}
func TestResolveTypes(t *testing.T) {
cases := []struct {
name string
raw string
as core.Identity
wantEffective string
wantStripped bool
}{
{"user empty", "", core.AsUser, "", false},
{"user p2p", "p2p", core.AsUser, "p2p", false},
{"user p2p,group", "p2p,group", core.AsUser, "p2p,group", false},
{"user group,p2p preserves order", "group,p2p", core.AsUser, "group,p2p", false},
{"user normalized casing", "P2P,GROUP", core.AsUser, "p2p,group", false},
{"bot empty", "", core.AsBot, "", false},
{"bot group only", "group", core.AsBot, "group", false},
{"bot p2p,group strips p2p", "p2p,group", core.AsBot, "group", true},
{"bot group,p2p strips p2p", "group,p2p", core.AsBot, "group", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{"types": c.raw}, nil, c.as)
effective, stripped, err := resolveTypes(rt)
if err != nil {
t.Fatalf("resolveTypes() unexpected err = %v", err)
}
if effective != c.wantEffective {
t.Fatalf("effective = %q; want %q", effective, c.wantEffective)
}
if stripped != c.wantStripped {
t.Fatalf("stripped = %v; want %v", stripped, c.wantStripped)
}
})
}
}
func TestBuildChatListParams_TypesPassthrough(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt, "p2p,group")
if got["types"] != "p2p,group" {
t.Fatalf("types = %v; want \"p2p,group\"", got["types"])
}
}
func TestImChatList_Validate_Types(t *testing.T) {
cases := []struct {
name string
typesRaw string
as core.Identity
wantErr string // substring; "" means no error
}{
{"user empty ok", "", core.AsUser, ""},
{"user p2p ok", "p2p", core.AsUser, ""},
{"user group ok", "group", core.AsUser, ""},
{"user p2p,group ok", "p2p,group", core.AsUser, ""},
{"user invalid element rejected", "private", core.AsUser, "expected one of p2p, group"},
{"user comma-only rejected", ",", core.AsUser, "must contain at least one of p2p, group"},
{"bot empty ok", "", core.AsBot, ""},
{"bot group ok", "group", core.AsBot, ""},
{"bot p2p,group ok (degraded at Execute)", "p2p,group", core.AsBot, ""},
{"bot single p2p rejected", "p2p", core.AsBot, "only supported with user identity"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t,
map[string]string{"types": c.typesRaw, "page-size": "20"},
nil, c.as)
err := ImChatList.Validate(context.Background(), rt)
if c.wantErr == "" {
if err != nil {
t.Fatalf("Validate() unexpected err = %v", err)
}
return
}
if err == nil {
t.Fatalf("Validate() err = nil; want substring %q", c.wantErr)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("Validate() err = %v; want substring %q", err, c.wantErr)
}
})
}
}
// attachChatListCmd builds a cobra.Command pre-loaded with all flags ImChatList
// reads, applies stringFlags / boolFlags, and assigns it to runtime.Cmd. Format
// is forced to "json" so Execute output lands in a parseable form on
// runtime.Factory.IOStreams.Out.
func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags map[string]string, boolFlags map[string]bool) {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().String("user-id-type", "open_id", "")
cmd.Flags().String("sort-type", "ByCreateTimeAsc", "")
cmd.Flags().StringSlice("types", nil, "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().Bool("exclude-muted", false, "")
cmd.Flags().Bool("dry-run", 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, strconv.FormatBool(val)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
runtime.Cmd = cmd
runtime.Format = "json"
}
// chatListOutBuf retrieves the captured stdout buffer for assertions.
func chatListOutBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer {
t.Helper()
buf, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if !ok {
t.Fatalf("expected IOStreams.Out to be *bytes.Buffer")
}
return buf
}
// chatListErrBuf retrieves the captured stderr buffer for assertions
// (used to verify request-level warnings like `bot_strip_p2p`).
func chatListErrBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer {
t.Helper()
buf, ok := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
if !ok {
t.Fatalf("expected IOStreams.ErrOut to be *bytes.Buffer")
}
return buf
}
func TestImChatList_Execute_BotStripsP2p(t *testing.T) {
var capturedURL string
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
capturedURL = req.URL.String()
body := `{"code":0,"msg":"ok","data":{"items":[{"chat_id":"oc_g","name":"G","chat_mode":"group"}],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
if !strings.Contains(capturedURL, "types=group") {
t.Fatalf("request URL = %s; want types=group (bot strips p2p)", capturedURL)
}
if strings.Contains(capturedURL, "p2p") {
t.Fatalf("request URL = %s; must NOT contain p2p (bot stripped it)", capturedURL)
}
// Structured notice: outData["notices"] contains a {code, message} entry
// for bot_strip_p2p (request-level adjustment, not a row-level filter).
out := chatListOutBuf(t, rt).String()
for _, want := range []string{`"notices"`, `"code": "bot_strip_p2p"`, `"message"`} {
if !strings.Contains(out, want) {
t.Fatalf("stdout JSON missing notice field %q:\n%s", want, out)
}
}
// filter slot must remain mute-scoped: bot_strip_p2p must not leak into
// outData["filter"].applied (no priority conflict by design).
if strings.Contains(out, `"applied": "bot_strip_p2p"`) {
t.Fatalf("bot_strip_p2p should not appear in filter.applied (separate slot):\n%s", out)
}
// Stderr: matches repo `warning: <code>: <message>` convention (cf.
// shortcuts/common/runner.go unknown-format fallback).
errOut := chatListErrBuf(t, rt).String()
if !strings.Contains(errOut, "warning: bot_strip_p2p:") {
t.Fatalf("stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut)
}
}
// TestImChatList_DryRun_BotStripsP2pStderrNotice verifies the DryRun branch
// also emits the bot_strip_p2p warning to stderr so a previewed request
// truthfully reflects what Execute would send (drive_search.go DryRun parity).
func TestImChatList_DryRun_BotStripsP2pStderrNotice(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("DryRun should not make HTTP calls; got: %s", req.URL.String())
return nil, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
dr := ImChatList.DryRun(context.Background(), rt)
if dr == nil {
t.Fatalf("DryRun returned nil")
}
errOut := chatListErrBuf(t, rt).String()
if !strings.Contains(errOut, "warning: bot_strip_p2p:") {
t.Fatalf("DryRun stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut)
}
}
func TestImChatList_RowRendering_P2pFields(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
for _, want := range []string{"oc_g", "oc_p", "Group", "Peer", `"chat_mode": "p2p"`, `"p2p_target_id": "ou_peer"`} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q; got: %s", want, out)
}
}
}
// TestImChatList_Execute_PrettyOutputRendersP2pRow exercises the pretty-format
// rendering closure in Execute, including the new chat_mode=="p2p" branch that
// surfaces p2p_target_type / p2p_target_id, and the has_more footer that
// echoes back the page_token.
func TestImChatList_Execute_PrettyOutputRendersP2pRow(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner","description":"a group","external":false,"chat_status":"normal"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":true,"page_token":"next_tok"}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
rt.Format = "pretty"
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
for _, want := range []string{"oc_g", "Group", "a group", "ou_owner", "normal"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing group-row field %q:\n%s", want, out)
}
}
for _, want := range []string{"oc_p", "Peer", "p2p", "ou_peer"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing p2p-row field %q:\n%s", want, out)
}
}
if !strings.Contains(out, "2 chat(s) listed") {
t.Fatalf("pretty output missing footer count:\n%s", out)
}
if !strings.Contains(out, "next_tok") {
t.Fatalf("pretty output missing page_token in has_more footer:\n%s", out)
}
}
func TestImChatList_DryRun_TypesPassthrough(t *testing.T) {
cases := []struct {
name string
as core.Identity
typesRaw string
wantSub string // substring expected in dry-run JSON
wantErr bool // whether Validate should reject before DryRun runs
}{
{"user p2p", core.AsUser, "p2p", `"types":"p2p"`, false},
{"user p2p,group", core.AsUser, "p2p,group", `"types":"p2p,group"`, false},
{"bot p2p,group strips to group", core.AsBot, "p2p,group", `"types":"group"`, false},
{"bot group passes", core.AsBot, "group", `"types":"group"`, false},
{"bot single p2p rejected at Validate", core.AsBot, "p2p", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
"page-size": "20",
"types": c.typesRaw,
}, nil, c.as)
if err := ImChatList.Validate(context.Background(), rt); err != nil {
if !c.wantErr {
t.Fatalf("Validate() unexpected err = %v", err)
}
return
}
if c.wantErr {
t.Fatalf("Validate() err = nil; want rejection")
}
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt))
if !strings.Contains(got, c.wantSub) {
t.Fatalf("DryRun = %s; want substring %q", got, c.wantSub)
}
})
}
}
func TestImChatList_RowRendering_ChatModeAbsent(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// Response items deliberately omit chat_mode / p2p_target_* (legacy/defensive case).
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g1","name":"Group1","owner_id":"ou_owner"},
{"chat_id":"oc_g2","name":"Group2","external":true}
],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, nil, nil) // no --types; default behavior
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
// chat_mode / p2p_target_* must NOT appear since the API didn't return them.
for _, forbidden := range []string{`"chat_mode"`, `"p2p_target_type"`, `"p2p_target_id"`} {
// "chats[].chat_mode" is the row-level field — JSON envelope might include it as null or omit it;
// asserting the rendered table fields are missing is the goal.
// The JSON pass-through preserves whatever API returned (omitted here),
// so neither path should produce these strings.
if strings.Contains(out, forbidden) {
t.Fatalf("output unexpectedly contains %q (should not appear when API omitted these fields); got: %s", forbidden, out)
}
}
// Sanity: the two chat IDs must still be present (renderer didn't crash).
for _, want := range []string{"oc_g1", "oc_g2", "Group1", "Group2"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q; got: %s", want, out)
}
}
}
func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
path := req.URL.Path
switch {
case strings.HasSuffix(path, "/im/v1/chats"):
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":false,"page_token":""}}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
case strings.HasSuffix(path, "/chat_user_setting/batch_get_mute_status"):
// Mark oc_p (the p2p) as muted; oc_g not muted.
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","is_muted":false},
{"chat_id":"oc_p","is_muted":true}
]}}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
}
t.Fatalf("unexpected request path: %s", path)
return nil, nil
}))
attachChatListCmd(t, rt,
map[string]string{"types": "p2p,group"},
map[string]bool{"exclude-muted": true})
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
var parsed struct {
Data struct {
Chats []map[string]interface{} `json:"chats"`
Filter struct {
Applied string `json:"applied"`
FilteredCount int `json:"filtered_count"`
} `json:"filter"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("Unmarshal output failed: %v; raw: %s", err, out)
}
if parsed.Data.Filter.Applied != "exclude_muted" {
t.Fatalf("filter.applied = %q; want exclude_muted (no bot_strip_p2p under user). Raw: %s",
parsed.Data.Filter.Applied, out)
}
if parsed.Data.Filter.FilteredCount != 1 {
t.Fatalf("filter.filtered_count = %d; want 1 (the muted p2p row). Raw: %s",
parsed.Data.Filter.FilteredCount, out)
}
// The muted p2p row should be gone from chats; only oc_g remains.
if len(parsed.Data.Chats) != 1 {
t.Fatalf("expected 1 chat after muting; got %d. Raw: %s", len(parsed.Data.Chats), out)
}
if parsed.Data.Chats[0]["chat_id"] != "oc_g" {
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"])
}
}

View File

@@ -73,7 +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-list`](references/lark-im-chat-list.md) | List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only) |
| [`+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) |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |

View File

@@ -2,7 +2,9 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
List groups the current user (or bot, with `--as bot`) is a member of. Useful for enumerating "my chats" without a search keyword, or for bulk operations against the caller's chats. Supports pagination, sort order, and (user identity only) muted-chat filtering.
List chats the current user (or bot, with `--as bot`) is a member of. **Not a search API — there is no `--query` parameter; the call always returns the full member list, paginated.** For keyword-based lookup (e.g. find a group by name or by member), use [`+chat-search`](lark-im-chat-search.md) instead.
**Defaults to groups only**; pass `--types=p2p,group` (or `--types p2p --types group`) to also include p2p single chats (user identity only — see ["Bot identity and p2p"](#bot-identity-and-p2p)). Supports pagination, sort order, and (user identity only) muted-chat filtering.
This skill maps to the shortcut: `lark-cli im +chat-list` (internally calls `GET /open-apis/im/v1/chats`).
@@ -29,6 +31,15 @@ lark-cli im +chat-list --format json
# Preview the request without executing it
lark-cli im +chat-list --dry-run
# Include p2p single chats (user identity only) — comma form
lark-cli im +chat-list --as user --types p2p,group
# Same, using repeat flag instead of CSV
lark-cli im +chat-list --as user --types p2p --types group
# Only p2p single chats (user identity only)
lark-cli im +chat-list --as user --types p2p
```
## Parameters
@@ -36,6 +47,7 @@ lark-cli im +chat-list --dry-run
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--user-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response |
| `--types <strings>` | No | `group`, `p2p` (comma-separated or repeated) | Chat types to include. Omitted = groups only (backward compatible). `p2p` requires user identity (`--as user`); under `--as bot`, `--types=p2p` alone is rejected and `--types=p2p,group` is silently downgraded to `group` |
| `--sort-type <type>` | No | `ByCreateTimeAsc` (default), `ByActiveTimeDesc` | Result ordering |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
@@ -55,6 +67,44 @@ lark-cli im +chat-list --dry-run
| `owner_id` | Owner ID (type controlled by `--user-id-type`) |
| `external` | Whether the chat is external |
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
| `chat_mode` | Chat mode discriminator: `group` (regular) / `topic` (topic group) / `p2p` (single chat) |
| `p2p_target_type` | Peer type, e.g., `user` |
| `p2p_target_id` | Peer ID (type controlled by `--user-id-type`) |
## Including p2p single chats
Default behavior lists groups only — same as before this feature. To include p2p, pass `--types`:
| User intent | Call | Identity |
|---|---|---|
| "list my groups" / 我的群 / 我加入了哪些群 | (default, omit `--types`) | user or bot |
| "list my p2p chats" / 我的单聊 / 我跟谁有 1v1 | `--types p2p` | **user only** |
| "all my chats" / 全部聊天 / 所有会话 (ambiguous) | `--types p2p,group` | **user only** |
For p2p rows in the response: `name` is the peer's display name, `owner_id` follows group semantics, `chat_mode = "p2p"`, and `p2p_target_type` / `p2p_target_id` identify the peer.
## Bot identity and p2p
`tenant_access_token` cannot list p2p chats — to protect user privacy, bot identity is not permitted to enumerate p2p single chats. Behavior under `--as bot`:
- `--as bot --types=p2p` → rejected at validation time with an actionable error; no request is sent.
- `--as bot --types=p2p,group` → CLI strips `p2p` and sends `types=group`. Request proceeds; only groups are returned. The strip is a **request-level adjustment**, surfaced two ways so neither humans nor agents miss it:
- **stderr**: `warning: bot_strip_p2p: To protect user privacy, bot identity cannot list p2p chats; --types=p2p,group was sent as types=group. Use --as user to include p2p.` (matches the `warning: <code>: <message>` convention in `shortcuts/common/runner.go`)
- **stdout JSON**: a top-level `notices` array gains a structured entry:
```json
{
"chats": [...],
"notices": [
{ "code": "bot_strip_p2p", "message": "To protect user privacy, bot identity cannot list p2p chats; …" }
]
}
```
- The `filter` slot stays scoped to `--exclude-muted`; `notices` is a separate top-level key, so the two never collide and no priority is needed when both fire.
- DryRun emits the same stderr warning so a previewed request truthfully reflects what Execute will send (parity with `shortcuts/drive/drive_search.go`).
- `--as bot --types=group` → accepted, returns groups normally.
- `--as bot` (no `--types`) → unchanged, returns groups.
To include p2p single chats, switch to user identity: `--as user --types=p2p,group`.
## Filtering muted chats
@@ -111,3 +161,6 @@ done
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
| `--exclude-muted` returns all chats unfiltered and `hint` says "no effect under bot identity" | Running under `--as bot` (mute API is UAT-only) | Switch to `--as user` for mute filtering |
| `--types=p2p (single chats) is only supported with user identity` | `--as bot` + `--types=p2p` (single-value only; mixed `--types=p2p,group` is downgraded to `group` and surfaces a `bot_strip_p2p` notice via stderr + `outData["notices"]` — see "Bot identity and p2p") | Use `--as user`, or include `group` in `--types` (the bot proceeds with `group` only and emits the `bot_strip_p2p` notice) |
> Full error message of the row above: `--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`