diff --git a/README.md b/README.md index 15fce35..0e2030b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,13 @@ chatwoot contact 456 conversations chatwoot inboxes / agents / labels / teams # List chatwoot me # Your profile +chatwoot hcs # List help centers +chatwoot hc default chatwoot-support # Save default help center and locale +chatwoot hc articles --query "account" +chatwoot hc articles --category getting-started +chatwoot hc article create-a-agent-bot +chatwoot hc articles --portal other-help-center --locale fr + chatwoot api /conversations/123 # Expands to /api/v1/accounts//conversations/123 chatwoot api -X PATCH /conversations/123 --data '{"status":"open"}' ``` diff --git a/internal/cmd/cli.go b/internal/cmd/cli.go index e70e3af..c955478 100644 --- a/internal/cmd/cli.go +++ b/internal/cmd/cli.go @@ -19,17 +19,19 @@ type CLI struct { Verbose bool `short:"v" help:"Show request/response details."` // Plurals — list commands. - Convs ConvsCmd `cmd:"" aliases:"conversations" help:"List conversations."` - Contacts ContactsCmd `cmd:"" help:"List or search contacts."` - Inboxes InboxesCmd `cmd:"" help:"List inboxes."` - Agents AgentsCmd `cmd:"" help:"List agents."` - Labels LabelsCmd `cmd:"" help:"List labels."` - Teams TeamsCmd `cmd:"" help:"List teams."` + Convs ConvsCmd `cmd:"" aliases:"conversations" help:"List conversations."` + Contacts ContactsCmd `cmd:"" help:"List or search contacts."` + Inboxes InboxesCmd `cmd:"" help:"List inboxes."` + Agents AgentsCmd `cmd:"" help:"List agents."` + Labels LabelsCmd `cmd:"" help:"List labels."` + Teams TeamsCmd `cmd:"" help:"List teams."` + HelpCenters HCsCmd `cmd:"" name:"hcs" aliases:"help-centers" help:"List help centers."` // 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."` - Inbox InboxCmd `cmd:"" help:"View an inbox."` + Conv ConvCmd `cmd:"" aliases:"conversation" help:"View or act on a conversation."` + Contact ContactCmd `cmd:"" help:"View a contact."` + Inbox InboxCmd `cmd:"" help:"View an inbox."` + HelpCenter HCCmd `cmd:"" name:"hc" aliases:"help-center" help:"List, search, or view help center articles."` // Workflow. `me` and `whoami` are aliases of `auth status`. Me MeCmd `cmd:"" help:"Show identity and connection (alias of 'auth status')."` diff --git a/internal/cmd/help_center.go b/internal/cmd/help_center.go new file mode 100644 index 0000000..01359c1 --- /dev/null +++ b/internal/cmd/help_center.go @@ -0,0 +1,287 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/chatwoot/cli/internal/config" + "github.com/chatwoot/cli/internal/output" + "github.com/chatwoot/cli/internal/sdk" +) + +// HCsCmd is `chatwoot hcs` — list help center portals on the account. +type HCsCmd struct{} + +func (c *HCsCmd) Run(app *App) error { + resp, err := app.Client.HelpCenter().ListPortals() + if err != nil { + return err + } + + if app.Printer.Format == "json" && !app.Printer.Quiet { + app.Printer.PrintJSON(resp) + return nil + } + + if len(resp.Payload) == 0 { + fmt.Println("No help centers found.") + return nil + } + + headers := []string{"ID", "Name", "Slug", "Default Locale", "Locales", "Articles", "Categories"} + rows := make([][]string, 0, len(resp.Payload)) + for _, portal := range resp.Payload { + rows = append(rows, []string{ + strconv.Itoa(portal.ID), + portal.Name, + portal.Slug, + portalDefaultLocale(portal), + portalLocales(portal.Config.AllowedLocales), + strconv.Itoa(portalArticlesCount(portal)), + strconv.Itoa(portal.Meta.CategoriesCount), + }) + } + + app.Printer.PrintTable(headers, rows) + return nil +} + +type HCCmd struct { + List HCsCmd `cmd:"" help:"List help centers."` + Default HCDefaultCmd `cmd:"" help:"Show, set, or clear the default help center."` + Articles HCArticlesCmd `cmd:"" help:"List or search public help center articles."` + Article HCArticleCmd `cmd:"" help:"Get a public help center article."` +} + +type HCDefaultCmd struct { + Slug string `arg:"" optional:"" help:"Help center portal slug to set as default."` + Clear bool `help:"Clear the configured default help center."` +} + +func (c *HCDefaultCmd) Run(app *App) error { + if c.Clear { + if app.Config == nil { + return fmt.Errorf("config is not loaded") + } + app.Config.HelpCenter = config.HelpCenterConfig{} + if err := config.Save(app.Config); err != nil { + return err + } + _, _ = fmt.Fprintln(app.Printer.Writer, "Cleared default help center.") + return nil + } + + if strings.TrimSpace(c.Slug) == "" { + return renderHelpCenterDefault(app) + } + + portal, err := findHelpCenterPortal(app, c.Slug) + if err != nil { + return err + } + + if app.Config == nil { + return fmt.Errorf("config is not loaded") + } + app.Config.HelpCenter = config.HelpCenterConfig{ + DefaultPortalSlug: portal.Slug, + DefaultLocale: portalDefaultLocale(portal), + } + if err := config.Save(app.Config); err != nil { + return err + } + + _, _ = fmt.Fprintf(app.Printer.Writer, "Default help center set to %s", portal.Slug) + if app.Config.HelpCenter.DefaultLocale != "" { + _, _ = fmt.Fprintf(app.Printer.Writer, " (%s)", app.Config.HelpCenter.DefaultLocale) + } + _, _ = fmt.Fprintln(app.Printer.Writer) + return nil +} + +type HCArticlesCmd struct { + PortalSlug string `name:"portal" help:"Help center portal slug. Defaults to 'chatwoot hc default'."` + Locale string `help:"Article locale, for example en. Defaults to the configured help center locale."` + CategorySlug string `name:"category" help:"Restrict results to a category slug."` + Query string `help:"Search query."` + Page int `short:"p" default:"1" help:"Page number."` + PerPage int `help:"Results per page, capped by Chatwoot at 100."` +} + +func (c *HCArticlesCmd) Run(app *App) error { + portalSlug, err := resolveHelpCenterPortal(app, c.PortalSlug) + if err != nil { + return err + } + locale, err := resolveHelpCenterLocale(app, c.Locale) + if err != nil { + return err + } + + resp, err := app.Client.HelpCenter().ListArticles(sdk.HelpCenterArticlesOptions{ + PortalSlug: portalSlug, + Locale: locale, + CategorySlug: c.CategorySlug, + Query: c.Query, + Page: c.Page, + PerPage: c.PerPage, + }) + if err != nil { + return err + } + + if app.Printer.Format == "json" && !app.Printer.Quiet { + app.Printer.PrintJSON(resp) + return nil + } + + if len(resp.Payload) == 0 { + fmt.Println("No articles found.") + return nil + } + + headers := []string{"ID", "Title", "Category", "Slug", "Link", "Snippet"} + rows := make([][]string, 0, len(resp.Payload)) + for _, article := range resp.Payload { + rows = append(rows, []string{ + strconv.Itoa(article.ID), + article.Title, + articleCategory(article), + article.Slug, + article.Link, + articleSnippet(article), + }) + } + + app.Printer.PrintTable(headers, rows) + return nil +} + +type HCArticleCmd struct { + PortalSlug string `name:"portal" help:"Help center portal slug. Defaults to 'chatwoot hc default'."` + ArticleSlug string `arg:"" help:"Article slug."` +} + +func (c *HCArticleCmd) Run(app *App) error { + portalSlug, err := resolveHelpCenterPortal(app, c.PortalSlug) + if err != nil { + return err + } + + article, err := app.Client.HelpCenter().GetArticle(portalSlug, c.ArticleSlug) + if err != nil { + return err + } + + if app.Printer.Format == "json" && !app.Printer.Quiet { + app.Printer.PrintJSON(article) + return nil + } + + app.Printer.PrintDetail([]output.KeyValue{ + {Key: "ID", Value: strconv.Itoa(article.ID)}, + {Key: "Title", Value: article.Title}, + {Key: "Slug", Value: article.Slug}, + {Key: "Category", Value: articleCategory(*article)}, + {Key: "Views", Value: strconv.Itoa(article.Views)}, + {Key: "Link", Value: article.Link}, + {Key: "Description", Value: article.Description}, + {Key: "Content", Value: truncate(strings.TrimSpace(article.Content), 500)}, + }) + return nil +} + +func renderHelpCenterDefault(app *App) error { + if app.Config == nil || + strings.TrimSpace(app.Config.HelpCenter.DefaultPortalSlug) == "" { + _, _ = fmt.Fprintln(app.Printer.Writer, "No default help center set.") + return nil + } + + app.Printer.PrintDetail([]output.KeyValue{ + {Key: "Portal", Value: app.Config.HelpCenter.DefaultPortalSlug}, + {Key: "Locale", Value: app.Config.HelpCenter.DefaultLocale}, + }) + return nil +} + +func findHelpCenterPortal(app *App, slug string) (sdk.HelpCenterPortal, error) { + slug = strings.TrimSpace(slug) + if slug == "" { + return sdk.HelpCenterPortal{}, fmt.Errorf("help center slug is required") + } + + resp, err := app.Client.HelpCenter().ListPortals() + if err != nil { + return sdk.HelpCenterPortal{}, err + } + for _, portal := range resp.Payload { + if portal.Slug == slug { + return portal, nil + } + } + return sdk.HelpCenterPortal{}, fmt.Errorf("no help center matched %q", slug) +} + +func resolveHelpCenterPortal(app *App, explicitPortal string) (string, error) { + if strings.TrimSpace(explicitPortal) != "" { + return strings.TrimSpace(explicitPortal), nil + } + if app.Config != nil && strings.TrimSpace(app.Config.HelpCenter.DefaultPortalSlug) != "" { + return strings.TrimSpace(app.Config.HelpCenter.DefaultPortalSlug), nil + } + return "", fmt.Errorf("no default help center set. Run 'chatwoot hc default ' or pass --portal") +} + +func resolveHelpCenterLocale(app *App, explicitLocale string) (string, error) { + if strings.TrimSpace(explicitLocale) != "" { + return strings.TrimSpace(explicitLocale), nil + } + if app.Config != nil && strings.TrimSpace(app.Config.HelpCenter.DefaultLocale) != "" { + return strings.TrimSpace(app.Config.HelpCenter.DefaultLocale), nil + } + return "", fmt.Errorf("no default help center locale set. Pass --locale or reset the default with 'chatwoot hc default '") +} + +func portalDefaultLocale(portal sdk.HelpCenterPortal) string { + if portal.Config.DefaultLocale != "" { + return portal.Config.DefaultLocale + } + return portal.Meta.DefaultLocale +} + +func portalLocales(locales []sdk.HelpCenterPortalLocale) string { + if len(locales) == 0 { + return "" + } + codes := make([]string, 0, len(locales)) + for _, locale := range locales { + codes = append(codes, locale.Code) + } + return strings.Join(codes, ", ") +} + +func portalArticlesCount(portal sdk.HelpCenterPortal) int { + if portal.Meta.PublishedCount > 0 { + return portal.Meta.PublishedCount + } + return portal.Meta.AllArticlesCount +} + +func articleCategory(article sdk.HelpCenterArticle) string { + if article.Category != nil && article.Category.Slug != "" { + return article.Category.Slug + } + if article.CategoryID > 0 { + return strconv.Itoa(article.CategoryID) + } + return "" +} + +func articleSnippet(article sdk.HelpCenterArticle) string { + if article.Description != "" { + return truncate(strings.TrimSpace(article.Description), 120) + } + return truncate(strings.TrimSpace(article.Content), 120) +} diff --git a/internal/cmd/help_center_test.go b/internal/cmd/help_center_test.go new file mode 100644 index 0000000..8d89069 --- /dev/null +++ b/internal/cmd/help_center_test.go @@ -0,0 +1,350 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/chatwoot/cli/internal/config" + "github.com/chatwoot/cli/internal/output" +) + +func TestHCDefaultSavesPortalAndLocale(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/portals" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "payload": [{ + "id": 1, + "name": "Pocket Casts Support", + "slug": "pocket-casts-support", + "config": {"default_locale": "en"} + }] + }`)) + })) + 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) + } + var out bytes.Buffer + app.Printer.Writer = &out + + if err := (&HCDefaultCmd{Slug: "pocket-casts-support"}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + + loaded, err := config.Load() + if err != nil { + t.Fatalf("config.Load: %v", err) + } + if loaded.HelpCenter.DefaultPortalSlug != "pocket-casts-support" { + t.Fatalf("DefaultPortalSlug = %q", loaded.HelpCenter.DefaultPortalSlug) + } + if loaded.HelpCenter.DefaultLocale != "en" { + t.Fatalf("DefaultLocale = %q", loaded.HelpCenter.DefaultLocale) + } + if !strings.Contains(out.String(), "Default help center set to pocket-casts-support (en)") { + t.Fatalf("unexpected output: %s", out.String()) + } +} + +func TestHCsListsHelpCenters(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/portals" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "payload": [{ + "id": 1, + "name": "Pocket Casts Support", + "slug": "pocket-casts-support", + "config": { + "default_locale": "en", + "allowed_locales": [{"code": "en"}, {"code": "fr"}] + }, + "meta": {"published_count": 12, "categories_count": 3} + }] + }`)) + })) + 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) + } + var out bytes.Buffer + app.Printer.Writer = &out + + if err := (&HCsCmd{}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + for _, want := range []string{"Pocket Casts Support", "pocket-casts-support", "en, fr", "12", "3"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("output missing %q:\n%s", want, out.String()) + } + } +} + +func TestHCDefaultShowsAndClearsDefault(t *testing.T) { + setupTestEnv(t) + + cfg := &config.Config{ + BaseURL: "https://example.test", + AccountID: 1, + HelpCenter: config.HelpCenterConfig{ + DefaultPortalSlug: "pocket-casts-support", + DefaultLocale: "en", + }, + } + if err := config.Save(cfg); err != nil { + t.Fatalf("config.Save: %v", err) + } + + var showOut bytes.Buffer + showApp := &App{Config: cfg, Printer: testPrinter(&showOut)} + if err := (&HCDefaultCmd{}).Run(showApp); err != nil { + t.Fatalf("show Run: %v", err) + } + if !strings.Contains(showOut.String(), "pocket-casts-support") || !strings.Contains(showOut.String(), "en") { + t.Fatalf("unexpected show output: %s", showOut.String()) + } + + var clearOut bytes.Buffer + clearApp := &App{Config: cfg, Printer: testPrinter(&clearOut)} + if err := (&HCDefaultCmd{Clear: true}).Run(clearApp); err != nil { + t.Fatalf("clear Run: %v", err) + } + + loaded, err := config.Load() + if err != nil { + t.Fatalf("config.Load: %v", err) + } + if loaded.HelpCenter.DefaultPortalSlug != "" || loaded.HelpCenter.DefaultLocale != "" { + t.Fatalf("help center default was not cleared: %#v", loaded.HelpCenter) + } +} + +func TestHCArticlesRendersTextTable(t *testing.T) { + setupTestEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/hc/pocket-casts-support/en/articles.json" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "payload": [{ + "id": 123, + "title": "Create an account", + "slug": "create-account", + "description": "Short setup guide", + "category": {"id": 4, "slug": "accounts", "locale": "en"}, + "link": "hc/pocket-casts-support/articles/create-account" + }], + "meta": {"articles_count": 1} + }`)) + })) + t.Cleanup(server.Close) + + if err := config.Save(&config.Config{ + BaseURL: server.URL, + AccountID: 1, + HelpCenter: config.HelpCenterConfig{ + DefaultPortalSlug: "pocket-casts-support", + DefaultLocale: "en", + }, + }); 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 := (&HCArticlesCmd{}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + for _, want := range []string{"123", "Create an account", "accounts", "create-account", "Short setup guide"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("output missing %q:\n%s", want, out.String()) + } + } +} + +func TestHCArticlesUsesConfiguredDefaults(t *testing.T) { + setupTestEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/hc/pocket-casts-support/en/articles.json" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + if got := r.URL.Query().Get("query"); got != "account" { + t.Errorf("query = %q, want account", got) + } + if r.URL.Query().Has("sort") { + t.Errorf("sort should not be sent: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "payload": [{"id": 123, "title": "Create an account", "slug": "create-account", "content": "Account setup"}], + "meta": {"articles_count": 1} + }`)) + })) + t.Cleanup(server.Close) + + if err := config.Save(&config.Config{ + BaseURL: server.URL, + AccountID: 1, + HelpCenter: config.HelpCenterConfig{ + DefaultPortalSlug: "pocket-casts-support", + DefaultLocale: "en", + }, + }); 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 + + if err := (&HCArticlesCmd{Query: "account"}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } + var got struct { + Payload []struct { + ID int `json:"id"` + } `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) != 1 || got.Payload[0].ID != 123 { + t.Fatalf("unexpected output: %#v", got) + } +} + +func TestHCArticlesOverridesConfiguredDefaults(t *testing.T) { + setupTestEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/hc/other/fr/categories/getting-started/articles.json" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("page = %q, want 2", got) + } + if got := r.URL.Query().Get("per_page"); got != "10" { + t.Errorf("per_page = %q, want 10", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payload": [], "meta": {"articles_count": 0}}`)) + })) + t.Cleanup(server.Close) + + if err := config.Save(&config.Config{ + BaseURL: server.URL, + AccountID: 1, + HelpCenter: config.HelpCenterConfig{ + DefaultPortalSlug: "pocket-casts-support", + DefaultLocale: "en", + }, + }); 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 := (&HCArticlesCmd{ + PortalSlug: "other", + Locale: "fr", + CategorySlug: "getting-started", + Page: 2, + PerPage: 10, + }).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestHCArticleUsesConfiguredPortal(t *testing.T) { + setupTestEnv(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/hc/pocket-casts-support/articles/create-account.json" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": 123, "title": "Create an account", "slug": "create-account", "content": "Full body"}`)) + })) + t.Cleanup(server.Close) + + if err := config.Save(&config.Config{ + BaseURL: server.URL, + AccountID: 1, + HelpCenter: config.HelpCenterConfig{ + DefaultPortalSlug: "pocket-casts-support", + DefaultLocale: "en", + }, + }); err != nil { + t.Fatalf("config.Save: %v", err) + } + app, err := NewApp(&CLI{Output: "json"}, false, "test") + if err != nil { + t.Fatalf("NewApp: %v", err) + } + app.Printer.Writer = &bytes.Buffer{} + + if err := (&HCArticleCmd{ArticleSlug: "create-account"}).Run(app); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestHCArticlesErrorsWithoutDefaultPortal(t *testing.T) { + var out bytes.Buffer + err := (&HCArticlesCmd{}).Run(&App{ + Config: &config.Config{}, + Printer: testPrinter(&out), + }) + if err == nil { + t.Fatal("expected missing default error") + } + if !strings.Contains(err.Error(), "chatwoot hc default ") { + t.Fatalf("unexpected error: %v", err) + } +} + +func testPrinter(out *bytes.Buffer) *output.Printer { + printer := output.NewPrinter("text", false, false) + printer.Writer = out + return printer +} diff --git a/internal/config/config.go b/internal/config/config.go index 4b50958..e9eb7d4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,9 +10,15 @@ import ( ) type Config struct { - BaseURL string `yaml:"base_url"` - AccountID int `yaml:"account_id"` - UserID int `yaml:"user_id,omitempty"` + BaseURL string `yaml:"base_url"` + AccountID int `yaml:"account_id"` + UserID int `yaml:"user_id,omitempty"` + HelpCenter HelpCenterConfig `yaml:"help_center,omitempty"` +} + +type HelpCenterConfig struct { + DefaultPortalSlug string `yaml:"default_portal_slug,omitempty"` + DefaultLocale string `yaml:"default_locale,omitempty"` } func ConfigDir() (string, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 220463b..1c76e02 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -38,6 +38,49 @@ func TestSaveOmitsAPIKey(t *testing.T) { } } +func TestSaveAndLoadHelpCenterDefaults(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + cfg := &Config{ + BaseURL: "https://app.chatwoot.com", + AccountID: 123, + HelpCenter: HelpCenterConfig{ + DefaultPortalSlug: "pocket-casts-support", + DefaultLocale: "en", + }, + } + + if err := Save(cfg); err != nil { + t.Fatalf("Save() error = %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.HelpCenter.DefaultPortalSlug != "pocket-casts-support" { + t.Fatalf("DefaultPortalSlug = %q, want pocket-casts-support", loaded.HelpCenter.DefaultPortalSlug) + } + if loaded.HelpCenter.DefaultLocale != "en" { + t.Fatalf("DefaultLocale = %q, want en", loaded.HelpCenter.DefaultLocale) + } + + path, err := ConfigPath() + if err != nil { + t.Fatalf("ConfigPath() error = %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + content := string(data) + for _, want := range []string{"help_center:", "default_portal_slug: pocket-casts-support", "default_locale: en"} { + if !strings.Contains(content, want) { + t.Fatalf("saved config missing %q: %s", want, content) + } + } +} + func TestLegacyAPIKeyIsIgnoredAndRemovedOnSave(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/internal/sdk/client.go b/internal/sdk/client.go index d3b312b..2999ded 100644 --- a/internal/sdk/client.go +++ b/internal/sdk/client.go @@ -332,3 +332,8 @@ func (c *Client) Teams() *TeamsService { func (c *Client) Profile() *ProfileService { return &ProfileService{client: c} } + +// HelpCenter returns the help center service. +func (c *Client) HelpCenter() *HelpCenterService { + return &HelpCenterService{client: c} +} diff --git a/internal/sdk/help_center.go b/internal/sdk/help_center.go new file mode 100644 index 0000000..694c8cd --- /dev/null +++ b/internal/sdk/help_center.go @@ -0,0 +1,167 @@ +package sdk + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +type HelpCenterService struct { + client *Client +} + +type HelpCenterPortal struct { + ID int `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + CustomDomain string `json:"custom_domain"` + HomepageLink string `json:"homepage_link"` + PageTitle string `json:"page_title"` + Archived bool `json:"archived"` + Config HelpCenterPortalConfig `json:"config"` + Meta HelpCenterPortalMeta `json:"meta"` +} + +type HelpCenterPortalConfig struct { + AllowedLocales []HelpCenterPortalLocale `json:"allowed_locales"` + DefaultLocale string `json:"default_locale"` + Layout string `json:"layout"` +} + +type HelpCenterPortalLocale struct { + Code string `json:"code"` + ArticlesCount int `json:"articles_count"` + CategoriesCount int `json:"categories_count"` + Draft bool `json:"draft"` +} + +type HelpCenterPortalMeta struct { + AllArticlesCount int `json:"all_articles_count"` + PublishedCount int `json:"published_count"` + CategoriesCount int `json:"categories_count"` + DefaultLocale string `json:"default_locale"` +} + +type HelpCenterPortalsResponse struct { + Payload []HelpCenterPortal `json:"payload"` + Meta map[string]any `json:"meta"` +} + +type HelpCenterArticlesResponse struct { + Payload []HelpCenterArticle `json:"payload"` + Meta HelpCenterArticlesMeta `json:"meta"` +} + +type HelpCenterArticlesMeta struct { + ArticlesCount int `json:"articles_count"` +} + +type HelpCenterArticle struct { + ID int `json:"id"` + CategoryID int `json:"category_id"` + Title string `json:"title"` + Content string `json:"content"` + Description string `json:"description"` + Status string `json:"status"` + Position int `json:"position"` + AccountID int `json:"account_id"` + LastUpdatedAt string `json:"last_updated_at"` + Slug string `json:"slug"` + Portal *HelpCenterPublicPortal `json:"portal"` + Category *HelpCenterArticleCategory `json:"category"` + Views int `json:"views"` + Link string `json:"link"` +} + +type HelpCenterPublicPortal struct { + Name string `json:"name"` + Slug string `json:"slug"` + CustomDomain string `json:"custom_domain"` + PageTitle string `json:"page_title"` +} + +type HelpCenterArticleCategory struct { + ID int `json:"id"` + Slug string `json:"slug"` + Locale string `json:"locale"` +} + +type HelpCenterArticlesOptions struct { + PortalSlug string + Locale string + CategorySlug string + Query string + Page int + PerPage int +} + +func (s *HelpCenterService) ListPortals() (*HelpCenterPortalsResponse, error) { + var resp HelpCenterPortalsResponse + if err := s.client.Get("/portals", nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (s *HelpCenterService) ListArticles(opts HelpCenterArticlesOptions) (*HelpCenterArticlesResponse, error) { + path, err := helpCenterArticlesPath(opts.PortalSlug, opts.Locale, opts.CategorySlug) + if err != nil { + return nil, err + } + + params := url.Values{} + if opts.Query != "" { + params.Set("query", opts.Query) + } + if opts.Page > 1 || (opts.Page > 0 && opts.PerPage > 0) { + params.Set("page", strconv.Itoa(opts.Page)) + } + if opts.PerPage > 0 { + params.Set("per_page", strconv.Itoa(opts.PerPage)) + } + + var resp HelpCenterArticlesResponse + if err := s.client.GetRaw(path, params, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (s *HelpCenterService) GetArticle(portalSlug, articleSlug string) (*HelpCenterArticle, error) { + if strings.TrimSpace(portalSlug) == "" { + return nil, fmt.Errorf("portal slug is required") + } + if strings.TrimSpace(articleSlug) == "" { + return nil, fmt.Errorf("article slug is required") + } + + var article HelpCenterArticle + path := fmt.Sprintf("/hc/%s/articles/%s.json", url.PathEscape(portalSlug), url.PathEscape(articleSlug)) + if err := s.client.GetRaw(path, nil, &article); err != nil { + return nil, err + } + return &article, nil +} + +func helpCenterArticlesPath(portalSlug, locale, categorySlug string) (string, error) { + if strings.TrimSpace(portalSlug) == "" { + return "", fmt.Errorf("portal slug is required") + } + if strings.TrimSpace(locale) == "" { + return "", fmt.Errorf("locale is required") + } + + portalSlug = url.PathEscape(portalSlug) + locale = url.PathEscape(locale) + if strings.TrimSpace(categorySlug) == "" { + return fmt.Sprintf("/hc/%s/%s/articles.json", portalSlug, locale), nil + } + + return fmt.Sprintf( + "/hc/%s/%s/categories/%s/articles.json", + portalSlug, + locale, + url.PathEscape(categorySlug), + ), nil +} diff --git a/internal/sdk/help_center_test.go b/internal/sdk/help_center_test.go new file mode 100644 index 0000000..3017a04 --- /dev/null +++ b/internal/sdk/help_center_test.go @@ -0,0 +1,149 @@ +package sdk + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestHelpCenterListPortalsCallsAccountEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/accounts/9/portals" { + 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(`{ + "payload": [{ + "id": 1, + "name": "Pocket Casts Support", + "slug": "pocket-casts-support", + "config": { + "default_locale": "en", + "allowed_locales": [{"code": "en", "articles_count": 12, "categories_count": 3}] + }, + "meta": {"published_count": 12, "categories_count": 3} + }], + "meta": {"portals_count": 1} + }`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client())) + resp, err := client.HelpCenter().ListPortals() + if err != nil { + t.Fatalf("ListPortals returned error: %v", err) + } + if len(resp.Payload) != 1 || resp.Payload[0].Slug != "pocket-casts-support" { + t.Fatalf("unexpected portals response: %#v", resp) + } +} + +func TestHelpCenterListArticlesBuildsPublicSearchURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/hc/pocket-casts-support/en/categories/getting-started/articles.json" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + if got := r.URL.Query().Get("query"); got != "sync account" { + t.Errorf("query = %q, want sync account", got) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("page = %q, want 2", got) + } + if got := r.URL.Query().Get("per_page"); got != "10" { + t.Errorf("per_page = %q, want 10", got) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "payload": [{ + "id": 123, + "category_id": 4, + "title": "Create a Pocket Casts Sync Account", + "content": "You can create an account from settings.", + "link": "hc/pocket-casts-support/articles/create-a-pocket-casts-sync-account" + }], + "meta": {"articles_count": 60} + }`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client())) + resp, err := client.HelpCenter().ListArticles(HelpCenterArticlesOptions{ + PortalSlug: "pocket-casts-support", + Locale: "en", + CategorySlug: "getting-started", + Query: "sync account", + Page: 2, + PerPage: 10, + }) + if err != nil { + t.Fatalf("ListArticles returned error: %v", err) + } + if resp.Meta.ArticlesCount != 60 { + t.Fatalf("articles_count = %d, want 60", resp.Meta.ArticlesCount) + } + if len(resp.Payload) != 1 || resp.Payload[0].Title != "Create a Pocket Casts Sync Account" { + t.Fatalf("unexpected articles response: %#v", resp) + } +} + +func TestHelpCenterListArticlesOmitsDefaultPagination(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/hc/pocket-casts-support/en/articles.json" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + if r.URL.RawQuery != "" { + t.Errorf("raw query = %q, want empty", r.URL.RawQuery) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payload": [], "meta": {"articles_count": 0}}`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client())) + if _, err := client.HelpCenter().ListArticles(HelpCenterArticlesOptions{ + PortalSlug: "pocket-casts-support", + Locale: "en", + Page: 1, + }); err != nil { + t.Fatalf("ListArticles returned error: %v", err) + } +} + +func TestHelpCenterGetArticleBuildsPublicArticleURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/hc/pocket-casts-support/articles/create-a-pocket-casts-sync-account.json" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id": 123, + "category_id": 4, + "title": "Create a Pocket Casts Sync Account", + "slug": "create-a-pocket-casts-sync-account", + "content": "Full article body", + "views": 25, + "link": "hc/pocket-casts-support/articles/create-a-pocket-casts-sync-account" + }`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client())) + article, err := client.HelpCenter().GetArticle("pocket-casts-support", "create-a-pocket-casts-sync-account") + if err != nil { + t.Fatalf("GetArticle returned error: %v", err) + } + if article.ID != 123 || article.Slug != "create-a-pocket-casts-sync-account" { + t.Fatalf("unexpected article: %#v", article) + } +} diff --git a/skills/chatwoot-cli/SKILL.md b/skills/chatwoot-cli/SKILL.md index b20d7ba..43f28b9 100644 --- a/skills/chatwoot-cli/SKILL.md +++ b/skills/chatwoot-cli/SKILL.md @@ -3,12 +3,12 @@ name: chatwoot-cli description: > Operate Chatwoot helpdesks from the terminal — list and triage conversations, send replies and private notes, assign agents and teams, change status, set - labels and priority, search contacts, and inspect inboxes via the `chatwoot` - CLI. Use when the user wants to read, summarize, or act on Chatwoot - conversations from the shell, scripts, agent workflows, or CI. Always load - this skill before running `chatwoot` commands — it contains the noun/verb - grammar, the output-format contract, and the safety rules that prevent - customer-visible mistakes. + labels and priority, search contacts, inspect inboxes, and search help + center articles via the `chatwoot` CLI. Use when the user wants to read, + summarize, or act on Chatwoot conversations or help center content from the + shell, scripts, agent workflows, or CI. Always load this skill before running + `chatwoot` commands — it contains the noun/verb grammar, the output-format + contract, and the safety rules that prevent customer-visible mistakes. license: MIT metadata: author: chatwoot @@ -39,6 +39,9 @@ explicitly. 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. +- Use help center lookup only when the user asks for help center content, + article search, or knowledge-base context. Do not make it the default step + for ordinary conversation triage. - 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 @@ -98,6 +101,10 @@ chatwoot convs --help # filters for the list command | `contact ` / ` conversations` | View a contact / list their conversations | | `inboxes` / `inbox ` | List inboxes / view one | | `agents` / `labels` / `teams` | List account-level resources | +| `hcs` | List help centers | +| `hc default [slug]` | Show or set default help center | +| `hc articles [--query text]` | List/search help center articles | +| `hc article ` | Fetch one help center article | | `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 | @@ -183,6 +190,15 @@ id=$(chatwoot contacts --search "jane@example.com" -o json | jq '.payload[0].id' chatwoot contact "$id" conversations -o json ``` +**Help center lookup** — set a default portal once, then search/fetch articles: +```bash +chatwoot hcs -o json +chatwoot hc default pocket-casts-support +chatwoot hc articles --query "account" -o json +chatwoot hc articles --category getting-started -o json +chatwoot hc article create-a-pocket-casts-sync-account -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