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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` and `contact <id> 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 <id> conversations -o json` now uses the standard conversation-list `.data.payload[]` response shape.

### Fixed

## [0.6.1] - 2026-06-03
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
25 changes: 20 additions & 5 deletions internal/cmd/contact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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,
Expand Down
106 changes: 106 additions & 0 deletions internal/cmd/contact_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
101 changes: 90 additions & 11 deletions internal/cmd/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject nonpositive contact flag values

When a caller explicitly supplies --contact 0 or a negative ID, this condition treats the flag as absent and executes the ordinary conversation-list request, potentially returning unrelated conversations instead of reporting invalid input. This also bypasses the ContactID <= 0 validation in filterConversations, so scripts can silently operate on the wrong result set; track whether the flag was supplied or validate it before choosing the request path.

Useful? React with 👍 / 👎.

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
}
Expand Down Expand Up @@ -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")
Comment on lines +534 to +536

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use documented operators for assignment-state filters

When --assignee assigned or --assignee unassigned is combined with --contact (including through contact <id> conversations), these branches send is_present or is_not_present. The pinned Chatwoot Conversations Filter contract in internal/sdk/testdata/application_swagger.json:3092-3100 permits only equal_to, not_equal_to, contains, and does_not_contain, so a server enforcing the documented contract rejects these advertised filters with HTTP 400. Encode the assignment state using a supported filter representation or remove these options from the contact-filter path.

Useful? React with 👍 / 👎.

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 == "" {
Expand Down
13 changes: 0 additions & 13 deletions internal/sdk/contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions internal/sdk/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,51 @@ 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: `{
"data": {
"meta": {"mine_count": 0, "unassigned_count": 0, "assigned_count": 1, "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{
Expand Down
Loading
Loading