-
Notifications
You must be signed in to change notification settings - Fork 5
feat: add native contact conversation filtering #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ecf4242
8beac58
ef6cf3c
d0783e6
6f37b8c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| 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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
Comment on lines
+534
to
+536
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 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 == "" { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a caller explicitly supplies
--contact 0or 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 theContactID <= 0validation infilterConversations, 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 👍 / 👎.