diff --git a/go.mod b/go.mod index 52b59de..d44eee0 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 09d5200..c6e3237 100644 --- a/go.sum +++ b/go.sum @@ -71,12 +71,18 @@ github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIj github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/cmd/help_center.go b/internal/cmd/help_center.go index 01359c1..a60c0ab 100644 --- a/internal/cmd/help_center.go +++ b/internal/cmd/help_center.go @@ -52,6 +52,7 @@ type HCCmd struct { 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."` + Import HCImportCmd `cmd:"" help:"Import help center content from another provider."` } type HCDefaultCmd struct { diff --git a/internal/cmd/help_center_import.go b/internal/cmd/help_center_import.go new file mode 100644 index 0000000..d892968 --- /dev/null +++ b/internal/cmd/help_center_import.go @@ -0,0 +1,348 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/chatwoot/cli/internal/config" + "github.com/chatwoot/cli/internal/importer" + "github.com/chatwoot/cli/internal/importer/intercom" + "github.com/chatwoot/cli/internal/prompt" +) + +// HCImportCmd groups per-provider import subcommands. Adding a provider later +// (Crisp, Zendesk) is a new subcommand here plus a new importer.Source. +type HCImportCmd struct { + Intercom HCImportIntercomCmd `cmd:"" help:"Import help center content from Intercom."` +} + +// HCImportIntercomCmd is `chatwoot hc import intercom` — an interactive importer +// that pulls collections/articles from Intercom into a Chatwoot portal. +type HCImportIntercomCmd struct { + Token string `required:"" help:"Intercom access token (sent as a Bearer token)."` + BaseURL string `name:"base-url" default:"https://api.intercom.io" help:"Intercom API base URL."` +} + +// portalOption is an existing Chatwoot portal offered as a target. +type portalOption struct { + Name string + Slug string +} + +// intercomImportDeps are the injected dependencies of the import orchestration, +// so it can be driven by fakes in tests (no TTY, no network). +type intercomImportDeps struct { + source importer.Source + prompter prompt.Prompter + sink importer.Sink + existingPortals []portalOption + agentEmails map[string]int + fallbackAuthor int + stateDir string + out io.Writer + // listCategorySlugs returns the slugs of categories already in a portal so + // the planner can avoid colliding with them. Optional (nil for fakes). + listCategorySlugs func(portalSlug string) ([]string, error) +} + +// Run wires real dependencies (TTY check, Intercom client, term prompter, +// Chatwoot sink, agents, current user) and runs the import. +func (c *HCImportIntercomCmd) Run(app *App) error { + if !prompt.IsInteractive() { + return fmt.Errorf("chatwoot hc import is interactive and requires a terminal (stdin is not a TTY)") + } + if app.Client == nil { + return fmt.Errorf("not authenticated. Run 'chatwoot auth login'") + } + + w := app.Printer.Writer + + // Fallback author = the token owner (admin). Articles require an author. + fallbackAuthor, err := resolveAgent(app, "me") + if err != nil { + return fmt.Errorf("cannot resolve the current user as the fallback author: %w", err) + } + if fallbackAuthor == 0 { + return fmt.Errorf("cannot determine the current user id; article author is required") + } + + agentEmails, err := chatwootAgentEmails(app) + if err != nil { + return err + } + + portals, err := app.Client.HelpCenter().ListPortals() + if err != nil { + return fmt.Errorf("failed to list Chatwoot portals: %w", err) + } + existing := make([]portalOption, 0, len(portals.Payload)) + for _, p := range portals.Payload { + existing = append(existing, portalOption{Name: p.Name, Slug: p.Slug}) + } + + dir, err := config.ConfigDir() + if err != nil { + return err + } + + deps := intercomImportDeps{ + source: intercom.New(c.BaseURL, c.Token), + prompter: prompt.NewTermPrompter(os.Stdin, w), + sink: importer.NewChatwootSink(app.Client), + existingPortals: existing, + agentEmails: agentEmails, + fallbackAuthor: fallbackAuthor, + stateDir: filepath.Join(dir, "imports"), + out: w, + listCategorySlugs: func(portalSlug string) ([]string, error) { + resp, err := app.Client.HelpCenter().ListCategories(portalSlug, "") + if err != nil { + return nil, err + } + slugs := make([]string, 0, len(resp.Payload)) + for _, c := range resp.Payload { + if c.Slug != "" { + slugs = append(slugs, c.Slug) + } + } + return slugs, nil + }, + } + + _, err = runIntercomImport(context.Background(), deps) + return err +} + +// runIntercomImport is the testable orchestration: validate → select source → +// select target → scan → select locales → plan → confirm → execute. +func runIntercomImport(ctx context.Context, deps intercomImportDeps) (*importer.Result, error) { + w := deps.out + + fmt.Fprintln(w, "chatwoot hc import · Intercom → Chatwoot") + workspaceID, err := deps.source.Validate(ctx) + if err != nil { + return nil, fmt.Errorf("intercom authentication failed: %w", err) + } + fmt.Fprintf(w, "✓ Intercom token valid (workspace %s)\n", workspaceID) + + sourceHC, err := selectSourceHelpCenter(ctx, deps.prompter, w, deps.source) + if err != nil { + return nil, err + } + + target, err := selectTargetPortal(deps.prompter, w, deps.existingPortals, sourceHC.Name) + if err != nil { + return nil, err + } + + fmt.Fprintf(w, "Scanning %q…\n", sourceHC.Name) + corpus, err := deps.source.Scan(ctx, sourceHC.ID) + if err != nil { + return nil, fmt.Errorf("failed to scan source help center: %w", err) + } + fmt.Fprintf(w, "✓ Found %d collections, %d articles, locales: %s\n", + len(corpus.Collections), len(corpus.Articles), strings.Join(corpus.Locales, ", ")) + + locales, err := selectLocales(deps.prompter, corpus.Locales) + if err != nil { + return nil, err + } + + resolver := importer.NewAuthorResolver(corpus.Authors, deps.agentEmails, deps.fallbackAuthor) + + key := importer.StateKey(deps.source.Name(), workspaceID, sourceHC.ID, target.Slug()) + statePath := importer.StatePath(deps.stateDir, key) + st, err := importer.LoadState(statePath) + if err != nil { + return nil, err + } + if st == nil { + st = importer.NewState(deps.source.Name(), workspaceID, sourceHC.ID) + } + + // Reserve slugs already in an existing target portal so generated category + // slugs don't collide with categories created outside this import. + var reservedCategorySlugs []string + if !target.IsCreate() && deps.listCategorySlugs != nil { + slugs, err := deps.listCategorySlugs(target.Slug()) + if err != nil { + return nil, fmt.Errorf("failed to read existing categories in %q: %w", target.Slug(), err) + } + reservedCategorySlugs = slugs + } + + sel := importer.Selections{SourceHCID: sourceHC.ID, Target: target, Locales: locales} + plan := importer.Plan(corpus, sel, resolver, st, reservedCategorySlugs) + + renderPlanSummary(w, plan, target) + ok, err := deps.prompter.Confirm("Proceed?") + if err != nil { + return nil, err + } + if !ok { + fmt.Fprintln(w, "Aborted. Nothing was written.") + return nil, nil + } + + res, err := importer.Execute(ctx, plan, importer.ExecuteOptions{ + Sink: deps.sink, + State: st, + StatePath: statePath, + Embeds: importer.DefaultEmbedRegistry(), + Log: func(msg string) { fmt.Fprintf(w, " • %s\n", msg) }, + }) + if err != nil { + return nil, err + } + + renderImportResult(w, res) + return res, nil +} + +func selectSourceHelpCenter(ctx context.Context, pr prompt.Prompter, w io.Writer, src importer.Source) (importer.HelpCenter, error) { + hcs, err := src.ListHelpCenters(ctx) + if err != nil { + return importer.HelpCenter{}, fmt.Errorf("failed to list source help centers: %w", err) + } + switch len(hcs) { + case 0: + return importer.HelpCenter{}, fmt.Errorf("no help centers found in the source workspace") + case 1: + fmt.Fprintf(w, "✓ Source help center: %s\n", hcs[0].Name) + return hcs[0], nil + default: + labels := make([]string, len(hcs)) + for i, hc := range hcs { + labels[i] = hc.Name + } + idx, err := pr.SelectOne("Source help center", labels) + if err != nil { + return importer.HelpCenter{}, err + } + return hcs[idx], nil + } +} + +func selectTargetPortal(pr prompt.Prompter, w io.Writer, existing []portalOption, sourceName string) (importer.PortalTarget, error) { + labels := []string{fmt.Sprintf("Create a new portal from %q", sourceName)} + for _, p := range existing { + labels = append(labels, fmt.Sprintf("%s (%s)", p.Name, p.Slug)) + } + + idx, err := pr.SelectOne("Target portal in Chatwoot", labels) + if err != nil { + return importer.PortalTarget{}, err + } + if idx == 0 { + name, err := pr.Input("New portal name", sourceName) + if err != nil { + return importer.PortalTarget{}, err + } + slug, err := pr.Input("New portal slug", importer.Slugify(name)) + if err != nil { + return importer.PortalTarget{}, err + } + return importer.PortalTarget{CreateName: name, CreateSlug: importer.Slugify(slug)}, nil + } + return importer.PortalTarget{ExistingSlug: existing[idx-1].Slug}, nil +} + +func selectLocales(pr prompt.Prompter, available []string) ([]string, error) { + if len(available) <= 1 { + return available, nil + } + idxs, err := pr.SelectMany("Locales to import", available, true) + if err != nil { + return nil, err + } + out := make([]string, 0, len(idxs)) + for _, i := range idxs { + out = append(out, available[i]) + } + return out, nil +} + +func chatwootAgentEmails(app *App) (map[string]int, error) { + agents, err := app.Client.Agents().List() + if err != nil { + return nil, fmt.Errorf("failed to list Chatwoot agents: %w", err) + } + byEmail := make(map[string]int, len(agents)) + for _, a := range agents { + if a.Email != "" { + byEmail[strings.ToLower(a.Email)] = a.ID + } + } + return byEmail, nil +} + +func renderPlanSummary(w io.Writer, plan *importer.ImportPlan, target importer.PortalTarget) { + catCreate, catSkip := countPlanned(len(plan.Categories), func(i int) bool { return plan.Categories[i].Skip }) + artCreate, artSkip := countPlanned(len(plan.Articles), func(i int) bool { return plan.Articles[i].Skip }) + + portalDesc := fmt.Sprintf("existing %q", target.Slug()) + if target.IsCreate() { + portalDesc = fmt.Sprintf("create %q (%s)", target.CreateName, target.CreateSlug) + } + + fmt.Fprintln(w, "") + fmt.Fprintln(w, "Plan") + fmt.Fprintf(w, " Portal %s\n", portalDesc) + fmt.Fprintf(w, " Locales %s\n", strings.Join(plan.PortalLocales, ", ")) + fmt.Fprintf(w, " Categories %d to create, %d already imported\n", catCreate, catSkip) + fmt.Fprintf(w, " Articles %d to create, %d already imported (all draft)\n", artCreate, artSkip) + fmt.Fprintf(w, " Authors %d matched by email, %d fall back to current user\n", plan.AuthorStats.Matched, plan.AuthorStats.Fallback) + fmt.Fprintln(w, " Images & embeds: best effort") + fmt.Fprintln(w, "") +} + +func countPlanned(n int, isSkip func(int) bool) (create, skip int) { + for i := 0; i < n; i++ { + if isSkip(i) { + skip++ + } else { + create++ + } + } + return create, skip +} + +func renderImportResult(w io.Writer, res *importer.Result) { + portalState := "existing" + if res.PortalCreated { + portalState = "created" + } + + fmt.Fprintln(w, "") + fmt.Fprintln(w, "Done.") + fmt.Fprintf(w, " Portal %s (%s)\n", res.PortalSlug, portalState) + fmt.Fprintf(w, " Categories %d created, %d skipped\n", res.CategoriesCreated, res.CategoriesSkipped) + fmt.Fprintf(w, " Articles %d created, %d skipped, %d failed\n", res.ArticlesCreated, res.ArticlesSkipped, len(res.Failures)) + fmt.Fprintf(w, " Images %d re-hosted, %d failed\n", res.ImagesSwapped, res.ImagesFailed) + fmt.Fprintf(w, " Embeds %d converted\n", res.EmbedsRewritten) + fmt.Fprintf(w, " Authors %d matched, %d fell back to current user\n", res.AuthorStats.Matched, res.AuthorStats.Fallback) + fmt.Fprintln(w, " Status imported as draft — review & publish in Chatwoot") + + if res.UncategorizedCount > 0 { + fmt.Fprintf(w, "\n ⚠ %d article(s) were imported uncategorized because their category failed to create.\n", res.UncategorizedCount) + } + + if len(res.Failures) > 0 { + fmt.Fprintf(w, "\n ⚠ %d item(s) failed:\n", len(res.Failures)) + limit := len(res.Failures) + if limit > 20 { + limit = 20 + } + for _, f := range res.Failures[:limit] { + fmt.Fprintf(w, " - [%s] %s\n", f.Kind, f.Error) + } + if len(res.Failures) > 20 { + fmt.Fprintf(w, " … and %d more (see the import state file)\n", len(res.Failures)-20) + } + fmt.Fprintln(w, " Re-run the same command to retry failed items.") + } +} diff --git a/internal/cmd/help_center_import_test.go b/internal/cmd/help_center_import_test.go new file mode 100644 index 0000000..6edf245 --- /dev/null +++ b/internal/cmd/help_center_import_test.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/chatwoot/cli/internal/importer" + "github.com/chatwoot/cli/internal/prompt" + "github.com/chatwoot/cli/internal/sdk" +) + +type fakeSource struct { + hcs []importer.HelpCenter + corpus *importer.Corpus +} + +func (f *fakeSource) Name() string { return "intercom" } +func (f *fakeSource) Validate(context.Context) (string, error) { + return "ws1", nil +} +func (f *fakeSource) ListHelpCenters(context.Context) ([]importer.HelpCenter, error) { + return f.hcs, nil +} +func (f *fakeSource) Scan(context.Context, string) (*importer.Corpus, error) { + return f.corpus, nil +} + +type fakeImportSink struct { + ensureCalls int + cats int + arts int +} + +func (f *fakeImportSink) EnsurePortal(t importer.PortalTarget, _ []string) (importer.PortalRef, bool, error) { + f.ensureCalls++ + return importer.PortalRef{Slug: t.Slug(), ID: 1}, true, nil +} +func (f *fakeImportSink) CreateCategory(_ string, req sdk.CreateCategoryRequest) (sdk.HelpCenterCategory, error) { + f.cats++ + return sdk.HelpCenterCategory{ID: f.cats, Slug: req.Slug, Locale: req.Locale}, nil +} +func (f *fakeImportSink) CreateArticle(_ string, _ sdk.CreateArticleRequest) (sdk.HelpCenterArticle, error) { + f.arts++ + return sdk.HelpCenterArticle{ID: 100 + f.arts}, nil +} +func (f *fakeImportSink) UploadImage(url string) (string, error) { return url, nil } + +func testCorpus() *importer.Corpus { + return &importer.Corpus{ + HelpCenter: importer.HelpCenter{ID: "hc1", Name: "Acme Support", DefaultLocale: "en"}, + Collections: []importer.Collection{{ID: "c1", Names: map[string]string{"en": "Getting Started"}}}, + Articles: []importer.Article{ + {ID: "a1", CollectionID: "c1", DefaultLocale: "en", + Variants: map[string]importer.ArticleVariant{"en": {Locale: "en", Title: "Setup", BodyHTML: "

