diff --git a/README.md b/README.md index 1cc5d9b..15fce35 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ chatwoot contact 456 conversations chatwoot inboxes / agents / labels / teams # List chatwoot me # Your profile + +chatwoot api /conversations/123 # Expands to /api/v1/accounts//conversations/123 +chatwoot api -X PATCH /conversations/123 --data '{"status":"open"}' ``` Run `chatwoot --help` or see the [full command reference](https://developers.chatwoot.com/cli/commands). diff --git a/internal/cmd/api.go b/internal/cmd/api.go new file mode 100644 index 0000000..0936d54 --- /dev/null +++ b/internal/cmd/api.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/textproto" + "net/url" + "os" + "strings" +) + +type ApiCmd struct { + Method string `short:"X" placeholder:"METHOD" help:"HTTP method. Defaults to POST with --data, otherwise GET."` + Data string `short:"d" placeholder:"JSON|@FILE" help:"JSON request body, or @file to read body from a file."` + Header []string `short:"H" placeholder:"HEADER" help:"Additional request header, as 'Name: value'. Repeatable."` + Exact bool `help:"Use the path exactly as provided under the configured base URL."` + Path string `arg:"" help:"Endpoint path. /conversations/123 expands under the configured account."` +} + +func (c *ApiCmd) Help() string { + return `Account-relative paths such as /conversations/123 are expanded under +/api/v1/accounts/. Use --exact for non-account-scoped paths. + +The default method is GET, or POST when --data is provided. Override with -X. + +Examples: + chatwoot api /conversations/123 + chatwoot api -X PATCH /conversations/123 --data '{"status":"open"}' + chatwoot api --exact /api/v1/profile` +} + +func (c *ApiCmd) Run(app *App) error { + method := strings.ToUpper(c.Method) + if method == "" { + if c.Data != "" { + method = http.MethodPost + } else { + method = http.MethodGet + } + } + + path, accountScoped, err := normalizeAPIPath(c.Path, c.Exact) + if err != nil { + return err + } + + body, err := apiRequestBody(c.Data) + if err != nil { + return err + } + + headers, err := parseAPIHeaders(c.Header) + if err != nil { + return err + } + + resp, err := app.Client.RequestRaw(method, path, body, accountScoped, headers) + if err != nil { + return err + } + + printAPIResponse(app.Printer.Writer, resp.Body) + return nil +} + +func normalizeAPIPath(path string, exact bool) (string, bool, error) { + if strings.TrimSpace(path) == "" { + return "", false, fmt.Errorf("path is required") + } + + parsed, err := url.Parse(path) + if err != nil { + return "", false, fmt.Errorf("invalid path: %w", err) + } + if parsed.IsAbs() { + return "", false, fmt.Errorf("absolute URLs are not supported; pass a path under the configured Chatwoot base URL") + } + + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if exact || strings.HasPrefix(path, "/api/") { + return path, false, nil + } + return path, true, nil +} + +func apiRequestBody(data string) (io.Reader, error) { + if data == "" { + return nil, nil + } + if !strings.HasPrefix(data, "@") { + return strings.NewReader(data), nil + } + if data == "@-" { + return os.Stdin, nil + } + + body, err := os.ReadFile(strings.TrimPrefix(data, "@")) + if err != nil { + return nil, fmt.Errorf("read request body: %w", err) + } + return bytes.NewReader(body), nil +} + +func parseAPIHeaders(headerArgs []string) (http.Header, error) { + headers := make(http.Header) + for _, arg := range headerArgs { + name, value, ok := strings.Cut(arg, ":") + if !ok || strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("invalid header %q; expected 'Name: value'", arg) + } + headers.Add(textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name)), strings.TrimSpace(value)) + } + return headers, nil +} + +func printAPIResponse(w io.Writer, body []byte) { + if len(strings.TrimSpace(string(body))) == 0 { + return + } + + var decoded any + if err := json.Unmarshal(body, &decoded); err == nil { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(decoded) + return + } + + _, _ = w.Write(body) + if body[len(body)-1] != '\n' { + _, _ = fmt.Fprintln(w) + } +} diff --git a/internal/cmd/api_test.go b/internal/cmd/api_test.go new file mode 100644 index 0000000..1028c42 --- /dev/null +++ b/internal/cmd/api_test.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/chatwoot/cli/internal/config" +) + +func TestApiCmdCallsAccountScopedEndpoint(t *testing.T) { + setupTestEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/accounts/1/conversations/123" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + if r.Header.Get("api_access_token") != "test-token" { + t.Errorf("api_access_token = %q, want test-token", r.Header.Get("api_access_token")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":123,"status":"open"}`)) + })) + 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: "text"}, false, "test") + if err != nil { + t.Fatalf("NewApp: %v", err) + } + + var out bytes.Buffer + app.Printer.Writer = &out + + if err := (&ApiCmd{Path: "/conversations/123"}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out.String()) + } + if got["id"] != float64(123) || got["status"] != "open" { + t.Fatalf("output = %#v, want conversation JSON", got) + } +} + +func TestApiCmdUsesExactAPIPath(t *testing.T) { + setupTestEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/profile" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":7,"name":"Ada"}`)) + })) + 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: "text"}, false, "test") + if err != nil { + t.Fatalf("NewApp: %v", err) + } + app.Printer.Writer = &bytes.Buffer{} + + if err := (&ApiCmd{Path: "/api/v1/profile"}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestApiCmdSendsMethodBodyAndHeaders(t *testing.T) { + setupTestEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("method = %s, want PATCH", r.Method) + } + if r.URL.Path != "/api/v1/accounts/1/conversations/123/custom_attributes" { + t.Errorf("path = %s", r.URL.Path) + } + if r.Header.Get("X-Test") != "yes" { + t.Errorf("X-Test header = %q, want yes", r.Header.Get("X-Test")) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + } + if strings.TrimSpace(string(body)) != `{"priority":"urgent"}` { + t.Errorf("body = %q", string(body)) + } + w.WriteHeader(http.StatusNoContent) + })) + 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: "text"}, false, "test") + if err != nil { + t.Fatalf("NewApp: %v", err) + } + app.Printer.Writer = &bytes.Buffer{} + + err = (&ApiCmd{ + Method: "patch", + Path: "conversations/123/custom_attributes", + Data: `{"priority":"urgent"}`, + Header: []string{"X-Test: yes"}, + }).Run(app) + if err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestNormalizeAPIPathRejectsAbsoluteURLs(t *testing.T) { + if _, _, err := normalizeAPIPath("https://example.com/api/v1/profile", false); err == nil { + t.Fatal("expected absolute URL error") + } +} diff --git a/internal/cmd/app.go b/internal/cmd/app.go index 167eb82..10adee7 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -44,7 +44,12 @@ func NewApp(cli *CLI, skipAuth bool, version string) (*App, error) { return nil, fmt.Errorf("not authenticated: %w", err) } - client := sdk.NewClient(effectiveCfg.BaseURL, apiKey, effectiveCfg.AccountID, sdk.WithVerbose(cli.Verbose)) + client := sdk.NewClient( + effectiveCfg.BaseURL, + apiKey, + effectiveCfg.AccountID, + sdk.WithVerbose(cli.Verbose), + ) return &App{ Client: client, diff --git a/internal/cmd/cli.go b/internal/cmd/cli.go index 84f165b..e70e3af 100644 --- a/internal/cmd/cli.go +++ b/internal/cmd/cli.go @@ -34,6 +34,7 @@ type CLI struct { // Workflow. `me` and `whoami` are aliases of `auth status`. Me MeCmd `cmd:"" help:"Show identity and connection (alias of 'auth status')."` Whoami WhoamiCmd `cmd:"" help:"Show identity and connection (alias of 'auth status')."` + Api ApiCmd `cmd:"" help:"Make an HTTP request to the Chatwoot API."` // Setup. Auth AuthCmd `cmd:"" help:"Login, logout, and status."` diff --git a/internal/cmd/conversation_test.go b/internal/cmd/conversation_test.go index 4e5201a..11ab510 100644 --- a/internal/cmd/conversation_test.go +++ b/internal/cmd/conversation_test.go @@ -236,4 +236,3 @@ func TestConvUnassignPostsZeroAssignee(t *testing.T) { t.Errorf("posted assignee_id = %v, want 0 (Chatwoot's unassign sentinel)", got.AssigneeID) } } - diff --git a/internal/sdk/client.go b/internal/sdk/client.go index 056cee4..97de9a1 100644 --- a/internal/sdk/client.go +++ b/internal/sdk/client.go @@ -19,6 +19,13 @@ type Client struct { httpClient *http.Client } +type RawResponse struct { + StatusCode int + Status string + Header http.Header + Body []byte +} + type ClientOption func(*Client) func WithHTTPClient(httpClient *http.Client) ClientOption { @@ -66,6 +73,18 @@ func (c *Client) request(method, path string, body io.Reader) (*http.Request, er return req, nil } +func (c *Client) rawRequest(method, path string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, fmt.Sprintf("%s%s", c.BaseURL, path), body) + if err != nil { + return nil, err + } + + req.Header.Set("api_access_token", c.APIKey) + req.Header.Set("Content-Type", "application/json") + + return req, nil +} + func (c *Client) do(req *http.Request, v interface{}) error { if c.Verbose { fmt.Fprintf(os.Stderr, "> %s %s\n", req.Method, req.URL.String()) @@ -109,6 +128,38 @@ func (c *Client) do(req *http.Request, v interface{}) error { return nil } +func (c *Client) doRaw(req *http.Request) (*RawResponse, error) { + if c.Verbose { + fmt.Fprintf(os.Stderr, "> %s %s\n", req.Method, req.URL.String()) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if c.Verbose { + fmt.Fprintf(os.Stderr, "< %s\n", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + return &RawResponse{ + StatusCode: resp.StatusCode, + Status: resp.Status, + Header: resp.Header.Clone(), + Body: body, + }, nil +} + func redactSensitiveJSON(body []byte) string { var value interface{} if err := json.Unmarshal(body, &value); err != nil { @@ -166,22 +217,44 @@ func (c *Client) Get(path string, params url.Values, v interface{}) error { // GetRaw makes a GET request to a non-account-scoped path (e.g. /api/v1/profile). func (c *Client) GetRaw(path string, params url.Values, v interface{}) error { - fullURL := fmt.Sprintf("%s%s", c.BaseURL, path) + fullPath := path if len(params) > 0 { - fullURL = fmt.Sprintf("%s?%s", fullURL, params.Encode()) + fullPath = fmt.Sprintf("%s?%s", fullPath, params.Encode()) } - req, err := http.NewRequest(http.MethodGet, fullURL, nil) + req, err := c.rawRequest(http.MethodGet, fullPath, nil) if err != nil { return err } - req.Header.Set("api_access_token", c.APIKey) - req.Header.Set("Content-Type", "application/json") - return c.do(req, v) } +// RequestRaw makes an authenticated request and returns the response body without decoding it. +// Account-scoped paths are resolved under /api/v1/accounts/{account_id}; exact paths are +// resolved relative to BaseURL. +func (c *Client) RequestRaw(method, path string, body io.Reader, accountScoped bool, headers http.Header) (*RawResponse, error) { + var req *http.Request + var err error + if accountScoped { + req, err = c.request(method, path, body) + } else { + req, err = c.rawRequest(method, path, body) + } + if err != nil { + return nil, err + } + + for key, values := range headers { + req.Header.Del(key) + for _, value := range values { + req.Header.Add(key, value) + } + } + + return c.doRaw(req) +} + func (c *Client) Post(path string, body io.Reader, v interface{}) error { req, err := c.request(http.MethodPost, path, body) if err != nil { diff --git a/skills/chatwoot-cli/SKILL.md b/skills/chatwoot-cli/SKILL.md index 4531eec..b20d7ba 100644 --- a/skills/chatwoot-cli/SKILL.md +++ b/skills/chatwoot-cli/SKILL.md @@ -37,6 +37,10 @@ explicitly. - `chatwoot auth login` is interactive (prompts for base URL, API key, account ID). If invoked headlessly it will fail — surface the env-var path instead. +- Prefer first-class commands over `chatwoot api`. Use raw API calls only when + no command exists or the user explicitly asks for an endpoint-level call. +- Before raw API calls, check the application Swagger: + https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/application_swagger.json - Use `-v` (verbose) to see the underlying HTTP request/response when debugging an unexpected result. @@ -95,6 +99,7 @@ chatwoot convs --help # filters for the list command | `inboxes` / `inbox ` | List inboxes / view one | | `agents` / `labels` / `teams` | List account-level resources | | `me` / `whoami` / `auth status` | Show current identity | +| `api ` | Call an arbitrary Chatwoot API endpoint with saved auth headers | | `auth login` / `logout` | Interactive login / remove credentials | | `config path` / `config view` | Inspect config file location and contents | | `completion ` | Print shell-completion script | @@ -116,9 +121,10 @@ chatwoot convs --help # filters for the list command ## Safety — customer-visible writes Some commands change shared state or send messages a customer or teammate -will see. Before running any of them in an agent context, **show the user -the exact command and get explicit approval.** Don't assume approval on one -conversation extends to another. +will see. Treat all write operations as privileged actions. Before running any +of them in an agent context, **show the user the exact command and get explicit +approval. Never perform writes without user confirmation.** Don't assume +approval on one conversation extends to another. Customer- or team-visible (effectively irreversible): - `reply` (without `--private`) — the message is sent and cannot be unsent. @@ -128,13 +134,17 @@ Customer- or team-visible (effectively irreversible): close out SLA tracking. - `label` — overwrites the existing label set (see mistake #3). - `priority` — visible in dashboards, used for SLA routing. +- `api -X ...` or `api --data ...` — arbitrary endpoint calls can + mutate any supported resource. Treat non-GET requests as writes unless the + endpoint contract proves otherwise. Show the exact method, path, and body + before running a mutating raw API call. - Any bulk operation composed with `-q | xargs` — pause, list what would be affected, then confirm. Read-only and safe to run freely: `convs`, `conv ` (view), `conv messages`, `conv contact`, `contacts`, `contact `, `inboxes`, `inbox `, `agents`, `labels`, `teams`, `me`, `whoami`, -`auth status`, `config path`, `config view`. +`auth status`, `config path`, `config view`, `api ` when it is a GET. ## Common Patterns @@ -172,3 +182,12 @@ chatwoot convs -l spam -q | xargs -I{} chatwoot conv {} resolve id=$(chatwoot contacts --search "jane@example.com" -o json | jq '.payload[0].id') chatwoot contact "$id" conversations -o json ``` + +**Raw API call** — account-relative paths are expanded under `/api/v1/accounts/`, so do not include the `/api/v1/accounts/...` prefix: +```bash +chatwoot api /conversations/123 -o json +chatwoot api -X PATCH /conversations/123 --data '{"status":"open"}' +``` + +Use the application Swagger as the endpoint reference before raw API calls: +https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/application_swagger.json