From ecf4242b0917ede3cb5490fbd897717be8c33734 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 21 Aug 2026 15:26:34 +0530 Subject: [PATCH 1/5] feat: add conversation filter API support --- internal/sdk/contract_test.go | 43 ++++++++++++++++++++++++ internal/sdk/conversations.go | 63 ++++++++++++++++++++++++++++++----- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/internal/sdk/contract_test.go b/internal/sdk/contract_test.go index 59ccc29..f37892a 100644 --- a/internal/sdk/contract_test.go +++ b/internal/sdk/contract_test.go @@ -165,6 +165,49 @@ func TestConversationsListContract(t *testing.T) { } } +func TestConversationsFilterContract(t *testing.T) { + client := newContractClient(t, func(t *testing.T, r *http.Request, _ *openapi3filter.RequestValidationInput) contractResponse { + assertQuery(t, r.URL.Query(), url.Values{"page": {"3"}}) + assertJSONBody(t, r, map[string]any{ + "payload": []any{ + map[string]any{ + "attribute_key": "contact_id", + "filter_operator": "equal_to", + "values": []any{"42"}, + "query_operator": "AND", + }, + map[string]any{ + "attribute_key": "status", + "filter_operator": "equal_to", + "values": []any{"open"}, + }, + }, + }) + + return contractResponse{body: `{ + "meta": {"mine_count": 0, "unassigned_count": 0, "all_count": 1}, + "payload": [{"id": 42, "status": "open"}] + }`} + }) + + got, err := client.Conversations().Filter(FilterOptions{ + Page: 3, + Filters: []ConversationFilter{ + {AttributeKey: "contact_id", FilterOperator: "equal_to", Values: []string{"42"}, QueryOperator: "AND"}, + {AttributeKey: "status", FilterOperator: "equal_to", Values: []string{"open"}}, + }, + }) + if err != nil { + t.Fatalf("Filter returned error: %v", err) + } + if got.Data.Meta.AllCount != 1 { + t.Fatalf("all_count = %d, want 1", got.Data.Meta.AllCount) + } + if len(got.Data.Payload) != 1 || got.Data.Payload[0].ID != 42 { + t.Fatalf("payload = %#v, want conversation 42", got.Data.Payload) + } +} + func TestMessagesListContract(t *testing.T) { client := newContractClient(t, func(t *testing.T, r *http.Request, _ *openapi3filter.RequestValidationInput) contractResponse { assertQuery(t, r.URL.Query(), url.Values{ diff --git a/internal/sdk/conversations.go b/internal/sdk/conversations.go index 9177248..7913d4c 100644 --- a/internal/sdk/conversations.go +++ b/internal/sdk/conversations.go @@ -65,16 +65,20 @@ type Inbox struct { Name string `json:"name"` } +type ConversationsListMeta struct { + AllCount int `json:"all_count"` + AssignedCount int `json:"assigned_count"` + UnassignedCount int `json:"unassigned_count"` + MineCount int `json:"mine_count"` +} + +type ConversationsListData struct { + Meta ConversationsListMeta `json:"meta"` + Payload []Conversation `json:"payload"` +} + type ConversationsListResponse struct { - Data struct { - Meta struct { - AllCount int `json:"all_count"` - AssignedCount int `json:"assigned_count"` - UnassignedCount int `json:"unassigned_count"` - MineCount int `json:"mine_count"` - } `json:"meta"` - Payload []Conversation `json:"payload"` - } `json:"data"` + Data ConversationsListData `json:"data"` } type ListOptions struct { @@ -88,6 +92,18 @@ type ListOptions struct { SortBy string } +type ConversationFilter struct { + AttributeKey string `json:"attribute_key"` + FilterOperator string `json:"filter_operator"` + Values []string `json:"values,omitempty"` + QueryOperator string `json:"query_operator,omitempty"` +} + +type FilterOptions struct { + Filters []ConversationFilter + Page int +} + func (s *ConversationsService) List(opts ListOptions) (*ConversationsListResponse, error) { params := url.Values{} @@ -121,6 +137,35 @@ func (s *ConversationsService) List(opts ListOptions) (*ConversationsListRespons return &resp, nil } +func (s *ConversationsService) Filter(opts FilterOptions) (*ConversationsListResponse, error) { + body, err := json.Marshal(struct { + Payload []ConversationFilter `json:"payload"` + }{Payload: opts.Filters}) + if err != nil { + return nil, fmt.Errorf("failed to encode conversation filters: %w", err) + } + + path := "/conversations/filter" + if opts.Page > 0 { + path += "?page=" + strconv.Itoa(opts.Page) + } + + var wireResp struct { + Meta ConversationsListMeta `json:"meta"` + Payload []Conversation `json:"payload"` + } + if err := s.client.Post(path, bytes.NewReader(body), &wireResp); err != nil { + return nil, err + } + + return &ConversationsListResponse{ + Data: ConversationsListData{ + Meta: wireResp.Meta, + Payload: wireResp.Payload, + }, + }, nil +} + func (s *ConversationsService) Get(id int) (*Conversation, error) { var conv Conversation if err := s.client.Get(fmt.Sprintf("/conversations/%d", id), nil, &conv); err != nil { From 8beac58f6f1c17fa195b37aa0c72c7cc8bbe4d81 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 21 Aug 2026 15:26:38 +0530 Subject: [PATCH 2/5] feat: filter contact conversations natively --- internal/cmd/contact.go | 25 +++++++-- internal/cmd/contact_test.go | 106 +++++++++++++++++++++++++++++++++++ internal/cmd/conversation.go | 101 +++++++++++++++++++++++++++++---- internal/sdk/contacts.go | 13 ----- 4 files changed, 216 insertions(+), 29 deletions(-) create mode 100644 internal/cmd/contact_test.go diff --git a/internal/cmd/contact.go b/internal/cmd/contact.go index c8ca0d1..f8725fe 100644 --- a/internal/cmd/contact.go +++ b/internal/cmd/contact.go @@ -104,11 +104,25 @@ func renderContact(app *App, contact *sdk.ContactFull) error { } type ContactConversationsCmd struct { - ID int `arg:"" help:"Contact ID."` + ID int `arg:"" help:"Contact ID."` + Status string `short:"s" default:"open" help:"Filter: open, resolved, pending, snoozed, all."` + Inbox int `short:"i" help:"Filter by inbox ID."` + Assignee string `default:"all" help:"Filter: me, assigned, unassigned, all."` + Team int `help:"Filter by team ID."` + Label []string `short:"l" help:"Filter by labels."` + Page int `short:"p" default:"1" help:"Page number."` } func (c *ContactConversationsCmd) Run(app *App) error { - resp, err := app.Client.Contacts().Conversations(c.ID) + resp, err := filterConversations(app, conversationFilterOptions{ + ContactID: c.ID, + Status: c.Status, + InboxID: c.Inbox, + Assignee: c.Assignee, + TeamID: c.Team, + Labels: c.Label, + Page: c.Page, + }) if err != nil { return err } @@ -118,14 +132,15 @@ func (c *ContactConversationsCmd) Run(app *App) error { return nil } - if len(resp.Payload) == 0 { + conversations := resp.Data.Payload + if len(conversations) == 0 { fmt.Println("No conversations found for this contact.") return nil } headers := []string{"ID", "Status", "Assignee", "Inbox", "Last Activity"} - rows := make([][]string, 0, len(resp.Payload)) - for _, conv := range resp.Payload { + rows := make([][]string, 0, len(conversations)) + for _, conv := range conversations { rows = append(rows, []string{ strconv.Itoa(conv.ID), conv.Status, diff --git a/internal/cmd/contact_test.go b/internal/cmd/contact_test.go new file mode 100644 index 0000000..171a41a --- /dev/null +++ b/internal/cmd/contact_test.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/chatwoot/cli/internal/config" + "github.com/chatwoot/cli/internal/sdk" +) + +func TestContactConversationCommandsUseNativeFiltersAndPagination(t *testing.T) { + setupTestEnv(t) + + wantFilters := []sdk.ConversationFilter{ + {AttributeKey: "contact_id", FilterOperator: "equal_to", Values: []string{"123"}, QueryOperator: "AND"}, + {AttributeKey: "status", FilterOperator: "equal_to", Values: []string{"open"}, QueryOperator: "AND"}, + {AttributeKey: "inbox_id", FilterOperator: "equal_to", Values: []string{"4"}, QueryOperator: "AND"}, + {AttributeKey: "team_id", FilterOperator: "equal_to", Values: []string{"7"}, QueryOperator: "AND"}, + {AttributeKey: "labels", FilterOperator: "equal_to", Values: []string{"billing", "vip"}}, + } + + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/api/v1/accounts/1/conversations/filter" { + t.Errorf("path = %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("page = %q, want 2", got) + } + + var body struct { + Payload []sdk.ConversationFilter `json:"payload"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + return + } + if !reflect.DeepEqual(body.Payload, wantFilters) { + t.Errorf("filters = %#v, want %#v", body.Payload, wantFilters) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"meta":{"all_count":1},"payload":[{"id":88,"status":"open","meta":{"channel":"Channel::Email"}}]}`)) + })) + defer server.Close() + + if err := config.Save(&config.Config{BaseURL: server.URL, AccountID: 1}); err != nil { + t.Fatalf("config.Save: %v", err) + } + app, err := NewApp(&CLI{Output: "json"}, false, "test") + if err != nil { + t.Fatalf("NewApp: %v", err) + } + var out bytes.Buffer + app.Printer.Writer = &out + + err = (&ContactConversationsCmd{ + ID: 123, + Status: "open", + Inbox: 4, + Assignee: "all", + Team: 7, + Label: []string{"billing", "vip"}, + Page: 2, + }).Run(app) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !strings.Contains(out.String(), `"id": 88`) { + t.Fatalf("output = %s, want filtered conversation", out.String()) + } + + out.Reset() + err = (&ConvsCmd{ + Contact: 123, + Status: "open", + Inbox: 4, + Assignee: "all", + Team: 7, + Label: []string{"billing", "vip"}, + Page: 2, + }).Run(app) + if err != nil { + t.Fatalf("ConvsCmd.Run: %v", err) + } + if requestCount != 2 { + t.Fatalf("filter request count = %d, want 2", requestCount) + } +} + +func TestConvsRejectsQueryWithContactFilter(t *testing.T) { + err := (&ConvsCmd{Contact: 123, Query: "refund"}).Run(&App{}) + if err == nil || !strings.Contains(err.Error(), "--query cannot be combined with --contact") { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/cmd/conversation.go b/internal/cmd/conversation.go index 72ee71c..c026cb0 100644 --- a/internal/cmd/conversation.go +++ b/internal/cmd/conversation.go @@ -18,25 +18,43 @@ import ( // ----------------------------------------------------------------------------- type ConvsCmd struct { - Status string `short:"s" default:"open" help:"Filter: open, resolved, pending, snoozed."` + Status string `short:"s" default:"open" help:"Filter: open, resolved, pending, snoozed, all."` Inbox int `short:"i" help:"Filter by inbox ID."` - Assignee string `default:"me" help:"Filter: me, unassigned, all."` + Assignee string `default:"me" help:"Filter: me, assigned, unassigned, all."` Team int `help:"Filter by team ID."` Label []string `short:"l" help:"Filter by labels."` Query string `help:"Search conversations by message content."` + Contact int `help:"Filter by contact ID."` Page int `short:"p" default:"1" help:"Page number."` } func (c *ConvsCmd) Run(app *App) error { - resp, err := app.Client.Conversations().List(sdk.ListOptions{ - Status: c.Status, - InboxID: c.Inbox, - AssigneeType: c.Assignee, - TeamID: c.Team, - Query: c.Query, - Labels: c.Label, - Page: c.Page, - }) + var resp *sdk.ConversationsListResponse + var err error + if c.Contact > 0 { + if c.Query != "" { + return fmt.Errorf("--query cannot be combined with --contact") + } + resp, err = filterConversations(app, conversationFilterOptions{ + ContactID: c.Contact, + Status: c.Status, + InboxID: c.Inbox, + Assignee: c.Assignee, + TeamID: c.Team, + Labels: c.Label, + Page: c.Page, + }) + } else { + resp, err = app.Client.Conversations().List(sdk.ListOptions{ + Status: c.Status, + InboxID: c.Inbox, + AssigneeType: c.Assignee, + TeamID: c.Team, + Query: c.Query, + Labels: c.Label, + Page: c.Page, + }) + } if err != nil { return err } @@ -474,6 +492,67 @@ func (c *ConvContactCmd) Run(app *App) error { // Helpers. // ----------------------------------------------------------------------------- +type conversationFilterOptions struct { + ContactID int + Status string + InboxID int + Assignee string + TeamID int + Labels []string + Page int +} + +func filterConversations(app *App, opts conversationFilterOptions) (*sdk.ConversationsListResponse, error) { + if opts.ContactID <= 0 { + return nil, fmt.Errorf("contact ID must be greater than zero") + } + + filters := make([]sdk.ConversationFilter, 0, 6) + filters = appendConversationFilter(filters, "contact_id", "equal_to", strconv.Itoa(opts.ContactID)) + if opts.Status != "" && opts.Status != "all" { + filters = appendConversationFilter(filters, "status", "equal_to", opts.Status) + } + if opts.InboxID > 0 { + filters = appendConversationFilter(filters, "inbox_id", "equal_to", strconv.Itoa(opts.InboxID)) + } + if opts.TeamID > 0 { + filters = appendConversationFilter(filters, "team_id", "equal_to", strconv.Itoa(opts.TeamID)) + } + if len(opts.Labels) > 0 { + filters = appendConversationFilter(filters, "labels", "equal_to", opts.Labels...) + } + + switch opts.Assignee { + case "", "all": + case "me": + agentID, err := resolveAgent(app, "me") + if err != nil { + return nil, err + } + filters = appendConversationFilter(filters, "assignee_id", "equal_to", strconv.Itoa(agentID)) + case "assigned": + filters = appendConversationFilter(filters, "assignee_id", "is_present") + case "unassigned": + filters = appendConversationFilter(filters, "assignee_id", "is_not_present") + default: + return nil, fmt.Errorf("invalid assignee %q: expected me, assigned, unassigned, or all", opts.Assignee) + } + + for i := 0; i < len(filters)-1; i++ { + filters[i].QueryOperator = "AND" + } + + return app.Client.Conversations().Filter(sdk.FilterOptions{Filters: filters, Page: opts.Page}) +} + +func appendConversationFilter(filters []sdk.ConversationFilter, key, operator string, values ...string) []sdk.ConversationFilter { + return append(filters, sdk.ConversationFilter{ + AttributeKey: key, + FilterOperator: operator, + Values: values, + }) +} + func resolveAgent(app *App, ref string) (int, error) { ref = strings.TrimSpace(ref) if ref == "" { diff --git a/internal/sdk/contacts.go b/internal/sdk/contacts.go index 75bceec..9eeb5c0 100644 --- a/internal/sdk/contacts.go +++ b/internal/sdk/contacts.go @@ -67,19 +67,6 @@ type ContactsSearchOptions struct { Sort string } -type ContactConversationsResponse struct { - Payload []Conversation `json:"payload"` -} - -// Conversations returns the conversations associated with a contact. -func (s *ContactsService) Conversations(id int) (*ContactConversationsResponse, error) { - var resp ContactConversationsResponse - if err := s.client.Get(fmt.Sprintf("/contacts/%d/conversations", id), nil, &resp); err != nil { - return nil, err - } - return &resp, nil -} - func (s *ContactsService) Search(opts ContactsSearchOptions) (*ContactsListResponse, error) { params := url.Values{} params.Set("q", opts.Query) From ef6cf3cb07b8a3ee80edeb357c0f2bde2291a3aa Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 21 Aug 2026 15:26:59 +0530 Subject: [PATCH 3/5] docs: document contact conversation filters --- CHANGELOG.md | 3 +++ README.md | 3 ++- skills/chatwoot-cli/SKILL.md | 23 ++++++++++++++++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c704ae..bac4072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Native contact filtering and pagination for conversation lists through `convs --contact ` and `contact conversations`, backed by Chatwoot's conversation filter API. - Per-conversation lock for all mutating `conv` verbs (`reply`, `resolve`, `open`, `pending`, `snooze`, `assign`, `unassign`, `label`, `priority`): concurrent mutations of the same conversation from separate terminals now fail fast instead of both running. Locks live in `~/.chatwoot/locks/` and are released automatically by the OS if the process dies. ### Changed +- `contact conversations -o json` now uses the standard conversation-list `.data.payload[]` response shape. + ### Fixed ## [0.6.1] - 2026-06-03 diff --git a/README.md b/README.md index 0e2030b..d4d2614 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ The CLI uses a simple noun grammar: chatwoot convs # Open conversations assigned to you chatwoot convs --assignee all --inbox 5 # All conversations in inbox 5 chatwoot convs --query "refund" # Search by message content +chatwoot convs --contact 456 -s open -p 2 # Page through a contact's open conversations chatwoot conv 123 # View chatwoot conv 123 reply "Looking into it" @@ -53,7 +54,7 @@ chatwoot conv 123 label billing,urgent chatwoot conv 123 priority urgent # urgent | high | medium | low | none chatwoot contacts --search "john" -chatwoot contact 456 conversations +chatwoot contact 456 conversations -s open -p 2 # Native server-side filtering and pagination chatwoot inboxes / agents / labels / teams # List chatwoot me # Your profile diff --git a/skills/chatwoot-cli/SKILL.md b/skills/chatwoot-cli/SKILL.md index dc6426e..7fc3bac 100644 --- a/skills/chatwoot-cli/SKILL.md +++ b/skills/chatwoot-cli/SKILL.md @@ -121,7 +121,7 @@ chatwoot convs --help # filters for the list command | `conv priority ` | `urgent`, `high`, `medium`, `low`, `none` | | `conv contact` | View the contact (sender) for the conversation | | `contacts` | List/search contacts | -| `contact ` / ` conversations` | View a contact / list their conversations | +| `contact ` / ` conversations` | View a contact / filter and paginate their conversations | | `inboxes` / `inbox ` | List inboxes / view one | | `agents` / `labels` / `teams` | List account-level resources | | `hcs` | List help centers | @@ -189,6 +189,22 @@ chatwoot convs --assignee me -s open -o json \ chatwoot convs --query "refund" --assignee all -s open -q # IDs only ``` +**Filter and paginate** — pagination composes with filters. Keep the same +filters on every request while advancing the 1-based `-p` page number: +```bash +chatwoot convs --assignee me -s open -l billing -p 2 +chatwoot contacts --search "alice" --sort=-last_activity_at -p 3 +``` + +For a known contact ID, filtering and pagination happen natively on the server: +```bash +chatwoot contact 123 conversations --status open --assignee all --page 2 -o json +chatwoot convs --contact 123 --status open --assignee all --page 2 -o json +``` +Both conversation forms return the standard `.data.payload[]` JSON shape. +`--query` cannot be combined with `--contact`; message-content search is not a +supported conversation-filter attribute. + **Read recent messages** — `message_type`: `0` customer, `1` agent, `2` activity, `3` template: ```bash chatwoot conv 123 messages -o json \ @@ -209,8 +225,9 @@ chatwoot convs -l spam -q | xargs -I{} chatwoot conv {} resolve **Chain contact → conversations:** ```bash -id=$(chatwoot contacts --search "jane@example.com" -o json | jq '.payload[0].id') -chatwoot contact "$id" conversations -o json +chatwoot contacts --search "jane@example.com" -o json \ + | jq '.payload[] | {id, name, email, phone_number}' +chatwoot contact 123 conversations --status open --assignee all -o json ``` **Help center lookup** — set a default portal once, then search/fetch articles: From d0783e6e2e6e38dec6bc5b79dfeff3ad5d86f469 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 21 Aug 2026 15:32:27 +0530 Subject: [PATCH 4/5] fix: support filter response envelopes --- internal/sdk/contract_test.go | 6 ++++-- internal/sdk/conversations.go | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/sdk/contract_test.go b/internal/sdk/contract_test.go index f37892a..2aa5d27 100644 --- a/internal/sdk/contract_test.go +++ b/internal/sdk/contract_test.go @@ -185,8 +185,10 @@ func TestConversationsFilterContract(t *testing.T) { }) return contractResponse{body: `{ - "meta": {"mine_count": 0, "unassigned_count": 0, "all_count": 1}, - "payload": [{"id": 42, "status": "open"}] + "data": { + "meta": {"mine_count": 0, "unassigned_count": 0, "assigned_count": 1, "all_count": 1}, + "payload": [{"id": 42, "status": "open"}] + } }`} }) diff --git a/internal/sdk/conversations.go b/internal/sdk/conversations.go index 7913d4c..3fd7695 100644 --- a/internal/sdk/conversations.go +++ b/internal/sdk/conversations.go @@ -151,12 +151,16 @@ func (s *ConversationsService) Filter(opts FilterOptions) (*ConversationsListRes } var wireResp struct { - Meta ConversationsListMeta `json:"meta"` - Payload []Conversation `json:"payload"` + Data *ConversationsListData `json:"data"` + Meta ConversationsListMeta `json:"meta"` + Payload []Conversation `json:"payload"` } if err := s.client.Post(path, bytes.NewReader(body), &wireResp); err != nil { return nil, err } + if wireResp.Data != nil { + return &ConversationsListResponse{Data: *wireResp.Data}, nil + } return &ConversationsListResponse{ Data: ConversationsListData{ From 6f37b8c8c82405195fa986dc859a6638bd2c95a9 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 21 Aug 2026 15:38:15 +0530 Subject: [PATCH 5/5] chore: upgrade Go to 1.26.6 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index c85080b..26b37cc 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/chatwoot/cli -go 1.26.5 +go 1.26.6 require ( github.com/alecthomas/kong v1.16.0