x

"}}}, + }, + Authors: map[string]importer.Author{}, + Locales: []string{"en"}, + } +} + +func newDeps(input string, sink importer.Sink, stateDir string) (intercomImportDeps, *bytes.Buffer) { + var out bytes.Buffer + deps := intercomImportDeps{ + source: &fakeSource{hcs: []importer.HelpCenter{{ID: "hc1", Name: "Acme Support", DefaultLocale: "en"}}, corpus: testCorpus()}, + prompter: prompt.NewTermPrompter(strings.NewReader(input), &out), + sink: sink, + existingPortals: nil, + agentEmails: map[string]int{}, + fallbackAuthor: 9, + stateDir: stateDir, + out: &out, + } + return deps, &out +} + +func TestRunIntercomImportHappyPath(t *testing.T) { + sink := &fakeImportSink{} + // Single HC (auto), target=create new (1), name default, slug default, + // single locale (auto, no prompt), confirm yes. + deps, out := newDeps("1\n\n\ny\n", sink, t.TempDir()) + + res, err := runIntercomImport(context.Background(), deps) + if err != nil { + t.Fatalf("runIntercomImport: %v", err) + } + if res == nil { + t.Fatal("expected a result") + } + if sink.ensureCalls != 1 { + t.Errorf("EnsurePortal calls = %d, want 1", sink.ensureCalls) + } + if sink.cats != 1 || sink.arts != 1 { + t.Errorf("created cats=%d arts=%d, want 1/1", sink.cats, sink.arts) + } + if res.PortalSlug != "acme-support" { + t.Errorf("portal slug = %q, want acme-support", res.PortalSlug) + } + if !strings.Contains(out.String(), "Done.") { + t.Errorf("summary not printed:\n%s", out.String()) + } +} + +func TestRunIntercomImportDeclineWritesNothing(t *testing.T) { + sink := &fakeImportSink{} + // Same as happy path but decline at the confirm prompt. + deps, out := newDeps("1\n\n\nn\n", sink, t.TempDir()) + + res, err := runIntercomImport(context.Background(), deps) + if err != nil { + t.Fatalf("runIntercomImport: %v", err) + } + if res != nil { + t.Errorf("expected nil result on decline, got %#v", res) + } + if sink.ensureCalls != 0 || sink.cats != 0 || sink.arts != 0 { + t.Errorf("nothing should be written on decline: ensure=%d cats=%d arts=%d", sink.ensureCalls, sink.cats, sink.arts) + } + if !strings.Contains(out.String(), "Aborted") { + t.Errorf("expected abort message:\n%s", out.String()) + } +} diff --git a/internal/importer/author.go b/internal/importer/author.go new file mode 100644 index 0000000..909103d --- /dev/null +++ b/internal/importer/author.go @@ -0,0 +1,42 @@ +package importer + +import "strings" + +// AuthorResolver maps a source author id to a Chatwoot user id by matching +// emails, falling back to the token owner when no match exists. It tracks how +// many authors were matched vs fell back, for the import summary. +type AuthorResolver struct { + sourceByID map[string]Author + cwByEmail map[string]int + fallbackID int + + Matched int + Fallback int +} + +// NewAuthorResolver builds a resolver. cwByEmail keys are lowercased +// defensively so matching is case-insensitive. +func NewAuthorResolver(sourceByID map[string]Author, cwByEmail map[string]int, fallbackID int) *AuthorResolver { + normalized := make(map[string]int, len(cwByEmail)) + for email, id := range cwByEmail { + normalized[strings.ToLower(strings.TrimSpace(email))] = id + } + return &AuthorResolver{ + sourceByID: sourceByID, + cwByEmail: normalized, + fallbackID: fallbackID, + } +} + +// Resolve returns the Chatwoot user id for a source author, falling back to the +// token owner on any miss (unknown author, no email, or email not an agent). +func (r *AuthorResolver) Resolve(sourceAuthorID string) int { + if a, ok := r.sourceByID[sourceAuthorID]; ok && a.Email != "" { + if id, ok := r.cwByEmail[strings.ToLower(strings.TrimSpace(a.Email))]; ok && id != 0 { + r.Matched++ + return id + } + } + r.Fallback++ + return r.fallbackID +} diff --git a/internal/importer/author_test.go b/internal/importer/author_test.go new file mode 100644 index 0000000..7cdff68 --- /dev/null +++ b/internal/importer/author_test.go @@ -0,0 +1,40 @@ +package importer + +import "testing" + +func TestAuthorResolverMatchesByEmailCaseInsensitive(t *testing.T) { + source := map[string]Author{ + "i1": {ID: "i1", Email: "Ada@Acme.com", Name: "Ada"}, + } + cw := map[string]int{"ada@acme.com": 42} + r := NewAuthorResolver(source, cw, 1) + + if got := r.Resolve("i1"); got != 42 { + t.Errorf("Resolve = %d, want 42", got) + } + if r.Matched != 1 || r.Fallback != 0 { + t.Errorf("matched=%d fallback=%d, want 1/0", r.Matched, r.Fallback) + } +} + +func TestAuthorResolverFallsBack(t *testing.T) { + source := map[string]Author{ + "i1": {ID: "i1", Email: "nobody@elsewhere.com"}, + "i2": {ID: "i2", Email: ""}, // no email + } + cw := map[string]int{"ada@acme.com": 42} + r := NewAuthorResolver(source, cw, 7) + + if got := r.Resolve("i1"); got != 7 { // email not an agent + t.Errorf("Resolve(i1) = %d, want fallback 7", got) + } + if got := r.Resolve("i2"); got != 7 { // no email + t.Errorf("Resolve(i2) = %d, want fallback 7", got) + } + if got := r.Resolve("unknown"); got != 7 { // unknown author id + t.Errorf("Resolve(unknown) = %d, want fallback 7", got) + } + if r.Fallback != 3 || r.Matched != 0 { + t.Errorf("matched=%d fallback=%d, want 0/3", r.Matched, r.Fallback) + } +} diff --git a/internal/importer/chatwoot.go b/internal/importer/chatwoot.go new file mode 100644 index 0000000..8ab38f7 --- /dev/null +++ b/internal/importer/chatwoot.go @@ -0,0 +1,120 @@ +package importer + +import ( + "fmt" + + "github.com/chatwoot/cli/internal/sdk" +) + +// chatwootSink implements Sink over the Chatwoot SDK client. +type chatwootSink struct { + client *sdk.Client +} + +// NewChatwootSink returns a Sink backed by the given Chatwoot client. +func NewChatwootSink(client *sdk.Client) Sink { + return &chatwootSink{client: client} +} + +// EnsurePortal reuses an existing portal by slug (adding any missing locales) +// or creates a new one. The created bool is true only when a portal is created. +func (s *chatwootSink) EnsurePortal(target PortalTarget, locales []string) (PortalRef, bool, error) { + hc := s.client.HelpCenter() + slug := target.Slug() + + portals, err := hc.ListPortals() + if err != nil { + return PortalRef{}, false, err + } + + var existing *sdk.HelpCenterPortal + for i := range portals.Payload { + if portals.Payload[i].Slug == slug { + existing = &portals.Payload[i] + break + } + } + + defaultLocale := "" + if len(locales) > 0 { + defaultLocale = locales[0] + } + + if existing != nil { + have := map[string]bool{} + for _, l := range existing.Config.AllowedLocales { + have[l.Code] = true + } + missing := false + for _, l := range locales { + if !have[l] { + missing = true + break + } + } + if missing { + union := unionLocales(allowedCodes(existing.Config.AllowedLocales), locales) + dflt := existing.Config.DefaultLocale + if dflt == "" { + dflt = defaultLocale + } + updated, err := hc.UpdatePortal(slug, sdk.PortalInput{ + Config: &sdk.PortalConfigInput{AllowedLocales: union, DefaultLocale: dflt}, + }) + if err != nil { + return PortalRef{}, false, fmt.Errorf("add locales to portal %q: %w", slug, err) + } + return PortalRef{Slug: updated.Slug, ID: updated.ID}, false, nil + } + return PortalRef{Slug: existing.Slug, ID: existing.ID}, false, nil + } + + if !target.IsCreate() { + return PortalRef{}, false, fmt.Errorf("portal %q not found", slug) + } + + created, err := hc.CreatePortal(sdk.PortalInput{ + Name: target.CreateName, + Slug: slug, + Config: &sdk.PortalConfigInput{ + AllowedLocales: locales, + DefaultLocale: defaultLocale, + }, + }) + if err != nil { + return PortalRef{}, false, err + } + return PortalRef{Slug: created.Slug, ID: created.ID}, true, nil +} + +func (s *chatwootSink) CreateCategory(portalSlug string, req sdk.CreateCategoryRequest) (sdk.HelpCenterCategory, error) { + cat, err := s.client.HelpCenter().CreateCategory(portalSlug, req) + if err != nil { + return sdk.HelpCenterCategory{}, err + } + return *cat, nil +} + +func (s *chatwootSink) CreateArticle(portalSlug string, req sdk.CreateArticleRequest) (sdk.HelpCenterArticle, error) { + art, err := s.client.HelpCenter().CreateArticle(portalSlug, req) + if err != nil { + return sdk.HelpCenterArticle{}, err + } + return *art, nil +} + +func (s *chatwootSink) UploadImage(externalURL string) (string, error) { + res, err := s.client.HelpCenter().UploadImageExternalURL(externalURL) + if err != nil { + return "", err + } + return res.FileURL, nil +} + +func allowedCodes(locales []sdk.HelpCenterPortalLocale) []string { + out := make([]string, 0, len(locales)) + for _, l := range locales { + out = append(out, l.Code) + } + return out +} diff --git a/internal/importer/embeds.go b/internal/importer/embeds.go new file mode 100644 index 0000000..509ca6d --- /dev/null +++ b/internal/importer/embeds.go @@ -0,0 +1,96 @@ +package importer + +import "regexp" + +// EmbedRegistry maps a provider embed/iframe URL back to the canonical "bare" +// URL form that Chatwoot's markdown embed registry (config/markdown_embeds.yml) +// recognizes and expands into an iframe at render time. Intercom stores rich +// media as ` + return &Corpus{ + HelpCenter: HelpCenter{ID: "hc1", DefaultLocale: "en"}, + Collections: []Collection{ + {ID: "c1", Names: map[string]string{"en": "Getting Started", "fr": "Commencer"}}, + }, + Articles: []Article{ + { + ID: "a1", CollectionID: "c1", DefaultLocale: "en", AuthorID: "500", + Variants: map[string]ArticleVariant{ + "en": {Locale: "en", Title: "Setup", BodyHTML: enBody, AuthorID: "500"}, + "fr": {Locale: "fr", Title: "Configuration", BodyHTML: "

