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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,21 @@ 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
- Retweet / Unretweet: manage retweets
- Bookmark: bookmark/unbookmark (`favorite/unfavorite` kept as compatibility aliases)
- Write commands also support explicit `--json` / `--yaml` output now

Long-form routing follows the twitter-text v3 NFC and character-weight ranges,
counts recognized URLs as 23 characters, and collapses common emoji sequences.
It is a routing estimate rather than a full composer validator: invalid control
characters remain server-validated, and malformed URLs or rare schemeless/IDN
domains can differ because the CLI uses a compact matcher instead of vendoring
twitter-text's generated URL/TLD grammar.

**Auth & Anti-Detection:**
- Cookie auth: use browser cookies or environment variables
- Full cookie forwarding: extracts ALL browser cookies for richer browser context
Expand Down Expand Up @@ -152,6 +160,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,13 +398,20 @@ git clone git@github.com:jackwener/twitter-cli.git .agents/skills/twitter-cli

**写入:**
- 发推:发布新推文和回复,支持附带图片(最多 4 张,支持 JPEG/PNG/GIF/WebP)
- 长文:Premium 账号在文本超过标准 280 加权字符限制时自动使用 X 长文发布
- 引用推文:带评论的转发,也支持附带图片
- 删除:删除自己的推文
- 点赞 / 取消点赞
- 转推 / 取消转推
- 书签 / 取消书签:bookmark/unbookmark(保留 `favorite/unfavorite` 兼容别名)
- 写操作现在也显式支持 `--json` / `--yaml`

长文路由遵循 twitter-text v3 的 NFC 规范化和字符权重区间,将识别到的 URL
按 23 个字符计数,并合并常见 emoji 序列。它只用于选择发布接口,不是完整的
编辑器校验器:非法控制字符仍由服务端校验;由于 CLI 没有内置 twitter-text
生成的完整 URL/TLD 语法,格式错误的 URL 或少见的无协议/IDN 域名可能与网页
编辑器计数不同。

**认证与反风控:**
- Cookie 认证:支持环境变量和浏览器自动提取
- 完整 Cookie 转发:提取浏览器中所有 Twitter Cookie,保留更多浏览器上下文
Expand Down Expand Up @@ -475,6 +491,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
79 changes: 79 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from twitter_cli.graphql import (
FEATURES,
FALLBACK_QUERY_IDS,
NOTE_TWEET_FEATURES,
_build_graphql_url,
_update_features_from_html,
)
Expand Down Expand Up @@ -1440,13 +1441,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 +1475,92 @@ 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_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, features))
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, features = calls[0]
assert operation_name == "CreateTweet"
assert "disallowed_reply_options" not in variables
assert features is FEATURES
assert NOTE_TWEET_FEATURES.keys().isdisjoint(features)

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, features))
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, features = 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"
assert features == dict(FEATURES, **NOTE_TWEET_FEATURES)

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, features))
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, features = calls[0]
assert operation_name == "CreateNoteTweet"
assert variables["disallowed_reply_options"] is None
assert variables["attachment_url"] == "https://x.com/i/status/456"
assert features == dict(FEATURES, **NOTE_TWEET_FEATURES)


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

class TestFetchSearchUsesPost:
Expand Down
40 changes: 40 additions & 0 deletions tests/test_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

from twitter_cli.text import (
CREATE_NOTE_TWEET_OPERATION,
CREATE_TWEET_OPERATION,
tweet_create_route,
tweet_weighted_length,
)


def test_weighted_length_uses_twitter_text_v3_unicode_ranges() -> None:
assert tweet_weighted_length("\u00e9" * 280) == 280
assert tweet_weighted_length("\u00df" * 280) == 280
assert tweet_weighted_length("\u4f60" * 140) == 280


def test_weighted_length_normalizes_nfc() -> None:
assert tweet_weighted_length("A\u0301B") == 2


def test_weighted_length_normalizes_http_url_to_23_characters() -> None:
assert tweet_weighted_length("Hi http://test.co") == 26
long_url = "https://example.com/" + ("path/" * 80)
assert tweet_weighted_length(long_url) == 23


