Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles

**Write:**
- Post: create new tweets and replies, with optional image attachments (up to 4)
- Long-form posts: Premium accounts automatically route text above the standard 280 weighted-character limit through X long-form posting
- Quote: quote-tweet with optional images
- Delete: remove your own tweets
- Like / Unlike: manage tweet likes
Expand Down Expand Up @@ -152,6 +153,7 @@ twitter following elonmusk --max 50

# Write operations
twitter post "Hello from twitter-cli!"
twitter post "Long-form text..." # Premium long-form posts route automatically
twitter post "Hello!" --image photo.jpg # Post with image
twitter post "Gallery" -i a.png -i b.jpg -i c.webp # Up to 4 images
twitter post "reply text" --reply-to 1234567890
Expand Down Expand Up @@ -389,6 +391,7 @@ git clone git@github.com:jackwener/twitter-cli.git .agents/skills/twitter-cli

**写入:**
- 发推:发布新推文和回复,支持附带图片(最多 4 张,支持 JPEG/PNG/GIF/WebP)
- 长文:Premium 账号在文本超过标准 280 加权字符限制时自动使用 X 长文发布
- 引用推文:带评论的转发,也支持附带图片
- 删除:删除自己的推文
- 点赞 / 取消点赞
Expand Down Expand Up @@ -475,6 +478,7 @@ twitter following elonmusk

# 写操作
twitter post "你好,世界!"
twitter post "长文内容..." # Premium 长文自动路由
twitter post "发图" --image photo.jpg # 带图发推
twitter post "多图" -i a.png -i b.jpg -i c.webp # 最多 4 张图片
twitter post "回复内容" --reply-to 1234567890
Expand Down
81 changes: 81 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

from twitter_cli.client import (
_best_chrome_target,
_tweet_create_operation,
_tweet_weighted_length,
TwitterClient,
)
from twitter_cli.exceptions import TwitterAPIError
Expand Down Expand Up @@ -1440,13 +1442,15 @@ def test_create_tweet_with_media_ids(self, mock_session):
captured_body = {}

def mock_graphql_post(operation_name, variables, features=None):
captured_body["operation_name"] = operation_name
captured_body.update(variables)
return {"data": {"create_tweet": {"tweet_results": {"result": {"rest_id": "99"}}}}}

client._graphql_post = mock_graphql_post

result = client.create_tweet("test", media_ids=["111", "222"])
assert result == "99"
assert captured_body["operation_name"] == "CreateTweet"

entities = captured_body["media"]["media_entities"]
assert len(entities) == 2
Expand All @@ -1472,16 +1476,93 @@ def test_create_tweet_without_media_ids(self, mock_session):
captured_body = {}

def mock_graphql_post(operation_name, variables, features=None):
captured_body["operation_name"] = operation_name
captured_body.update(variables)
return {"data": {"create_tweet": {"tweet_results": {"result": {"rest_id": "88"}}}}}

client._graphql_post = mock_graphql_post

result = client.create_tweet("no media")
assert result == "88"
assert captured_body["operation_name"] == "CreateTweet"
assert captured_body["media"]["media_entities"] == []


class TestCreateLongFormTweet:
"""Tests routing oversized posts through CreateNoteTweet."""

@staticmethod
def _make_client():
client = TwitterClient.__new__(TwitterClient)
client._write_delay = lambda: None
return client

def test_weighted_length_routes_non_ascii_text_to_note_tweet(self):
text = "你" * 141
assert _tweet_weighted_length(text) == 282
assert _tweet_create_operation(text) == "CreateNoteTweet"

def test_standard_tweet_uses_create_tweet(self):
client = self._make_client()
calls = []

def mock_graphql_post(operation_name, variables, features=None):
calls.append((operation_name, variables))
return {"data": {"create_tweet": {"tweet_results": {"result": {"rest_id": "88"}}}}}

client._graphql_post = mock_graphql_post

assert client.create_tweet("x" * 280) == "88"
operation_name, variables = calls[0]
assert operation_name == "CreateTweet"
assert "disallowed_reply_options" not in variables

def test_long_tweet_uses_create_note_tweet(self):
client = self._make_client()
calls = []

def mock_graphql_post(operation_name, variables, features=None):
calls.append((operation_name, variables))
return {
"data": {
"notetweet_create": {
"tweet_results": {"result": {"rest_id": "99"}}
}
}
}

client._graphql_post = mock_graphql_post

assert client.create_tweet("x" * 281, reply_to_id="123") == "99"
operation_name, variables = calls[0]
assert operation_name == "CreateNoteTweet"
assert variables["disallowed_reply_options"] is None
assert variables["includePromotedContent"] is False
assert variables["reply"]["in_reply_to_tweet_id"] == "123"

def test_long_quote_uses_create_note_tweet(self):
client = self._make_client()
calls = []

def mock_graphql_post(operation_name, variables, features=None):
calls.append((operation_name, variables))
return {
"data": {
"notetweet_create": {
"tweet_results": {"result": {"rest_id": "100"}}
}
}
}

client._graphql_post = mock_graphql_post

assert client.quote_tweet("456", "x" * 281) == "100"
operation_name, variables = calls[0]
assert operation_name == "CreateNoteTweet"
assert variables["disallowed_reply_options"] is None
assert variables["attachment_url"] == "https://x.com/i/status/456"


# ── fetch_search uses POST ────────────────────────────────────────────────

class TestFetchSearchUsesPost:
Expand Down
61 changes: 55 additions & 6 deletions twitter_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@

