diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 0436c8e..d4e3a1a 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -559,10 +559,28 @@ def upload_media(self, file_path): return media_id + @staticmethod + def _weighted_length(text): + # type: (str) -> int + """Return X's weighted character count for a tweet. + + X counts basic ASCII characters as 1 and most other characters (CJK, + emoji, etc.) as 2. The standard tweet limit is 280 weighted units; + anything above must go through the CreateNoteTweet endpoint (X + Premium long-form post). + """ + n = 0 + for ch in text: + n += 1 if ord(ch) < 0x80 else 2 + return n + 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. + Automatically routes to the CreateNoteTweet GraphQL endpoint when the + weighted character count exceeds 280 (X Premium long-form post). + Args: text: Tweet text content. reply_to_id: Optional tweet ID to reply to. @@ -582,12 +600,32 @@ def create_tweet(self, text, reply_to_id=None, media_ids=None): "in_reply_to_tweet_id": reply_to_id, "exclude_reply_user_ids": [], } - data = self._graphql_post("CreateTweet", variables, FEATURES) + + # Route to long-form endpoint if the tweet exceeds the standard limit. + weighted = self._weighted_length(text) + if weighted > 280: + op_name = "CreateNoteTweet" + # CreateNoteTweet requires this field; without it X returns + # HTTP 200 with an empty tweet_results object (silent failure). + variables["disallowed_reply_options"] = None + logger.info( + "Tweet weighted=%d > 280, using CreateNoteTweet (long-form)", + weighted, + ) + else: + op_name = "CreateTweet" + + data = self._graphql_post(op_name, variables, FEATURES) self._write_delay() - result = _deep_get(data, "data", "create_tweet", "tweet_results", "result") + if op_name == "CreateNoteTweet": + result = _deep_get( + data, "data", "notetweet_create", "tweet_results", "result" + ) + else: + result = _deep_get(data, "data", "create_tweet", "tweet_results", "result") if result: return result.get("rest_id", "") - raise TwitterAPIError(0, "Failed to create tweet") + raise TwitterAPIError(0, "Failed to create tweet (op=%s)" % op_name) def delete_tweet(self, tweet_id): # type: (str) -> bool diff --git a/twitter_cli/graphql.py b/twitter_cli/graphql.py index d34ea35..becd0a5 100644 --- a/twitter_cli/graphql.py +++ b/twitter_cli/graphql.py @@ -39,6 +39,7 @@ "Followers": "IOh4aS6UdGWGJUYTqliQ7Q", "Following": "zx6e-TLzRkeDO_a7p4b3JQ", "CreateTweet": "IID9x6WsdMnTlXnzXGq8ng", + "CreateNoteTweet": "yeInFtqpUoABoBE_YWPYgA", "DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA", @@ -64,8 +65,13 @@ "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, + "responsive_web_twitter_article_data_v2_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, "rweb_video_timestamps_enabled": True,