FR

", AuthorID: "500"}, + }, + }, + }, + Authors: map[string]Author{"500": {ID: "500", Email: "ada@acme.com"}}, + Locales: []string{"en", "fr"}, + } +} + +func runImport(t *testing.T, corpus *Corpus, sel Selections, st *State) (*fakeSink, *Result, *State) { + t.Helper() + if st == nil { + st = NewState("intercom", "ws", "hc1") + } + resolver := NewAuthorResolver(corpus.Authors, map[string]int{"ada@acme.com": 42}, 1) + plan := Plan(corpus, sel, resolver, st, nil) + + sink := &fakeSink{} + statePath := filepath.Join(t.TempDir(), "state.json") + res, err := Execute(context.Background(), plan, ExecuteOptions{ + Sink: sink, + State: st, + StatePath: statePath, + Embeds: DefaultEmbedRegistry(), + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + return sink, res, st +} + +func catByLocale(cats []sdk.CreateCategoryRequest, locale string) (sdk.CreateCategoryRequest, bool) { + for _, c := range cats { + if c.Locale == locale { + return c, true + } + } + return sdk.CreateCategoryRequest{}, false +} + +func artByLocale(arts []sdk.CreateArticleRequest, locale string) (sdk.CreateArticleRequest, bool) { + for _, a := range arts { + if a.Locale == locale { + return a, true + } + } + return sdk.CreateArticleRequest{}, false +} + +func TestExecuteLinksTranslationsAndOrders(t *testing.T) { + corpus := sampleCorpus() + sel := Selections{ + SourceHCID: "hc1", + Target: PortalTarget{CreateName: "Acme Support", CreateSlug: "acme-support"}, + Locales: []string{"en", "fr"}, + } + sink, res, st := runImport(t, corpus, sel, nil) + + // --- Categories: en (root) before fr (variant); fr links to en. --- + if len(sink.cats) != 2 { + t.Fatalf("categories created = %d, want 2", len(sink.cats)) + } + if sink.cats[0].Locale != "en" { + t.Errorf("first category locale = %q, want en (root first)", sink.cats[0].Locale) + } + enCat, _ := catByLocale(sink.cats, "en") + frCat, _ := catByLocale(sink.cats, "fr") + if enCat.Slug != frCat.Slug { + t.Errorf("per-locale categories should share a slug: %q vs %q", enCat.Slug, frCat.Slug) + } + if enCat.AssociatedCategoryID != 0 { + t.Errorf("root category should have no associated id, got %d", enCat.AssociatedCategoryID) + } + if frCat.AssociatedCategoryID != 1 { + t.Errorf("fr category associated_category_id = %d, want 1 (en root)", frCat.AssociatedCategoryID) + } + + // --- Articles: en root before fr variant; fr links to en root. --- + if len(sink.arts) != 2 { + t.Fatalf("articles created = %d, want 2", len(sink.arts)) + } + if sink.arts[0].Locale != "en" { + t.Errorf("first article locale = %q, want en (root first)", sink.arts[0].Locale) + } + enArt, _ := artByLocale(sink.arts, "en") + frArt, _ := artByLocale(sink.arts, "fr") + if enArt.Status != "draft" || frArt.Status != "draft" { + t.Errorf("articles must be draft: en=%q fr=%q", enArt.Status, frArt.Status) + } + if enArt.AssociatedArticleID != 0 { + t.Errorf("root article should have no associated id, got %d", enArt.AssociatedArticleID) + } + if frArt.AssociatedArticleID != 100 { + t.Errorf("fr article associated_article_id = %d, want 100 (en root)", frArt.AssociatedArticleID) + } + + // --- Variant placed in MATCHING-locale category (locale inheritance!). --- + if enArt.CategoryID != 1 { + t.Errorf("en article category = %d, want 1 (en category)", enArt.CategoryID) + } + if frArt.CategoryID != 2 { + t.Errorf("fr article category = %d, want 2 (fr category, not en)", frArt.CategoryID) + } + + // --- Author matched by email. --- + if enArt.AuthorID != 42 { + t.Errorf("author = %d, want 42 (matched)", enArt.AuthorID) + } + + // --- Image re-hosted and embed rewritten in the en body. --- + if !strings.Contains(enArt.Content, "rails/active_storage") { + t.Errorf("image not swapped: %q", enArt.Content) + } + if strings.Contains(enArt.Content, "x

"}}}, + }, + Authors: map[string]Author{}, + Locales: []string{"en"}, + } + sel := Selections{SourceHCID: "hc1", Target: PortalTarget{CreateName: "P", CreateSlug: "p"}, Locales: []string{"en"}} + sink, res, _ := runImport(t, corpus, sel, nil) + + if len(sink.cats) != 0 { + t.Errorf("no categories expected, got %d", len(sink.cats)) + } + if len(sink.arts) != 1 { + t.Fatalf("articles = %d, want 1", len(sink.arts)) + } + if sink.arts[0].CategoryID != 0 { + t.Errorf("uncategorized article should have no category, got %d", sink.arts[0].CategoryID) + } + if sink.arts[0].Locale != "en" { + t.Errorf("uncategorized article must still send explicit locale, got %q", sink.arts[0].Locale) + } + if res.UncategorizedCount != 0 { + // CollectionID was empty (genuinely uncategorized at source), not a failed + // category, so this is not counted as a fallback. + t.Errorf("UncategorizedCount = %d, want 0 for source-uncategorized", res.UncategorizedCount) + } +} + +func TestExecuteResumeSkipsCreatedItems(t *testing.T) { + corpus := sampleCorpus() + sel := Selections{ + SourceHCID: "hc1", + Target: PortalTarget{CreateName: "Acme", CreateSlug: "acme-support"}, + Locales: []string{"en", "fr"}, + } + // Pre-seed state as if a prior run created the en category and en root article. + st := NewState("intercom", "ws", "hc1") + st.SetCategory("c1", "en", ItemRef{ID: 1, Slug: "getting-started"}) + st.SetArticle("a1", "en", ItemRef{ID: 100}) + + sink, res, _ := runImport(t, corpus, sel, st) + + // en category + en article already present -> only fr category + fr article created. + if len(sink.cats) != 1 || sink.cats[0].Locale != "fr" { + t.Errorf("expected only fr category created, got %#v", sink.cats) + } + if len(sink.arts) != 1 || sink.arts[0].Locale != "fr" { + t.Errorf("expected only fr article created, got %#v", sink.arts) + } + if res.CategoriesSkipped != 1 || res.ArticlesSkipped != 1 { + t.Errorf("skipped cats=%d arts=%d, want 1/1", res.CategoriesSkipped, res.ArticlesSkipped) + } + // fr variant still links to the pre-existing en root id from state. + if sink.arts[0].AssociatedArticleID != 100 { + t.Errorf("fr article associated id = %d, want 100 (resumed root)", sink.arts[0].AssociatedArticleID) + } +} + +func TestPlanDisambiguatesDuplicateCategorySlugs(t *testing.T) { + corpus := &Corpus{ + HelpCenter: HelpCenter{ID: "hc1", DefaultLocale: "en"}, + Collections: []Collection{ + {ID: "c1", Names: map[string]string{"en": "FAQ"}}, + {ID: "c2", Names: map[string]string{"en": "FAQ"}}, + }, + Articles: []Article{ + {ID: "a1", CollectionID: "c1", DefaultLocale: "en", Variants: map[string]ArticleVariant{"en": {Locale: "en", Title: "x", BodyHTML: "

x

"}}}, + {ID: "a2", CollectionID: "c2", DefaultLocale: "en", Variants: map[string]ArticleVariant{"en": {Locale: "en", Title: "y", BodyHTML: "

y

"}}}, + }, + Authors: map[string]Author{}, + Locales: []string{"en"}, + } + resolver := NewAuthorResolver(corpus.Authors, map[string]int{}, 1) + plan := Plan(corpus, Selections{SourceHCID: "hc1", Target: PortalTarget{CreateSlug: "p"}, Locales: []string{"en"}}, resolver, NewState("i", "w", "hc1"), nil) + + slugByColl := map[string]string{} + for _, pc := range plan.Categories { + slugByColl[pc.CollectionID] = pc.Slug + } + if slugByColl["c1"] == "" || slugByColl["c2"] == "" { + t.Fatalf("missing slugs: %#v", slugByColl) + } + if slugByColl["c1"] == slugByColl["c2"] { + t.Fatalf("duplicate-named collections must get distinct slugs, both = %q", slugByColl["c1"]) + } +} + +func TestPlanAvoidsReservedCategorySlugs(t *testing.T) { + corpus := &Corpus{ + HelpCenter: HelpCenter{ID: "hc1", DefaultLocale: "en"}, + Collections: []Collection{{ID: "c1", Names: map[string]string{"en": "Getting Started"}}}, + Articles: []Article{ + {ID: "a1", CollectionID: "c1", DefaultLocale: "en", Variants: map[string]ArticleVariant{"en": {Locale: "en", Title: "x", BodyHTML: "

x

