diff --git a/go.mod b/go.mod index 87c8b0c..fde1896 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/fopost/fopost-cli go 1.22 require ( - github.com/fopost/fopost-go v0.2.1-0.20260920195809-0f2a3f293638 + github.com/fopost/fopost-go v0.2.1-0.20261003215548-65ec0bbbe207 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.27.0 ) diff --git a/go.sum b/go.sum index 65f5873..1e9e0cf 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/fopost/fopost-go v0.2.1-0.20260920181919-3c2f1c56810a h1:fnSVISo/7C+R github.com/fopost/fopost-go v0.2.1-0.20260920181919-3c2f1c56810a/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= github.com/fopost/fopost-go v0.2.1-0.20260920195809-0f2a3f293638 h1:HyHOHnuVqcv8PVp2VtX2OT1Q/kfhxWZI+gnIfH5Re2Y= github.com/fopost/fopost-go v0.2.1-0.20260920195809-0f2a3f293638/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= +github.com/fopost/fopost-go v0.2.1-0.20261003215548-65ec0bbbe207 h1:mHYksMJZWjyhMiJPmJOxa4fxvjUyaBkYjog9GWM+MnU= +github.com/fopost/fopost-go v0.2.1-0.20261003215548-65ec0bbbe207/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/cmd/ads.go b/internal/cmd/ads.go index 4d5651a..137a17b 100644 --- a/internal/cmd/ads.go +++ b/internal/cmd/ads.go @@ -15,7 +15,7 @@ func init() { register(newAdsCmd) } func newAdsCmd(state *State) *cobra.Command { cmd := &cobra.Command{ Use: "ads", - Short: "Inspect campaigns and catalogs, change status, and read insights, leads and the ad archive", + Short: "Inspect campaigns and catalogs, change status, and read insights, leads, the ad archive and ad comments", } cmd.AddCommand( newAdsTreeCmd(state), @@ -26,10 +26,30 @@ func newAdsCmd(state *State) *cobra.Command { newAdsCatalogsCmd(state), newAdsLibraryCmd(state), newAdsGoogleCmd(state), + newAdsIdentitiesCmd(state), + newAdsSparkPostsCmd(state), + newAdsCommentsCmd(state), + newAdsReplyCmd(state), + newAdsCommentStateCmd(state, "hide", true), + newAdsCommentStateCmd(state, "unhide", false), + newAdsDeleteCommentCmd(state), ) return cmd } +// The client and the resolved workspace, the two every ads command needs. +func (s *State) clientAndWorkspace() (*fopost.Client, string, error) { + client, err := s.Client() + if err != nil { + return nil, "", err + } + resolved, err := s.Resolved() + if err != nil { + return nil, "", err + } + return client, resolved.Workspace, nil +} + func minorAmount(minor *int) string { if minor == nil { return "-" @@ -296,3 +316,241 @@ func newAdsLeadsCmd(state *State) *cobra.Command { cmd.Flags().IntVar(&limit, "limit", 0, "leads per page, 1 to 100") return cmd } + +func newAdsIdentitiesCmd(state *State) *cobra.Command { + var connection, adAccount string + cmd := &cobra.Command{ + Use: "identities", + Short: "List the TikTok identities an ad can run as", + Long: "An identity id is what every other ads command calls a page id. " + + "TikTok is the only network with this read; others answer unsupported.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if connection == "" || adAccount == "" { + return usageErrorf("--connection and --ad-account are required") + } + client, resolved, err := state.clientAndWorkspace() + if err != nil { + return err + } + identities, err := client.Ads.TikTokIdentities(cmd.Context(), &fopost.ListAudiencesParams{ + WorkspaceID: resolved, + ConnectionID: connection, + AdAccountID: adAccount, + }) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(identities, func() { + rows := make([][]string, 0, len(identities)) + for _, identity := range identities { + rows = append(rows, []string{identity.ID, output.Truncate(identity.Name, 32), identity.Type}) + } + printer.Table([]string{"id", "name", "type"}, rows) + }) + }, + } + cmd.Flags().StringVar(&connection, "connection", "", "ads connection id (required)") + cmd.Flags().StringVar(&adAccount, "ad-account", "", "ad account id (required)") + return cmd +} + +func newAdsSparkPostsCmd(state *State) *cobra.Command { + var connection, adAccount, identity string + cmd := &cobra.Command{ + Use: "spark-posts", + Short: "List posts already live under an identity, each a candidate Spark ad", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if connection == "" || adAccount == "" || identity == "" { + return usageErrorf("--connection, --ad-account and --identity are required") + } + client, resolved, err := state.clientAndWorkspace() + if err != nil { + return err + } + posts, err := client.Ads.SparkPosts(cmd.Context(), &fopost.ListSparkPostsParams{ + WorkspaceID: resolved, + ConnectionID: connection, + AdAccountID: adAccount, + IdentityID: identity, + }) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(posts, func() { + rows := make([][]string, 0, len(posts)) + for _, post := range posts { + views := "-" + if post.Views != nil { + views = fmt.Sprintf("%d", *post.Views) + } + rows = append(rows, []string{post.ID, output.Truncate(post.Caption, 48), views}) + } + printer.Table([]string{"id", "caption", "views"}, rows) + }) + }, + } + cmd.Flags().StringVar(&connection, "connection", "", "ads connection id (required)") + cmd.Flags().StringVar(&adAccount, "ad-account", "", "ad account id (required)") + cmd.Flags().StringVar(&identity, "identity", "", "identity id (required)") + return cmd +} + +func newAdsCommentsCmd(state *State) *cobra.Command { + var connection, ad, cursor string + cmd := &cobra.Command{ + Use: "comments", + Short: "List the comments on an ad, read live from the network", + Long: "Pass the printed next cursor back as --cursor for the next page.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if connection == "" || ad == "" { + return usageErrorf("--connection and --ad are required") + } + client, resolved, err := state.clientAndWorkspace() + if err != nil { + return err + } + page, err := client.Ads.Comments(cmd.Context(), &fopost.ListAdCommentsParams{ + WorkspaceID: resolved, + ConnectionID: connection, + AdID: ad, + After: cursor, + }) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(page, func() { + rows := make([][]string, 0, len(page.Comments)) + for _, comment := range page.Comments { + state := "public" + if comment.Hidden { + state = "hidden" + } + rows = append(rows, []string{ + comment.ID, + output.Truncate(output.Dash(comment.AuthorName), 20), + output.Truncate(comment.Text, 48), + state, + }) + } + printer.Table([]string{"id", "author", "text", "state"}, rows) + if page.NextCursor != "" { + printer.Line("Next cursor: %s", page.NextCursor) + } + }) + }, + } + cmd.Flags().StringVar(&connection, "connection", "", "ads connection id (required)") + cmd.Flags().StringVar(&ad, "ad", "", "the ad whose comments to read (required)") + cmd.Flags().StringVar(&cursor, "cursor", "", "the next cursor of the previous page") + return cmd +} + +func newAdsReplyCmd(state *State) *cobra.Command { + var connection, ad, text string + cmd := &cobra.Command{ + Use: "reply ", + Short: "Answer a comment on an ad", + Long: "The reply is published under the ad's identity. Needs the publish scope as well as ads.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if connection == "" || ad == "" || text == "" { + return usageErrorf("--connection, --ad and --text are required") + } + client, resolved, err := state.clientAndWorkspace() + if err != nil { + return err + } + replyID, err := client.Ads.ReplyToComment(cmd.Context(), args[0], &fopost.AdCommentRequest{ + WorkspaceID: resolved, + ConnectionID: connection, + AdID: ad, + Text: text, + }) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(map[string]string{"replyId": replyID}, func() { + printer.Line("Replied: %s", replyID) + }) + }, + } + cmd.Flags().StringVar(&connection, "connection", "", "ads connection id (required)") + cmd.Flags().StringVar(&ad, "ad", "", "the ad the comment sits on (required)") + cmd.Flags().StringVar(&text, "text", "", "what to say (required)") + return cmd +} + +func newAdsCommentStateCmd(state *State, verb string, hidden bool) *cobra.Command { + var connection, ad string + cmd := &cobra.Command{ + Use: verb + " ", + Short: strings.ToUpper(verb[:1]) + verb[1:] + " a comment on an ad", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if connection == "" || ad == "" { + return usageErrorf("--connection and --ad are required") + } + client, resolved, err := state.clientAndWorkspace() + if err != nil { + return err + } + body := &fopost.AdCommentRequest{ + WorkspaceID: resolved, + ConnectionID: connection, + AdID: ad, + Hidden: fopost.Bool(hidden), + } + if err := client.Ads.SetCommentHidden(cmd.Context(), args[0], body); err != nil { + return err + } + printer := state.Printer() + return printer.Value(map[string]string{"id": args[0]}, func() { + printer.Line("%sd %s", strings.ToUpper(verb[:1])+verb[1:], args[0]) + }) + }, + } + cmd.Flags().StringVar(&connection, "connection", "", "ads connection id (required)") + cmd.Flags().StringVar(&ad, "ad", "", "the ad the comment sits on (required)") + return cmd +} + +func newAdsDeleteCommentCmd(state *State) *cobra.Command { + var connection, ad string + cmd := &cobra.Command{ + Use: "delete-comment ", + Short: "Remove a comment from the ad on the network", + Long: "One already gone succeeds. Needs the publish scope as well as ads.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if connection == "" || ad == "" { + return usageErrorf("--connection and --ad are required") + } + client, resolved, err := state.clientAndWorkspace() + if err != nil { + return err + } + body := &fopost.AdCommentRequest{ + WorkspaceID: resolved, + ConnectionID: connection, + AdID: ad, + } + if err := client.Ads.DeleteComment(cmd.Context(), args[0], body); err != nil { + return err + } + printer := state.Printer() + return printer.Value(map[string]string{"id": args[0]}, func() { + printer.Line("Deleted %s", args[0]) + }) + }, + } + cmd.Flags().StringVar(&connection, "connection", "", "ads connection id (required)") + cmd.Flags().StringVar(&ad, "ad", "", "the ad the comment sits on (required)") + return cmd +} diff --git a/internal/cmd/cli_test.go b/internal/cmd/cli_test.go index eae623e..2ab1e13 100644 --- a/internal/cmd/cli_test.go +++ b/internal/cmd/cli_test.go @@ -1084,3 +1084,95 @@ func TestAccountsMetricsSurfacesAPendingGrant(t *testing.T) { t.Fatalf("stderr = %q", stderr) } } + +func TestAdsSparkPostsSendsTheIdentityAndPrintsTheViews(t *testing.T) { + isolate(t) + var query string + api := newFakeAPI(t, func(w http.ResponseWriter, r *http.Request) { + query = r.URL.RawQuery + json.NewEncoder(w).Encode(map[string]any{"data": []any{ + map[string]any{"id": "item_99", "identityId": "idt_1", "caption": "Behind the scenes", "views": 48213}, + }}) + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL, Workspace: "ws_1"}); err != nil { + t.Fatal(err) + } + + if _, _, code := run(t, "", "ads", "spark-posts", "--connection", "conn_1"); code != ExitUsage { + t.Fatalf("exit = %d without --ad-account, want %d", code, ExitUsage) + } + + stdout, stderr, code := run(t, "", "ads", "spark-posts", + "--connection", "conn_1", "--ad-account", "7011", "--identity", "idt_1") + if code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + api.find(t, http.MethodGet, "/ads/spark-posts") + if !strings.Contains(query, "identity_id=idt_1") || !strings.Contains(query, "ad_account_id=7011") { + t.Fatalf("query = %q", query) + } + for _, want := range []string{"item_99", "48213"} { + if !strings.Contains(stdout, want) { + t.Fatalf("spark-posts output is missing %q:\n%s", want, stdout) + } + } +} + +func TestAdsCommentsReadsAPageAndTheWritesCarryTheAd(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/reply"): + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"replyId": "cm_2"}}) + case r.Method == http.MethodGet: + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "comments": []any{map[string]any{ + "id": "cm_1", "text": "where can I get this?", "authorName": "someone", "hidden": true, + }}, + "nextCursor": "2", + }}) + default: + json.NewEncoder(w).Encode(map[string]any{"message": "ok"}) + } + }) + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: api.URL, Workspace: "ws_1"}); err != nil { + t.Fatal(err) + } + + stdout, stderr, code := run(t, "", "ads", "comments", "--connection", "conn_1", "--ad", "ad_1") + if code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + api.find(t, http.MethodGet, "/ads/comments") + for _, want := range []string{"cm_1", "hidden", "Next cursor: 2"} { + if !strings.Contains(stdout, want) { + t.Fatalf("comments output is missing %q:\n%s", want, stdout) + } + } + + if _, stderr, code = run(t, "", "ads", "reply", "cm_1", + "--connection", "conn_1", "--ad", "ad_1", "--text", "Friday!"); code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + sent := api.find(t, http.MethodPost, "/ads/comments/cm_1/reply") + if sent.Body["adId"] != "ad_1" || sent.Body["text"] != "Friday!" { + t.Fatalf("body = %v", sent.Body) + } + + if _, stderr, code = run(t, "", "ads", "hide", "cm_1", + "--connection", "conn_1", "--ad", "ad_1"); code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + if hide := api.find(t, http.MethodPost, "/ads/comments/cm_1/hide"); hide.Body["hidden"] != true { + t.Fatalf("body = %v", hide.Body) + } + + if _, stderr, code = run(t, "", "ads", "delete-comment", "cm_1", + "--connection", "conn_1", "--ad", "ad_1"); code != ExitOK { + t.Fatalf("exit = %d: %s", code, stderr) + } + // The ad travels in the body, because the path already carries the comment. + if del := api.find(t, http.MethodDelete, "/ads/comments/cm_1"); del.Body["adId"] != "ad_1" { + t.Fatalf("body = %v", del.Body) + } +}