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