"}}}, + }, + Authors: map[string]Author{}, + Locales: []string{"en"}, + } + resolver := NewAuthorResolver(corpus.Authors, map[string]int{}, 1) + // "getting-started" already exists in the portal. + plan := Plan(corpus, Selections{SourceHCID: "hc1", Target: PortalTarget{ExistingSlug: "p"}, Locales: []string{"en"}}, resolver, NewState("i", "w", "hc1"), []string{"getting-started"}) + + if len(plan.Categories) != 1 { + t.Fatalf("categories = %d, want 1", len(plan.Categories)) + } + if plan.Categories[0].Slug == "getting-started" { + t.Fatalf("slug should avoid the reserved one, got %q", plan.Categories[0].Slug) + } +} diff --git a/internal/importer/intercom/client.go b/internal/importer/intercom/client.go new file mode 100644 index 0000000..9ef94b5 --- /dev/null +++ b/internal/importer/intercom/client.go @@ -0,0 +1,228 @@ +// Package intercom is a small, hand-wrapped client for the Intercom REST API +// (help centers, collections, articles, admins) plus a Source implementation +// that maps Intercom data into the provider-neutral importer IR. It depends +// only on the standard library — no Intercom SDK. +package intercom + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + defaultBaseURL = "https://api.intercom.io" + defaultVersion = "2.11" + defaultPerPage = 150 + maxRetries = 5 +) + +// Client is an Intercom REST client with bearer auth, version header, cursor +// pagination, and bounded retry/backoff on 429 and 5xx responses. +type Client struct { + baseURL string + token string + version string + httpClient *http.Client + sleep func(time.Duration) +} + +// Option configures a Client. +type Option func(*Client) + +// WithHTTPClient injects a custom *http.Client (used in tests). +func WithHTTPClient(h *http.Client) Option { + return func(c *Client) { c.httpClient = h } +} + +// WithVersion overrides the Intercom-Version header. +func WithVersion(v string) Option { + return func(c *Client) { + if strings.TrimSpace(v) != "" { + c.version = v + } + } +} + +// withSleep overrides the backoff sleep (used in tests to avoid real waits). +func withSleep(fn func(time.Duration)) Option { + return func(c *Client) { c.sleep = fn } +} + +// NewClient builds an Intercom client. An empty baseURL defaults to +// https://api.intercom.io. +func NewClient(baseURL, token string, opts ...Option) *Client { + c := &Client{ + baseURL: strings.TrimSuffix(strings.TrimSpace(baseURL), "/"), + token: token, + version: defaultVersion, + httpClient: &http.Client{Timeout: 60 * time.Second}, + sleep: time.Sleep, + } + if c.baseURL == "" { + c.baseURL = defaultBaseURL + } + for _, opt := range opts { + opt(c) + } + return c +} + +func (c *Client) get(ctx context.Context, path string, query url.Values, out any) error { + full := c.baseURL + path + if len(query) > 0 { + full += "?" + query.Encode() + } + + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Intercom-Version", c.version) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = fmt.Errorf("intercom request failed: %w", err) + if attempt < maxRetries { + c.sleep(backoff(attempt, 0)) + continue + } + return lastErr + } + + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + return fmt.Errorf("read intercom response: %w", readErr) + } + + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { + lastErr = fmt.Errorf("intercom API %d: %s", resp.StatusCode, snippet(body)) + if attempt < maxRetries { + c.sleep(backoff(attempt, retryAfter(resp.Header))) + continue + } + return lastErr + } + if resp.StatusCode >= 400 { + return fmt.Errorf("intercom API %d: %s", resp.StatusCode, snippet(body)) + } + if out == nil { + return nil + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("decode intercom response: %w", err) + } + return nil + } + return lastErr +} + +// fetchList fetches every page of an Intercom cursor-paginated list endpoint. +func fetchList[T any](ctx context.Context, c *Client, path string) ([]T, error) { + var all []T + startingAfter := "" + guard := 0 + for { + q := url.Values{} + q.Set("per_page", strconv.Itoa(defaultPerPage)) + if startingAfter != "" { + q.Set("starting_after", startingAfter) + } + + var env listEnvelope[T] + if err := c.get(ctx, path, q, &env); err != nil { + return nil, err + } + all = append(all, env.Data...) + + next := env.Pages.nextCursor() + if next == "" { + break + } + startingAfter = next + + // Safety guard against a misbehaving cursor that never terminates. + guard++ + if guard > 10000 { + break + } + } + return all, nil +} + +// Me returns the authenticated admin and workspace info (used for validation). +func (c *Client) Me(ctx context.Context) (*meResponse, error) { + var me meResponse + if err := c.get(ctx, "/me", nil, &me); err != nil { + return nil, err + } + return &me, nil +} + +// ListAdmins returns all teammates. +func (c *Client) ListAdmins(ctx context.Context) ([]admin, error) { + var list adminList + if err := c.get(ctx, "/admins", nil, &list); err != nil { + return nil, err + } + return list.Admins, nil +} + +// ListHelpCenters returns all help centers. +func (c *Client) ListHelpCenters(ctx context.Context) ([]helpCenter, error) { + return fetchList[helpCenter](ctx, c, "/help_center/help_centers") +} + +// ListCollections returns all collections across help centers. +func (c *Client) ListCollections(ctx context.Context) ([]collection, error) { + return fetchList[collection](ctx, c, "/help_center/collections") +} + +// ListArticles returns all articles in the workspace. +func (c *Client) ListArticles(ctx context.Context) ([]article, error) { + return fetchList[article](ctx, c, "/articles") +} + +// backoff returns the wait before a retry: honors a server-provided +// Retry-After (seconds) when present, else exponential (0.5s * 2^attempt) +// capped at 30s. +func backoff(attempt int, retryAfterSecs int) time.Duration { + if retryAfterSecs > 0 { + return time.Duration(retryAfterSecs) * time.Second + } + d := 500 * time.Millisecond * time.Duration(1< 30*time.Second { + d = 30 * time.Second + } + return d +} + +func retryAfter(h http.Header) int { + v := strings.TrimSpace(h.Get("Retry-After")) + if v == "" { + return 0 + } + if secs, err := strconv.Atoi(v); err == nil && secs >= 0 { + return secs + } + return 0 +} + +func snippet(body []byte) string { + s := strings.TrimSpace(string(body)) + if len(s) > 300 { + return s[:300] + "…" + } + return s +} diff --git a/internal/importer/intercom/client_test.go b/internal/importer/intercom/client_test.go new file mode 100644 index 0000000..eab0c20 --- /dev/null +++ b/internal/importer/intercom/client_test.go @@ -0,0 +1,107 @@ +package intercom + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestGetRetriesOn429ThenSucceeds(t *testing.T) { + var calls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"errors":[{"code":"rate_limit_exceeded"}]}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"type":"admin","id":"42","email":"me@acme.com","app":{"id_code":"ws_123"}}`)) + })) + t.Cleanup(server.Close) + + var slept time.Duration + c := NewClient(server.URL, "tok", + WithHTTPClient(server.Client()), + withSleep(func(d time.Duration) { slept += d }), + ) + + me, err := c.Me(context.Background()) + if err != nil { + t.Fatalf("Me: %v", err) + } + if me.App.IDCode != "ws_123" { + t.Fatalf("id_code = %q, want ws_123", me.App.IDCode) + } + if atomic.LoadInt32(&calls) != 2 { + t.Fatalf("calls = %d, want 2 (one retry)", calls) + } + if slept != time.Second { + t.Errorf("slept = %v, want 1s (honored Retry-After)", slept) + } +} + +func TestGetSetsAuthAndVersionHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer tok" { + t.Errorf("Authorization = %q, want Bearer tok", got) + } + if got := r.Header.Get("Intercom-Version"); got != "2.13" { + t.Errorf("Intercom-Version = %q, want 2.13", got) + } + _, _ = w.Write([]byte(`{"type":"admin","id":"1","app":{"id_code":"w"}}`)) + })) + t.Cleanup(server.Close) + + c := NewClient(server.URL, "tok", WithHTTPClient(server.Client()), WithVersion("2.13")) + if _, err := c.Me(context.Background()); err != nil { + t.Fatalf("Me: %v", err) + } +} + +func TestFetchListPaginatesWithStartingAfter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("starting_after") { + case "": + _, _ = fmt.Fprint(w, `{"type":"list","data":[{"id":"1"},{"id":"2"}], + "pages":{"next":{"page":2,"starting_after":"CURSOR2"},"per_page":2},"total_count":3}`) + case "CURSOR2": + _, _ = fmt.Fprint(w, `{"type":"list","data":[{"id":"3"}], + "pages":{"next":null,"per_page":2},"total_count":3}`) + default: + http.Error(w, "unexpected cursor", http.StatusBadRequest) + } + })) + t.Cleanup(server.Close) + + c := NewClient(server.URL, "tok", WithHTTPClient(server.Client())) + got, err := fetchList[helpCenter](context.Background(), c, "/help_center/help_centers") + if err != nil { + t.Fatalf("fetchList: %v", err) + } + if len(got) != 3 { + t.Fatalf("got %d items, want 3 (across 2 pages)", len(got)) + } + if got[2].ID.String() != "3" { + t.Errorf("last id = %q, want 3", got[2].ID.String()) + } +} + +func TestGetReturnsErrorOn4xx(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"errors":[{"code":"unauthorized"}]}`)) + })) + t.Cleanup(server.Close) + + c := NewClient(server.URL, "bad", WithHTTPClient(server.Client())) + if _, err := c.Me(context.Background()); err == nil { + t.Fatal("expected error on 401") + } +} diff --git a/internal/importer/intercom/source.go b/internal/importer/intercom/source.go new file mode 100644 index 0000000..54cfc69 --- /dev/null +++ b/internal/importer/intercom/source.go @@ -0,0 +1,228 @@ +package intercom + +import ( + "context" + "sort" + + "github.com/chatwoot/cli/internal/importer" +) + +// Source adapts the Intercom client to the importer.Source interface. +type Source struct { + client *Client +} + +// New builds an Intercom Source. +func New(baseURL, token string, opts ...Option) *Source { + return &Source{client: NewClient(baseURL, token, opts...)} +} + +// Name returns the stable provider key. +func (s *Source) Name() string { return "intercom" } + +// Validate confirms credentials and returns the workspace id. +func (s *Source) Validate(ctx context.Context) (string, error) { + me, err := s.client.Me(ctx) + if err != nil { + return "", err + } + if me.App.IDCode != "" { + return me.App.IDCode, nil + } + return me.ID.String(), nil +} + +// ListHelpCenters returns the workspace's help centers. +func (s *Source) ListHelpCenters(ctx context.Context) ([]importer.HelpCenter, error) { + hcs, err := s.client.ListHelpCenters(ctx) + if err != nil { + return nil, err + } + out := make([]importer.HelpCenter, 0, len(hcs)) + for _, hc := range hcs { + name := hc.DisplayName + if name == "" { + name = hc.Identifier + } + out = append(out, importer.HelpCenter{ID: hc.ID.String(), Name: name}) + } + return out, nil +} + +// Scan pulls the full IR graph for one help center. +func (s *Source) Scan(ctx context.Context, helpCenterID string) (*importer.Corpus, error) { + collections, err := s.client.ListCollections(ctx) + if err != nil { + return nil, err + } + articles, err := s.client.ListArticles(ctx) + if err != nil { + return nil, err + } + admins, err := s.client.ListAdmins(ctx) + if err != nil { + return nil, err + } + + // Collections belonging to the chosen help center. + hcCollections := make([]collection, 0, len(collections)) + collectionSet := make(map[string]bool) + for _, c := range collections { + if c.HelpCenterID.String() == helpCenterID { + hcCollections = append(hcCollections, c) + collectionSet[c.ID.String()] = true + } + } + + // Articles in this help center: those whose parent collection belongs to it, + // plus uncategorized articles (no parent). In a multi-help-center workspace + // uncategorized articles cannot be attributed to a specific help center, so + // they are associated with the chosen one. + irArticles := make([]importer.Article, 0, len(articles)) + for _, a := range articles { + parent := a.ParentID.String() + if parent != "" && !collectionSet[parent] { + continue + } + irArticles = append(irArticles, mapArticle(a, parent, collectionSet)) + } + + defaultLocale := deriveDefaultLocale(irArticles) + locales := deriveLocales(irArticles, defaultLocale) + + irCollections := make([]importer.Collection, 0, len(hcCollections)) + for _, c := range hcCollections { + irCollections = append(irCollections, mapCollection(c, collectionSet, defaultLocale)) + } + + authors := make(map[string]importer.Author, len(admins)) + for _, a := range admins { + authors[a.ID.String()] = importer.Author{ID: a.ID.String(), Email: a.Email, Name: a.Name} + } + + return &importer.Corpus{ + HelpCenter: importer.HelpCenter{ID: helpCenterID, DefaultLocale: defaultLocale}, + Collections: irCollections, + Articles: irArticles, + Authors: authors, + Locales: locales, + }, nil +} + +func mapCollection(c collection, collectionSet map[string]bool, defaultLocale string) importer.Collection { + names := map[string]string{} + descriptions := map[string]string{} + if c.Name != "" { + names[defaultLocale] = c.Name + } + if c.Description != "" { + descriptions[defaultLocale] = c.Description + } + for locale, content := range parseTranslatedContent(c.TranslatedContent) { + if content.Name != "" { + names[locale] = content.Name + } + if content.Description != "" { + descriptions[locale] = content.Description + } + } + + // Only keep a parent link if the parent belongs to the same help center. + parent := c.ParentID.String() + if parent != "" && !collectionSet[parent] { + parent = "" + } + + return importer.Collection{ + ID: c.ID.String(), + ParentID: parent, + Names: names, + Descriptions: descriptions, + Order: c.Order, + } +} + +func mapArticle(a article, parent string, collectionSet map[string]bool) importer.Article { + defaultLocale := a.DefaultLocale + if defaultLocale == "" { + defaultLocale = "en" + } + + variants := map[string]importer.ArticleVariant{} + for locale, content := range parseTranslatedContent(a.TranslatedContent) { + variants[locale] = importer.ArticleVariant{ + Locale: locale, + Title: content.Title, + Description: content.Description, + BodyHTML: content.Body, + AuthorID: content.AuthorID.String(), + State: content.State, + } + } + // Ensure the default-locale (root) variant exists, built from the top-level + // article fields when translated_content lacks it. + if _, ok := variants[defaultLocale]; !ok { + variants[defaultLocale] = importer.ArticleVariant{ + Locale: defaultLocale, + Title: a.Title, + Description: a.Description, + BodyHTML: a.Body, + AuthorID: a.AuthorID.String(), + State: a.State, + } + } + + collectionID := parent + if collectionID != "" && !collectionSet[collectionID] { + collectionID = "" + } + + return importer.Article{ + ID: a.ID.String(), + CollectionID: collectionID, + DefaultLocale: defaultLocale, + AuthorID: a.AuthorID.String(), + Variants: variants, + SourceURL: a.URL, + } +} + +// deriveDefaultLocale picks the most common article default locale, falling +// back to "en". +func deriveDefaultLocale(articles []importer.Article) string { + counts := map[string]int{} + for _, a := range articles { + if a.DefaultLocale != "" { + counts[a.DefaultLocale]++ + } + } + best := "" + bestN := 0 + for locale, n := range counts { + // Tie-break deterministically by locale string. + if n > bestN || (n == bestN && locale < best) { + best, bestN = locale, n + } + } + if best == "" { + return "en" + } + return best +} + +// deriveLocales returns the unique locale set across all article variants and +// default locales, with the corpus default locale first and the rest sorted. +func deriveLocales(articles []importer.Article, defaultLocale string) []string { + seen := map[string]bool{defaultLocale: true} + var rest []string + for _, a := range articles { + for locale := range a.Variants { + if !seen[locale] { + seen[locale] = true + rest = append(rest, locale) + } + } + } + sort.Strings(rest) + return append([]string{defaultLocale}, rest...) +} diff --git a/internal/importer/intercom/source_test.go b/internal/importer/intercom/source_test.go new file mode 100644 index 0000000..daef116 --- /dev/null +++ b/internal/importer/intercom/source_test.go @@ -0,0 +1,109 @@ +package intercom + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// fakeIntercom serves canned responses for the Source mapping test. +func fakeIntercom(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/admins": + _, _ = w.Write([]byte(`{"type":"admin.list","admins":[ + {"id":"500","name":"Ada","email":"ada@acme.com"} + ]}`)) + case "/help_center/collections": + _, _ = w.Write([]byte(`{"type":"list","data":[ + {"id":"10","name":"Getting Started","help_center_id":"hc1","parent_id":null, + "translated_content":{"type":"group_translated_content","fr":{"type":"group_content","name":"Commencer"}}}, + {"id":"11","name":"Other HC Collection","help_center_id":"hc2","parent_id":null} + ],"pages":{"next":null},"total_count":2}`)) + case "/articles": + _, _ = w.Write([]byte(`{"type":"list","data":[ + {"id":"1001","parent_id":10,"parent_type":"collection","title":"Setup","description":"d", + "body":"