# Hard ceiling to prevent accidental massive fetches
_ABSOLUTE_MAX_COUNT = 500
_STANDARD_TWEET_WEIGHT_LIMIT = 280


# ── Session management ───────────────────────────────────────────────────
Expand Down Expand Up @@ -130,6 +131,30 @@ def _url_fetch(url, headers=None):
return resp.text


def _tweet_weighted_length(text):
# type: (str) -> int
"""Return X's approximate weighted character count for composer routing."""
return sum(1 if ord(ch) < 0x80 else 2 for ch in text)


def _tweet_create_operation(text):
# type: (str) -> str
"""Return the GraphQL mutation for a standard or long-form post."""
if _tweet_weighted_length(text) > _STANDARD_TWEET_WEIGHT_LIMIT:
return "CreateNoteTweet"
return "CreateTweet"
Comment on lines +134 to +145


def _created_tweet_result(data):
# type: (Dict[str, Any]) -> Optional[Dict[str, Any]]
"""Extract tweet result from CreateTweet or CreateNoteTweet responses."""
return (
_deep_get(data, "data", "create_tweet", "tweet_results", "result")
or _deep_get(data, "data", "notetweet_create", "tweet_results", "result")
or _deep_get(data, "data", "create_note_tweet", "tweet_results", "result")
)


# ── TwitterClient ────────────────────────────────────────────────────────


Expand Down Expand Up @@ -563,6 +588,10 @@ def create_tweet(self, text, reply_to_id=None, media_ids=None):
# type: (str, Optional[str], Optional[List[str]]) -> str
"""Post a new tweet. Returns the new tweet ID.

Posts over the standard weighted length limit are routed through
CreateNoteTweet, which is the GraphQL mutation X uses for Premium
long-form posts.

Args:
text: Tweet text content.
reply_to_id: Optional tweet ID to reply to.
Expand All @@ -576,18 +605,29 @@ def create_tweet(self, text, reply_to_id=None, media_ids=None):
"media": {"media_entities": media_entities, "possibly_sensitive": False},
"semantic_annotation_ids": [],
"dark_request": False,
"includePromotedContent": False,
} # type: Dict[str, Any]
if reply_to_id:
variables["reply"] = {
"in_reply_to_tweet_id": reply_to_id,
"exclude_reply_user_ids": [],
}
data = self._graphql_post("CreateTweet", variables, FEATURES)
operation_name = _tweet_create_operation(text)
if operation_name == "CreateNoteTweet":
# Required by the long-form mutation. Without it X can return HTTP
# 200 with an empty tweet_results object instead of creating a post.
variables["disallowed_reply_options"] = None
logger.info(
"Tweet weighted=%d > %d, using CreateNoteTweet (long-form)",
_tweet_weighted_length(text),
_STANDARD_TWEET_WEIGHT_LIMIT,
)
data = self._graphql_post(operation_name, variables, FEATURES)
Comment on lines +615 to +625
self._write_delay()
result = _deep_get(data, "data", "create_tweet", "tweet_results", "result")
result = _created_tweet_result(data)
if result:
return result.get("rest_id", "")
raise TwitterAPIError(0, "Failed to create tweet")
raise TwitterAPIError(0, "Failed to create tweet (op=%s)" % operation_name)

def delete_tweet(self, tweet_id):
# type: (str) -> bool
Expand Down Expand Up @@ -711,13 +751,22 @@ def quote_tweet(self, tweet_id, text, media_ids=None):
"media": {"media_entities": media_entities, "possibly_sensitive": False},
"semantic_annotation_ids": [],
"dark_request": False,
"includePromotedContent": False,
}
data = self._graphql_post("CreateTweet", variables, FEATURES)
operation_name = _tweet_create_operation(text)
if operation_name == "CreateNoteTweet":
variables["disallowed_reply_options"] = None
logger.info(
"Quote weighted=%d > %d, using CreateNoteTweet (long-form)",
_tweet_weighted_length(text),
_STANDARD_TWEET_WEIGHT_LIMIT,
)
data = self._graphql_post(operation_name, variables, FEATURES)
self._write_delay()
result = _deep_get(data, "data", "create_tweet", "tweet_results", "result")
result = _created_tweet_result(data)
if result:
return result.get("rest_id", "")
raise TwitterAPIError(0, "Failed to create quote tweet")
raise TwitterAPIError(0, "Failed to create quote tweet (op=%s)" % operation_name)

def follow_user(self, user_id):
# type: (str) -> bool
Expand Down
5 changes: 5 additions & 0 deletions twitter_cli/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"Followers": "IOh4aS6UdGWGJUYTqliQ7Q",
"Following": "zx6e-TLzRkeDO_a7p4b3JQ",
"CreateTweet": "IID9x6WsdMnTlXnzXGq8ng",
"CreateNoteTweet": "dAlh5Gh9rR5pKk4HU4vW8g",
"DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA",
Expand All @@ -64,8 +65,12 @@
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
"view_counts_everywhere_api_enabled": True,
"longform_notetweets_consumption_enabled": True,
"longform_notetweets_creation_enabled": True,
"longform_notetweets_richtext_consumption_enabled": True,
"responsive_web_twitter_article_tweet_consumption_enabled": True,
"articles_preview_enabled": True,
"tweet_awards_web_tipping_enabled": False,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
"longform_notetweets_rich_text_read_enabled": True,
"longform_notetweets_inline_media_enabled": True,
Comment on lines 65 to 75
"rweb_video_timestamps_enabled": True,
Expand Down