diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e63bfd..0f1e3ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Global `search` command across conversations, contacts, messages, and help-center articles, with `--only` to restrict the output to a single bucket. + ### Changed ### Fixed diff --git a/README.md b/README.md index 0e2030b..7c99992 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ chatwoot conv 123 priority urgent # urgent | high | medium | low | chatwoot contacts --search "john" chatwoot contact 456 conversations +chatwoot search "refund" # Global search: conversations, contacts, messages, articles +chatwoot search "refund" --only messages # Restrict to one bucket + chatwoot inboxes / agents / labels / teams # List chatwoot me # Your profile diff --git a/internal/cmd/cli.go b/internal/cmd/cli.go index c955478..8313083 100644 --- a/internal/cmd/cli.go +++ b/internal/cmd/cli.go @@ -27,6 +27,9 @@ type CLI struct { Teams TeamsCmd `cmd:"" help:"List teams."` HelpCenters HCsCmd `cmd:"" name:"hcs" aliases:"help-centers" help:"List help centers."` + // Global search across all buckets. + Search SearchCmd `cmd:"" help:"Global search across conversations, contacts, messages, and help-center articles."` + // Singulars — context for verbs and subresources. Conv ConvCmd `cmd:"" aliases:"conversation" help:"View or act on a conversation."` Contact ContactCmd `cmd:"" help:"View a contact."` diff --git a/internal/cmd/search.go b/internal/cmd/search.go new file mode 100644 index 0000000..eaa5ade --- /dev/null +++ b/internal/cmd/search.go @@ -0,0 +1,266 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/chatwoot/cli/internal/sdk" +) + +type SearchCmd struct { + Query string `arg:"" help:"Search query."` + Page int `short:"p" default:"1" help:"Page number (applies to all result buckets)."` + Only string `help:"Restrict to one bucket: conversations, contacts, messages, or articles." enum:",conversations,contacts,messages,articles" default:""` +} + +func (c *SearchCmd) Run(app *App) error { + resp, err := app.Client.Search().Global(sdk.SearchOptions{ + Query: c.Query, + Page: c.Page, + }) + if err != nil { + return err + } + + if app.Printer.Format == "json" && !app.Printer.Quiet { + app.Printer.PrintJSON(payloadOnly(resp, c.Only)) + return nil + } + + if app.Printer.Format == "csv" { + if c.Only == "" { + return fmt.Errorf("csv output needs a single bucket; use --only= or -o json") + } + return printSearchSection(app, resp, c.Only, false) + } + + if app.Printer.Quiet { + return printSearchQuiet(app, resp, c.Only) + } + + return printSearchText(app, c.Query, resp, c.Only) +} + +func printSearchText(app *App, query string, resp *sdk.SearchResponse, only string) error { + p := resp.Payload + total := len(p.Conversations) + len(p.Contacts) + len(p.Messages) + len(p.Articles) + + if only != "" { + return printSearchSection(app, resp, only, false) + } + + if total == 0 { + _, _ = fmt.Fprintf(app.Printer.Writer, "No results for %q.\n", query) + return nil + } + + first := true + if len(p.Conversations) > 0 { + first = printSectionHeader(app, first, "CONVERSATIONS") + printConversationRows(app, p.Conversations) + } + if len(p.Contacts) > 0 { + first = printSectionHeader(app, first, "CONTACTS") + printContactRows(app, p.Contacts) + } + if len(p.Messages) > 0 { + first = printSectionHeader(app, first, "MESSAGES") + printMessageRows(app, p.Messages) + } + if len(p.Articles) > 0 { + _ = printSectionHeader(app, first, "ARTICLES") + printArticleRows(app, p.Articles) + } + return nil +} + +// payloadOnly returns a response restricted to a single bucket so that --only is +// honored consistently across every output format (text, csv, quiet, and json). +// An empty bucket returns the response unchanged. +func payloadOnly(resp *sdk.SearchResponse, only string) *sdk.SearchResponse { + if only == "" { + return resp + } + out := &sdk.SearchResponse{} + switch only { + case "conversations": + out.Payload.Conversations = resp.Payload.Conversations + case "contacts": + out.Payload.Contacts = resp.Payload.Contacts + case "messages": + out.Payload.Messages = resp.Payload.Messages + case "articles": + out.Payload.Articles = resp.Payload.Articles + } + return out +} + +func printSearchSection(app *App, resp *sdk.SearchResponse, only string, prefixIDs bool) error { + _ = prefixIDs // reserved for future use; quiet mode handles prefixing separately + // In CSV mode an empty bucket must still emit a header row (valid CSV) rather + // than the human-readable "No X found." message, so scripts get parseable output. + prose := app.Printer.Format != "csv" + switch only { + case "conversations": + if len(resp.Payload.Conversations) == 0 && prose { + _, _ = fmt.Fprintln(app.Printer.Writer, "No conversations found.") + return nil + } + printConversationRows(app, resp.Payload.Conversations) + case "contacts": + if len(resp.Payload.Contacts) == 0 && prose { + _, _ = fmt.Fprintln(app.Printer.Writer, "No contacts found.") + return nil + } + printContactRows(app, resp.Payload.Contacts) + case "messages": + if len(resp.Payload.Messages) == 0 && prose { + _, _ = fmt.Fprintln(app.Printer.Writer, "No messages found.") + return nil + } + printMessageRows(app, resp.Payload.Messages) + case "articles": + if len(resp.Payload.Articles) == 0 && prose { + _, _ = fmt.Fprintln(app.Printer.Writer, "No articles found.") + return nil + } + printArticleRows(app, resp.Payload.Articles) + default: + return fmt.Errorf("unknown bucket: %q", only) + } + return nil +} + +func printSearchQuiet(app *App, resp *sdk.SearchResponse, only string) error { + w := app.Printer.Writer + p := resp.Payload + + if only != "" { + switch only { + case "conversations": + for _, c := range p.Conversations { + _, _ = fmt.Fprintln(w, c.ID) + } + case "contacts": + for _, c := range p.Contacts { + _, _ = fmt.Fprintln(w, c.ID) + } + case "messages": + for _, m := range p.Messages { + _, _ = fmt.Fprintln(w, m.ID) + } + case "articles": + for _, a := range p.Articles { + _, _ = fmt.Fprintln(w, a.ID) + } + default: + return fmt.Errorf("unknown bucket: %q", only) + } + return nil + } + + for _, c := range p.Conversations { + _, _ = fmt.Fprintf(w, "conversation:%d\n", c.ID) + } + for _, c := range p.Contacts { + _, _ = fmt.Fprintf(w, "contact:%d\n", c.ID) + } + for _, m := range p.Messages { + _, _ = fmt.Fprintf(w, "message:%d\n", m.ID) + } + for _, a := range p.Articles { + _, _ = fmt.Fprintf(w, "article:%d\n", a.ID) + } + return nil +} + +func printSectionHeader(app *App, first bool, title string) bool { + if !first { + _, _ = fmt.Fprintln(app.Printer.Writer) + } + _, _ = fmt.Fprintln(app.Printer.Writer, title) + return false +} + +func printConversationRows(app *App, convs []sdk.ConversationSearchResult) { + headers := []string{"ID", "Contact", "Inbox", "Channel", "Created"} + rows := make([][]string, 0, len(convs)) + for _, c := range convs { + contact := "" + if c.Contact != nil { + contact = c.Contact.Name + } + inboxName := "" + channel := "" + if c.Inbox != nil { + inboxName = c.Inbox.Name + channel = c.Inbox.ChannelType + } + rows = append(rows, []string{ + strconv.Itoa(c.ID), + contact, + inboxName, + channel, + formatTimestamp(c.CreatedAt), + }) + } + app.Printer.PrintTable(headers, rows) +} + +func printContactRows(app *App, contacts []sdk.ContactSearchResult) { + headers := []string{"ID", "Name", "Email", "Phone", "Last Activity"} + rows := make([][]string, 0, len(contacts)) + for _, c := range contacts { + rows = append(rows, []string{ + strconv.Itoa(c.ID), + c.Name, + c.Email, + c.PhoneNumber, + formatTimestamp(c.LastActivityAt), + }) + } + app.Printer.PrintTable(headers, rows) +} + +func printMessageRows(app *App, messages []sdk.MessageSearchResult) { + headers := []string{"ID", "Conv", "Sender", "Time", "Content"} + rows := make([][]string, 0, len(messages)) + for _, m := range messages { + sender := "" + if m.Sender != nil { + sender = m.Sender.Name + } + content := truncate(sanitizeCell(m.Content), 60) + rows = append(rows, []string{ + strconv.Itoa(m.ID), + strconv.Itoa(m.ConversationID), + sender, + formatTimestamp(m.CreatedAt), + content, + }) + } + app.Printer.PrintTable(headers, rows) +} + +func printArticleRows(app *App, articles []sdk.ArticleSearchResult) { + headers := []string{"ID", "Title", "Locale", "Status", "Updated"} + rows := make([][]string, 0, len(articles)) + for _, a := range articles { + rows = append(rows, []string{ + strconv.Itoa(a.ID), + truncate(sanitizeCell(a.Title), 60), + a.Locale, + a.Status, + formatTimestamp(a.UpdatedAt), + }) + } + app.Printer.PrintTable(headers, rows) +} + +// sanitizeCell collapses newlines, tabs, and carriage returns into single +// spaces so that tabwriter alignment stays intact. +func sanitizeCell(s string) string { + r := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ") + return strings.Join(strings.Fields(r.Replace(s)), " ") +} diff --git a/internal/cmd/search_test.go b/internal/cmd/search_test.go new file mode 100644 index 0000000..4636f95 --- /dev/null +++ b/internal/cmd/search_test.go @@ -0,0 +1,203 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/chatwoot/cli/internal/config" +) + +// canned 4-bucket /search response with deliberately distinct values per bucket +// so --only assertions can tell buckets apart. +const searchPayloadJSON = `{ + "payload": { + "conversations": [{ + "id": 462, + "account_id": 1, + "created_at": 1700000000, + "contact": {"id": 5, "name": "Ada Lovelace"}, + "inbox": {"id": 2, "name": "Support Email", "channel_type": "Channel::Email"} + }], + "contacts": [{"id": 5, "name": "Ada Lovelace", "email": "ada@example.com", "phone_number": "+100", "last_activity_at": 1700000000}], + "messages": [{"id": 9, "content": "refund question", "conversation_id": 462, "created_at": 1700000000, "sender": {"name": "Bob Agent"}}], + "articles": [{"id": 3, "title": "Billing guide", "locale": "en", "status": "published", "updated_at": 1700000000}] + } +}` + +// newSearchTestApp wires an App against an httptest server returning the canned +// payload, and lets the test inspect the request the command made. +func newSearchTestApp(t *testing.T, cli *CLI, inspect func(*http.Request)) (*App, *bytes.Buffer) { + t.Helper() + setupTestEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/accounts/1/search" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + if inspect != nil { + inspect(r) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(searchPayloadJSON)) + })) + t.Cleanup(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, false, "test") + if err != nil { + t.Fatalf("NewApp: %v", err) + } + out := &bytes.Buffer{} + app.Printer.Writer = out + return app, out +} + +func TestSearchRendersAllSections(t *testing.T) { + app, out := newSearchTestApp(t, &CLI{Output: "text"}, nil) + + if err := (&SearchCmd{Query: "ada", Page: 1}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + for _, want := range []string{ + "CONVERSATIONS", "CONTACTS", "MESSAGES", "ARTICLES", + "Ada Lovelace", "ada@example.com", "Support Email", "refund question", "Bob Agent", "Billing guide", + } { + if !strings.Contains(out.String(), want) { + t.Fatalf("output missing %q:\n%s", want, out.String()) + } + } +} + +func TestSearchOnlyFiltersToSingleBucket(t *testing.T) { + app, out := newSearchTestApp(t, &CLI{Output: "text"}, nil) + + if err := (&SearchCmd{Query: "refund", Only: "messages", Page: 1}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + got := out.String() + if !strings.Contains(got, "refund question") { + t.Fatalf("--only messages should show the message:\n%s", got) + } + for _, absent := range []string{"CONVERSATIONS", "Billing guide", "ada@example.com"} { + if strings.Contains(got, absent) { + t.Fatalf("--only messages should not show %q:\n%s", absent, got) + } + } +} + +func TestSearchOnlyRestrictsJSONToBucket(t *testing.T) { + app, out := newSearchTestApp(t, &CLI{Output: "json"}, nil) + + if err := (&SearchCmd{Query: "refund", Only: "messages", Page: 1}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + var got struct { + Payload struct { + Conversations []json.RawMessage `json:"conversations"` + Contacts []json.RawMessage `json:"contacts"` + Messages []json.RawMessage `json:"messages"` + Articles []json.RawMessage `json:"articles"` + } `json:"payload"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out.String()) + } + if len(got.Payload.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(got.Payload.Messages)) + } + if len(got.Payload.Conversations) != 0 || len(got.Payload.Contacts) != 0 || len(got.Payload.Articles) != 0 { + t.Fatalf("--only messages must drop other buckets in JSON too: %#v", got.Payload) + } +} + +func TestSearchCSVEmptyBucketEmitsHeader(t *testing.T) { + setupTestEnv(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payload": {"conversations": [], "contacts": [], "messages": [], "articles": []}}`)) + })) + t.Cleanup(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: "csv"}, false, "test") + if err != nil { + t.Fatalf("NewApp: %v", err) + } + out := &bytes.Buffer{} + app.Printer.Writer = out + + if err := (&SearchCmd{Query: "nothing", Only: "messages", Page: 1}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + // An empty bucket in CSV must still produce the header row, not prose. + if got := strings.TrimSpace(out.String()); got != "ID,Conv,Sender,Time,Content" { + t.Fatalf("CSV empty bucket should emit a header row, got: %q", got) + } +} + +func TestSearchJSONOutput(t *testing.T) { + app, out := newSearchTestApp(t, &CLI{Output: "json"}, nil) + + if err := (&SearchCmd{Query: "ada", Page: 1}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + var got struct { + Payload struct { + Conversations []struct { + ID int `json:"id"` + } `json:"conversations"` + } `json:"payload"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out.String()) + } + if len(got.Payload.Conversations) != 1 || got.Payload.Conversations[0].ID != 462 { + t.Fatalf("unexpected JSON payload: %#v", got) + } +} + +func TestSearchQuietPrintsPrefixedIDs(t *testing.T) { + app, out := newSearchTestApp(t, &CLI{Output: "text", Quiet: true}, nil) + + if err := (&SearchCmd{Query: "ada", Page: 1}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + for _, want := range []string{"conversation:462", "contact:5", "message:9", "article:3"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("quiet output missing %q:\n%s", want, out.String()) + } + } +} + +func TestSearchNoResults(t *testing.T) { + setupTestEnv(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payload": {"conversations": [], "contacts": [], "messages": [], "articles": []}}`)) + })) + t.Cleanup(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: "text"}, false, "test") + if err != nil { + t.Fatalf("NewApp: %v", err) + } + out := &bytes.Buffer{} + app.Printer.Writer = out + + if err := (&SearchCmd{Query: "nothing", Page: 1}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + if !strings.Contains(out.String(), `No results for "nothing"`) { + t.Fatalf("unexpected no-results output: %s", out.String()) + } +} diff --git a/internal/sdk/client.go b/internal/sdk/client.go index 2999ded..9570807 100644 --- a/internal/sdk/client.go +++ b/internal/sdk/client.go @@ -337,3 +337,8 @@ func (c *Client) Profile() *ProfileService { func (c *Client) HelpCenter() *HelpCenterService { return &HelpCenterService{client: c} } + +// Search returns the search service +func (c *Client) Search() *SearchService { + return &SearchService{client: c} +} diff --git a/internal/sdk/conversations.go b/internal/sdk/conversations.go index 9177248..6b97071 100644 --- a/internal/sdk/conversations.go +++ b/internal/sdk/conversations.go @@ -53,6 +53,8 @@ type Agent struct { Email string `json:"email"` Thumbnail string `json:"thumbnail"` AvailabilityStatus string `json:"availability_status"` + Role string `json:"role,omitempty"` + AvailableName string `json:"available_name,omitempty"` } type Team struct { @@ -61,8 +63,10 @@ type Team struct { } type Inbox struct { - ID int `json:"id"` - Name string `json:"name"` + ID int `json:"id"` + Name string `json:"name"` + ChannelType string `json:"channel_type,omitempty"` + ChannelID int `json:"channel_id,omitempty"` } type ConversationsListResponse struct { diff --git a/internal/sdk/search.go b/internal/sdk/search.go new file mode 100644 index 0000000..962da15 --- /dev/null +++ b/internal/sdk/search.go @@ -0,0 +1,97 @@ +package sdk + +import ( + "net/url" + "strconv" +) + +type SearchService struct { + client *Client +} + +type SearchOptions struct { + Query string + Page int +} + +type SearchResponse struct { + Payload SearchPayload `json:"payload"` +} + +type SearchPayload struct { + Conversations []ConversationSearchResult `json:"conversations"` + Contacts []ContactSearchResult `json:"contacts"` + Messages []MessageSearchResult `json:"messages"` + Articles []ArticleSearchResult `json:"articles"` +} + +// ConversationSearchResult is the trimmed shape returned by /search — it has +// fewer fields than the regular conversation list (no status, labels, priority). +// ID is the conversation's display_id (account-scoped). +type ConversationSearchResult struct { + ID int `json:"id"` + AccountID int `json:"account_id"` + CreatedAt int64 `json:"created_at"` + Message *MessageSearchResult `json:"message,omitempty"` + Contact *ContactSearchResult `json:"contact,omitempty"` + Inbox *Inbox `json:"inbox,omitempty"` + Agent *Agent `json:"agent,omitempty"` +} + +type ContactSearchResult struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + Identifier string `json:"identifier"` + AdditionalAttributes map[string]interface{} `json:"additional_attributes"` + LastActivityAt int64 `json:"last_activity_at"` +} + +// MessageSearchResult mirrors api/v1/models/_message.json.jbuilder: conversation +// is flattened to ConversationID (display_id scalar), not a nested object. +type MessageSearchResult struct { + ID int `json:"id"` + Content string `json:"content"` + InboxID int `json:"inbox_id"` + EchoID string `json:"echo_id,omitempty"` + ConversationID int `json:"conversation_id"` + MessageType int `json:"message_type"` + ContentType string `json:"content_type"` + Status string `json:"status"` + ContentAttributes map[string]interface{} `json:"content_attributes"` + CreatedAt int64 `json:"created_at"` + Private bool `json:"private"` + SourceID *string `json:"source_id"` + Sender *MessageSender `json:"sender,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` +} + +type ArticleSearchResult struct { + ID int `json:"id"` + Title string `json:"title"` + Locale string `json:"locale"` + Content string `json:"content"` + Slug string `json:"slug"` + PortalSlug string `json:"portal_slug"` + AccountID int `json:"account_id"` + CategoryName string `json:"category_name"` + Status string `json:"status"` + UpdatedAt int64 `json:"updated_at"` +} + +// Global searches across conversations, contacts, messages, and help-center +// articles in one call (GET /api/v1/accounts/{id}/search). +func (s *SearchService) Global(opts SearchOptions) (*SearchResponse, error) { + params := url.Values{} + params.Set("q", opts.Query) + if opts.Page > 0 { + params.Set("page", strconv.Itoa(opts.Page)) + } + + var resp SearchResponse + if err := s.client.Get("/search", params, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/internal/sdk/search_test.go b/internal/sdk/search_test.go new file mode 100644 index 0000000..3216622 --- /dev/null +++ b/internal/sdk/search_test.go @@ -0,0 +1,96 @@ +package sdk + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// The global /search endpoint is the dashboard's Api::V1::Accounts::SearchController. +// It is not part of Chatwoot's published OpenAPI spec (testdata/application_swagger.json +// only documents /contacts/search), so it cannot be exercised through the swagger +// contract harness in contract_test.go. These tests assert the request shape and +// response decoding against a plain httptest server instead. + +func TestSearchGlobalSendsQueryParamsAndDecodesAllBuckets(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/accounts/1/search" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + q := r.URL.Query() + for key, want := range map[string]string{ + "q": "ada", + "page": "2", + } { + if got := q.Get(key); got != want { + t.Errorf("query %s = %q, want %q", key, got, want) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "payload": { + "conversations": [{ + "id": 462, + "account_id": 1, + "created_at": 1700000000, + "contact": {"id": 5, "name": "Ada Lovelace"}, + "inbox": {"id": 2, "name": "Email", "channel_type": "Channel::Email"} + }], + "contacts": [{"id": 5, "name": "Ada Lovelace", "email": "ada@example.com", "phone_number": "+100", "last_activity_at": 1700000000}], + "messages": [{"id": 9, "content": "hello ada", "conversation_id": 462, "created_at": 1700000000, "sender": {"name": "Ada Lovelace"}}], + "articles": [{"id": 3, "title": "Ada guide", "locale": "en", "status": "published", "updated_at": 1700000000}] + } + }`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "api-key", 1, WithHTTPClient(server.Client())) + + resp, err := client.Search().Global(SearchOptions{Query: "ada", Page: 2}) + if err != nil { + t.Fatalf("Global returned error: %v", err) + } + + p := resp.Payload + if len(p.Conversations) != 1 || p.Conversations[0].ID != 462 { + t.Fatalf("conversations = %#v", p.Conversations) + } + if p.Conversations[0].Contact == nil || p.Conversations[0].Contact.Name != "Ada Lovelace" { + t.Fatalf("conversation contact = %#v", p.Conversations[0].Contact) + } + if len(p.Contacts) != 1 || p.Contacts[0].Email != "ada@example.com" { + t.Fatalf("contacts = %#v", p.Contacts) + } + if len(p.Messages) != 1 || p.Messages[0].ConversationID != 462 { + t.Fatalf("messages = %#v", p.Messages) + } + if len(p.Articles) != 1 || p.Articles[0].Title != "Ada guide" { + t.Fatalf("articles = %#v", p.Articles) + } +} + +// An optional zero page must be omitted so the server applies its own default +// rather than receiving page=0. +func TestSearchGlobalOmitsZeroValuedParams(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("q") != "ada" { + t.Errorf("q = %q, want ada", q.Get("q")) + } + for _, key := range []string{"page"} { + if q.Has(key) { + t.Errorf("param %s should be omitted, raw query: %s", key, r.URL.RawQuery) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payload": {"conversations": [], "contacts": [], "messages": [], "articles": []}}`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "api-key", 1, WithHTTPClient(server.Client())) + + if _, err := client.Search().Global(SearchOptions{Query: "ada"}); err != nil { + t.Fatalf("Global returned error: %v", err) + } +} diff --git a/skills/chatwoot-cli/SKILL.md b/skills/chatwoot-cli/SKILL.md index dc6426e..44e7ccf 100644 --- a/skills/chatwoot-cli/SKILL.md +++ b/skills/chatwoot-cli/SKILL.md @@ -122,6 +122,7 @@ chatwoot convs --help # filters for the list command | `conv contact` | View the contact (sender) for the conversation | | `contacts` | List/search contacts | | `contact ` / ` conversations` | View a contact / list their conversations | +| `search [--only X]` | Global search across conversations, contacts, messages, articles (`--only` restricts to one bucket) | | `inboxes` / `inbox ` | List inboxes / view one | | `agents` / `labels` / `teams` | List account-level resources | | `hcs` | List help centers | @@ -172,7 +173,7 @@ Customer- or team-visible (effectively irreversible): affected, then confirm. Read-only and safe to run freely: -`convs`, `conv ` (view), `conv messages`, `conv contact`, `contacts`, `contact `, +`convs`, `conv ` (view), `conv messages`, `conv contact`, `contacts`, `contact `, `search `, `inboxes`, `inbox `, `agents`, `labels`, `teams`, `me`, `whoami`, `auth status`, `config path`, `config view`, `api ` when it is a GET.