EN body

","author_id":500,"state":"published","default_locale":"en","url":"https://h/en/articles/1001-setup", + "translated_content":{"type":"article_translated_content", + "fr":{"type":"article_content","title":"Configuration","description":"df","body":"

FR body

","author_id":500,"state":"published"}}}, + {"id":"2002","parent_id":99,"title":"Other HC article","body":"x","default_locale":"en"} + ],"pages":{"next":null},"total_count":2}`)) + default: + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + } + })) +} + +func TestScanMapsToIR(t *testing.T) { + server := fakeIntercom(t) + t.Cleanup(server.Close) + + src := New(server.URL, "tok", WithHTTPClient(server.Client())) + corpus, err := src.Scan(context.Background(), "hc1") + if err != nil { + t.Fatalf("Scan: %v", err) + } + + // Only hc1's collection is kept. + if len(corpus.Collections) != 1 || corpus.Collections[0].ID != "10" { + t.Fatalf("collections = %#v, want only id 10", corpus.Collections) + } + coll := corpus.Collections[0] + if coll.Names["en"] != "Getting Started" || coll.Names["fr"] != "Commencer" { + t.Errorf("collection names = %#v, want en+fr", coll.Names) + } + + // Article 2002 belongs to collection 99 (not in hc1) -> excluded. + if len(corpus.Articles) != 1 || corpus.Articles[0].ID != "1001" { + t.Fatalf("articles = %#v, want only 1001", corpus.Articles) + } + art := corpus.Articles[0] + if art.CollectionID != "10" { + t.Errorf("article collection = %q, want 10", art.CollectionID) + } + if art.DefaultLocale != "en" { + t.Errorf("default locale = %q, want en", art.DefaultLocale) + } + if len(art.Variants) != 2 { + t.Fatalf("variants = %#v, want en+fr", art.Variants) + } + if art.Variants["en"].BodyHTML != "

EN body

" { + t.Errorf("en body = %q", art.Variants["en"].BodyHTML) + } + if art.Variants["fr"].Title != "Configuration" { + t.Errorf("fr title = %q, want Configuration", art.Variants["fr"].Title) + } + + // author_id (number 500) resolves to admin id "500". + if a, ok := corpus.Authors["500"]; !ok || a.Email != "ada@acme.com" { + t.Errorf("authors = %#v, want 500 -> ada@acme.com", corpus.Authors) + } + + // Locales: default first. + if len(corpus.Locales) != 2 || corpus.Locales[0] != "en" || corpus.Locales[1] != "fr" { + t.Errorf("locales = %#v, want [en fr]", corpus.Locales) + } +} + +func TestValidateReturnsWorkspaceID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/me" { + http.Error(w, "unexpected", http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"type":"admin","id":"42","email":"me@acme.com","app":{"id_code":"ws_abc"}}`)) + })) + t.Cleanup(server.Close) + + src := New(server.URL, "tok", WithHTTPClient(server.Client())) + ws, err := src.Validate(context.Background()) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if ws != "ws_abc" { + t.Fatalf("workspace = %q, want ws_abc", ws) + } +} diff --git a/internal/importer/intercom/types.go b/internal/importer/intercom/types.go new file mode 100644 index 0000000..0fa773a --- /dev/null +++ b/internal/importer/intercom/types.go @@ -0,0 +1,166 @@ +package intercom + +import ( + "encoding/json" + "net/url" + "strings" +) + +// flexID normalizes Intercom ids that arrive as either JSON strings or numbers +// (e.g. collection ids are strings, but an article's parent_id/author_id are +// numbers) into a single string representation so they can be compared. +type flexID string + +func (f *flexID) UnmarshalJSON(b []byte) error { + s := strings.TrimSpace(string(b)) + if s == "" || s == "null" { + *f = "" + return nil + } + if s[0] == '"' { + var str string + if err := json.Unmarshal(b, &str); err != nil { + return err + } + *f = flexID(str) + return nil + } + *f = flexID(s) + return nil +} + +func (f flexID) String() string { return string(f) } + +// listEnvelope is Intercom's standard cursor-paginated list response. +type listEnvelope[T any] struct { + Type string `json:"type"` + Data []T `json:"data"` + Pages *pages `json:"pages"` + TotalCount int `json:"total_count"` +} + +type pages struct { + Next json.RawMessage `json:"next"` + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalPages int `json:"total_pages"` +} + +// nextCursor extracts the starting_after cursor from pages.next, which may be +// either an object {page, starting_after} (v2.x) or a bare URL string. +func (p *pages) nextCursor() string { + if p == nil || len(p.Next) == 0 || string(p.Next) == "null" { + return "" + } + var obj struct { + StartingAfter string `json:"starting_after"` + } + if err := json.Unmarshal(p.Next, &obj); err == nil && obj.StartingAfter != "" { + return obj.StartingAfter + } + var s string + if err := json.Unmarshal(p.Next, &s); err == nil && s != "" { + // next may be a full URL or a bare query string. Parse it and read the + // decoded starting_after value, so percent-encoded cursors (%2F, %3D…) + // are not re-encoded when we put them back on the next request. + if u, err := url.Parse(s); err == nil && u.RawQuery != "" { + if sa := u.Query().Get("starting_after"); sa != "" { + return sa + } + } + if vals, err := url.ParseQuery(s); err == nil { + if sa := vals.Get("starting_after"); sa != "" { + return sa + } + } + } + return "" +} + +// meResponse is GET /me — the authenticated admin plus workspace (app) info. +type meResponse struct { + Type string `json:"type"` + ID flexID `json:"id"` + Email string `json:"email"` + App struct { + IDCode string `json:"id_code"` + Name string `json:"name"` + } `json:"app"` +} + +// adminList is GET /admins (note: uses "admins", not the standard "data"). +type adminList struct { + Type string `json:"type"` + Admins []admin `json:"admins"` +} + +type admin struct { + ID flexID `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +type helpCenter struct { + ID flexID `json:"id"` + DisplayName string `json:"display_name"` + Identifier string `json:"identifier"` + WorkspaceID string `json:"workspace_id"` +} + +type collection struct { + ID flexID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ParentID flexID `json:"parent_id"` + HelpCenterID flexID `json:"help_center_id"` + Order int `json:"order"` + TranslatedContent json.RawMessage `json:"translated_content"` +} + +type article struct { + ID flexID `json:"id"` + ParentID flexID `json:"parent_id"` + ParentType string `json:"parent_type"` + Title string `json:"title"` + Description string `json:"description"` + Body string `json:"body"` + AuthorID flexID `json:"author_id"` + State string `json:"state"` + URL string `json:"url"` + DefaultLocale string `json:"default_locale"` + TranslatedContent json.RawMessage `json:"translated_content"` +} + +// localizedContent is one locale entry inside a translated_content object. +type localizedContent struct { + Name string `json:"name"` // collections + Title string `json:"title"` // articles + Description string `json:"description"` + Body string `json:"body"` + AuthorID flexID `json:"author_id"` + State string `json:"state"` +} + +// parseTranslatedContent unmarshals an Intercom translated_content object, +// skipping its non-locale "type" key, into a locale -> content map. +func parseTranslatedContent(raw json.RawMessage) map[string]localizedContent { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var byKey map[string]json.RawMessage + if err := json.Unmarshal(raw, &byKey); err != nil { + return nil + } + out := make(map[string]localizedContent) + for key, val := range byKey { + if key == "type" { + continue + } + var c localizedContent + if err := json.Unmarshal(val, &c); err != nil { + continue + } + out[key] = c + } + return out +} diff --git a/internal/importer/intercom/types_test.go b/internal/importer/intercom/types_test.go new file mode 100644 index 0000000..24c8f8a --- /dev/null +++ b/internal/importer/intercom/types_test.go @@ -0,0 +1,39 @@ +package intercom + +import ( + "encoding/json" + "testing" +) + +func TestNextCursorObjectForm(t *testing.T) { + p := &pages{Next: json.RawMessage(`{"page":2,"starting_after":"abc123"}`)} + if got := p.nextCursor(); got != "abc123" { + t.Fatalf("nextCursor = %q, want abc123", got) + } +} + +func TestNextCursorURLFormDecodesPercentEncoding(t *testing.T) { + // A full-URL cursor whose starting_after is percent-encoded (%3D == "="). + // The old substring extraction returned it still-encoded, which got + // double-encoded on the next request and skipped pages. + p := &pages{Next: json.RawMessage(`"https://api.intercom.io/articles?per_page=150&starting_after=WzE3MF0%3D"`)} + if got := p.nextCursor(); got != "WzE3MF0=" { + t.Fatalf("nextCursor = %q, want WzE3MF0= (decoded)", got) + } +} + +func TestNextCursorBareQueryForm(t *testing.T) { + p := &pages{Next: json.RawMessage(`"starting_after=a%2Fb"`)} + if got := p.nextCursor(); got != "a/b" { + t.Fatalf("nextCursor = %q, want a/b (decoded)", got) + } +} + +func TestNextCursorNullOrEmpty(t *testing.T) { + if got := (&pages{Next: json.RawMessage(`null`)}).nextCursor(); got != "" { + t.Errorf("null next = %q, want empty", got) + } + if got := (&pages{}).nextCursor(); got != "" { + t.Errorf("absent next = %q, want empty", got) + } +} diff --git a/internal/importer/ir.go b/internal/importer/ir.go new file mode 100644 index 0000000..12583c8 --- /dev/null +++ b/internal/importer/ir.go @@ -0,0 +1,170 @@ +// Package importer is a source-agnostic engine for importing Help Center +// content into Chatwoot. Providers (Intercom today; Crisp/Zendesk later) +// implement the Source interface and map their data into the provider-neutral +// intermediate representation (IR) defined here. The engine then plans and +// executes the writes against Chatwoot via a Sink. +// +// The engine is intentionally non-interactive: all prompting lives in the CLI +// layer, which passes the user's choices in as a Selections value. +package importer + +// HelpCenter is a selectable source help center. +type HelpCenter struct { + ID string + Name string + DefaultLocale string +} + +// Collection is a provider-neutral category/section. Nesting is expressed via +// ParentID. Names/Descriptions are keyed by locale and must include the +// collection's default-locale name. +type Collection struct { + ID string + ParentID string + Names map[string]string + Descriptions map[string]string + Order int +} + +// Name returns the best name for a locale, falling back to the default locale +// then any available name. +func (c Collection) Name(locale, defaultLocale string) string { + if n, ok := c.Names[locale]; ok && n != "" { + return n + } + if n, ok := c.Names[defaultLocale]; ok && n != "" { + return n + } + for _, n := range c.Names { + if n != "" { + return n + } + } + return c.ID +} + +// Article carries the default-locale body as the root plus per-locale variants. +type Article struct { + ID string + CollectionID string + DefaultLocale string + AuthorID string + Variants map[string]ArticleVariant + SourceURL string +} + +// ArticleVariant is one localized rendition of an article. +type ArticleVariant struct { + Locale string + Title string + Description string + BodyHTML string + AuthorID string // per-locale author override; falls back to Article.AuthorID + State string // provider state, kept for reporting; imports always write draft +} + +// Author is a provider teammate/admin used for author matching. +type Author struct { + ID string + Email string + Name string +} + +// Corpus is the fully-scanned source graph for one help center. +type Corpus struct { + HelpCenter HelpCenter + Collections []Collection + Articles []Article + Authors map[string]Author + Locales []string // derived during scan; default locale first +} + +// PortalTarget describes the chosen Chatwoot portal: either an existing slug +// or a new portal to create. +type PortalTarget struct { + ExistingSlug string + CreateName string + CreateSlug string +} + +// IsCreate reports whether a new portal should be created. +func (t PortalTarget) IsCreate() bool { return t.ExistingSlug == "" } + +// Slug returns the portal slug (existing or to-be-created). +func (t PortalTarget) Slug() string { + if t.ExistingSlug != "" { + return t.ExistingSlug + } + return t.CreateSlug +} + +// Selections is the user's interactive choices, built by the CLI layer and +// passed to Plan. +type Selections struct { + SourceHCID string + Target PortalTarget + Locales []string // locales chosen for import; gates non-root variants +} + +// --------------------------------------------------------------------------- +// Plan — the resolved, ordered set of writes Execute will perform. Built by +// Plan() with no network calls (author resolution uses pre-fetched maps). +// --------------------------------------------------------------------------- + +// PlannedCategory is one category create (a collection in a specific locale). +type PlannedCategory struct { + CollectionID string + ParentCollectionID string + Locale string + IsRoot bool // root = the collection's default-locale category + Name string + Description string + Slug string + Skip bool // already recorded in state +} + +// PlannedArticle is one article create (an article variant in a locale). +type PlannedArticle struct { + ArticleID string + CollectionID string + Locale string + RootLocale string // the article's default locale (the root variant's locale) + IsRoot bool // root = the article's default-locale variant + Title string + Description string + BodyHTML string // raw; Execute transforms images/embeds before writing + AuthorID int // resolved Chatwoot user id + Skip bool +} + +// ImportPlan is the full ordered plan plus summary metadata. +type ImportPlan struct { + Portal PortalTarget + DefaultLocale string // corpus default locale (the per-collection root locale) + PortalLocales []string // effective allowed locales to ensure on the portal + Categories []PlannedCategory + Articles []PlannedArticle + AuthorStats AuthorStats +} + +// AuthorStats summarizes author matching for the plan/summary. +type AuthorStats struct { + Matched int + Fallback int +} + +// Result is the outcome of Execute, for the final summary. +type Result struct { + PortalSlug string + PortalCreated bool + CategoriesCreated int + CategoriesSkipped int + ArticlesCreated int + ArticlesSkipped int + ImagesSwapped int + ImagesFailed int + EmbedsRewritten int + UncategorizedCount int + Failures []FailureRec + AuthorStats AuthorStats +} diff --git a/internal/importer/source.go b/internal/importer/source.go new file mode 100644 index 0000000..d319a84 --- /dev/null +++ b/internal/importer/source.go @@ -0,0 +1,24 @@ +package importer + +import "context" + +// Source is the provider abstraction. Intercom is the first implementation; +// Crisp/Zendesk can be added later as new packages without touching the engine. +type Source interface { + // Name is a stable provider key used in the state-file id (e.g. "intercom"). + Name() string + + // Validate confirms credentials work and returns a stable workspace + // identifier used for state keying. + Validate(ctx context.Context) (workspaceID string, err error) + + // ListHelpCenters returns the selectable source help centers. Providers + // without a multi-help-center concept return a single synthetic entry. + ListHelpCenters(ctx context.Context) ([]HelpCenter, error) + + // Scan pulls the full IR graph for one help center: collections (with + // parents), articles (with per-locale variants + author ids), authors, and + // the derived locale set. Implementations handle pagination and rate + // limiting internally. + Scan(ctx context.Context, helpCenterID string) (*Corpus, error) +} diff --git a/internal/importer/state.go b/internal/importer/state.go new file mode 100644 index 0000000..4297561 --- /dev/null +++ b/internal/importer/state.go @@ -0,0 +1,173 @@ +package importer + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// stateVersion is the on-disk schema version. +const stateVersion = 1 + +// ItemRef is a created Chatwoot record (category or article). +type ItemRef struct { + ID int `json:"id"` + Slug string `json:"slug,omitempty"` +} + +// FailureRec records a per-item failure for reporting and retry. +type FailureRec struct { + Kind string `json:"kind"` + Error string `json:"error"` + At string `json:"at"` +} + +// PortalRef is the resolved target portal. +type PortalRef struct { + Slug string `json:"slug"` + ID int `json:"id"` +} + +// State is the resumable import state persisted under ~/.chatwoot/imports/. +// Categories and Articles map a source id -> locale -> created Chatwoot record. +type State struct { + Version int `json:"version"` + Provider string `json:"provider"` + WorkspaceID string `json:"workspace_id"` + SourceHCID string `json:"source_help_center_id"` + TargetPortal PortalRef `json:"target_portal"` + Locales []string `json:"locales"` + Categories map[string]map[string]ItemRef `json:"categories"` + Articles map[string]map[string]ItemRef `json:"articles"` + Failures map[string]FailureRec `json:"failures"` + UpdatedAt string `json:"updated_at"` +} + +// NewState returns an initialized, empty state. +func NewState(provider, workspaceID, sourceHCID string) *State { + return &State{ + Version: stateVersion, + Provider: provider, + WorkspaceID: workspaceID, + SourceHCID: sourceHCID, + Categories: map[string]map[string]ItemRef{}, + Articles: map[string]map[string]ItemRef{}, + Failures: map[string]FailureRec{}, + } +} + +// StateKey derives a stable file key from the import coordinates. Target slug +// is known before any write (portal slugs are used verbatim by Chatwoot). +func StateKey(provider, workspaceID, sourceHCID, targetSlug string) string { + sum := sha256.Sum256([]byte(provider + ":" + workspaceID + ":" + sourceHCID + ":" + targetSlug)) + return hex.EncodeToString(sum[:])[:16] +} + +// StatePath returns the on-disk path for a state key within dir. +func StatePath(dir, key string) string { + return filepath.Join(dir, key+".json") +} + +// LoadState reads state from path, returning nil (not an error) when the file +// does not exist. +func LoadState(path string) (*State, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read import state: %w", err) + } + var s State + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("failed to parse import state: %w", err) + } + s.ensureMaps() + return &s, nil +} + +func (s *State) ensureMaps() { + if s.Categories == nil { + s.Categories = map[string]map[string]ItemRef{} + } + if s.Articles == nil { + s.Articles = map[string]map[string]ItemRef{} + } + if s.Failures == nil { + s.Failures = map[string]FailureRec{} + } +} + +// Save writes state atomically (tmp + rename) with 0600 perms, creating the +// parent directory (0700) if needed. +func (s *State) Save(path string) error { + s.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("failed to create import state dir: %w", err) + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("failed to write import state: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("failed to commit import state: %w", err) + } + return nil +} + +// CategoryRef returns the recorded category for a collection+locale, if any. +func (s *State) CategoryRef(collectionID, locale string) (ItemRef, bool) { + byLocale, ok := s.Categories[collectionID] + if !ok { + return ItemRef{}, false + } + ref, ok := byLocale[locale] + return ref, ok +} + +// SetCategory records a created category and clears any prior failure for it. +func (s *State) SetCategory(collectionID, locale string, ref ItemRef) { + if s.Categories[collectionID] == nil { + s.Categories[collectionID] = map[string]ItemRef{} + } + s.Categories[collectionID][locale] = ref + delete(s.Failures, categoryKey(collectionID, locale)) +} + +// ArticleRef returns the recorded article for an article+locale, if any. +func (s *State) ArticleRef(articleID, locale string) (ItemRef, bool) { + byLocale, ok := s.Articles[articleID] + if !ok { + return ItemRef{}, false + } + ref, ok := byLocale[locale] + return ref, ok +} + +// SetArticle records a created article and clears any prior failure for it. +func (s *State) SetArticle(articleID, locale string, ref ItemRef) { + if s.Articles[articleID] == nil { + s.Articles[articleID] = map[string]ItemRef{} + } + s.Articles[articleID][locale] = ref + delete(s.Failures, articleKey(articleID, locale)) +} + +// RecordFailure stores a per-item failure keyed by its composite id. +func (s *State) RecordFailure(key, kind, errMsg string) { + s.Failures[key] = FailureRec{Kind: kind, Error: errMsg, At: time.Now().UTC().Format(time.RFC3339)} +} + +func categoryKey(collectionID, locale string) string { + return "category:" + collectionID + ":" + locale +} +func articleKey(articleID, locale string) string { return "article:" + articleID + ":" + locale } diff --git a/internal/importer/state_test.go b/internal/importer/state_test.go new file mode 100644 index 0000000..5e0721d --- /dev/null +++ b/internal/importer/state_test.go @@ -0,0 +1,97 @@ +package importer + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestStateKeyIsStableAndCoordinateSensitive(t *testing.T) { + a := StateKey("intercom", "ws1", "hc1", "acme-support") + b := StateKey("intercom", "ws1", "hc1", "acme-support") + if a != b { + t.Fatalf("key not stable: %q != %q", a, b) + } + if a == StateKey("intercom", "ws1", "hc1", "other-portal") { + t.Fatal("different target slug should produce a different key") + } + if len(a) != 16 { + t.Fatalf("key length = %d, want 16", len(a)) + } +} + +func TestLoadStateMissingReturnsNil(t *testing.T) { + s, err := LoadState(filepath.Join(t.TempDir(), "nope.json")) + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if s != nil { + t.Fatalf("expected nil state for missing file, got %#v", s) + } +} + +func TestStateSaveLoadRoundTripAndPerms(t *testing.T) { + dir := t.TempDir() + path := StatePath(dir, "key123") + + s := NewState("intercom", "ws1", "hc1") + s.TargetPortal = PortalRef{Slug: "acme-support", ID: 7} + s.Locales = []string{"en", "fr"} + s.SetCategory("coll1", "en", ItemRef{ID: 10, Slug: "faq"}) + s.SetArticle("art1", "en", ItemRef{ID: 100, Slug: "hello"}) + + if err := s.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("perm = %o, want 600", perm) + } + } + + loaded, err := LoadState(path) + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if loaded == nil { + t.Fatal("expected loaded state") + } + if ref, ok := loaded.CategoryRef("coll1", "en"); !ok || ref.ID != 10 { + t.Errorf("category ref = %#v ok=%v, want id 10", ref, ok) + } + if ref, ok := loaded.ArticleRef("art1", "en"); !ok || ref.ID != 100 { + t.Errorf("article ref = %#v ok=%v, want id 100", ref, ok) + } + if loaded.TargetPortal.ID != 7 { + t.Errorf("portal id = %d, want 7", loaded.TargetPortal.ID) + } +} + +func TestSetCategoryClearsFailure(t *testing.T) { + s := NewState("intercom", "ws1", "hc1") + key := categoryKey("coll1", "fr") + s.RecordFailure(key, "category", "boom") + if _, ok := s.Failures[key]; !ok { + t.Fatal("failure not recorded") + } + s.SetCategory("coll1", "fr", ItemRef{ID: 5}) + if _, ok := s.Failures[key]; ok { + t.Fatal("failure should be cleared after success") + } +} + +func TestMissingRefsReturnFalse(t *testing.T) { + s := NewState("intercom", "ws1", "hc1") + if _, ok := s.CategoryRef("nope", "en"); ok { + t.Error("expected no category ref") + } + if _, ok := s.ArticleRef("nope", "en"); ok { + t.Error("expected no article ref") + } +} diff --git a/internal/importer/transform.go b/internal/importer/transform.go new file mode 100644 index 0000000..09a84a7 --- /dev/null +++ b/internal/importer/transform.go @@ -0,0 +1,187 @@ +package importer + +import ( + "bytes" + "strings" + + "golang.org/x/net/html" +) + +// ImageUploader re-hosts a remote image URL and returns the new hosted URL. +// It is injected so the transform stays pure/testable; in production it wraps +// the Chatwoot upload endpoint. +type ImageUploader func(srcURL string) (newURL string, err error) + +// TransformResult is the rewritten body plus per-article counters. +type TransformResult struct { + HTML string + ImagesSwapped int + ImagesFailed int + EmbedsRewritten int +} + +// TransformBody rewrites an HTML article body: sources are re-hosted via +// upload (best-effort — failures keep the original src), and recognized +// provider ` + res := TransformBody(body, nil, DefaultEmbedRegistry()) + if res.EmbedsRewritten != 1 { + t.Fatalf("embeds=%d, want 1", res.EmbedsRewritten) + } + if strings.Contains(res.HTML, "` + res := TransformBody(body, nil, DefaultEmbedRegistry()) + if res.EmbedsRewritten != 0 { + t.Fatalf("embeds=%d, want 0", res.EmbedsRewritten) + } + if !strings.Contains(res.HTML, "unclosed = 1 && n <= len(options) { + return n - 1, nil + } + fmt.Fprintf(p.w, "Please enter a number between 1 and %d.\n", len(options)) + } +} + +// SelectMany shows a numbered list and reads a comma-separated set of choices. +// "all" (when allowAll) selects everything. Re-prompts on invalid input. +func (p *TermPrompter) SelectMany(label string, options []string, allowAll bool) ([]int, error) { + if len(options) == 0 { + return nil, fmt.Errorf("no options to choose from") + } + p.printOptions(label, options) + hint := "Enter numbers (comma-separated)" + if allowAll { + hint += " or 'all'" + } + for { + fmt.Fprintf(p.w, "%s: ", hint) + line, err := p.readLine() + if err != nil { + return nil, err + } + if allowAll && strings.EqualFold(line, "all") { + idxs := make([]int, len(options)) + for i := range options { + idxs[i] = i + } + return idxs, nil + } + idxs, ok := parseIndexList(line, len(options)) + if ok && len(idxs) > 0 { + return idxs, nil + } + fmt.Fprintf(p.w, "Please enter one or more numbers between 1 and %d, separated by commas.\n", len(options)) + } +} + +// parseIndexList parses "1, 3,2" into a deduplicated, ordered []int of 0-based +// indices. Returns ok=false if any token is not a valid in-range number. +func parseIndexList(line string, n int) ([]int, bool) { + if strings.TrimSpace(line) == "" { + return nil, false + } + seen := make(map[int]bool) + var out []int + for tok := range strings.SplitSeq(line, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + v, err := strconv.Atoi(tok) + if err != nil || v < 1 || v > n { + return nil, false + } + if !seen[v-1] { + seen[v-1] = true + out = append(out, v-1) + } + } + return out, true +} + +// Input reads a line, returning def on empty input. +func (p *TermPrompter) Input(label, def string) (string, error) { + if def != "" { + fmt.Fprintf(p.w, "%s [%s]: ", label, def) + } else { + fmt.Fprintf(p.w, "%s: ", label) + } + line, err := p.readLine() + if err != nil { + return "", err + } + if line == "" { + return def, nil + } + return line, nil +} + +// Confirm asks a yes/no question, defaulting to no on empty input. +func (p *TermPrompter) Confirm(label string) (bool, error) { + fmt.Fprintf(p.w, "%s [y/N]: ", label) + line, err := p.readLine() + if err != nil { + return false, err + } + switch strings.ToLower(line) { + case "y", "yes": + return true, nil + default: + return false, nil + } +} diff --git a/internal/prompt/prompt_test.go b/internal/prompt/prompt_test.go new file mode 100644 index 0000000..8a71884 --- /dev/null +++ b/internal/prompt/prompt_test.go @@ -0,0 +1,120 @@ +package prompt + +import ( + "bytes" + "strings" + "testing" +) + +func newTest(input string) (*TermPrompter, *bytes.Buffer) { + var out bytes.Buffer + return NewTermPrompter(strings.NewReader(input), &out), &out +} + +func TestSelectOne(t *testing.T) { + p, _ := newTest("2\n") + idx, err := p.SelectOne("Pick one", []string{"a", "b", "c"}) + if err != nil { + t.Fatalf("SelectOne: %v", err) + } + if idx != 1 { + t.Fatalf("idx = %d, want 1", idx) + } +} + +func TestSelectOneRepromptsThenSucceeds(t *testing.T) { + p, out := newTest("9\nx\n1\n") + idx, err := p.SelectOne("Pick one", []string{"a", "b"}) + if err != nil { + t.Fatalf("SelectOne: %v", err) + } + if idx != 0 { + t.Fatalf("idx = %d, want 0", idx) + } + if !strings.Contains(out.String(), "between 1 and 2") { + t.Errorf("expected reprompt message, got: %q", out.String()) + } +} + +func TestSelectOneEOFReturnsError(t *testing.T) { + p, _ := newTest("") + if _, err := p.SelectOne("Pick", []string{"a"}); err == nil { + t.Fatal("expected error on EOF") + } +} + +func TestSelectManyCommaList(t *testing.T) { + p, _ := newTest("1,3\n") + idxs, err := p.SelectMany("Pick many", []string{"a", "b", "c"}, true) + if err != nil { + t.Fatalf("SelectMany: %v", err) + } + if len(idxs) != 2 || idxs[0] != 0 || idxs[1] != 2 { + t.Fatalf("idxs = %v, want [0 2]", idxs) + } +} + +func TestSelectManyAll(t *testing.T) { + p, _ := newTest("all\n") + idxs, err := p.SelectMany("Pick many", []string{"a", "b", "c"}, true) + if err != nil { + t.Fatalf("SelectMany: %v", err) + } + if len(idxs) != 3 { + t.Fatalf("idxs = %v, want all three", idxs) + } +} + +func TestSelectManyDedupsAndReprompts(t *testing.T) { + p, _ := newTest("5\n2,2,1\n") + idxs, err := p.SelectMany("Pick", []string{"a", "b", "c"}, false) + if err != nil { + t.Fatalf("SelectMany: %v", err) + } + if len(idxs) != 2 || idxs[0] != 1 || idxs[1] != 0 { + t.Fatalf("idxs = %v, want [1 0] (deduped, ordered)", idxs) + } +} + +func TestInputDefaultOnEmpty(t *testing.T) { + p, _ := newTest("\n") + got, err := p.Input("Slug", "acme-support") + if err != nil { + t.Fatalf("Input: %v", err) + } + if got != "acme-support" { + t.Fatalf("got %q, want default", got) + } +} + +func TestInputOverride(t *testing.T) { + p, _ := newTest("custom-slug\n") + got, err := p.Input("Slug", "acme-support") + if err != nil { + t.Fatalf("Input: %v", err) + } + if got != "custom-slug" { + t.Fatalf("got %q, want custom-slug", got) + } +} + +func TestConfirm(t *testing.T) { + cases := map[string]bool{ + "y\n": true, + "yes\n": true, + "Y\n": true, + "\n": false, + "n\n": false, + "nope\n": false, + } + for in, want := range cases { + p, _ := newTest(in) + got, err := p.Confirm("Proceed?") + if err != nil { + t.Fatalf("Confirm(%q): %v", in, err) + } + if got != want { + t.Errorf("Confirm(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/internal/sdk/help_center.go b/internal/sdk/help_center.go index 694c8cd..6149920 100644 --- a/internal/sdk/help_center.go +++ b/internal/sdk/help_center.go @@ -1,7 +1,11 @@ package sdk import ( + "bytes" + "encoding/json" "fmt" + "mime/multipart" + "net/http" "net/url" "strconv" "strings" @@ -165,3 +169,240 @@ func helpCenterArticlesPath(portalSlug, locale, categorySlug string) (string, er url.PathEscape(categorySlug), ), nil } + +// --------------------------------------------------------------------------- +// Write API (authoring) — account-scoped, admin token required. +// +// Response wrappers differ per endpoint (verified against the Chatwoot +// controllers/jbuilder views): portal create/update return a BARE portal +// object, while category and article create/update wrap the record under a +// "payload" key. +// --------------------------------------------------------------------------- + +// PortalConfigInput is the WRITE shape of a portal's config. Note that +// allowed_locales is a flat []string on write, even though the read API +// renders it as an array of objects. +type PortalConfigInput struct { + AllowedLocales []string `json:"allowed_locales,omitempty"` + DefaultLocale string `json:"default_locale,omitempty"` + Layout string `json:"layout,omitempty"` +} + +// PortalInput is the request body for creating/updating a portal. +type PortalInput struct { + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + Config *PortalConfigInput `json:"config,omitempty"` +} + +// CreatePortal creates a help center portal. The response is a bare portal +// object (no payload wrapper). +func (s *HelpCenterService) CreatePortal(req PortalInput) (*HelpCenterPortal, error) { + body, err := json.Marshal(map[string]PortalInput{"portal": req}) + if err != nil { + return nil, err + } + + var portal HelpCenterPortal + if err := s.client.Post("/portals", bytes.NewReader(body), &portal); err != nil { + return nil, err + } + return &portal, nil +} + +// UpdatePortal updates a portal by slug (used to reconcile allowed_locales). +// The response is a bare portal object. +func (s *HelpCenterService) UpdatePortal(slug string, req PortalInput) (*HelpCenterPortal, error) { + if strings.TrimSpace(slug) == "" { + return nil, fmt.Errorf("portal slug is required") + } + + body, err := json.Marshal(map[string]PortalInput{"portal": req}) + if err != nil { + return nil, err + } + + var portal HelpCenterPortal + path := fmt.Sprintf("/portals/%s", url.PathEscape(slug)) + if err := s.client.Patch(path, bytes.NewReader(body), &portal); err != nil { + return nil, err + } + return &portal, nil +} + +// HelpCenterCategory is a category record as returned by the write API. +type HelpCenterCategory struct { + ID int `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Locale string `json:"locale"` + Description string `json:"description"` + Position int `json:"position"` + AccountID int `json:"account_id"` +} + +type HelpCenterCategoriesResponse struct { + Payload []HelpCenterCategory `json:"payload"` + Meta map[string]any `json:"meta"` +} + +type categoryEnvelope struct { + Payload HelpCenterCategory `json:"payload"` +} + +// CreateCategoryRequest is the request body for creating a category. slug is +// REQUIRED — unlike articles, the server does not auto-generate it. +type CreateCategoryRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + Locale string `json:"locale"` + Description string `json:"description,omitempty"` + ParentCategoryID int `json:"parent_category_id,omitempty"` + AssociatedCategoryID int `json:"associated_category_id,omitempty"` + Position int `json:"position,omitempty"` +} + +// ListCategories lists a portal's categories, optionally filtered by locale. +func (s *HelpCenterService) ListCategories(portalSlug, locale string) (*HelpCenterCategoriesResponse, error) { + if strings.TrimSpace(portalSlug) == "" { + return nil, fmt.Errorf("portal slug is required") + } + + params := url.Values{} + if strings.TrimSpace(locale) != "" { + params.Set("locale", locale) + } + + var resp HelpCenterCategoriesResponse + path := fmt.Sprintf("/portals/%s/categories", url.PathEscape(portalSlug)) + if err := s.client.Get(path, params, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// CreateCategory creates a category under a portal. Returns the created +// category (unwrapped from the payload envelope). +func (s *HelpCenterService) CreateCategory(portalSlug string, req CreateCategoryRequest) (*HelpCenterCategory, error) { + if strings.TrimSpace(portalSlug) == "" { + return nil, fmt.Errorf("portal slug is required") + } + + body, err := json.Marshal(map[string]CreateCategoryRequest{"category": req}) + if err != nil { + return nil, err + } + + var env categoryEnvelope + path := fmt.Sprintf("/portals/%s/categories", url.PathEscape(portalSlug)) + if err := s.client.Post(path, bytes.NewReader(body), &env); err != nil { + return nil, err + } + return &env.Payload, nil +} + +type articleEnvelope struct { + Payload HelpCenterArticle `json:"payload"` +} + +// ArticleMetaInput is the optional SEO/meta object on an article. +type ArticleMetaInput struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +// CreateArticleRequest is the request body for creating/updating an article. +// title and author_id are required; content is required only when published +// (imports use draft). slug may be omitted — the server auto-generates it. +// associated_article_id is honored at create time and flattened to the root. +type CreateArticleRequest struct { + Title string `json:"title,omitempty"` + Content string `json:"content,omitempty"` + Description string `json:"description,omitempty"` + Slug string `json:"slug,omitempty"` + Status string `json:"status,omitempty"` + Locale string `json:"locale,omitempty"` + AuthorID int `json:"author_id,omitempty"` + Position int `json:"position,omitempty"` + CategoryID int `json:"category_id,omitempty"` + AssociatedArticleID int `json:"associated_article_id,omitempty"` + Meta *ArticleMetaInput `json:"meta,omitempty"` +} + +// CreateArticle creates an article under a portal. Returns the created article +// (unwrapped from the payload envelope). +func (s *HelpCenterService) CreateArticle(portalSlug string, req CreateArticleRequest) (*HelpCenterArticle, error) { + if strings.TrimSpace(portalSlug) == "" { + return nil, fmt.Errorf("portal slug is required") + } + + body, err := json.Marshal(map[string]CreateArticleRequest{"article": req}) + if err != nil { + return nil, err + } + + var env articleEnvelope + path := fmt.Sprintf("/portals/%s/articles", url.PathEscape(portalSlug)) + if err := s.client.Post(path, bytes.NewReader(body), &env); err != nil { + return nil, err + } + return &env.Payload, nil +} + +// UpdateArticle updates an article by id (used for re-link/repair). Returns the +// updated article. +func (s *HelpCenterService) UpdateArticle(portalSlug string, id int, req CreateArticleRequest) (*HelpCenterArticle, error) { + if strings.TrimSpace(portalSlug) == "" { + return nil, fmt.Errorf("portal slug is required") + } + + body, err := json.Marshal(map[string]CreateArticleRequest{"article": req}) + if err != nil { + return nil, err + } + + var env articleEnvelope + path := fmt.Sprintf("/portals/%s/articles/%d", url.PathEscape(portalSlug), id) + if err := s.client.Patch(path, bytes.NewReader(body), &env); err != nil { + return nil, err + } + return &env.Payload, nil +} + +// UploadResult is the response from the /upload endpoint. blob_id is an +// ActiveStorage signed_id (a string). +type UploadResult struct { + FileURL string `json:"file_url"` + BlobID string `json:"blob_id"` +} + +// UploadImageExternalURL uploads an image by asking the server to fetch a +// remote URL (via SafeFetch). Returns the hosted file_url to embed in article +// content. Uses a multipart form with a single external_url field. +func (s *HelpCenterService) UploadImageExternalURL(externalURL string) (*UploadResult, error) { + if strings.TrimSpace(externalURL) == "" { + return nil, fmt.Errorf("external_url is required") + } + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + if err := w.WriteField("external_url", externalURL); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + + headers := http.Header{"Content-Type": {w.FormDataContentType()}} + resp, err := s.client.RequestRaw(http.MethodPost, "/upload", &buf, true, headers) + if err != nil { + return nil, err + } + + var result UploadResult + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, fmt.Errorf("failed to decode upload response: %w", err) + } + return &result, nil +} diff --git a/internal/sdk/help_center_write_test.go b/internal/sdk/help_center_write_test.go new file mode 100644 index 0000000..658eee3 --- /dev/null +++ b/internal/sdk/help_center_write_test.go @@ -0,0 +1,253 @@ +package sdk + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// decodeBody decodes a request's JSON body into a generic map for assertions. +func decodeBody(t *testing.T, r *http.Request) map[string]any { + t.Helper() + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("decode body %q: %v", string(raw), err) + } + return m +} + +func TestCreatePortalSendsConfigAndDecodesBareResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/accounts/9/portals" { + http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound) + return + } + body := decodeBody(t, r) + portal, ok := body["portal"].(map[string]any) + if !ok { + t.Fatalf("body not wrapped under portal: %#v", body) + } + if portal["slug"] != "acme-support" { + t.Errorf("slug = %v, want acme-support", portal["slug"]) + } + cfg, ok := portal["config"].(map[string]any) + if !ok { + t.Fatalf("config missing: %#v", portal) + } + locales, ok := cfg["allowed_locales"].([]any) + if !ok || len(locales) != 2 || locales[0] != "en" || locales[1] != "fr" { + t.Errorf("allowed_locales = %#v, want [en fr] as strings", cfg["allowed_locales"]) + } + + w.Header().Set("Content-Type", "application/json") + // Bare portal object — NO payload wrapper. + _, _ = w.Write([]byte(`{"id": 7, "name": "Acme Support", "slug": "acme-support"}`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client())) + portal, err := client.HelpCenter().CreatePortal(PortalInput{ + Name: "Acme Support", + Slug: "acme-support", + Config: &PortalConfigInput{ + AllowedLocales: []string{"en", "fr"}, + DefaultLocale: "en", + }, + }) + if err != nil { + t.Fatalf("CreatePortal: %v", err) + } + if portal.ID != 7 || portal.Slug != "acme-support" { + t.Fatalf("unexpected portal: %#v", portal) + } +} + +func TestUpdatePortalPatchesAllowedLocales(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/api/v1/accounts/9/portals/acme-support" { + http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound) + return + } + body := decodeBody(t, r) + cfg := body["portal"].(map[string]any)["config"].(map[string]any) + locales := cfg["allowed_locales"].([]any) + if len(locales) != 3 { + t.Errorf("allowed_locales len = %d, want 3", len(locales)) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": 7, "slug": "acme-support"}`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client())) + if _, err := client.HelpCenter().UpdatePortal("acme-support", PortalInput{ + Config: &PortalConfigInput{AllowedLocales: []string{"en", "fr", "de"}}, + }); err != nil { + t.Fatalf("UpdatePortal: %v", err) + } +} + +func TestCreateCategorySendsSlugAndLinksAndUnwrapsPayload(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/accounts/9/portals/acme-support/categories" { + http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound) + return + } + cat := decodeBody(t, r)["category"].(map[string]any) + if cat["slug"] == nil || cat["slug"] == "" { + t.Errorf("slug must be present, got %#v", cat["slug"]) + } + if cat["locale"] != "fr" { + t.Errorf("locale = %v, want fr", cat["locale"]) + } + if cat["associated_category_id"] != float64(11) { + t.Errorf("associated_category_id = %v, want 11", cat["associated_category_id"]) + } + if cat["parent_category_id"] != float64(5) { + t.Errorf("parent_category_id = %v, want 5", cat["parent_category_id"]) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payload": {"id": 21, "slug": "getting-started", "locale": "fr"}}`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client())) + cat, err := client.HelpCenter().CreateCategory("acme-support", CreateCategoryRequest{ + Name: "Commencer", + Slug: "getting-started", + Locale: "fr", + ParentCategoryID: 5, + AssociatedCategoryID: 11, + }) + if err != nil { + t.Fatalf("CreateCategory: %v", err) + } + if cat.ID != 21 || cat.Locale != "fr" { + t.Fatalf("unexpected category: %#v", cat) + } +} + +func TestListCategoriesPassesLocaleQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/accounts/9/portals/acme-support/categories" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + if got := r.URL.Query().Get("locale"); got != "en" { + t.Errorf("locale = %q, want en", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payload": [{"id": 3, "slug": "faq", "locale": "en"}], "meta": {"categories_count": 1}}`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client())) + resp, err := client.HelpCenter().ListCategories("acme-support", "en") + if err != nil { + t.Fatalf("ListCategories: %v", err) + } + if len(resp.Payload) != 1 || resp.Payload[0].Slug != "faq" { + t.Fatalf("unexpected categories: %#v", resp) + } +} + +func TestCreateArticleSendsLinkLocaleStatusAndUnwrapsPayload(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/accounts/9/portals/acme-support/articles" { + http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound) + return + } + art := decodeBody(t, r)["article"].(map[string]any) + if art["status"] != "draft" { + t.Errorf("status = %v, want draft", art["status"]) + } + if art["locale"] != "fr" { + t.Errorf("locale = %v, want fr", art["locale"]) + } + if art["associated_article_id"] != float64(100) { + t.Errorf("associated_article_id = %v, want 100", art["associated_article_id"]) + } + if art["category_id"] != float64(21) { + t.Errorf("category_id = %v, want 21", art["category_id"]) + } + if art["author_id"] != float64(2) { + t.Errorf("author_id = %v, want 2", art["author_id"]) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payload": {"id": 101, "title": "Configurer SSO", "status": "draft"}}`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client())) + art, err := client.HelpCenter().CreateArticle("acme-support", CreateArticleRequest{ + Title: "Configurer SSO", + Content: "

