Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
260 changes: 259 additions & 1 deletion internal/cmd/ads.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 "-"
Expand Down Expand Up @@ -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 <comment-id>",
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 + " <comment-id>",
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 <comment-id>",
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
}
92 changes: 92 additions & 0 deletions internal/cmd/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading