Skip to content

Commit 82852bb

Browse files
committed
feat(parity): close XActions feature gaps + fix retweet/longform rendering
User confirmed v0.4 works end-to-end (auth import via headless Chrome, tweets list returns real data with HumanCount metrics + emojis + Chinese rendering correct). Their tweets list output also surfaced two real rendering bugs and several missing scrape commands vs XActions. This commit closes the gap. ## Parser fixes api/tweets.go ParseTweet: Longform "note tweets" support. X Premium users can post tweets up to 25k chars via the note_tweet feature. The full body lives at note_tweet.note_tweet_results.result.text while legacy.full_text only carries the truncated 280-char preview with a `…` marker. ParseTweet now reads note_tweet first and falls back to legacy.full_text. XActions misses this too — we're now ahead. ## Rendering fixes cmd/tweets.go formatTweetRow (new): Replaces the inline format string with a structured renderer that fixes three problems from the user's live test output: 1. Retweets showed `RT @user: <truncated>` because legacy.full_text for a retweet is the truncated retweet header. The actual retweeted content is in RetweetOf.Text. We now display "RT @<original_author>: <full content>" using the parsed retweet payload. 2. Replies, quotes, and media were invisible. The new format adds: ↳ for replies →q for tweets that quote another 📷N for N image attachments 🎬N for video 🎞N for animated GIFs so a glance at the list tells you what's a thread reply, what quotes another tweet, and what has media. 3. Quotes count was missing from the metrics. We now show L (likes), R (retweets), Q (quotes), V (views) in compact HumanCount form (1.2k, 3.4M). Truncation is bumped from 100 to 120 runes since we have more signal density now. ## New scrape commands (XActions parity) cmd/relationships.go: x likes <tweet-id|url> users who liked a tweet (Favoriters) x retweeters <tweet-id|url> users who retweeted x nonfollowers <screen-name> XActions' #1 feature: scrape both followers and following lists, take the set difference (following \\ followers), report who doesn't follow you back All three reuse the existing relationships.go renderer, support --json and -n/--limit, and route through the throttle. ## New engagement mutations api/actions.go: LikeTweet / UnlikeTweet FavoriteTweet / UnfavoriteTweet BookmarkTweet / UnbookmarkTweet CreateBookmark / DeleteBookmark All four go through a new graphqlMutation helper that calls Client.GraphQL and pipes the response through classifyMutationErrors — same idempotent / rate-limit / not-found dispatch as the REST follow path. "You have already favorited" becomes a silent success so re-runs are safe. cmd/engage.go (new): x engage like <tweet> x engage unlike <tweet> x engage bookmark <tweet> x engage unbookmark <tweet> Each subcommand uses Go method values ((*api.Client).LikeTweet, etc.) to share one runEngageOp helper without duplicating the client-construction boilerplate four times. No --apply gate because each call targets one tweet — for bulk mutations the user goes through `x grow` which has --max + --min-followers + --apply + --i-know-its-a-cloud-ip. endpoints.yaml: + FavoriteTweet lI07N6Otwv1PhnEgXILM7A + UnfavoriteTweet ZYKSe-w7KEslx3JhSIk5LA + CreateRetweet ojPdsZsimiJrUGLR1sjUtA + DeleteRetweet iQtK4dl5hBmXewYZuEOKVw + CreateBookmark aoDbu3RHznuiSkQ9aNM67Q + DeleteBookmark Wlmlj2-xzyS1GN3a6cj-mQ All marked kind: mutation with rps 0.1 / burst 1 so the read bucket throttles them without sharing budget with the friendships daily cap. CreateRetweet / DeleteRetweet are wired in the YAML so a future commit can add `x engage retweet` without touching the schema. ## What's still XActions-only - postTweet / replyToTweet / quoteTweet / deleteTweet (writing tweets) — out of scope for v1 - DMs — privacy-sensitive, deferred - blockUser / muteUser — easy to add but not urgent - scrapeListMembers — easy to add when someone needs it - schedulePost / postThread / chunked media upload — write-side, out of scope
1 parent 86d4bb2 commit 82852bb

6 files changed

Lines changed: 380 additions & 9 deletions

File tree

api/actions.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,45 @@ func (c *Client) FollowByUsername(ctx context.Context, screenName string) error
5050
return c.FollowUser(ctx, uid)
5151
}
5252

53+
// LikeTweet favorites a tweet via the FavoriteTweet GraphQL mutation.
54+
// Idempotent: returns nil if X says "you have already favorited".
55+
func (c *Client) LikeTweet(ctx context.Context, tweetID string) error {
56+
return c.graphqlMutation(ctx, "FavoriteTweet", map[string]any{"tweet_id": tweetID})
57+
}
58+
59+
// UnlikeTweet unfavorites a tweet via UnfavoriteTweet.
60+
func (c *Client) UnlikeTweet(ctx context.Context, tweetID string) error {
61+
return c.graphqlMutation(ctx, "UnfavoriteTweet", map[string]any{"tweet_id": tweetID})
62+
}
63+
64+
// BookmarkTweet adds a bookmark via CreateBookmark.
65+
func (c *Client) BookmarkTweet(ctx context.Context, tweetID string) error {
66+
return c.graphqlMutation(ctx, "CreateBookmark", map[string]any{"tweet_id": tweetID})
67+
}
68+
69+
// UnbookmarkTweet removes a bookmark via DeleteBookmark.
70+
func (c *Client) UnbookmarkTweet(ctx context.Context, tweetID string) error {
71+
return c.graphqlMutation(ctx, "DeleteBookmark", map[string]any{"tweet_id": tweetID})
72+
}
73+
74+
// graphqlMutation runs a GraphQL mutation by name, parses the response
75+
// envelope, and routes idempotent successes / rate-limits / not-found
76+
// the same way classifyMutationErrors does for REST.
77+
//
78+
// Throttle accounting: GraphQL mutations go through Client.GraphQL
79+
// which already runs through the read token bucket. This is a
80+
// deliberate compromise — the dedicated mutation budget is tied to
81+
// the REST friendshipsCreate path. For per-op rate limits on like /
82+
// retweet / bookmark X enforces its own server-side caps; we observe
83+
// 429s via the throttle and back off.
84+
func (c *Client) graphqlMutation(ctx context.Context, name string, vars map[string]any) error {
85+
var raw map[string]any
86+
if err := c.GraphQL(ctx, name, vars, &raw); err != nil {
87+
return err
88+
}
89+
return classifyMutationErrors(name, raw)
90+
}
91+
5392
// restMutationCheckErrors wraps Client.REST and inspects the decoded body
5493
// for X's `errors[]` envelope. The envelope dispatch is:
5594
//

api/tweets.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,9 +276,22 @@ func parseTweetDepth(raw any, depth int) *Tweet {
276276
id = getString(legacy, "id_str")
277277
}
278278

279+
// Tweet body. For longform "note tweets" (X Premium 25k-char
280+
// posts), the full text lives at
281+
// note_tweet.note_tweet_results.result.text
282+
// while legacy.full_text only carries the first 280 chars +
283+
// truncation marker. Read note_tweet first, fall back to legacy.
284+
text := ""
285+
if note := walkPathMap(tweet, "note_tweet", "note_tweet_results", "result"); note != nil {
286+
text = getString(note, "text")
287+
}
288+
if text == "" {
289+
text = getString(legacy, "full_text")
290+
}
291+
279292
return &Tweet{
280293
ID: id,
281-
Text: getString(legacy, "full_text"),
294+
Text: text,
282295
CreatedAt: parseTwitterDate(getString(legacy, "created_at")),
283296
Author: author,
284297
Metrics: metrics,

cmd/engage.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
"github.com/thevibeworks/x-cli/api"
9+
"github.com/thevibeworks/x-cli/internal/cmdutil"
10+
)
11+
12+
// engage groups the lightweight engagement mutations: like, retweet,
13+
// bookmark, and their inverses. These are throttled but not gated
14+
// behind --apply (unlike `grow`) because they target a single tweet
15+
// each — no risk of bulk-following spam, no need for dry-run.
16+
//
17+
// Each subcommand calls the corresponding GraphQL mutation through
18+
// Client.graphqlMutation, which inspects the response envelope and
19+
// treats idempotent failures ("you have already favorited") as
20+
// success. Re-running is safe.
21+
//
22+
// For BULK engagement (follow likers / follow keyword authors), see
23+
// `x grow` which has --apply, --max, --min-followers, and per-mutation
24+
// pacing through Throttle.AwaitMutation.
25+
26+
var engageCmd = &cobra.Command{
27+
Use: "engage",
28+
Short: "Engagement actions on a tweet (like / unlike / bookmark / unbookmark)",
29+
}
30+
31+
var engageLikeCmd = &cobra.Command{
32+
Use: "like <tweet-id|url>",
33+
Short: "Like (favorite) a tweet",
34+
Args: cobra.ExactArgs(1),
35+
RunE: func(cmd *cobra.Command, args []string) error {
36+
return runEngageOp(cmd, args[0], "like", (*api.Client).LikeTweet)
37+
},
38+
}
39+
40+
var engageUnlikeCmd = &cobra.Command{
41+
Use: "unlike <tweet-id|url>",
42+
Short: "Remove a like (unfavorite)",
43+
Args: cobra.ExactArgs(1),
44+
RunE: func(cmd *cobra.Command, args []string) error {
45+
return runEngageOp(cmd, args[0], "unlike", (*api.Client).UnlikeTweet)
46+
},
47+
}
48+
49+
var engageBookmarkCmd = &cobra.Command{
50+
Use: "bookmark <tweet-id|url>",
51+
Short: "Bookmark a tweet",
52+
Args: cobra.ExactArgs(1),
53+
RunE: func(cmd *cobra.Command, args []string) error {
54+
return runEngageOp(cmd, args[0], "bookmark", (*api.Client).BookmarkTweet)
55+
},
56+
}
57+
58+
var engageUnbookmarkCmd = &cobra.Command{
59+
Use: "unbookmark <tweet-id|url>",
60+
Short: "Remove a bookmark",
61+
Args: cobra.ExactArgs(1),
62+
RunE: func(cmd *cobra.Command, args []string) error {
63+
return runEngageOp(cmd, args[0], "unbookmark", (*api.Client).UnbookmarkTweet)
64+
},
65+
}
66+
67+
func init() {
68+
engageCmd.AddCommand(engageLikeCmd)
69+
engageCmd.AddCommand(engageUnlikeCmd)
70+
engageCmd.AddCommand(engageBookmarkCmd)
71+
engageCmd.AddCommand(engageUnbookmarkCmd)
72+
rootCmd.AddCommand(engageCmd)
73+
}
74+
75+
// runEngageOp resolves the tweet id from arg, builds a client, and
76+
// runs the given method-value op. Method values let us write
77+
//
78+
// (*api.Client).LikeTweet
79+
//
80+
// once per subcommand instead of duplicating the boilerplate four times.
81+
func runEngageOp(cmd *cobra.Command, arg, label string, op func(*api.Client, context.Context, string) error) error {
82+
tweetID := extractTweetID(arg)
83+
if tweetID == "" {
84+
return fmt.Errorf("could not extract tweet ID from %q", arg)
85+
}
86+
client, err := newClient(cmd.Context())
87+
if err != nil {
88+
return err
89+
}
90+
ctx, cancel := withTimeout(cmd.Context())
91+
defer cancel()
92+
93+
if err := op(client, ctx, tweetID); err != nil {
94+
return fmt.Errorf("%s: %w", label, err)
95+
}
96+
cmdutil.Success("%s ok: %s", label, tweetID)
97+
return nil
98+
}

cmd/relationships.go

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77

88
"github.com/spf13/cobra"
9+
910
"github.com/thevibeworks/x-cli/api"
1011
"github.com/thevibeworks/x-cli/internal/cmdutil"
1112
)
@@ -32,6 +33,35 @@ var followingCmd = &cobra.Command{
3233
},
3334
}
3435

36+
var likesCmd = &cobra.Command{
37+
Use: "likes <tweet-id|url>",
38+
Short: "Scrape users who liked a tweet (Favoriters)",
39+
Args: cobra.ExactArgs(1),
40+
RunE: runScrapeLikers,
41+
}
42+
43+
var retweetersCmd = &cobra.Command{
44+
Use: "retweeters <tweet-id|url>",
45+
Short: "Scrape users who retweeted a tweet",
46+
Args: cobra.ExactArgs(1),
47+
RunE: runScrapeRetweeters,
48+
}
49+
50+
var nonfollowersCmd = &cobra.Command{
51+
Use: "nonfollowers <screen-name>",
52+
Short: "Find accounts the user follows that don't follow back",
53+
Long: `Scrapes both the followers and following lists, then takes the
54+
set difference: accounts in `+"`following`"+` that aren't in `+"`followers`"+`.
55+
This is XActions' most-asked feature ("who doesn't follow me back").
56+
57+
Two GraphQL roundtrips per page on each list, so a fresh run on a
58+
~10k account takes a minute or two. The result is sorted by
59+
follower count desc — drop the high-value mutuals first if you
60+
plan to unfollow.`,
61+
Args: cobra.ExactArgs(1),
62+
RunE: runScrapeNonFollowers,
63+
}
64+
3565
type relationshipKind int
3666

3767
const (
@@ -40,12 +70,118 @@ const (
4070
)
4171

4272
func init() {
43-
for _, c := range []*cobra.Command{followersCmd, followingCmd} {
73+
for _, c := range []*cobra.Command{followersCmd, followingCmd, likesCmd, retweetersCmd, nonfollowersCmd} {
4474
c.Flags().IntVarP(&relLimit, "limit", "n", 200, "max users to fetch")
4575
rootCmd.AddCommand(c)
4676
}
4777
}
4878

79+
func runScrapeLikers(cmd *cobra.Command, args []string) error {
80+
tweetID := extractTweetID(args[0])
81+
if tweetID == "" {
82+
return fmt.Errorf("could not extract tweet ID from %q", args[0])
83+
}
84+
client, err := newClient(cmd.Context())
85+
if err != nil {
86+
return err
87+
}
88+
ctx, cancel := withTimeout(cmd.Context())
89+
defer cancel()
90+
users, err := client.Likers(ctx, tweetID, api.PageOptions{
91+
Limit: relLimit,
92+
OnPage: func(fetched, limit int) {
93+
if !jsonOut && verbose {
94+
cmdutil.Info("fetched %d/%d", fetched, limit)
95+
}
96+
},
97+
})
98+
if err != nil {
99+
return err
100+
}
101+
if jsonOut {
102+
return cmdutil.PrintJSON(users)
103+
}
104+
return renderUserList(users)
105+
}
106+
107+
func runScrapeRetweeters(cmd *cobra.Command, args []string) error {
108+
tweetID := extractTweetID(args[0])
109+
if tweetID == "" {
110+
return fmt.Errorf("could not extract tweet ID from %q", args[0])
111+
}
112+
client, err := newClient(cmd.Context())
113+
if err != nil {
114+
return err
115+
}
116+
ctx, cancel := withTimeout(cmd.Context())
117+
defer cancel()
118+
users, err := client.Retweeters(ctx, tweetID, api.PageOptions{
119+
Limit: relLimit,
120+
OnPage: func(fetched, limit int) {
121+
if !jsonOut && verbose {
122+
cmdutil.Info("fetched %d/%d", fetched, limit)
123+
}
124+
},
125+
})
126+
if err != nil {
127+
return err
128+
}
129+
if jsonOut {
130+
return cmdutil.PrintJSON(users)
131+
}
132+
return renderUserList(users)
133+
}
134+
135+
// runScrapeNonFollowers scrapes both the user's followers and following
136+
// lists, then returns following \ followers (set difference).
137+
//
138+
// Implementation note: we read the FULL following list (capped by
139+
// --limit) and the FULL followers list (also capped), then compute
140+
// the set difference in memory keyed by user ID. For users with
141+
// hundreds of thousands of followers, --limit is essentially
142+
// mandatory or both pulls take forever.
143+
func runScrapeNonFollowers(cmd *cobra.Command, args []string) error {
144+
screen := strings.TrimPrefix(args[0], "@")
145+
client, err := newClient(cmd.Context())
146+
if err != nil {
147+
return err
148+
}
149+
ctx, cancel := withTimeout(cmd.Context())
150+
defer cancel()
151+
152+
cmdutil.Info("scraping %s's following list (cap: %d)...", screen, relLimit)
153+
following, err := client.Following(ctx, screen, api.PageOptions{Limit: relLimit})
154+
if err != nil {
155+
return fmt.Errorf("following: %w", err)
156+
}
157+
158+
cmdutil.Info("scraping %s's followers list (cap: %d)...", screen, relLimit)
159+
followers, err := client.Followers(ctx, screen, api.PageOptions{Limit: relLimit})
160+
if err != nil {
161+
return fmt.Errorf("followers: %w", err)
162+
}
163+
164+
followerSet := make(map[string]struct{}, len(followers))
165+
for _, u := range followers {
166+
followerSet[u.ID] = struct{}{}
167+
}
168+
169+
nonback := make([]*api.UserSummary, 0, len(following))
170+
for _, u := range following {
171+
if _, ok := followerSet[u.ID]; !ok {
172+
nonback = append(nonback, u)
173+
}
174+
}
175+
176+
cmdutil.Success("%d / %d accounts you follow do not follow back",
177+
len(nonback), len(following))
178+
179+
if jsonOut {
180+
return cmdutil.PrintJSON(nonback)
181+
}
182+
return renderUserList(nonback)
183+
}
184+
49185
func runRelationshipScrape(cmd *cobra.Command, args []string, kind relationshipKind) error {
50186
screen := strings.TrimPrefix(args[0], "@")
51187
client, err := newClient(cmd.Context())

0 commit comments

Comments
 (0)