def test_weighted_length_normalizes_common_bare_domain() -> None:
assert tweet_weighted_length("See example.com/a/very/long/path") == 27


def test_weighted_length_collapses_emoji_sequences() -> None:
family = "\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466"
flag = "\U0001f1fa\U0001f1f8"
assert tweet_weighted_length(family * 140) == 280
assert tweet_weighted_length(flag) == 2


def test_route_uses_weighted_limit() -> None:
assert tweet_create_route("\u00e9" * 280) == (CREATE_TWEET_OPERATION, 280)
assert tweet_create_route("\u4f60" * 141) == (CREATE_NOTE_TWEET_OPERATION, 282)
47 changes: 41 additions & 6 deletions twitter_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
FEATURES,
_build_graphql_url,
_invalidate_query_id,
operation_features,
_resolve_query_id,
_update_features_from_html,
)
Expand All @@ -55,6 +56,7 @@
parse_tweet_result,
parse_user_result,
)
from .text import CREATE_NOTE_TWEET_OPERATION, tweet_create_route

if TYPE_CHECKING:
from typing import Dict, List, Optional, Set, Tuple # noqa: F401
Expand Down Expand Up @@ -130,6 +132,31 @@ def _url_fetch(url, headers=None):
return resp.text


def _prepare_tweet_create(text, variables):
# type: (str, Dict[str, Any]) -> Tuple[str, Dict[str, Any]]
"""Apply operation-specific variables and return its feature set."""
operation_name, weighted_length = tweet_create_route(text)
if operation_name == CREATE_NOTE_TWEET_OPERATION:
# 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, using CreateNoteTweet (long-form)",
weighted_length,
)
return operation_name, operation_features(operation_name)


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 +590,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 +607,20 @@ 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, features = _prepare_tweet_create(text, variables)
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 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 +744,15 @@ 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, features = _prepare_tweet_create(text, variables)
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
21 changes: 20 additions & 1 deletion twitter_cli/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from typing import Any, Dict, Optional # noqa: F401

from .exceptions import QueryIdError
from .text import CREATE_NOTE_TWEET_OPERATION, CREATE_TWEET_OPERATION

logger = logging.getLogger(__name__)

Expand All @@ -38,7 +39,8 @@
"ListLatestTweetsTimeline": "RlZzktZY_9wJynoepm8ZsA",
"Followers": "IOh4aS6UdGWGJUYTqliQ7Q",
"Following": "zx6e-TLzRkeDO_a7p4b3JQ",
"CreateTweet": "IID9x6WsdMnTlXnzXGq8ng",
CREATE_TWEET_OPERATION: "IID9x6WsdMnTlXnzXGq8ng",
CREATE_NOTE_TWEET_OPERATION: "dAlh5Gh9rR5pKk4HU4vW8g",
"DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA",
Expand Down Expand Up @@ -78,11 +80,28 @@
# Features dict that gets updated dynamically from x.com JS bundles
FEATURES = dict(_DEFAULT_FEATURES)

# Extra composer flags belong only on CreateNoteTweet. Merging at call time
# preserves any live updates made to the shared base feature set.
NOTE_TWEET_FEATURES = {
"longform_notetweets_creation_enabled": True,
"longform_notetweets_richtext_consumption_enabled": True,
"articles_preview_enabled": True,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
}

# Module-level caches (not thread-safe — CLI is single-threaded)
_cached_query_ids: Dict[str, str] = {}
_bundles_scanned = False


def operation_features(operation_name):
# type: (str) -> Dict[str, Any]
"""Return base features plus overrides scoped to one operation."""
if operation_name == CREATE_NOTE_TWEET_OPERATION:
return dict(FEATURES, **NOTE_TWEET_FEATURES)
return FEATURES


def _build_graphql_url(query_id, operation_name, variables, features, field_toggles=None):
# type: (str, str, Dict[str, Any], Dict[str, Any], Optional[Dict[str, Any]]) -> str
"""Build GraphQL GET URL with encoded variables/features/fieldToggles.
Expand Down
Loading