...

", + Status: "draft", + Locale: "fr", + AuthorID: 2, + CategoryID: 21, + AssociatedArticleID: 100, + }) + if err != nil { + t.Fatalf("CreateArticle: %v", err) + } + if art.ID != 101 || art.Status != "draft" { + t.Fatalf("unexpected article: %#v", art) + } +} + +func TestUpdateArticlePatchesByID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || r.URL.Path != "/api/v1/accounts/9/portals/acme-support/articles/101" { + http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payload": {"id": 101, "title": "Configurer SSO"}}`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client())) + if _, err := client.HelpCenter().UpdateArticle("acme-support", 101, CreateArticleRequest{ + AssociatedArticleID: 100, + }); err != nil { + t.Fatalf("UpdateArticle: %v", err) + } +} + +func TestUploadImageExternalURLSendsMultipartField(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/accounts/9/upload" { + http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound) + return + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("ParseMultipartForm: %v", err) + } + if got := r.FormValue("external_url"); got != "https://cdn.intercom.io/img.png" { + t.Errorf("external_url = %q", got) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"file_url": "https://app/rails/active_storage/blobs/x/img.png", "blob_id": "signed-abc"}`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client())) + res, err := client.HelpCenter().UploadImageExternalURL("https://cdn.intercom.io/img.png") + if err != nil { + t.Fatalf("UploadImageExternalURL: %v", err) + } + if res.BlobID != "signed-abc" || res.FileURL == "" { + t.Fatalf("unexpected upload result: %#v", res) + } +}