From 2346c22e243c5236bc6428321c8c03346356910e Mon Sep 17 00:00:00 2001 From: d60 Date: Sun, 1 Dec 2024 12:29:18 +0900 Subject: [PATCH] FIxed --- .github/FUNDING.yml | 2 + .readthedocs.yaml | 13 + LICENSE | 21 + README-ja.md | 127 + README-zh.md | 128 + README.md | 148 + ToProtectYourAccount.md | 34 + docs/Makefile | 20 + docs/conf.py | 42 + docs/index.rst | 16 + docs/make.bat | 35 + docs/requirements.txt | 5 + docs/twikit.rst | 146 + examples/delete_all_tweets.py | 40 + examples/dm_auto_reply.py | 41 + examples/download_tweet_media.py | 23 + examples/example.py | 137 + examples/guest.py | 26 + examples/listen_for_new_tweets.py | 37 + ratelimits.md | 74 + requirements.txt | 5 + setup.py | 28 + twikit/__init__.py | 31 + twikit/_captcha/__init__.py | 2 + twikit/_captcha/base.py | 111 + twikit/_captcha/capsolver.py | 95 + twikit/bookmark.py | 64 + twikit/client/client.py | 4267 ++++++++++++++++++++ twikit/client/gql.py | 693 ++++ twikit/client/v11.py | 512 +++ twikit/community.py | 282 ++ twikit/constants.py | 229 ++ twikit/errors.py | 110 + twikit/geo.py | 82 + twikit/group.py | 259 ++ twikit/guest/__init__.py | 3 + twikit/guest/client.py | 393 ++ twikit/guest/tweet.py | 215 + twikit/guest/user.py | 196 + twikit/list.py | 255 ++ twikit/message.py | 143 + twikit/notification.py | 47 + twikit/streaming.py | 266 ++ twikit/trend.py | 93 + twikit/tweet.py | 684 ++++ twikit/user.py | 521 +++ twikit/utils.py | 394 ++ twikit/x_client_transaction/__init__.py | 1 + twikit/x_client_transaction/cubic_curve.py | 48 + twikit/x_client_transaction/interpolate.py | 23 + twikit/x_client_transaction/rotation.py | 27 + twikit/x_client_transaction/transaction.py | 164 + twikit/x_client_transaction/utils.py | 84 + 53 files changed, 11442 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .readthedocs.yaml create mode 100644 LICENSE create mode 100644 README-ja.md create mode 100644 README-zh.md create mode 100644 README.md create mode 100644 ToProtectYourAccount.md create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/requirements.txt create mode 100644 docs/twikit.rst create mode 100644 examples/delete_all_tweets.py create mode 100644 examples/dm_auto_reply.py create mode 100644 examples/download_tweet_media.py create mode 100644 examples/example.py create mode 100644 examples/guest.py create mode 100644 examples/listen_for_new_tweets.py create mode 100644 ratelimits.md create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 twikit/__init__.py create mode 100644 twikit/_captcha/__init__.py create mode 100644 twikit/_captcha/base.py create mode 100644 twikit/_captcha/capsolver.py create mode 100644 twikit/bookmark.py create mode 100644 twikit/client/client.py create mode 100644 twikit/client/gql.py create mode 100644 twikit/client/v11.py create mode 100644 twikit/community.py create mode 100644 twikit/constants.py create mode 100644 twikit/errors.py create mode 100644 twikit/geo.py create mode 100644 twikit/group.py create mode 100644 twikit/guest/__init__.py create mode 100644 twikit/guest/client.py create mode 100644 twikit/guest/tweet.py create mode 100644 twikit/guest/user.py create mode 100644 twikit/list.py create mode 100644 twikit/message.py create mode 100644 twikit/notification.py create mode 100644 twikit/streaming.py create mode 100644 twikit/trend.py create mode 100644 twikit/tweet.py create mode 100644 twikit/user.py create mode 100644 twikit/utils.py create mode 100644 twikit/x_client_transaction/__init__.py create mode 100644 twikit/x_client_transaction/cubic_curve.py create mode 100644 twikit/x_client_transaction/interpolate.py create mode 100644 twikit/x_client_transaction/rotation.py create mode 100644 twikit/x_client_transaction/transaction.py create mode 100644 twikit/x_client_transaction/utils.py diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..cd410a6f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: d60 +buy_me_a_coffee: d60py diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..887581ac --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.12" + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: "docs/requirements.txt" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..02c0ce95 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 d60 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README-ja.md b/README-ja.md new file mode 100644 index 00000000..07142b49 --- /dev/null +++ b/README-ja.md @@ -0,0 +1,127 @@ + + + + +![Number of GitHub stars](https://img.shields.io/github/stars/d60/twikit) +![GitHub commit activity](https://img.shields.io/github/commit-activity/m/d60/twikit) +![Version](https://img.shields.io/pypi/v/twikit?label=PyPI) +[![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Create%20your%20own%20Twitter%20bot%20for%20free%20with%20%22Twikit%22!%20%23python%20%23twitter%20%23twikit%20%23programming%20%23github%20%23bot&url=https%3A%2F%2Fgithub.com%2Fd60%2Ftwikit) +[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/nCrByrr8cX) +[![BuyMeACoffee](https://img.shields.io/badge/-buy_me_a%C2%A0coffee-gray?logo=buy-me-a-coffee)](https://www.buymeacoffee.com/d60py) + +[[English](https://github.com/d60/twikit/blob/main/README.md)] +[[中文](https://github.com/d60/twikit/blob/main/README-zh.md)] + +# Twikit + +このライブラリを使用することで、APIキーなしで、ツイートの投稿や検索などの機能を使用することができます。 + +- [ドキュメント](https://twikit.readthedocs.io/en/latest/twikit.html) + +[Discord](https://discord.gg/nCrByrr8cX) + + + +## 特徴 + +### APIキー不要 + +このライブラリは、ツイッターの非公式APIを使用しているため、APIキーは必要ありません。 + +### 無料 + +このライブラリは、無料で使用することができます。 + + +## 機能 + +このライブラリを使用することで、 + +- ツイートの投稿 + +- ツイートの検索 + +- トレンドの取得 + +などのさまざまな機能を使用することができます。 + + + +## インストール + +```bash + +pip install twikit + +``` + + +## 使用例 + +**クライアントを定義し、アカウントにログインする。** + +```python +import asyncio +from twikit import Client + +USERNAME = 'example_user' +EMAIL = 'email@example.com' +PASSWORD = 'password0000' + +# Initialize client +client = Client('en-US') + +async def main(): + # アカウントにログイン + client.login( + auth_info_1=USERNAME , + auth_info_2=EMAIL, + password=PASSWORD + ) + +asyncio.run(main()) +``` + +**メディア付きツイートを作成する。** + +```python +# メディアをアップロードし、メディアIDを取得する。 +media_ids = [ + await client.upload_media('media1.jpg'), + await client.upload_media('media2.jpg') +] + +# ツイートを投稿する +await client.create_tweet( + text='Example Tweet', + media_ids=media_ids +) + +``` + +**ツイートを検索する** +```python +tweets = await client.search_tweet('python', 'Latest') + +for tweet in tweets: + print( + tweet.user.name, + tweet.text, + tweet.created_at + ) +``` + +**ユーザーのツイートを取得する** +```python +tweets = await client.get_user_tweets('123456', 'Tweet') + +for tweet in tweets: + print(tweet.text) +``` + +**トレンドを取得する** +```python +await client.get_trends('trending') +``` + +[examples](https://github.com/d60/twikit/tree/main/examples)
diff --git a/README-zh.md b/README-zh.md new file mode 100644 index 00000000..9ba28b89 --- /dev/null +++ b/README-zh.md @@ -0,0 +1,128 @@ + + + + +![Number of GitHub stars](https://img.shields.io/github/stars/d60/twikit) +![GitHub commit activity](https://img.shields.io/github/commit-activity/m/d60/twikit) +![Version](https://img.shields.io/pypi/v/twikit?label=PyPI) +[![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Create%20your%20own%20Twitter%20bot%20for%20free%20with%20%22Twikit%22!%20%23python%20%23twitter%20%23twikit%20%23programming%20%23github%20%23bot&url=https%3A%2F%2Fgithub.com%2Fd60%2Ftwikit) +[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/nCrByrr8cX) +[![BuyMeACoffee](https://img.shields.io/badge/-buy_me_a%C2%A0coffee-gray?logo=buy-me-a-coffee)](https://www.buymeacoffee.com/d60py) + +[[English](https://github.com/d60/twikit/blob/main/README.md)] +[[日本語](https://github.com/d60/twikit/blob/main/README-ja.md)] + +# Twikit + +一个简单的爬取 Twitter API 的客户端。 + +本库提供的函数允许你进行对推特的操作,如发布或搜索推文,并且无需开发者 API 密钥。 + +- [文档(英文)](https://twikit.readthedocs.io/en/latest/twikit.html) + +[Discord 服务器](https://discord.gg/nCrByrr8cX) + + + +## 特性 + +### 无需开发者 API 密钥 + +本库直接爬取推特的公共 API 进行请求,无需申请官方开发者密钥。 + +### 免费 + +本库无需付费。 + + +## 功能 + +使用 Twikit,你可以: + +- 创建推文 + +- 搜索推文 + +- 检索热门话题 + +- 等等... + + + +## 安装 + +```bash + +pip install twikit + +``` + + +## 使用样例 + +**定义一个客户端并登录** + +```python +import asyncio +from twikit import Client + +USERNAME = 'example_user' +EMAIL = 'email@example.com' +PASSWORD = 'password0000' + +# 初始化客户端 +client = Client('en-US') + +async def main(): + await client.login( + auth_info_1=USERNAME , + auth_info_2=EMAIL, + password=PASSWORD + ) + +asyncio.run(main()) +``` + +**创建一条附带媒体的推文** + +```python +# 上传媒体文件并获取媒体ID +media_ids = [ + await client.upload_media('media1.jpg'), + await client.upload_media('media2.jpg') +] + +# 创建一条带有提供的文本和附加媒体的推文 +await client.create_tweet( + text='Example Tweet', + media_ids=media_ids +) + +``` + +**搜索推文** +```python +tweets = await client.search_tweet('python', 'Latest') + +for tweet in tweets: + print( + tweet.user.name, + tweet.text, + tweet.created_at + ) +``` + +**检索用户的推文** +```python +tweets = await client.get_user_tweets('123456', 'Tweet') + +for tweet in tweets: + print(tweet.text) +``` + +**获取趋势** +```python +await client.get_trends('trending') +``` + +[更多样例...](https://github.com/d60/twikit/tree/main/examples)
diff --git a/README.md b/README.md new file mode 100644 index 00000000..13267733 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ + + + + +![Number of GitHub stars](https://img.shields.io/github/stars/d60/twikit) +![GitHub commit activity](https://img.shields.io/github/commit-activity/m/d60/twikit) +![Version](https://img.shields.io/pypi/v/twikit?label=PyPI) +[![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Create%20your%20own%20Twitter%20bot%20for%20free%20with%20%22Twikit%22!%20%23python%20%23twitter%20%23twikit%20%23programming%20%23github%20%23bot&url=https%3A%2F%2Fgithub.com%2Fd60%2Ftwikit) +[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/nCrByrr8cX) +[![BuyMeACoffee](https://img.shields.io/badge/-buy_me_a%C2%A0coffee-gray?logo=buy-me-a-coffee)](https://www.buymeacoffee.com/d60py) + +[[日本語](https://github.com/d60/twikit/blob/main/README-ja.md)] +[[中文](https://github.com/d60/twikit/blob/main/README-zh.md)] + + +# Twikit + +A Simple Twitter API Scraper + +You can use functions such as posting or searching for tweets without an API key using this library. + +- [Documentation (English)](https://twikit.readthedocs.io/en/latest/twikit.html) + + +🔵 [Discord](https://discord.gg/nCrByrr8cX) + +> [!IMPORTANT] +> With the release of version 2.0.0 on July 11, there have been some specification changes, including the discontinuation of the synchronous version. Existing code will no longer work with v2.0.0 or later, so please refer to the [documentation](https://twikit.readthedocs.io/en/latest/twikit.html) or the code in the [examples folder](https://github.com/d60/twikit/tree/main/examples) for adjustments. +> We apologize for any inconvenience this may cause. + + + + +## Features + +### No API Key Required + +This library uses scraping and does not require an API key. + +### Free + +This library is free to use. + + +## Functionality + +By using Twikit, you can access functionalities such as the following: + +- Create tweets + +- Search tweets + +- Retrieve trending topics + +- etc... + + + +## Installing + +```bash + +pip install twikit + +``` + + + +## Quick Example + +**Define a client and log in to the account.** + +```python +import asyncio +from twikit import Client + +USERNAME = 'example_user' +EMAIL = 'email@example.com' +PASSWORD = 'password0000' + +# Initialize client +client = Client('en-US') + +async def main(): + await client.login( + auth_info_1=USERNAME , + auth_info_2=EMAIL, + password=PASSWORD + ) + +asyncio.run(main()) +``` + +**Create a tweet with media attached.** + +```python +# Upload media files and obtain media_ids +media_ids = [ + await client.upload_media('media1.jpg'), + await client.upload_media('media2.jpg') +] + +# Create a tweet with the provided text and attached media +await client.create_tweet( + text='Example Tweet', + media_ids=media_ids +) + +``` + +**Search the latest tweets based on a keyword** +```python +tweets = await client.search_tweet('python', 'Latest') + +for tweet in tweets: + print( + tweet.user.name, + tweet.text, + tweet.created_at + ) +``` + +**Retrieve user tweets** +```python +tweets = await client.get_user_tweets('123456', 'Tweets') + +for tweet in tweets: + print(tweet.text) +``` + +**Send a dm** +```python +await client.send_dm('123456789', 'Hello') +``` + +**Get trends** +```python +await client.get_trends('trending') +``` + +More Examples: [examples](https://github.com/d60/twikit/tree/main/examples)
+ +## Contributing + +If you encounter any bugs or issues, please report them on [issues](https://github.com/d60/twikit/issues). + + +If you find this library useful, consider starring this repository⭐️ diff --git a/ToProtectYourAccount.md b/ToProtectYourAccount.md new file mode 100644 index 00000000..17a65d89 --- /dev/null +++ b/ToProtectYourAccount.md @@ -0,0 +1,34 @@ +# What you need to do to protect your account +Since this library uses an unofficial API, your account may be banned if you use it incorrectly. Therefore, please be sure to follow the measures below. + +## Avoid sending too many requests +Sending too many requests may be perceived as suspicious behavior. Therefore, please avoid sending consecutive requests and allow time for a cooldown. Specifically, you should not send so many requests that you get stuck in a [rate limit](https://github.com/d60/twikit/blob/main/ratelimits.md). + +## Reuse login information +As mentioned earlier, sending many requests can be perceived as suspicious behavior, especially logins, which are closely monitored. Therefore, the act of repeatedly calling the `login` method should be avoided. To do so, it is useful to reuse the login information contained in cookies by using the `save_cookies` and `load_cookies` methods. The specific methods are shown below: + +The first time, there is a way to log in using the `login` method. +```python +client.login( + auth_info_1='...', + auth_info_2='...', + password='...' +) +``` +Then save the cookies. +```python +client.save_cookies('cookies.json') +``` +After the second time, load the saved cookies. +```python +client.load_cookies('cookies.json') +``` + +## Do not send too many messages. +Twitter seems to monitor messages carefully, so it is best to refrain from excessive messaging. + +## Don't tweet sensitive content. +You should not tweet sensitive content, especially content related to sexuality, violence, politics, discrimination, or hate speech. This is because such content violates Twitter's terms and conditions and may be banned. + +# +**Please use Twikit safely in accordance with the above instructions!** diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..d4bb2cbb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..13e49e07 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,42 @@ +import os +import sys +sys.path.insert(0, os.path.abspath('..')) + +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'twikit' +copyright = '2024, twikit' +author = 'twikit' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx.ext.todo', + 'sphinx.ext.napoleon', + 'sphinx_rtd_theme' +] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +language = 'en' + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] + +# -- Options for todo extension ---------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration + +todo_include_todos = True diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..9fabb5a7 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,16 @@ +Welcome to twikit's documentation! +================================== + +.. toctree:: + :maxdepth: 1 + :caption: Contents: + + twikit + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..954237b9 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..7ac5c4ea --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +sphinx_rtd_theme +httpx +filetype +beautifulsoup4 +pyotp diff --git a/docs/twikit.rst b/docs/twikit.rst new file mode 100644 index 00000000..bcc9cd59 --- /dev/null +++ b/docs/twikit.rst @@ -0,0 +1,146 @@ +twikit package +============== + +.. automodule:: twikit + :members: + :undoc-members: + +Client +-------------------- + +.. autoclass:: twikit.client.client.Client + :members: + :undoc-members: + :member-order: bysource + +Tweet +------------------- + +.. automodule:: twikit.tweet + :members: + :exclude-members: TweetTombstone + :member-order: bysource + +User +------------------ + +.. automodule:: twikit.user + :members: + :undoc-members: + :member-order: bysource + +Message +--------------------- + +.. automodule:: twikit.message + :members: + :undoc-members: + :member-order: bysource + +Streaming +--------------------- + +With the streaming API, you can receive real-time events such as tweet engagements, +DM updates, and DM typings. The basic procedure involves looping through the +stream session obtained with :attr:`.Client.get_streaming_session` and, if necessary, +updating the topics to be streamed using :attr:`.StreamingSession.update_subscriptions`. + +Example Code: + +.. code-block:: python + + from twikit.streaming import Topic + + topics = { + Topic.tweet_engagement('1739617652'), # Stream tweet engagement + Topic.dm_update('17544932482-174455537996'), # Stream DM update + Topic.dm_typing('17544932482-174455537996') # Stream DM typing + } + session = client.get_streaming_session(topics) + + for topic, payload in session: + if payload.dm_update: + conversation_id = payload.dm_update.conversation_id + user_id = payload.dm_update.user_id + print(f'{conversation_id}: {user_id} sent a message') + + if payload.dm_typing: + conversation_id = payload.dm_typing.conversation_id + user_id = payload.dm_typing.user_id + print(f'{conversation_id}: {user_id} is typing') + + if payload.tweet_engagement: + like = payload.tweet_engagement.like_count + retweet = payload.tweet_engagement.retweet_count + view = payload.tweet_engagement.view_count + print(f'Tweet engagement updated likes: {like} retweets: {retweet} views: {view}') + +.. automodule:: twikit.streaming + :members: + :undoc-members: + :member-order: bysource + +Trend +------------------- + +.. automodule:: twikit.trend + :members: + :undoc-members: + :member-order: bysource + +List +------------------- + +.. autoclass:: twikit.list.List + :members: + :undoc-members: + :member-order: bysource + +Community +------------------- + +.. automodule:: twikit.community + :members: + :undoc-members: + :member-order: bysource + +Notification +------------------- + +.. autoclass:: twikit.notification.Notification + :members: + :undoc-members: + :member-order: bysource + +Geo +------------------- + +.. autoclass:: twikit.geo.Place + :members: + :undoc-members: + :member-order: bysource + +Capsolver +------------------- + +.. autoclass:: twikit._captcha.capsolver.Capsolver + :members: + :undoc-members: + :member-order: bysource + :exclude-members: create_task,get_task_result,solve_funcaptcha + +Utils +------------------- + +.. autoclass:: twikit.utils.Result + :members: + :undoc-members: + :member-order: bysource + +Errors +-------------------- + +.. automodule:: twikit.errors + :members: + :undoc-members: + :member-order: bysource diff --git a/examples/delete_all_tweets.py b/examples/delete_all_tweets.py new file mode 100644 index 00000000..0a169b81 --- /dev/null +++ b/examples/delete_all_tweets.py @@ -0,0 +1,40 @@ +import asyncio +import time + +from twikit import Client + +AUTH_INFO_1 = '...' +AUTH_INFO_2 = '...' +PASSWORD = '...' + +client = Client('en-US') + + +async def main(): + started_time = time.time() + + client.load_cookies('cookies.json') + client_user = await client.user() + + # Get all posts + all_tweets = [] + tweets = await client_user.get_tweets('Replies') + all_tweets += tweets + + while len(tweets) != 0: + tweets = await tweets.next() + all_tweets += tweets + + tasks = [] + for tweet in all_tweets: + tasks.append(tweet.delete()) + + gather = asyncio.gather(*tasks) + await gather + + print( + f'Deleted {len(all_tweets)} tweets\n' + f'Time: {time.time() - started_time}' + ) + +asyncio.run(main()) diff --git a/examples/dm_auto_reply.py b/examples/dm_auto_reply.py new file mode 100644 index 00000000..3ae60e15 --- /dev/null +++ b/examples/dm_auto_reply.py @@ -0,0 +1,41 @@ +import asyncio +import os + +from twikit import Client +from twikit.streaming import Topic + +AUTH_INFO_1 = '' +AUTH_INFO_2 = '' +PASSWORD = '' + +client = Client() + + +async def main(): + if os.path.exists('cookies.json'): + client.load_cookies('cookies.json') + else: + await client.login( + auth_info_1=AUTH_INFO_1, + auth_info_2=AUTH_INFO_2, + password=PASSWORD + ) + client.save_cookies('cookies.json') + + + user_id = '1752362966203469824' # User ID of the DM partner to stream. + reply_message = 'Hello' + + topics = { + Topic.dm_update(f'{await client.user_id()}-{user_id}') + } + streaming_session = await client.get_streaming_session(topics) + + async for topic, payload in streaming_session: + if payload.dm_update: + if await client.user_id() == payload.dm_update.user_id: + continue + await client.send_dm(payload.dm_update.user_id, reply_message) + +asyncio.run(main()) + diff --git a/examples/download_tweet_media.py b/examples/download_tweet_media.py new file mode 100644 index 00000000..556449c5 --- /dev/null +++ b/examples/download_tweet_media.py @@ -0,0 +1,23 @@ +import asyncio +from twikit import Client + +AUTH_INFO_1 = '...' +AUTH_INFO_2 = '...' +PASSWORD = '...' + +client = Client('en-US') + + +async def main(): + tweet = await client.get_tweet_by_id('...') + + for i, media in enumerate(tweet.media): + media_url = media.get('media_url_https') + extension = media_url.rsplit('.', 1)[-1] + + response = await client.get(media_url, headers=client._base_headers) + + with open(f'media_{i}.{extension}', 'wb') as f: + f.write(response.content) + +asyncio.run(main()) diff --git a/examples/example.py b/examples/example.py new file mode 100644 index 00000000..ddf0af14 --- /dev/null +++ b/examples/example.py @@ -0,0 +1,137 @@ +import asyncio + +from twikit import Client + +########################################### + +# Enter your account information +USERNAME = ... +EMAIL = ... +PASSWORD = ... + +client = Client('en-US') + +async def main(): + # Asynchronous client methods are coroutines and + # must be called using `await`. + await client.login( + auth_info_1=USERNAME, + auth_info_2=EMAIL, + password=PASSWORD + ) + + ########################################### + + # Search Latest Tweets + tweets = await client.search_tweet('query', 'Latest') + for tweet in tweets: + print(tweet) + # Search more tweets + more_tweets = await tweets.next() + + ########################################### + + # Search users + users = await client.search_user('query') + for user in users: + print(user) + # Search more users + more_users = await users.next() + + ########################################### + + # Get user by screen name + USER_SCREEN_NAME = 'example_user' + user = await client.get_user_by_screen_name(USER_SCREEN_NAME) + + # Access user attributes + print( + f'id: {user.id}', + f'name: {user.name}', + f'followers: {user.followers_count}', + f'tweets count: {user.statuses_count}', + sep='\n' + ) + + # Follow user + await user.follow() + # Unfollow user + await user.unfollow() + + # Get user tweets + user_tweets = await user.get_tweets('Tweets') + for tweet in user_tweets: + print(tweet) + # Get more tweets + more_user_tweets = await user_tweets.next() + + ########################################### + + # Send dm to a user + media_id = await client.upload_media('./image.png', 0) + await user.send_dm('dm text', media_id) + + # Get dm history + messages = await user.get_dm_history() + for message in messages: + print(message) + # Get more messages + more_messages = await messages.next() + + ########################################### + + # Get tweet by ID + TWEET_ID = '0000000000' + tweet = await client.get_tweet_by_id(TWEET_ID) + + # Access tweet attributes + print( + f'id: {tweet.id}', + f'text {tweet.text}', + f'favorite count: {tweet.favorite_count}', + f'media: {tweet.media}', + sep='\n' + ) + + # Favorite tweet + await tweet.favorite() + # Unfavorite tweet + await tweet.unfavorite() + # Retweet tweet + await tweet.retweet() + # Delete retweet + await tweet.delete_retweet() + + # Reply to tweet + await tweet.reply('tweet content') + + ########################################### + + # Create tweet with media + TWEET_TEXT = 'tweet text' + MEDIA_IDS = [ + await client.upload_media('./media1.png', 0), + await client.upload_media('./media2.png', 1), + await client.upload_media('./media3.png', 2) + ] + + client.create_tweet(TWEET_TEXT, MEDIA_IDS) + + # Create tweet with a poll + TWEET_TEXT = 'tweet text' + POLL_URI = await client.create_poll( + ['Option 1', 'Option 2', 'Option 3'] + ) + + await client.create_tweet(TWEET_TEXT, poll_uri=POLL_URI) + + ########################################### + + # Get news trends + trends = await client.get_trends('news') + for trend in trends: + print(trend) + + ########################################### + +asyncio.run(main()) diff --git a/examples/guest.py b/examples/guest.py new file mode 100644 index 00000000..4c48444c --- /dev/null +++ b/examples/guest.py @@ -0,0 +1,26 @@ +import asyncio + +from twikit.guest import GuestClient + +client = GuestClient() + + +async def main(): + # Activate the client by generating a guest token. + await client.activate() + + # Get user by screen name + user = await client.get_user_by_screen_name('elonmusk') + print(user) + # Get user by ID + user = await client.get_user_by_id('44196397') + print(user) + + + user_tweets = await client.get_user_tweets('44196397') + print(user_tweets) + + tweet = await client.get_tweet_by_id('1519480761749016577') + print(tweet) + +asyncio.run(main()) diff --git a/examples/listen_for_new_tweets.py b/examples/listen_for_new_tweets.py new file mode 100644 index 00000000..ec2a6619 --- /dev/null +++ b/examples/listen_for_new_tweets.py @@ -0,0 +1,37 @@ +import asyncio +from typing import NoReturn + +from twikit import Client, Tweet + +AUTH_INFO_1 = '...' +AUTH_INFO_2 = '...' +PASSWORD = '...' + +client = Client() + +USER_ID = '44196397' +CHECK_INTERVAL = 60 * 5 + + +def callback(tweet: Tweet) -> None: + print(f'New tweet posted : {tweet.text}') + + +async def get_latest_tweet() -> Tweet: + return await client.get_user_tweets(USER_ID, 'Replies')[0] + + +async def main() -> NoReturn: + before_tweet = await get_latest_tweet() + + while True: + await asyncio.sleep(CHECK_INTERVAL) + latest_tweet = await get_latest_tweet() + if ( + before_tweet != latest_tweet and + before_tweet.created_at_datetime < latest_tweet.created_at_datetime + ): + callable(latest_tweet) + before_tweet = latest_tweet + +asyncio.run(main()) diff --git a/ratelimits.md b/ratelimits.md new file mode 100644 index 00000000..c5bade6d --- /dev/null +++ b/ratelimits.md @@ -0,0 +1,74 @@ +# Rate Limits + +**The rate limits reset every 15 minutes.** + +| Functions | Limit | Endpoint | +|---------------------------------------|-------|-------------------------------------| +| add_members_to_group | - | AddParticipantsMutation | +| block_user | 187 | blocks/create.json | +| get_user_verified_followers | 500 | BlueVerifiedFollowers | +| get_bookmarks | 500 | Bookmarks | +| delete_all_bookmarks | - | BookmarksAllDelete | +| change_group_name | 900 | {GroupID}/update_name.json | +| get_group_dm_history, get_dm_history | 900 | conversation/{ConversationID}.json | +| bookmark_tweet | - | CreateBookmark | +| create_poll | - | cards/create.json | +| follow_user | 15 | friendships/create.json | +| create_list | - | CreateList | +| retweet | - | CreateRetweet | +| create_scheduled_tweet | - | CreateScheduledTweet | +| create_tweet | - | CreateTweet | +| delete_bookmark | - | DeleteBookmark | +| delete_dm | - | DMMessageDeleteMutation | +| delete_list_banner | - | DeleteListBanner | +| delete_retweet | - | DeleteRetweet | +| delete_scheduled_tweet | - | DeleteScheduledTweet | +| delete_tweet | - | DeleteTweet | +| unfollow_user | 187 | friendships/destroy.json | +| edit_list_banner | - | EditListBanner | +| get_favoriters | 500 | Favoriters | +| favorite_tweet | - | FavoriteTweet | +| get_scheduled_tweets | 500 | FetchScheduledTweets | +| get_user_followers | 50 | Followers | +| get_user_followers_you_know | 500 | FollowersYouKnow | +| get_user_following | 500 | Following | +| get_guest_token | - | guest/activate.json | +| get_latest_timeline | 500 | HomeLatestTimeline | +| get_timeline | 500 | HomeTimeline | +| - | 450 | dm/inbox_initial_state.json | +| add_list_member | - | ListAddMember | +| get_list | 500 | ListByRestId | +| get_list_tweets | 500 | ListLatestTweetsTimeline | +| get_lists | 500 | ListsManagementPageTimeline | +| get_list_members | 500 | ListMembers | +| remove_list_member | - | ListRemoveMember | +| get_list_subscribers | 500 | ListSubscribers | +| logout | 187 | account/logout.json | +| add_reaction_to_message | - | /useDMReactionMutationAddMutation | +| remove_reaction_from_message | - | useDMReactionMutationRemoveMutation | +| - | - | MuteList | +| mute_user | 187 | mutes/users/create.json | +| get_notifications[type="All"] | 180 | notifications/all.json | +| get_notifications[type="Mentions"] | 180 | notifications/mentions.json | +| get_notifications[type="Verified"] | 180 | notifications/verified.json | +| get_retweeters | 500 | Retweeters | +| search_tweet, search_user | 50 | SearchTimeline | +| send_dm | 187 | dm/new2.json | +| user_id | - | account/settings.json | +| get_user_subscriptions | 500 | UserCreatorSubscriptions | +| login | 187 | onboarding/task.json | +| get_trends | 20000 | guide.json | +| get_tweet_by_id | 150 | TweetDetail | +| unblock_user | 187 | blocks/destroy.json | +| unfavorite_tweet | - | UnfavoriteTweet | +| - | - | UnmuteList | +| unmute_user | 187 | mutes/users/destroy.json | +| edit_list | - | UpdateList | +| upload_media | - | media/upload.json | +| get_user_by_id | 500 | UserByRestId | +| get_user_by_screen_name | 95 | UserByScreenName | +| get_user_tweets[tweet_type="Likes"] | 500 | Likes | +| get_user_tweets[tweet_type="Media"] | 500 | UserMedia | +| get_user_tweets[tweet_type="Tweets"] | 50 | UserTweets | +| get_user_tweets[tweet_type="Replies"] | 50 | UserTweetsAndReplies | +| vote | - | capi/passthrough/1 | diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..2efdee88 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +httpx[socks] +filetype +beautifulsoup4 +pyotp +lxml \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..2dd78c53 --- /dev/null +++ b/setup.py @@ -0,0 +1,28 @@ +import re + +from setuptools import setup + +with open('README.md', encoding='utf-8') as f: + long_description = f.read() + +with open('./twikit/__init__.py') as f: + version = re.findall(r"__version__ = '(.+)'", f.read())[0] + +setup( + name='twikit', + version=version, + install_requires=[ + 'httpx[socks]', + 'filetype', + 'beautifulsoup4', + 'pyotp', + 'lxml' + ], + python_requires='>=3.8', + description='Twitter API wrapper for python with **no API key required**.', + long_description=long_description, + long_description_content_type='text/markdown', + license='MIT', + url='https://github.com/d60/twikit', + package_data={'twikit': ['py.typed']} +) diff --git a/twikit/__init__.py b/twikit/__init__.py new file mode 100644 index 00000000..442fa4d4 --- /dev/null +++ b/twikit/__init__.py @@ -0,0 +1,31 @@ +""" +========================== +Twikit Twitter API Wrapper +========================== + +https://github.com/d60/twikit +A Python library for interacting with the Twitter API. +""" + +__version__ = '2.1.3' + +import asyncio +import os + +if os.name == 'nt': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +from ._captcha import Capsolver +from .bookmark import BookmarkFolder +from .errors import * +from .utils import build_query +from .client.client import Client +from .community import Community, CommunityCreator, CommunityMember, CommunityRule +from .geo import Place +from .group import Group, GroupMessage +from .list import List +from .message import Message +from .notification import Notification +from .trend import Trend +from .tweet import CommunityNote, Poll, ScheduledTweet, Tweet +from .user import User diff --git a/twikit/_captcha/__init__.py b/twikit/_captcha/__init__.py new file mode 100644 index 00000000..85f95d91 --- /dev/null +++ b/twikit/_captcha/__init__.py @@ -0,0 +1,2 @@ +from .base import CaptchaSolver +from .capsolver import Capsolver diff --git a/twikit/_captcha/base.py b/twikit/_captcha/base.py new file mode 100644 index 00000000..cdf376c7 --- /dev/null +++ b/twikit/_captcha/base.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, NamedTuple + +from bs4 import BeautifulSoup +from httpx import Response +from ..constants import DOMAIN + +if TYPE_CHECKING: + from ..client.client import Client + + +class UnlockHTML(NamedTuple): + authenticity_token: str + assignment_token: str + needs_unlock: bool + start_button: bool + finish_button: bool + delete_button: bool + blob: str + + +class CaptchaSolver: + client: Client + max_attempts: int + + CAPTCHA_URL = f'https://{DOMAIN}/account/access' + CAPTCHA_SITE_KEY = '0152B4EB-D2DC-460A-89A1-629838B529C9' + + async def get_unlock_html(self) -> tuple[Response, UnlockHTML]: + headers = { + 'X-Twitter-Client-Language': 'en-US', + 'User-Agent': self.client._user_agent, + 'Upgrade-Insecure-Requests': '1' + } + _, response = await self.client.get( + self.CAPTCHA_URL, headers=headers + ) + return response, parse_unlock_html(response.text) + + async def ui_metrix(self) -> str: + js, _ = await self.client.get( + f'https://{DOMAIN}/i/js_inst?c_name=ui_metrics' + ) + return re.findall(r'return ({.*?});', js, re.DOTALL)[0] + + async def confirm_unlock( + self, + authenticity_token: str, + assignment_token: str, + verification_string: str = None, + ui_metrics: bool = False + ) -> tuple[Response, UnlockHTML]: + data = { + 'authenticity_token': authenticity_token, + 'assignment_token': assignment_token, + 'lang': 'en', + 'flow': '', + } + params = {} + if verification_string: + data['verification_string'] = verification_string + data['language_code'] = 'en' + params['lang'] = 'en' + if ui_metrics: + data['ui_metrics'] = await self.client._ui_metrix() + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Upgrade-Insecure-Requests': '1', + 'Referer': self.CAPTCHA_URL + } + _, response = await self.client.post( + self.CAPTCHA_URL, params=params, data=data, headers=headers + ) + return response, parse_unlock_html(response.text) + + +def parse_unlock_html(html: str) -> UnlockHTML: + soup = BeautifulSoup(html, 'lxml') + + authenticity_token = None + authenticity_token_element = soup.find( + 'input', {'name': 'authenticity_token'} + ) + if authenticity_token_element is not None: + authenticity_token: str = authenticity_token_element.get('value') + + assignment_token = None + assignment_token_element = soup.find('input', {'name': 'assignment_token'}) + if assignment_token_element is not None: + assignment_token = assignment_token_element.get('value') + + verification_string = soup.find('input', id='verification_string') + needs_unlock = bool(verification_string) + start_button = bool(soup.find('input', value='Start')) + finish_button = bool(soup.find('input', value='Continue to X')) + delete_button = bool(soup.find('input', value='Delete')) + + iframe = soup.find(id='arkose_iframe') + blob = re.findall(r'data=(.+)', iframe['src'])[0] if iframe else None + + return UnlockHTML( + authenticity_token, + assignment_token, + needs_unlock, + start_button, + finish_button, + delete_button, + blob + ) diff --git a/twikit/_captcha/capsolver.py b/twikit/_captcha/capsolver.py new file mode 100644 index 00000000..8224de87 --- /dev/null +++ b/twikit/_captcha/capsolver.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from time import sleep + +import httpx + +from .base import CaptchaSolver + + +class Capsolver(CaptchaSolver): + """ + You can automatically unlock the account by passing the `captcha_solver` + argument when initialising the :class:`.Client`. + + First, visit https://capsolver.com and obtain your Capsolver API key. + Next, pass the Capsolver instance to the client as shown in the example. + + .. code-block:: python + + from twikit.twikit_async import Capsolver, Client + solver = Capsolver( + api_key='your_api_key', + max_attempts=10 + ) + client = Client(captcha_solver=solver) + + Parameters + ---------- + api_key : :class:`str` + Capsolver API key. + max_attempts : :class:`int`, default=3 + The maximum number of attempts to solve the captcha. + get_result_interval : :class:`float`, default=1.0 + + use_blob_data : :class:`bool`, default=False + """ + + def __init__( + self, + api_key: str, + max_attempts: int = 3, + get_result_interval: float = 1.0, + use_blob_data: bool = False + ) -> None: + self.api_key = api_key + self.get_result_interval = get_result_interval + self.max_attempts = max_attempts + self.use_blob_data = use_blob_data + + def create_task(self, task_data: dict) -> dict: + data = { + 'clientKey': self.api_key, + 'task': task_data + } + response = httpx.post( + 'https://api.capsolver.com/createTask', + json=data, + headers={'content-type': 'application/json'} + ).json() + return response + + def get_task_result(self, task_id: str) -> dict: + data = { + 'clientKey': self.api_key, + 'taskId': task_id + } + response = httpx.post( + 'https://api.capsolver.com/getTaskResult', + json=data, + headers={'content-type': 'application/json'} + ).json() + return response + + def solve_funcaptcha(self, blob: str) -> dict: + if self.client.proxy is None: + captcha_type = 'FunCaptchaTaskProxyLess' + else: + captcha_type = 'FunCaptchaTask' + + task_data = { + 'type': captcha_type, + 'websiteURL': 'https://iframe.arkoselabs.com', + 'websitePublicKey': self.CAPTCHA_SITE_KEY, + 'funcaptchaApiJSSubdomain': 'https://client-api.arkoselabs.com', + 'proxy': self.client.proxy + } + if self.use_blob_data: + task_data['data'] = '{"blob":"%s"}' % blob + task_data['userAgent'] = self.client._user_agent + task = self.create_task(task_data) + while True: + sleep(self.get_result_interval) + result = self.get_task_result(task['taskId']) + if result['status'] in ('ready', 'failed'): + return result diff --git a/twikit/bookmark.py b/twikit/bookmark.py new file mode 100644 index 00000000..2ea8c893 --- /dev/null +++ b/twikit/bookmark.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from httpx import Response + + from .client.client import Client + from .tweet import Tweet + from .utils import Result + + +class BookmarkFolder: + """ + Attributes + ---------- + id : :class:`str` + The ID of the folder. + name : :class:`str` + The name of the folder + media : :class:`str` + Icon image data. + """ + def __init__(self, client: Client, data: dict) -> None: + self._client = client + + self.id: str = data['id'] + self.name: str = data['name'] + self.media: dict = data['media'] + + async def get_tweets(self, cursor: str | None = None) -> Result[Tweet]: + """ + Retrieves tweets from the folder. + """ + return await self._client.get_bookmarks( + cursor=cursor, folder_id=self.id + ) + + async def edit(self, name: str) -> BookmarkFolder: + """ + Edits the folder. + """ + return await self._client.edit_bookmark_folder(self.id, name) + + async def delete(self) -> Response: + """ + Deletes the folder. + """ + return await self._client.delete_bookmark_folder(self.id) + + async def add(self, tweet_id: str) -> Response: + """ + Adds a tweet to the folder. + """ + return await self._client.bookmark_tweet(tweet_id, self.id) + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, BookmarkFolder) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value + + def __repr__(self) -> str: + return f'' diff --git a/twikit/client/client.py b/twikit/client/client.py new file mode 100644 index 00000000..ee03690e --- /dev/null +++ b/twikit/client/client.py @@ -0,0 +1,4267 @@ +from __future__ import annotations + +import asyncio +import io +import json +import re + +import warnings +from functools import partial +from typing import Any, AsyncGenerator, Literal +from urllib.parse import urlparse + +import filetype +import pyotp +from httpx import AsyncClient, AsyncHTTPTransport, Response +from httpx._utils import URLPattern + +from .._captcha import Capsolver +from ..bookmark import BookmarkFolder +from ..community import Community, CommunityMember +from ..constants import TOKEN, DOMAIN +from ..errors import ( + AccountLocked, + AccountSuspended, + BadRequest, + CouldNotTweet, + Forbidden, + InvalidMedia, + NotFound, + RequestTimeout, + ServerError, + TooManyRequests, + TweetNotAvailable, + TwitterException, + Unauthorized, + UserNotFound, + UserUnavailable, + raise_exceptions_from_response +) +from ..geo import Place, _places_from_response +from ..group import Group, GroupMessage +from ..list import List +from ..message import Message +from ..notification import Notification +from ..streaming import Payload, StreamingSession, _payload_from_data +from ..trend import Location, PlaceTrend, PlaceTrends, Trend +from ..tweet import CommunityNote, Poll, ScheduledTweet, Tweet, tweet_from_data +from ..user import User +from ..utils import ( + Flow, + Result, + build_tweet_data, + build_user_data, + find_dict, + find_entry_by_type, + httpx_transport_to_url +) +from ..x_client_transaction.utils import handle_x_migration +from ..x_client_transaction import ClientTransaction +from .gql import GQLClient +from .v11 import V11Client + + +class Client: + """ + A client for interacting with the Twitter API. + Since this class is for asynchronous use, + methods must be executed using await. + + Parameters + ---------- + language : :class:`str` | None, default=None + The language code to use in API requests. + proxy : :class:`str` | None, default=None + The proxy server URL to use for request + (e.g., 'http://0.0.0.0:0000'). + captcha_solver : :class:`.Capsolver` | None, default=None + See :class:`.Capsolver`. + + Examples + -------- + >>> client = Client(language='en-US') + + >>> await client.login( + ... auth_info_1='example_user', + ... auth_info_2='email@example.com', + ... password='00000000' + ... ) + """ + + def __init__( + self, + language: str | None = None, + proxy: str | None = None, + captcha_solver: Capsolver | None = None, + user_agent: str | None = None, + **kwargs + ) -> None: + if 'proxies' in kwargs: + message = ( + "The 'proxies' argument is now deprecated. Use 'proxy' " + "instead. https://github.com/encode/httpx/pull/2879" + ) + warnings.warn(message) + + self.http = AsyncClient(proxy=proxy, **kwargs) + self.language = language + self.proxy = proxy + self.captcha_solver = captcha_solver + if captcha_solver is not None: + captcha_solver.client = self + self.client_transaction = ClientTransaction() + + self._token = TOKEN + self._user_id = None + self._user_agent = user_agent or 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15' + self._act_as = None + + self.gql = GQLClient(self) + self.v11 = V11Client(self) + + async def request( + self, + method: str, + url: str, + auto_unlock: bool = True, + raise_exception: bool = True, + **kwargs + ) -> tuple[dict | Any, Response]: + ':meta private:' + headers = kwargs.pop('headers', {}) + + if not self.client_transaction.home_page_response: + cookies_backup = self.get_cookies().copy() + ct_headers = { + 'Accept-Language': f'{self.language},{self.language.split("-")[0]};q=0.9', + 'Cache-Control': 'no-cache', + 'Referer': f'https://{DOMAIN}', + 'User-Agent': self._user_agent + } + await self.client_transaction.init(self.http, ct_headers) + self.set_cookies(cookies_backup, clear_cookies=True) + + tid = self.client_transaction.generate_transaction_id(method=method, path=urlparse(url).path) + headers['X-Client-Transaction-Id'] = tid + + cookies_backup = self.get_cookies().copy() + response = await self.http.request(method, url, headers=headers, **kwargs) + self._remove_duplicate_ct0_cookie() + + try: + response_data = response.json() + except json.decoder.JSONDecodeError: + response_data = response.text + + if isinstance(response_data, dict) and 'errors' in response_data: + error_code = response_data['errors'][0]['code'] + error_message = response_data['errors'][0].get('message') + if error_code in (37, 64): + # Account suspended + raise AccountSuspended(error_message) + + if error_code == 326: + # Account unlocking + if self.captcha_solver is None: + raise AccountLocked( + 'Your account is locked. Visit ' + f'https://{DOMAIN}/account/access to unlock it.' + ) + if auto_unlock: + await self.unlock() + self.set_cookies(cookies_backup, clear_cookies=True) + response = await self.http.request(method, url, **kwargs) + self._remove_duplicate_ct0_cookie() + try: + response_data = response.json() + except json.decoder.JSONDecodeError: + response_data = response.text + + status_code = response.status_code + + if status_code >= 400 and raise_exception: + message = f'status: {status_code}, message: "{response.text}"' + if status_code == 400: + raise BadRequest(message, headers=response.headers) + elif status_code == 401: + raise Unauthorized(message, headers=response.headers) + elif status_code == 403: + raise Forbidden(message, headers=response.headers) + elif status_code == 404: + raise NotFound(message, headers=response.headers) + elif status_code == 408: + raise RequestTimeout(message, headers=response.headers) + elif status_code == 429: + if await self._get_user_state() == 'suspended': + raise AccountSuspended(message, headers=response.headers) + raise TooManyRequests(message, headers=response.headers) + elif 500 <= status_code < 600: + raise ServerError(message, headers=response.headers) + else: + raise TwitterException(message, headers=response.headers) + + if status_code == 200: + return response_data, response + + return response_data, response + + async def get(self, url, **kwargs) -> tuple[dict | Any, Response]: + ':meta private:' + return await self.request('GET', url, **kwargs) + + async def post(self, url, **kwargs) -> tuple[dict | Any, Response]: + ':meta private:' + return await self.request('POST', url, **kwargs) + + def _remove_duplicate_ct0_cookie(self) -> None: + cookies = {} + for cookie in self.http.cookies.jar: + if 'ct0' in cookies and cookie.name == 'ct0': + continue + cookies[cookie.name] = cookie.value + self.http.cookies = list(cookies.items()) + + @property + def proxy(self) -> str: + ':meta private:' + transport: AsyncHTTPTransport = self.http._mounts.get(URLPattern('all://')) + if transport is None: + return None + if not hasattr(transport._pool, '_proxy_url'): + return None + return httpx_transport_to_url(transport) + + @proxy.setter + def proxy(self, url: str) -> None: + self.http._mounts = {URLPattern('all://'): AsyncHTTPTransport(proxy=url)} + + def _get_csrf_token(self) -> str: + """ + Retrieves the Cross-Site Request Forgery (CSRF) token from the + current session's cookies. + + Returns + ------- + :class:`str` + The CSRF token as a string. + """ + return self.http.cookies.get('ct0') + + @property + def _base_headers(self) -> dict[str, str]: + """ + Base headers for Twitter API requests. + """ + headers = { + 'authorization': f'Bearer {self._token}', + 'content-type': 'application/json', + 'X-Twitter-Auth-Type': 'OAuth2Session', + 'X-Twitter-Active-User': 'yes', + 'Referer': f'https://{DOMAIN}/', + 'User-Agent': self._user_agent, + } + + if self.language is not None: + headers['Accept-Language'] = self.language + headers['X-Twitter-Client-Language'] = self.language + + csrf_token = self._get_csrf_token() + if csrf_token is not None: + headers['X-Csrf-Token'] = csrf_token + if self._act_as is not None: + headers['X-Act-As-User-Id'] = self._act_as + return headers + + async def _get_guest_token(self) -> str: + response, _ = await self.v11.guest_activate() + guest_token = response['guest_token'] + return guest_token + + async def _ui_metrix(self) -> str: + js, _ = await self.get(f'https://twitter.com/i/js_inst?c_name=ui_metrics') # keep twitter.com here + return re.findall(r'return ({.*?});', js, re.DOTALL)[0] + + async def login( + self, + *, + auth_info_1: str, + auth_info_2: str | None = None, + password: str, + totp_secret: str | None = None + ) -> dict: + """ + Logs into the account using the specified login information. + `auth_info_1` and `password` are required parameters. + `auth_info_2` is optional and can be omitted, but it is + recommended to provide if available. + The order in which you specify authentication information + (auth_info_1 and auth_info_2) is flexible. + + Parameters + ---------- + auth_info_1 : :class:`str` + The first piece of authentication information, + which can be a username, email address, or phone number. + auth_info_2 : :class:`str`, default=None + The second piece of authentication information, + which is optional but recommended to provide. + It can be a username, email address, or phone number. + password : :class:`str` + The password associated with the account. + totp_secret : :class:`str` + The TOTP (Time-Based One-Time Password) secret key used for + two-factor authentication (2FA). + + Examples + -------- + >>> await client.login( + ... auth_info_1='example_user', + ... auth_info_2='email@example.com', + ... password='00000000' + ... ) + """ + self.http.cookies.clear() + guest_token = await self._get_guest_token() + + flow = Flow(self, guest_token) + + await flow.execute_task(params={'flow_name': 'login'}, data={ + 'input_flow_data': { + 'flow_context': { + 'debug_overrides': {}, + 'start_location': { + 'location': 'splash_screen' + } + } + }, + 'subtask_versions': { + 'action_list': 2, + 'alert_dialog': 1, + 'app_download_cta': 1, + 'check_logged_in_account': 1, + 'choice_selection': 3, + 'contacts_live_sync_permission_prompt': 0, + 'cta': 7, + 'email_verification': 2, + 'end_flow': 1, + 'enter_date': 1, + 'enter_email': 2, + 'enter_password': 5, + 'enter_phone': 2, + 'enter_recaptcha': 1, + 'enter_text': 5, + 'enter_username': 2, + 'generic_urt': 3, + 'in_app_notification': 1, + 'interest_picker': 3, + 'js_instrumentation': 1, + 'menu_dialog': 1, + 'notifications_permission_prompt': 2, + 'open_account': 2, + 'open_home_timeline': 1, + 'open_link': 1, + 'phone_verification': 4, + 'privacy_options': 1, + 'security_key': 3, + 'select_avatar': 4, + 'select_banner': 2, + 'settings_list': 7, + 'show_code': 1, + 'sign_up': 2, + 'sign_up_review': 4, + 'tweet_selection_urt': 1, + 'update_users': 1, + 'upload_media': 1, + 'user_recommendations_list': 4, + 'user_recommendations_urt': 1, + 'wait_spinner': 3, + 'web_modal': 1 + } + }) + await flow.sso_init('apple') + await flow.execute_task({ + "subtask_id": "LoginJsInstrumentationSubtask", + "js_instrumentation": { + "response": await self._ui_metrix(), + "link": "next_link" + } + }) + await flow.execute_task({ + 'subtask_id': 'LoginEnterUserIdentifierSSO', + 'settings_list': { + 'setting_responses': [ + { + 'key': 'user_identifier', + 'response_data': { + 'text_data': {'result': auth_info_1} + } + } + ], + 'link': 'next_link' + } + }) + + if flow.task_id == 'LoginEnterAlternateIdentifierSubtask': + await flow.execute_task({ + 'subtask_id': 'LoginEnterAlternateIdentifierSubtask', + 'enter_text': { + 'text': auth_info_2, + 'link': 'next_link' + } + }) + + if flow.task_id == 'DenyLoginSubtask': + raise TwitterException(flow.response['subtasks'][0]['cta']['secondary_text']['text']) + + await flow.execute_task({ + 'subtask_id': 'LoginEnterPassword', + 'enter_password': { + 'password': password, + 'link': 'next_link' + } + }) + + if flow.task_id == 'DenyLoginSubtask': + raise TwitterException(flow.response['subtasks'][0]['cta']['secondary_text']['text']) + + if flow.task_id == 'LoginAcid': + print(find_dict(flow.response, 'secondary_text', find_one=True)[0]['text']) + + await flow.execute_task({ + 'subtask_id': 'LoginAcid', + 'enter_text': { + 'text': input('>>> '), + 'link': 'next_link' + } + }) + return flow.response + + await flow.execute_task({ + 'subtask_id': 'AccountDuplicationCheck', + 'check_logged_in_account': { + 'link': 'AccountDuplicationCheck_false' + } + }) + + if not flow.response['subtasks']: + return + + self._user_id = find_dict(flow.response, 'id_str', find_one=True)[0] + + if flow.task_id == 'LoginTwoFactorAuthChallenge': + if totp_secret is None: + print(find_dict(flow.response, 'secondary_text', find_one=True)[0]['text']) + totp_code = input('>>>') + else: + totp_code = pyotp.TOTP(totp_secret).now() + + await flow.execute_task({ + 'subtask_id': 'LoginTwoFactorAuthChallenge', + 'enter_text': { + 'text': totp_code, + 'link': 'next_link' + } + }) + + return flow.response + + async def logout(self) -> Response: + """ + Logs out of the currently logged-in account. + """ + response, _ = await self.v11.account_logout() + return response + + async def unlock(self) -> None: + """ + Unlocks the account using the provided CAPTCHA solver. + + See Also + -------- + .capsolver + """ + if self.captcha_solver is None: + raise ValueError('Captcha solver is not provided.') + + response, html = await self.captcha_solver.get_unlock_html() + + if html.delete_button: + response, html = await self.captcha_solver.confirm_unlock( + html.authenticity_token, + html.assignment_token, + ui_metrics=True + ) + + if html.start_button or html.finish_button: + response, html = await self.captcha_solver.confirm_unlock( + html.authenticity_token, + html.assignment_token, + ui_metrics=True + ) + + cookies_backup = self.get_cookies().copy() + max_unlock_attempts = self.captcha_solver.max_attempts + attempt = 0 + while attempt < max_unlock_attempts: + attempt += 1 + + if html.authenticity_token is None: + response, html = await self.captcha_solver.get_unlock_html() + + result = self.captcha_solver.solve_funcaptcha(html.blob) + if result['errorId'] == 1: + continue + + self.set_cookies(cookies_backup, clear_cookies=True) + response, html = await self.captcha_solver.confirm_unlock( + html.authenticity_token, + html.assignment_token, + result['solution']['token'], + ) + + if html.finish_button: + response, html = await self.captcha_solver.confirm_unlock( + html.authenticity_token, + html.assignment_token, + ui_metrics=True + ) + finished = ( + response.next_request is not None and + response.next_request.url.path == '/' + ) + if finished: + return + raise Exception('could not unlock the account.') + + def get_cookies(self) -> dict: + """ + Get the cookies. + You can skip the login procedure by loading the saved cookies + using the :func:`set_cookies` method. + + Examples + -------- + >>> client.get_cookies() + + See Also + -------- + .set_cookies + .load_cookies + .save_cookies + """ + return dict(self.http.cookies) + + def save_cookies(self, path: str) -> None: + """ + Save cookies to file in json format. + You can skip the login procedure by loading the saved cookies + using the :func:`load_cookies` method. + + Parameters + ---------- + path : :class:`str` + The path to the file where the cookie will be stored. + + Examples + -------- + >>> client.save_cookies('cookies.json') + + See Also + -------- + .load_cookies + .get_cookies + .set_cookies + """ + with open(path, 'w', encoding='utf-8') as f: + json.dump(self.get_cookies(), f) + + def set_cookies(self, cookies: dict, clear_cookies: bool = False) -> None: + """ + Sets cookies. + You can skip the login procedure by loading a saved cookies. + + Parameters + ---------- + cookies : :class:`dict` + The cookies to be set as key value pair. + + Examples + -------- + >>> with open('cookies.json', 'r', encoding='utf-8') as f: + ... client.set_cookies(json.load(f)) + + See Also + -------- + .get_cookies + .load_cookies + .save_cookies + """ + if clear_cookies: + self.http.cookies.clear() + self.http.cookies.update(cookies) + + def load_cookies(self, path: str) -> None: + """ + Loads cookies from a file. + You can skip the login procedure by loading a saved cookies. + + Parameters + ---------- + path : :class:`str` + Path to the file where the cookie is stored. + + Examples + -------- + >>> client.load_cookies('cookies.json') + + See Also + -------- + .get_cookies + .save_cookies + .set_cookies + """ + with open(path, 'r', encoding='utf-8') as f: + self.set_cookies(json.load(f)) + + def set_delegate_account(self, user_id: str | None) -> None: + """ + Sets the account to act as. + + Parameters + ---------- + user_id : :class:`str` | None + The user ID of the account to act as. + Set to None to clear the delegated account. + """ + self._act_as = user_id + + async def user_id(self) -> str: + """ + Retrieves the user ID associated with the authenticated account. + """ + if self._user_id is not None: + return self._user_id + response, _ = await self.v11.settings() + screen_name = response['screen_name'] + self._user_id = (await self.get_user_by_screen_name(screen_name)).id + return self._user_id + + async def user(self) -> User: + """ + Retrieve detailed information about the authenticated user. + """ + return await self.get_user_by_id(await self.user_id()) + + async def search_tweet( + self, + query: str, + product: Literal['Top', 'Latest', 'Media'], + count: int = 20, + cursor: str | None = None + ) -> Result[Tweet]: + """ + Searches for tweets based on the specified query and + product type. + + Parameters + ---------- + query : :class:`str` + The search query. + product : {'Top', 'Latest', 'Media'} + The type of tweets to retrieve. + count : :class:`int`, default=20 + The number of tweets to retrieve, between 1 and 20. + cursor : :class:`str`, default=20 + Token to retrieve more tweets. + + Returns + ------- + Result[:class:`Tweet`] + An instance of the `Result` class containing the + search results. + + Examples + -------- + >>> tweets = await client.search_tweet('query', 'Top') + >>> for tweet in tweets: + ... print(tweet) + + + ... + ... + + >>> more_tweets = await tweets.next() # Retrieve more tweets + >>> for tweet in more_tweets: + ... print(tweet) + + + ... + ... + + >>> # Retrieve previous tweets + >>> previous_tweets = await tweets.previous() + """ + product = product.capitalize() + + response, _ = await self.gql.search_timeline(query, product, count, cursor) + instructions = find_dict(response, 'instructions', find_one=True) + if not instructions: + return Result([]) + instructions = instructions[0] + + if product == 'Media' and cursor is not None: + items = find_dict(instructions, 'moduleItems', find_one=True)[0] + else: + items_ = find_dict(instructions, 'entries', find_one=True) + if items_: + items = items_[0] + else: + items = [] + if product == 'Media': + if 'items' in items[0]['content']: + items = items[0]['content']['items'] + else: + items = [] + + next_cursor = None + previous_cursor = None + + results = [] + for item in items: + if item['entryId'].startswith('cursor-bottom'): + next_cursor = item['content']['value'] + if item['entryId'].startswith('cursor-top'): + previous_cursor = item['content']['value'] + if not item['entryId'].startswith(('tweet', 'search-grid')): + continue + + tweet = tweet_from_data(self, item) + if tweet is not None: + results.append(tweet) + + if next_cursor is None: + if product == 'Media': + entries = find_dict(instructions, 'entries', find_one=True)[0] + next_cursor = entries[-1]['content']['value'] + previous_cursor = entries[-2]['content']['value'] + else: + next_cursor = instructions[-1]['entry']['content']['value'] + previous_cursor = instructions[-2]['entry']['content']['value'] + + return Result( + results, + partial(self.search_tweet, query, product, count, next_cursor), + next_cursor, + partial(self.search_tweet, query, product, count, previous_cursor), + previous_cursor + ) + + async def search_user( + self, + query: str, + count: int = 20, + cursor: str | None = None + ) -> Result[User]: + """ + Searches for users based on the provided query. + + Parameters + ---------- + query : :class:`str` + The search query for finding users. + count : :class:`int`, default=20 + The number of users to retrieve in each request. + cursor : :class:`str`, default=None + Token to retrieve more users. + + Returns + ------- + Result[:class:`User`] + An instance of the `Result` class containing the + search results. + + Examples + -------- + >>> result = await client.search_user('query') + >>> for user in result: + ... print(user) + + + ... + ... + + >>> more_results = await result.next() # Retrieve more search results + >>> for user in more_results: + ... print(user) + + + ... + ... + """ + response, _ = await self.gql.search_timeline(query, 'People', count, cursor) + items = find_dict(response, 'entries', find_one=True)[0] + next_cursor = items[-1]['content']['value'] + + results = [] + for item in items: + if 'itemContent' not in item['content']: + continue + user_info = find_dict(item, 'result', find_one=True)[0] + results.append(User(self, user_info)) + + return Result( + results, + partial(self.search_user, query, count, next_cursor), + next_cursor + ) + + async def get_similar_tweets(self, tweet_id: str) -> list[Tweet]: + """ + Retrieves tweets similar to the specified tweet (Twitter premium only). + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet for which similar tweets are to be retrieved. + + Returns + ------- + list[:class:`Tweet`] + A list of Tweet objects representing tweets + similar to the specified tweet. + """ + response, _ = await self.gql.similar_posts(tweet_id) + items_ = find_dict(response, 'entries', find_one=True) + results = [] + if not items_: + return results + + for item in items_[0]: + if not item['entryId'].startswith('tweet'): + continue + + tweet = tweet_from_data(self, item) + if tweet is not None: + results.append(tweet) + + return results + + async def get_user_highlights_tweets( + self, + user_id: str, + count: int = 20, + cursor: str | None = None + ) -> Result[Tweet]: + """ + Retrieves highlighted tweets from a user's timeline. + + Parameters + ---------- + user_id : :class:`str` + The user ID + count : :class:`int`, default=20 + The number of tweets to retrieve. + + Returns + ------- + Result[:class:`Tweet`] + An instance of the `Result` class containing the highlighted tweets. + + Examples + -------- + >>> result = await client.get_user_highlights_tweets('123456789') + >>> for tweet in result: + ... print(tweet) + + + ... + ... + + >>> more_results = await result.next() # Retrieve more highlighted tweets + >>> for tweet in more_results: + ... print(tweet) + + + ... + ... + """ + response, _ = await self.gql.user_highlights_tweets(user_id, count, cursor) + + instructions = response['data']['user']['result']['timeline']['timeline']['instructions'] + instruction = find_entry_by_type(instructions, 'TimelineAddEntries') + if instruction is None: + return Result.empty() + entries = instruction['entries'] + previous_cursor = None + next_cursor = None + results = [] + + for entry in entries: + entryId = entry['entryId'] + if entryId.startswith('tweet'): + results.append(tweet_from_data(self, entry)) + elif entryId.startswith('cursor-top'): + previous_cursor = entry['content']['value'] + elif entryId.startswith('cursor-bottom'): + next_cursor = entry['content']['value'] + + return Result( + results, + partial(self.get_user_highlights_tweets, user_id, count, next_cursor), + next_cursor, + partial(self.get_user_highlights_tweets, user_id, count, previous_cursor), + previous_cursor + ) + + async def upload_media( + self, + source: str | bytes, + wait_for_completion: bool = False, + status_check_interval: float | None = None, + media_type: str | None = None, + media_category: str | None = None, + is_long_video: bool = False + ) -> str: + """ + Uploads media to twitter. + + Parameters + ---------- + source : :class:`str` | :class:`bytes` + The source of the media to be uploaded. + It can be either a file path or bytes of the media content. + wait_for_completion : :class:`bool`, default=False + Whether to wait for the completion of the media upload process. + status_check_interval : :class:`float`, default=1.0 + The interval (in seconds) to check the status of the + media upload process. + media_type : :class:`str`, default=None + The MIME type of the media. + If not specified, it will be guessed from the source. + media_category : :class:`str`, default=None + The media category. + is_long_video : :class:`bool`, default=False + If this is True, videos longer than 2:20 can be uploaded. + (Twitter Premium only) + + Returns + ------- + :class:`str` + The media ID of the uploaded media. + + Examples + -------- + Videos, images and gifs can be uploaded. + + >>> media_id_1 = await client.upload_media( + ... 'media1.jpg', + ... ) + + >>> media_id_2 = await client.upload_media( + ... 'media2.mp4', + ... wait_for_completion=True + ... ) + + >>> media_id_3 = await client.upload_media( + ... 'media3.gif', + ... wait_for_completion=True, + ... media_category='tweet_gif' # media_category must be specified + ... ) + """ + if not isinstance(wait_for_completion, bool): + raise TypeError( + 'wait_for_completion must be bool,' + f' not {wait_for_completion.__class__.__name__}' + ) + + if isinstance(source, str): + # If the source is a path + with open(source, 'rb') as file: + binary = file.read() + elif isinstance(source, bytes): + # If the source is bytes + binary = source + + if media_type is None: + # Guess mimetype if not specified + media_type = filetype.guess(binary).mime + + if wait_for_completion: + if media_type == 'image/gif': + if media_category is None: + raise TwitterException( + "`media_category` must be specified to check the " + "upload status of gif images ('dm_gif' or 'tweet_gif')" + ) + elif media_type.startswith('image'): + # Checking the upload status of an image is impossible. + wait_for_completion = False + + total_bytes = len(binary) + + # ============ INIT ============= + response, _ = await self.v11.upload_media_init( + media_type, total_bytes, media_category, is_long_video + ) + media_id = response['media_id'] + # =========== APPEND ============ + segment_index = 0 + bytes_sent = 0 + MAX_SEGMENT_SIZE = 8 * 1024 * 1024 # The maximum segment size is 8 MB + append_tasks = [] + chunk_streams: list[io.BytesIO] = [] + + while bytes_sent < total_bytes: + chunk = binary[bytes_sent:bytes_sent + MAX_SEGMENT_SIZE] + chunk_stream = io.BytesIO(chunk) + coro = self.v11.upload_media_append(is_long_video, media_id, segment_index, chunk_stream) + append_tasks.append(asyncio.create_task(coro)) + chunk_streams.append(chunk_stream) + + segment_index += 1 + bytes_sent += len(chunk) + + append_gather = asyncio.gather(*append_tasks) + await append_gather + + # Close chunk streams + for chunk_stream in chunk_streams: + chunk_stream.close() + + # ========== FINALIZE =========== + await self.v11.upload_media_finelize(is_long_video, media_id) + # =============================== + + if wait_for_completion: + while True: + state = await self.check_media_status(media_id, is_long_video) + processing_info = state['processing_info'] + if 'error' in processing_info: + raise InvalidMedia(processing_info['error'].get('message')) + if processing_info['state'] == 'succeeded': + break + await asyncio.sleep(status_check_interval or processing_info['check_after_secs']) + + return media_id + + async def check_media_status( + self, media_id: str, is_long_video: bool = False + ) -> dict: + """ + Check the status of uploaded media. + + Parameters + ---------- + media_id : :class:`str` + The media ID of the uploaded media. + + Returns + ------- + dict + A dictionary containing information about the status of + the uploaded media. + """ + response, _ = await self.v11.upload_media_status(is_long_video, media_id) + return response + + async def create_media_metadata( + self, + media_id: str, + alt_text: str | None = None, + sensitive_warning: list[Literal['adult_content', 'graphic_violence', 'other']] = None + ) -> Response: + """ + Adds metadata to uploaded media. + + Parameters + ---------- + media_id : :class:`str` + The media id for which to create metadata. + alt_text : :class:`str` | None, default=None + Alternative text for the media. + sensitive_warning : list{'adult_content', 'graphic_violence', 'other'} + A list of sensitive content warnings for the media. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> media_id = await client.upload_media('media.jpg') + >>> await client.create_media_metadata( + ... media_id, + ... alt_text='This is a sample media', + ... sensitive_warning=['other'] + ... ) + >>> await client.create_tweet(media_ids=[media_id]) + """ + _, response = await self.v11.create_media_metadata(media_id, alt_text, sensitive_warning) + return response + + async def create_poll( + self, + choices: list[str], + duration_minutes: int + ) -> str: + """ + Creates a poll and returns card-uri. + + Parameters + ---------- + choices : list[:class:`str`] + A list of choices for the poll. Maximum of 4 choices. + duration_minutes : :class:`int` + The duration of the poll in minutes. + + Returns + ------- + :class:`str` + The URI of the created poll card. + + Examples + -------- + Create a poll with three choices lasting for 60 minutes: + + >>> choices = ['Option A', 'Option B', 'Option C'] + >>> duration_minutes = 60 + >>> card_uri = await client.create_poll(choices, duration_minutes) + >>> print(card_uri) + 'card://0000000000000000000' + """ + response, _ = await self.v11.create_card(choices, duration_minutes) + return response['card_uri'] + + async def vote( + self, + selected_choice: str, + card_uri: str, + tweet_id: str, + card_name: str + ) -> Poll: + """ + Vote on a poll with the selected choice. + Parameters + ---------- + selected_choice : :class:`str` + The label of the selected choice for the vote. + card_uri : :class:`str` + The URI of the poll card. + tweet_id : :class:`str` + The ID of the original tweet containing the poll. + card_name : :class:`str` + The name of the poll card. + Returns + ------- + :class:`Poll` + The Poll object representing the updated poll after voting. + """ + response, _ = await self.v11.vote(selected_choice, card_uri, tweet_id, card_name) + card_data = { + 'rest_id': response['card']['url'], + 'legacy': response['card'] + } + return Poll(self, card_data, None) + + async def create_tweet( + self, + text: str = '', + media_ids: list[str] | None = None, + poll_uri: str | None = None, + reply_to: str | None = None, + conversation_control: Literal['followers', 'verified', 'mentioned'] | None = None, + attachment_url: str | None = None, + community_id: str | None = None, + share_with_followers: bool = False, + is_note_tweet: bool = False, + richtext_options: list[dict] = None, + edit_tweet_id: str | None = None + ) -> Tweet: + """ + Creates a new tweet on Twitter with the specified + text, media, and poll. + + Parameters + ---------- + text : :class:`str`, default='' + The text content of the tweet. + media_ids : list[:class:`str`], default=None + A list of media IDs or URIs to attach to the tweet. + media IDs can be obtained by using the `upload_media` method. + poll_uri : :class:`str`, default=None + The URI of a Twitter poll card to attach to the tweet. + Poll URIs can be obtained by using the `create_poll` method. + reply_to : :class:`str`, default=None + The ID of the tweet to which this tweet is a reply. + conversation_control : {'followers', 'verified', 'mentioned'} + The type of conversation control for the tweet: + - 'followers': Limits replies to followers only. + - 'verified': Limits replies to verified accounts only. + - 'mentioned': Limits replies to mentioned accounts only. + attachment_url : :class:`str` + URL of the tweet to be quoted. + is_note_tweet : :class:`bool`, default=False + If this option is set to True, tweets longer than 280 characters + can be posted (Twitter Premium only). + richtext_options : list[:class:`dict`], default=None + Options for decorating text (Twitter Premium only). + edit_tweet_id : :class:`str` | None, default=None + ID of the tweet to edit (Twitter Premium only). + + Raises + ------ + :exc:`DuplicateTweet` : If the tweet is a duplicate of another tweet. + + Returns + ------- + :class:`Tweet` + The Created Tweet. + + Examples + -------- + Create a tweet with media: + + >>> tweet_text = 'Example text' + >>> media_ids = [ + ... await client.upload_media('image1.png'), + ... await client.upload_media('image2.png') + ... ] + >>> await client.create_tweet( + ... tweet_text, + ... media_ids=media_ids + ... ) + + Create a tweet with a poll: + + >>> tweet_text = 'Example text' + >>> poll_choices = ['Option A', 'Option B', 'Option C'] + >>> duration_minutes = 60 + >>> poll_uri = await client.create_poll(poll_choices, duration_minutes) + >>> await client.create_tweet( + ... tweet_text, + ... poll_uri=poll_uri + ... ) + + See Also + -------- + .upload_media + .create_poll + """ + media_entities = [ + {'media_id': media_id, 'tagged_users': []} + for media_id in (media_ids or []) + ] + limit_mode = None + if conversation_control is not None: + conversation_control = conversation_control.lower() + limit_mode = { + 'followers': 'Community', + 'verified': 'Verified', + 'mentioned': 'ByInvitation' + }[conversation_control] + + response, _ = await self.gql.create_tweet( + is_note_tweet, text, media_entities, poll_uri, + reply_to, attachment_url, community_id, share_with_followers, + richtext_options, edit_tweet_id, limit_mode + ) + if 'errors' in response: + raise_exceptions_from_response(response['errors']) + raise CouldNotTweet( + response['errors'][0] if response['errors'] else 'Failed to post a tweet.' + ) + if is_note_tweet: + _result = response['data']['notetweet_create']['tweet_results'] + else: + _result = response['data']['create_tweet']['tweet_results'] + return tweet_from_data(self, _result) + + async def create_scheduled_tweet( + self, + scheduled_at: int, + text: str = '', + media_ids: list[str] | None = None, + ) -> str: + """ + Schedules a tweet to be posted at a specified timestamp. + + Parameters + ---------- + scheduled_at : :class:`int` + The timestamp when the tweet should be scheduled for posting. + text : :class:`str`, default='' + The text content of the tweet, by default an empty string. + media_ids : list[:class:`str`], default=None + A list of media IDs to be attached to the tweet, by default None. + + Returns + ------- + :class:`str` + The ID of the scheduled tweet. + + Examples + -------- + Create a tweet with media: + + >>> scheduled_time = int(time.time()) + 3600 # One hour from now + >>> tweet_text = 'Example text' + >>> media_ids = [ + ... await client.upload_media('image1.png'), + ... await client.upload_media('image2.png') + ... ] + >>> await client.create_scheduled_tweet( + ... scheduled_time + ... tweet_text, + ... media_ids=media_ids + ... ) + """ + response, _ = await self.gql.create_scheduled_tweet(scheduled_at, text, media_ids) + return response['data']['tweet']['rest_id'] + + async def delete_tweet(self, tweet_id: str) -> Response: + """Deletes a tweet. + + Parameters + ---------- + tweet_id : :class:`str` + ID of the tweet to be deleted. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> tweet_id = '0000000000' + >>> await delete_tweet(tweet_id) + """ + _, response = await self.gql.delete_tweet(tweet_id) + return response + + async def get_user_by_screen_name(self, screen_name: str) -> User: + """ + Fetches a user by screen name. + + Parameter + --------- + screen_name : :class:`str` + The screen name of the Twitter user. + + Returns + ------- + :class:`User` + An instance of the User class representing the + Twitter user. + + Examples + -------- + >>> target_screen_name = 'example_user' + >>> user = await client.get_user_by_name(target_screen_name) + >>> print(user) + + """ + response, _ = await self.gql.user_by_screen_name(screen_name) + + if 'user' not in response['data']: + raise UserNotFound('The user does not exist.') + user_data = response['data']['user']['result'] + if user_data.get('__typename') == 'UserUnavailable': + raise UserUnavailable(user_data.get('message')) + + return User(self, user_data) + + async def get_user_by_id(self, user_id: str) -> User: + """ + Fetches a user by ID + + Parameter + --------- + user_id : :class:`str` + The ID of the Twitter user. + + Returns + ------- + :class:`User` + An instance of the User class representing the + Twitter user. + + Examples + -------- + >>> target_screen_name = '000000000' + >>> user = await client.get_user_by_id(target_screen_name) + >>> print(user) + + """ + response, _ = await self.gql.user_by_rest_id(user_id) + if 'result' not in response['data']['user']: + raise TwitterException(f'Invalid user id: {user_id}') + user_data = response['data']['user']['result'] + if user_data.get('__typename') == 'UserUnavailable': + raise UserUnavailable(user_data.get('message')) + return User(self, user_data) + + async def reverse_geocode( + self, lat: float, long: float, accuracy: str | float | None = None, + granularity: str | None = None, max_results: int | None = None + ) -> list[Place]: + """ + Given a latitude and a longitude, searches for up to 20 places that + + Parameters + ---------- + lat : :class:`float` + The latitude to search around. + long : :class:`float` + The longitude to search around. + accuracy : :class:`str` | :class:`float` None, default=None + A hint on the "region" in which to search. + granularity : :class:`str` | None, default=None + This is the minimal granularity of place types to return and must + be one of: `neighborhood`, `city`, `admin` or `country`. + max_results : :class:`int` | None, default=None + A hint as to the number of results to return. + + Returns + ------- + list[:class:`.Place`] + """ + response, _ = await self.v11.reverse_geocode(lat, long, accuracy, granularity, max_results) + return _places_from_response(self, response) + + async def search_geo( + self, lat: float | None = None, long: float | None = None, + query: str | None = None, ip: str | None = None, + granularity: str | None = None, max_results: int | None = None + ) -> list[Place]: + """ + Search for places that can be attached to a Tweet via POST + statuses/update. + + Parameters + ---------- + lat : :class:`float` | None + The latitude to search around. + long : :class:`float` | None + The longitude to search around. + query : :class:`str` | None + Free-form text to match against while executing a geo-based query, + best suited for finding nearby locations by name. + Remember to URL encode the query. + ip : :class:`str` | None + An IP address. Used when attempting to + fix geolocation based off of the user's IP address. + granularity : :class:`str` | None + This is the minimal granularity of place types to return and must + be one of: `neighborhood`, `city`, `admin` or `country`. + max_results : :class:`int` | None + A hint as to the number of results to return. + + Returns + ------- + list[:class:`.Place`] + """ + response, _ = await self.v11.search_geo(lat, long, query, ip, granularity, max_results) + return _places_from_response(self, response) + + async def get_place(self, id: str) -> Place: + """ + Parameters + ---------- + id : :class:`str` + The ID of the place. + + Returns + ------- + :class:`.Place` + """ + response, _ = await self.v11.get_place(id) + return Place(self, response) + + async def _get_more_replies( + self, tweet_id: str, cursor: str + ) -> Result[Tweet]: + response, _ = await self.gql.tweet_detail(tweet_id, cursor) + entries = find_dict(response, 'entries', find_one=True)[0] + + results = [] + for entry in entries: + if entry['entryId'].startswith(('cursor', 'label')): + continue + tweet = tweet_from_data(self, entry) + if tweet is not None: + results.append(tweet) + + if entries[-1]['entryId'].startswith('cursor'): + next_cursor = entries[-1]['content']['itemContent']['value'] + _fetch_next_result = partial(self._get_more_replies, tweet_id, next_cursor) + else: + next_cursor = None + _fetch_next_result = None + + return Result( + results, + _fetch_next_result, + next_cursor + ) + + async def _show_more_replies( + self, tweet_id: str, cursor: str + ) -> Result[Tweet]: + response, _ = await self.gql.tweet_detail(tweet_id, cursor) + items = find_dict(response, 'moduleItems', find_one=True)[0] + results = [] + for item in items: + if 'tweet' not in item['entryId']: + continue + tweet = tweet_from_data(self, item) + if tweet is not None: + results.append(tweet) + return Result(results) + + async def get_tweet_by_id( + self, tweet_id: str, cursor: str | None = None + ) -> Tweet: + """ + Fetches a tweet by tweet ID. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet. + + Returns + ------- + :class:`Tweet` + A Tweet object representing the fetched tweet. + + Examples + -------- + >>> target_tweet_id = '...' + >>> tweet = client.get_tweet_by_id(target_tweet_id) + >>> print(tweet) + + """ + response, _ = await self.gql.tweet_detail(tweet_id, cursor) + + if 'errors' in response: + raise TweetNotAvailable(response['errors'][0]['message']) + + entries = find_dict(response, 'entries', find_one=True)[0] + reply_to = [] + replies_list = [] + related_tweets = [] + tweet = None + + for entry in entries: + if entry['entryId'].startswith('cursor'): + continue + tweet_object = tweet_from_data(self, entry) + if tweet_object is None: + continue + + if entry['entryId'].startswith('tweetdetailrelatedtweets'): + related_tweets.append(tweet_object) + continue + + if entry['entryId'] == f'tweet-{tweet_id}': + tweet = tweet_object + else: + if tweet is None: + reply_to.append(tweet_object) + else: + replies = [] + sr_cursor = None + show_replies = None + + for reply in entry['content']['items'][1:]: + if 'tweetcomposer' in reply['entryId']: + continue + if 'tweet' in reply.get('entryId'): + rpl = tweet_from_data(self, reply) + if rpl is None: + continue + replies.append(rpl) + if 'cursor' in reply.get('entryId'): + sr_cursor = reply['item']['itemContent']['value'] + show_replies = partial( + self._show_more_replies, + tweet_id, + sr_cursor + ) + tweet_object.replies = Result( + replies, + show_replies, + sr_cursor + ) + replies_list.append(tweet_object) + + display_type = find_dict(entry, 'tweetDisplayType', True) + if display_type and display_type[0] == 'SelfThread': + tweet.thread = [tweet_object, *replies] + + if entries[-1]['entryId'].startswith('cursor'): + # if has more replies + reply_next_cursor = entries[-1]['content']['itemContent']['value'] + _fetch_more_replies = partial(self._get_more_replies, + tweet_id, reply_next_cursor) + else: + reply_next_cursor = None + _fetch_more_replies = None + + tweet.replies = Result( + replies_list, + _fetch_more_replies, + reply_next_cursor + ) + tweet.reply_to = reply_to + tweet.related_tweets = related_tweets + + return tweet + + async def get_scheduled_tweets(self) -> list[ScheduledTweet]: + """ + Retrieves scheduled tweets. + + Returns + ------- + list[:class:`ScheduledTweet`] + List of ScheduledTweet objects representing the scheduled tweets. + """ + response, _ = await self.gql.fetch_scheduled_tweets() + tweets = find_dict(response, 'scheduled_tweet_list', find_one=True)[0] + return [ScheduledTweet(self, tweet) for tweet in tweets] + + async def delete_scheduled_tweet(self, tweet_id: str) -> Response: + """ + Delete a scheduled tweet. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the scheduled tweet to delete. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + """ + _, response = await self.gql.delete_scheduled_tweet(tweet_id) + return response + + async def _get_tweet_engagements( + self, tweet_id: str, count: int, cursor: str, f + ) -> Result[User]: + """ + Base function to get tweet engagements. + type0: retweeters + type1: favoriters + """ + response, _ = await f(tweet_id, count, cursor) + items_ = find_dict(response, 'entries', True) + if not items_: + return Result([]) + items = items_[0] + next_cursor = items[-1]['content']['value'] + previous_cursor = items[-2]['content']['value'] + + results = [] + for item in items: + if not item['entryId'].startswith('user'): + continue + user_info_ = find_dict(item, 'result', True) + if not user_info_: + continue + user_info = user_info_[0] + results.append(User(self, user_info)) + + return Result( + results, + partial(self._get_tweet_engagements, tweet_id, count, next_cursor, f), + next_cursor, + partial(self._get_tweet_engagements, tweet_id, count, previous_cursor, f), + previous_cursor + ) + + async def get_retweeters( + self, tweet_id: str, count: int = 40, cursor: str | None = None + ) -> Result[User]: + """ + Retrieve users who retweeted a specific tweet. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet. + count : :class:`int`, default=40 + The maximum number of users to retrieve. + cursor : :class:`str`, default=None + A string indicating the position of the cursor for pagination. + + Returns + ------- + Result[:class:`User`] + A list of users who retweeted the tweet. + + Examples + -------- + >>> tweet_id = '...' + >>> retweeters = client.get_retweeters(tweet_id) + >>> print(retweeters) + [, , ..., ] + + >>> more_retweeters = retweeters.next() # Retrieve more retweeters. + >>> print(more_retweeters) + [, , ..., ] + """ + return await self._get_tweet_engagements(tweet_id, count, cursor, self.gql.retweeters) + + async def get_favoriters( + self, tweet_id: str, count: int = 40, cursor: str | None = None + ) -> Result[User]: + """ + Retrieve users who favorited a specific tweet. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet. + count : int, default=40 + The maximum number of users to retrieve. + cursor : :class:`str`, default=None + A string indicating the position of the cursor for pagination. + + Returns + ------- + Result[:class:`User`] + A list of users who favorited the tweet. + + Examples + -------- + >>> tweet_id = '...' + >>> favoriters = await client.get_favoriters(tweet_id) + >>> print(favoriters) + [, , ..., ] + + >>> # Retrieve more favoriters. + >>> more_favoriters = await favoriters.next() + >>> print(more_favoriters) + [, , ..., ] + """ + return await self._get_tweet_engagements(tweet_id, count, cursor, self.gql.favoriters) + + async def get_community_note(self, note_id: str) -> CommunityNote: + """ + Fetches a community note by ID. + + Parameters + ---------- + note_id : :class:`str` + The ID of the community note. + + Returns + ------- + :class:`CommunityNote` + A CommunityNote object representing the fetched community note. + + Raises + ------ + :exc:`TwitterException` + Invalid note ID. + + Examples + -------- + >>> note_id = '...' + >>> note = client.get_community_note(note_id) + >>> print(note) + + """ + response, _ = await self.gql.bird_watch_one_note(note_id) + note_data = response['data']['birdwatch_note_by_rest_id'] + if 'data_v1' not in note_data: + raise TwitterException(f'Invalid note id: {note_id}') + return CommunityNote(self, note_data) + + async def get_user_tweets( + self, + user_id: str, + tweet_type: Literal['Tweets', 'Replies', 'Media', 'Likes'], + count: int = 40, + cursor: str | None = None + ) -> Result[Tweet]: + """ + Fetches tweets from a specific user's timeline. + + Parameters + ---------- + user_id : :class:`str` + The ID of the Twitter user whose tweets to retrieve. + To get the user id from the screen name, you can use + `get_user_by_screen_name` method. + tweet_type : {'Tweets', 'Replies', 'Media', 'Likes'} + The type of tweets to retrieve. + count : :class:`int`, default=40 + The number of tweets to retrieve. + cursor : :class:`str`, default=None + The cursor for fetching the next set of results. + + Returns + ------- + Result[:class:`Tweet`] + A Result object containing a list of `Tweet` objects. + + Examples + -------- + >>> user_id = '...' + + If you only have the screen name, you can get the user id as follows: + + >>> screen_name = 'example_user' + >>> user = client.get_user_by_screen_name(screen_name) + >>> user_id = user.id + + >>> tweets = await client.get_user_tweets(user_id, 'Tweets', count=20) + >>> for tweet in tweets: + ... print(tweet) + + + ... + ... + + >>> more_tweets = await tweets.next() # Retrieve more tweets + >>> for tweet in more_tweets: + ... print(tweet) + + + ... + ... + + >>> # Retrieve previous tweets + >>> previous_tweets = await tweets.previous() + + See Also + -------- + .get_user_by_screen_name + """ + tweet_type = tweet_type.capitalize() + f = { + 'Tweets': self.gql.user_tweets, + 'Replies': self.gql.user_tweets_and_replies, + 'Media': self.gql.user_media, + 'Likes': self.gql.user_likes, + }[tweet_type] + response, _ = await f(user_id, count, cursor) + + instructions_ = find_dict(response, 'instructions', True) + if not instructions_: + return Result([]) + instructions = instructions_[0] + + items = instructions[-1]['entries'] + next_cursor = items[-1]['content']['value'] + previous_cursor = items[-2]['content']['value'] + + if tweet_type == 'Media': + if cursor is None: + items = items[0]['content']['items'] + else: + items = instructions[0]['moduleItems'] + + results = [] + for item in items: + entry_id = item['entryId'] + + if not entry_id.startswith(('tweet', 'profile-conversation', 'profile-grid')): + continue + + if entry_id.startswith('profile-conversation'): + tweets = item['content']['items'] + replies = [] + for reply in tweets[1:]: + tweet_object = tweet_from_data(self, reply) + if tweet_object is None: + continue + replies.append(tweet_object) + item = tweets[0] + else: + replies = None + + tweet = tweet_from_data(self, item) + if tweet is None: + continue + tweet.replies = replies + results.append(tweet) + + return Result( + results, + partial(self.get_user_tweets, user_id, tweet_type, count, next_cursor), + next_cursor, + partial(self.get_user_tweets, user_id, tweet_type, count, previous_cursor), + previous_cursor + ) + + async def get_timeline( + self, + count: int = 20, + seen_tweet_ids: list[str] | None = None, + cursor: str | None = None + ) -> Result[Tweet]: + """ + Retrieves the timeline. + Retrieves tweets from Home -> For You. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of tweets to retrieve. + seen_tweet_ids : list[:class:`str`], default=None + A list of tweet IDs that have been seen. + cursor : :class:`str`, default=None + A cursor for pagination. + + Returns + ------- + Result[:class:`Tweet`] + A Result object containing a list of Tweet objects. + + Example + ------- + >>> tweets = await client.get_timeline() + >>> for tweet in tweets: + ... print(tweet) + + + ... + ... + >>> more_tweets = await tweets.next() # Retrieve more tweets + >>> for tweet in more_tweets: + ... print(tweet) + + + ... + ... + """ + response, _ = await self.gql.home_timeline(count, seen_tweet_ids, cursor) + items = find_dict(response, 'entries', find_one=True)[0] + next_cursor = items[-1]['content']['value'] + results = [] + + for item in items: + if 'itemContent' not in item['content']: + continue + tweet = tweet_from_data(self, item) + if tweet is None: + continue + results.append(tweet) + + return Result( + results, + partial(self.get_timeline, count, seen_tweet_ids, next_cursor), + next_cursor + ) + + async def get_latest_timeline( + self, + count: int = 20, + seen_tweet_ids: list[str] | None = None, + cursor: str | None = None + ) -> Result[Tweet]: + """ + Retrieves the timeline. + Retrieves tweets from Home -> Following. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of tweets to retrieve. + seen_tweet_ids : list[:class:`str`], default=None + A list of tweet IDs that have been seen. + cursor : :class:`str`, default=None + A cursor for pagination. + + Returns + ------- + Result[:class:`Tweet`] + A Result object containing a list of Tweet objects. + + Example + ------- + >>> tweets = await client.get_latest_timeline() + >>> for tweet in tweets: + ... print(tweet) + + + ... + ... + >>> more_tweets = await tweets.next() # Retrieve more tweets + >>> for tweet in more_tweets: + ... print(tweet) + + + ... + ... + """ + response, _ = await self.gql.home_latest_timeline(count, seen_tweet_ids, cursor) + items = find_dict(response, 'entries', find_one=True)[0] + next_cursor = items[-1]['content']['value'] + results = [] + + for item in items: + if 'itemContent' not in item['content']: + continue + tweet = tweet_from_data(self, item) + if tweet is None: + continue + results.append(tweet) + + return Result( + results, + partial(self.get_latest_timeline, count, seen_tweet_ids, next_cursor), + next_cursor + ) + + async def favorite_tweet(self, tweet_id: str) -> Response: + """ + Favorites a tweet. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet to be liked. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> tweet_id = '...' + >>> await client.favorite_tweet(tweet_id) + + See Also + -------- + .unfavorite_tweet + """ + _, response = await self.gql.favorite_tweet(tweet_id) + return response + + async def unfavorite_tweet(self, tweet_id: str) -> Response: + """ + Unfavorites a tweet. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet to be unliked. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> tweet_id = '...' + >>> await client.unfavorite_tweet(tweet_id) + + See Also + -------- + .favorite_tweet + """ + _, response = await self.gql.unfavorite_tweet(tweet_id) + return response + + async def retweet(self, tweet_id: str) -> Response: + """ + Retweets a tweet. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet to be retweeted. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> tweet_id = '...' + >>> await client.retweet(tweet_id) + + See Also + -------- + .delete_retweet + """ + _, response = await self.gql.retweet(tweet_id) + return response + + async def delete_retweet(self, tweet_id: str) -> Response: + """ + Deletes the retweet. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the retweeted tweet to be unretweeted. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> tweet_id = '...' + >>> await client.delete_retweet(tweet_id) + + See Also + -------- + .retweet + """ + _, response = await self.gql.delete_retweet(tweet_id) + return response + + async def bookmark_tweet( + self, tweet_id: str, folder_id: str | None = None + ) -> Response: + """ + Adds the tweet to bookmarks. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet to be bookmarked. + folder_id : :class:`str` | None, default=None + The ID of the folder to add the bookmark to. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> tweet_id = '...' + >>> await client.bookmark_tweet(tweet_id) + """ + if folder_id is None: + _, response = await self.gql.create_bookmark(tweet_id) + else: + _, response = await self.gql.bookmark_tweet_to_folder(tweet_id, folder_id) + return response + + async def delete_bookmark(self, tweet_id: str) -> Response: + """ + Removes the tweet from bookmarks. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet to be removed from bookmarks. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> tweet_id = '...' + >>> await client.delete_bookmark(tweet_id) + + See Also + -------- + .bookmark_tweet + """ + _, response = await self.gql.delete_bookmark(tweet_id) + return response + + async def get_bookmarks( + self, count: int = 20, + cursor: str | None = None, folder_id: str | None = None + ) -> Result[Tweet]: + """ + Retrieves bookmarks from the authenticated user's Twitter account. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of bookmarks to retrieve. + folder_id : :class:`str` | None, default=None + Folder to retrieve bookmarks. + + Returns + ------- + Result[:class:`Tweet`] + A Result object containing a list of Tweet objects + representing bookmarks. + + Example + ------- + >>> bookmarks = await client.get_bookmarks() + >>> for bookmark in bookmarks: + ... print(bookmark) + + + + >>> # # To retrieve more bookmarks + >>> more_bookmarks = await bookmarks.next() + >>> for bookmark in more_bookmarks: + ... print(bookmark) + + + """ + if folder_id is None: + response, _ = await self.gql.bookmarks(count, cursor) + else: + response, _ = await self.gql.bookmark_folder_timeline(count, cursor, folder_id) + + items_ = find_dict(response, 'entries', find_one=True) + if not items_: + return Result([]) + items = items_[0] + next_cursor = items[-1]['content']['value'] + if folder_id is None: + previous_cursor = items[-2]['content']['value'] + fetch_previous_result = partial(self.get_bookmarks, count, previous_cursor, folder_id) + else: + previous_cursor = None + fetch_previous_result = None + + results = [] + for item in items: + tweet = tweet_from_data(self, item) + if tweet is None: + continue + results.append(tweet) + + return Result( + results, + partial(self.get_bookmarks, count, next_cursor, folder_id), + next_cursor, + fetch_previous_result, + previous_cursor + ) + + async def delete_all_bookmarks(self) -> Response: + """ + Deleted all bookmarks. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> await client.delete_all_bookmarks() + """ + _, response = await self.gql.delete_all_bookmarks() + return response + + async def get_bookmark_folders(self, cursor: str | None = None) -> Result[BookmarkFolder]: + """ + Retrieves bookmark folders. + + Returns + ------- + Result[:class:`BookmarkFolder`] + Result object containing a list of bookmark folders. + + Examples + -------- + >>> folders = await client.get_bookmark_folders() + >>> print(folders) + [, ..., ] + >>> more_folders = await folders.next() # Retrieve more folders + """ + response, _ = await self.gql.bookmark_folders_slice(cursor) + + slice = find_dict(response, 'bookmark_collections_slice', find_one=True)[0] + results = [] + for item in slice['items']: + results.append(BookmarkFolder(self, item)) + + if 'next_cursor' in slice['slice_info']: + next_cursor = slice['slice_info']['next_cursor'] + fetch_next_result = partial(self.get_bookmark_folders, next_cursor) + else: + next_cursor = None + fetch_next_result = None + + return Result( + results, + fetch_next_result, + next_cursor + ) + + async def edit_bookmark_folder( + self, folder_id: str, name: str + ) -> BookmarkFolder: + """ + Edits a bookmark folder. + + Parameters + ---------- + folder_id : :class:`str` + ID of the folder to edit. + name : :class:`str` + New name for the folder. + + Returns + ------- + :class:`BookmarkFolder` + Updated bookmark folder. + + Examples + -------- + >>> await client.edit_bookmark_folder('123456789', 'MyFolder') + """ + response, _ = await self.gql.edit_bookmark_folder(folder_id, name) + return BookmarkFolder(self, response['data']['bookmark_collection_update']) + + async def delete_bookmark_folder(self, folder_id: str) -> Response: + """ + Deletes a bookmark folder. + + Parameters + ---------- + folder_id : :class:`str` + ID of the folder to delete. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + """ + _, response = await self.gql.delete_bookmark_folder(folder_id) + return response + + async def create_bookmark_folder(self, name: str) -> BookmarkFolder: + """Creates a bookmark folder. + + Parameters + ---------- + name : :class:`str` + Name of the folder. + + Returns + ------- + :class:`BookmarkFolder` + Newly created bookmark folder. + """ + response, _ = await self.gql.create_bookmark_folder(name) + return BookmarkFolder(self, response['data']['bookmark_collection_create']) + + async def follow_user(self, user_id: str) -> User: + """ + Follows a user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to follow. + + Returns + ------- + :class:`User` + The followed user. + + Examples + -------- + >>> user_id = '...' + >>> await client.follow_user(user_id) + + See Also + -------- + .unfollow_user + """ + response, _ = await self.v11.create_friendships(user_id) + return User(self, build_user_data(response)) + + async def unfollow_user(self, user_id: str) -> User: + """ + Unfollows a user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to unfollow. + + Returns + ------- + :class:`User` + The unfollowed user. + + Examples + -------- + >>> user_id = '...' + >>> await client.unfollow_user(user_id) + + See Also + -------- + .follow_user + """ + response, _ = await self.v11.destroy_friendships(user_id) + return User(self, build_user_data(response)) + + async def block_user(self, user_id: str) -> User: + """ + Blocks a user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to block. + + Returns + ------- + :class:`User` + The blocked user. + + See Also + -------- + .unblock_user + """ + response, _ = await self.v11.create_blocks(user_id) + return User(self, build_user_data(response)) + + async def unblock_user(self, user_id: str) -> User: + """ + Unblocks a user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to unblock. + + Returns + ------- + :class:`User` + The unblocked user. + + See Also + -------- + .block_user + """ + response, _ = await self.v11.destroy_blocks(user_id) + return User(self, build_user_data(response)) + + async def mute_user(self, user_id: str) -> User: + """ + Mutes a user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to mute. + + Returns + ------- + :class:`User` + The muted user. + + See Also + -------- + .unmute_user + """ + response, _ = await self.v11.create_mutes(user_id) + return User(self, build_user_data(response)) + + async def unmute_user(self, user_id: str) -> User: + """ + Unmutes a user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to unmute. + + Returns + ------- + :class:`User` + The unmuted user. + + See Also + -------- + .mute_user + """ + response, _ = await self.v11.destroy_mutes(user_id) + return User(self, build_user_data(response)) + + async def get_trends( + self, + category: Literal['trending', 'for-you', 'news', 'sports', 'entertainment'], + count: int = 20, + retry: bool = True, + additional_request_params: dict | None = None + ) -> list[Trend]: + """ + Retrieves trending topics on Twitter. + + Parameters + ---------- + category : {'trending', 'for-you', 'news', 'sports', 'entertainment'} + The category of trends to retrieve. Valid options include: + - 'trending': General trending topics. + - 'for-you': Trends personalized for the user. + - 'news': News-related trends. + - 'sports': Sports-related trends. + - 'entertainment': Entertainment-related trends. + count : :class:`int`, default=20 + The number of trends to retrieve. + retry : :class:`bool`, default=True + If no trends are fetched continuously retry to fetch trends. + additional_request_params : :class:`dict`, default=None + Parameters to be added on top of the existing trends API + parameters. Typically, it is used as `additional_request_params = + {'candidate_source': 'trends'}` when this function doesn't work + otherwise. + + Returns + ------- + list[:class:`Trend`] + A list of Trend objects representing the retrieved trends. + + Examples + -------- + >>> trends = await client.get_trends('trending') + >>> for trend in trends: + ... print(trend) + + + ... + """ + category = category.lower() + if category in ['news', 'sports', 'entertainment']: + category += '_unified' + response, _ = await self.v11.guide(category, count, additional_request_params) + + entry_id_prefix = 'trends' if category == 'trending' else 'Guide' + entries = [ + i for i in find_dict(response, 'entries', find_one=True)[0] + if i['entryId'].startswith(entry_id_prefix) + ] + + if not entries: + if not retry: + return [] + # Recall the method again, as the trend information + # may not be returned due to a Twitter error. + return await self.get_trends(category, count, retry, additional_request_params) + + items = entries[-1]['content']['timelineModule']['items'] + + results = [] + for item in items: + trend_info = item['item']['content']['trend'] + results.append(Trend(self, trend_info)) + + return results + + async def get_available_locations(self) -> list[Location]: + """ + Retrieves locations where trends can be retrieved. + + Returns + ------- + list[:class:`.Location`] + """ + response, _ = await self.v11.available_trends() + return [Location(self, data) for data in response] + + async def get_place_trends(self, woeid: int) -> PlaceTrends: + """ + Retrieves the top 50 trending topics for a specific id. + You can get available woeid using + :attr:`.Client.get_available_locations`. + """ + response, _ = await self.v11.place_trends(woeid) + trend_data = response[0] + trends = [PlaceTrend(self, data) for data in trend_data['trends']] + trend_data['trends'] = trends + return trend_data + + async def _get_user_friendship( + self, + user_id: str, + count: int, + f, + cursor: str | None + ) -> Result[User]: + """ + Base function to get friendship. + """ + response, _ = await f(user_id, count, cursor) + + items_ = find_dict(response, 'entries', find_one=True) + if not items_: + return Result.empty() + items = items_[0] + results = [] + for item in items: + entry_id = item['entryId'] + if entry_id.startswith('user'): + user_info = find_dict(item, 'result', find_one=True) + if not user_info: + warnings.warn( + 'Some followers are excluded because ' + '"Quality Filter" is enabled. To get all followers, ' + 'turn off it in the Twitter settings.' + ) + continue + if user_info[0].get('__typename') == 'UserUnavailable': + continue + results.append(User(self, user_info[0])) + elif entry_id.startswith('cursor-bottom'): + next_cursor = item['content']['value'] + + return Result( + results, + partial(self._get_user_friendship, user_id, count, f, next_cursor), + next_cursor + ) + + async def _get_user_friendship_2( + self, user_id: str, screen_name: str, + count: int, f, cursor: str + ) -> Result[User]: + response, _ = await f(user_id, screen_name, count, cursor) + users = response['users'] + results = [] + for user in users: + results.append(User(self, build_user_data(user))) + + previous_cursor = response['previous_cursor'] + next_cursor = response['next_cursor'] + + return Result( + results, + partial(self._get_user_friendship_2, user_id, screen_name, count, f, next_cursor), + next_cursor, + partial(self._get_user_friendship_2, user_id, screen_name, count, f, previous_cursor), + previous_cursor + ) + + async def get_user_followers( + self, user_id: str, count: int = 20, cursor: str | None = None + ) -> Result[User]: + """ + Retrieves a list of followers for a given user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user for whom to retrieve followers. + count : int, default=20 + The number of followers to retrieve. + + Returns + ------- + Result[:class:`User`] + A list of User objects representing the followers. + """ + return await self._get_user_friendship( + user_id, count, self.gql.followers, cursor + ) + + async def get_latest_followers( + self, user_id: str | None = None, screen_name: str | None = None, + count: int = 200, cursor: str | None = None + ) -> Result[User]: + """ + Retrieves the latest followers. + Max count : 200 + """ + return await self._get_user_friendship_2( + user_id, screen_name, count, self.v11.followers_list, cursor + ) + + async def get_latest_friends( + self, user_id: str | None = None, screen_name: str | None = None, + count: int = 200, cursor: str | None = None + ) -> Result[User]: + """ + Retrieves the latest friends (following users). + Max count : 200 + """ + return await self._get_user_friendship_2( + user_id, screen_name, count, self.v11.friends_list, cursor + ) + + async def get_user_verified_followers( + self, user_id: str, count: int = 20, cursor: str | None = None + ) -> Result[User]: + """ + Retrieves a list of verified followers for a given user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user for whom to retrieve verified followers. + count : :class:`int`, default=20 + The number of verified followers to retrieve. + + Returns + ------- + Result[:class:`User`] + A list of User objects representing the verified followers. + """ + return await self._get_user_friendship( + user_id, count, self.gql.blue_verified_followers, cursor + ) + + async def get_user_followers_you_know( + self, user_id: str, count: int = 20, cursor: str | None = None + ) -> Result[User]: + """ + Retrieves a list of common followers. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user for whom to retrieve followers you might know. + count : :class:`int`, default=20 + The number of followers you might know to retrieve. + + Returns + ------- + Result[:class:`User`] + A list of User objects representing the followers you might know. + """ + return await self._get_user_friendship( + user_id, count, self.gql.followers_you_know, cursor + ) + + async def get_user_following( + self, user_id: str, count: int = 20, cursor: str | None = None + ) -> Result[User]: + """ + Retrieves a list of users whom the given user is following. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user for whom to retrieve the following users. + count : :class:`int`, default=20 + The number of following users to retrieve. + + Returns + ------- + Result[:class:`User`] + A list of User objects representing the users being followed. + """ + return await self._get_user_friendship( + user_id, count, self.gql.following, cursor + ) + + async def get_user_subscriptions( + self, user_id: str, count: int = 20, cursor: str | None = None + ) -> Result[User]: + """ + Retrieves a list of users to which the specified user is subscribed. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user for whom to retrieve subscriptions. + count : :class:`int`, default=20 + The number of subscriptions to retrieve. + + Returns + ------- + Result[:class:`User`] + A list of User objects representing the subscribed users. + """ + return await self._get_user_friendship( + user_id, count, self.gql.user_creator_subscriptions, cursor + ) + + async def _get_friendship_ids( + self, + user_id: str | None, + screen_name: str | None, + count: int, + f, + cursor: str | None + ) -> Result[int]: + response, _ = await f(user_id, screen_name, count, cursor) + previous_cursor = response['previous_cursor'] + next_cursor = response['next_cursor'] + + return Result( + response['ids'], + partial(self._get_friendship_ids, user_id, screen_name, count, f, next_cursor), + next_cursor, + partial(self._get_friendship_ids, user_id, screen_name, count, f, previous_cursor), + previous_cursor + ) + + async def get_followers_ids( + self, + user_id: str | None = None, + screen_name: str | None = None, + count: int = 5000, + cursor: str | None = None + ) -> Result[int]: + """ + Fetches the IDs of the followers of a specified user. + + Parameters + ---------- + user_id : :class:`str` | None, default=None + The ID of the user for whom to return results. + screen_name : :class:`str` | None, default=None + The screen name of the user for whom to return results. + count : :class:`int`, default=5000 + The maximum number of IDs to retrieve. + + Returns + ------- + :class:`Result`[:class:`int`] + A Result object containing the IDs of the followers. + """ + return await self._get_friendship_ids(user_id, screen_name, count, self.v11.followers_ids, cursor) + + async def get_friends_ids( + self, + user_id: str | None = None, + screen_name: str | None = None, + count: int = 5000, + cursor: str | None = None + ) -> Result[int]: + """ + Fetches the IDs of the friends (following users) of a specified user. + + Parameters + ---------- + user_id : :class:`str` | None, default=None + The ID of the user for whom to return results. + screen_name : :class:`str` | None, default=None + The screen name of the user for whom to return results. + count : :class:`int`, default=5000 + The maximum number of IDs to retrieve. + + Returns + ------- + :class:`Result`[:class:`int`] + A Result object containing the IDs of the friends. + """ + return await self._get_friendship_ids( + user_id, screen_name, count, self.v11.friends_ids, cursor + ) + + async def _send_dm( + self, + conversation_id: str, + text: str, + media_id: str | None, + reply_to: str | None + ) -> dict: + """ + Base function to send dm. + """ + response, _ = await self.v11.dm_new(conversation_id, text, media_id, reply_to) + return response + + async def _get_dm_history( + self, + conversation_id: str, + max_id: str | None = None + ) -> dict: + """ + Base function to get dm history. + """ + response, _ = await self.v11.dm_conversation(conversation_id, max_id) + return response + + async def send_dm( + self, + user_id: str, + text: str, + media_id: str | None = None, + reply_to: str | None = None + ) -> Message: + """ + Send a direct message to a user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to whom the direct message will be sent. + text : :class:`str` + The text content of the direct message. + media_id : :class:`str`, default=None + The media ID associated with any media content + to be included in the message. + Media ID can be received by using the :func:`.upload_media` method. + reply_to : :class:`str`, default=None + Message ID to reply to. + + Returns + ------- + :class:`Message` + `Message` object containing information about the message sent. + + Examples + -------- + >>> # send DM with media + >>> user_id = '000000000' + >>> media_id = await client.upload_media('image.png') + >>> message = await client.send_dm(user_id, 'text', media_id) + >>> print(message) + + + See Also + -------- + .upload_media + .delete_dm + """ + response = await self._send_dm( + f'{user_id}-{await self.user_id()}', text, media_id, reply_to + ) + + message_data = find_dict(response, 'message_data', find_one=True)[0] + users = list(response['users'].values()) + return Message( + self, + message_data, + users[0]['id_str'], + users[1]['id_str'] if len(users) == 2 else users[0]['id_str'] + ) + + async def add_reaction_to_message( + self, message_id: str, conversation_id: str, emoji: str + ) -> Response: + """ + Adds a reaction emoji to a specific message in a conversation. + + Parameters + ---------- + message_id : :class:`str` + The ID of the message to which the reaction emoji will be added. + Group ID ('00000000') or partner_ID-your_ID ('00000000-00000001') + conversation_id : :class:`str` + The ID of the conversation containing the message. + emoji : :class:`str` + The emoji to be added as a reaction. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> message_id = '00000000' + >>> conversation_id = f'00000001-{await client.user_id()}' + >>> await client.add_reaction_to_message( + ... message_id, conversation_id, 'Emoji here' + ... ) + """ + _, response = await self.gql.user_dm_reaction_mutation_add_mutation( + message_id, conversation_id, emoji + ) + return response + + async def remove_reaction_from_message( + self, message_id: str, conversation_id: str, emoji: str + ) -> Response: + """ + Remove a reaction from a message. + + Parameters + ---------- + message_id : :class:`str` + The ID of the message from which to remove the reaction. + conversation_id : :class:`str` + The ID of the conversation where the message is located. + Group ID ('00000000') or partner_ID-your_ID ('00000000-00000001') + emoji : :class:`str` + The emoji to remove as a reaction. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> message_id = '00000000' + >>> conversation_id = f'00000001-{await client.user_id()}' + >>> await client.remove_reaction_from_message( + ... message_id, conversation_id, 'Emoji here' + ... ) + """ + _, response = await self.gql.user_dm_reaction_mutation_remove_mutation( + message_id, conversation_id, emoji + ) + return response + + async def delete_dm(self, message_id: str) -> Response: + """ + Deletes a direct message with the specified message ID. + + Parameters + ---------- + message_id : :class:`str` + The ID of the direct message to be deleted. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> await client.delete_dm('0000000000') + """ + _, response = await self.gql.dm_message_delete_mutation(message_id) + return response + + async def get_dm_history( + self, + user_id: str, + max_id: str | None = None + ) -> Result[Message]: + """ + Retrieves the DM conversation history with a specific user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user with whom the DM conversation + history will be retrieved. + max_id : :class:`str`, default=None + If specified, retrieves messages older than the specified max_id. + + Returns + ------- + Result[:class:`Message`] + A Result object containing a list of Message objects representing + the DM conversation history. + + Examples + -------- + >>> messages = await client.get_dm_history('0000000000') + >>> for message in messages: + >>> print(message) + + + ... + ... + + >>> more_messages = await messages.next() # Retrieve more messages + >>> for message in more_messages: + >>> print(message) + + + ... + ... + """ + response = await self._get_dm_history( + f'{user_id}-{await self.user_id()}', max_id + ) + + if 'entries' not in response['conversation_timeline']: + return Result([]) + items = response['conversation_timeline']['entries'] + + messages = [] + for item in items: + message_info = item['message']['message_data'] + messages.append(Message( + self, + message_info, + message_info['sender_id'], + message_info['recipient_id'] + )) + + return Result( + messages, + partial(self.get_dm_history, user_id, messages[-1].id), + messages[-1].id + ) + + async def send_dm_to_group( + self, + group_id: str, + text: str, + media_id: str | None = None, + reply_to: str | None = None + ) -> GroupMessage: + """ + Sends a message to a group. + + Parameters + ---------- + group_id : :class:`str` + The ID of the group in which the direct message will be sent. + text : :class:`str` + The text content of the direct message. + media_id : :class:`str`, default=None + The media ID associated with any media content + to be included in the message. + Media ID can be received by using the :func:`.upload_media` method. + reply_to : :class:`str`, default=None + Message ID to reply to. + + Returns + ------- + :class:`GroupMessage` + `GroupMessage` object containing information about + the message sent. + + Examples + -------- + >>> # send DM with media + >>> group_id = '000000000' + >>> media_id = await client.upload_media('image.png') + >>> message = await client.send_dm_to_group(group_id, 'text', media_id) + >>> print(message) + + + See Also + -------- + .upload_media + .delete_dm + """ + response = await self._send_dm(group_id, text, media_id, reply_to) + + message_data = find_dict(response, 'message_data', find_one=True)[0] + users = list(response['users'].values()) + return GroupMessage( + self, + message_data, + users[0]['id_str'], + group_id + ) + + async def get_group_dm_history( + self, + group_id: str, + max_id: str | None = None + ) -> Result[GroupMessage]: + """ + Retrieves the DM conversation history in a group. + + Parameters + ---------- + group_id : :class:`str` + The ID of the group in which the DM conversation + history will be retrieved. + max_id : :class:`str`, default=None + If specified, retrieves messages older than the specified max_id. + + Returns + ------- + Result[:class:`GroupMessage`] + A Result object containing a list of GroupMessage objects + representing the DM conversation history. + + Examples + -------- + >>> messages = await client.get_group_dm_history('0000000000') + >>> for message in messages: + >>> print(message) + + + ... + ... + + >>> more_messages = await messages.next() # Retrieve more messages + >>> for message in more_messages: + >>> print(message) + + + ... + ... + """ + response = await self._get_dm_history(group_id, max_id) + if 'entries' not in response['conversation_timeline']: + return Result([]) + + items = response['conversation_timeline']['entries'] + messages = [] + for item in items: + if 'message' not in item: + continue + message_info = item['message']['message_data'] + messages.append(GroupMessage( + self, + message_info, + message_info['sender_id'], + group_id + )) + + return Result( + messages, + partial(self.get_group_dm_history, group_id, messages[-1].id), + messages[-1].id + ) + + async def get_group(self, group_id: str) -> Group: + """ + Fetches a guild by ID. + + Parameters + ---------- + group_id : :class:`str` + The ID of the group to retrieve information for. + + Returns + ------- + :class:`Group` + An object representing the retrieved group. + """ + response = await self._get_dm_history(group_id) + return Group(self, group_id, response) + + async def add_members_to_group( + self, group_id: str, user_ids: list[str] + ) -> Response: + """Adds members to a group. + + Parameters + ---------- + group_id : :class:`str` + ID of the group to which the member is to be added. + user_ids : list[:class:`str`] + List of IDs of users to be added. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> group_id = '...' + >>> members = ['...'] + >>> await client.add_members_to_group(group_id, members) + """ + _, response = await self.gql.add_participants_mutation(group_id, user_ids) + return response + + async def change_group_name(self, group_id: str, name: str) -> Response: + """Changes group name + + Parameters + ---------- + group_id : :class:`str` + ID of the group to be renamed. + name : :class:`str` + New name. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + """ + _, response = await self.v11.conversation_update_name(group_id, name) + return response + + async def create_list( + self, name: str, description: str = '', is_private: bool = False + ) -> List: + """ + Creates a list. + + Parameters + ---------- + name : :class:`str` + The name of the list. + description : :class:`str`, default='' + The description of the list. + is_private : :class:`bool`, default=False + Indicates whether the list is private (True) or public (False). + + Returns + ------- + :class:`List` + The created list. + + Examples + -------- + >>> list = await client.create_list( + ... 'list name', + ... 'list description', + ... is_private=True + ... ) + >>> print(list) + + """ + response, _ = await self.gql.create_list(name, description, is_private) + list_info = find_dict(response, 'list', find_one=True)[0] + return List(self, list_info) + + async def edit_list_banner(self, list_id: str, media_id: str) -> Response: + """ + Edit the banner image of a list. + + Parameters + ---------- + list_id : :class:`str` + The ID of the list. + media_id : :class:`str` + The ID of the media to use as the new banner image. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> list_id = '...' + >>> media_id = await client.upload_media('image.png') + >>> await client.edit_list_banner(list_id, media_id) + """ + _, response = await self.gql.edit_list_banner(list_id, media_id) + return response + + async def delete_list_banner(self, list_id: str) -> Response: + """Deletes list banner. + + Parameters + ---------- + list_id : :class:`str` + ID of the list from which the banner is to be removed. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + """ + _, response = await self.gql.delete_list_banner(list_id) + return response + + async def edit_list( + self, + list_id: str, + name: str | None = None, + description: str | None = None, + is_private: bool | None = None + ) -> List: + """ + Edits list information. + + Parameters + ---------- + list_id : :class:`str` + The ID of the list to edit. + name : :class:`str`, default=None + The new name for the list. + description : :class:`str`, default=None + The new description for the list. + is_private : :class:`bool`, default=None + Indicates whether the list should be private + (True) or public (False). + + Returns + ------- + :class:`List` + The updated Twitter list. + + Examples + -------- + >>> await client.edit_list( + ... 'new name', 'new description', True + ... ) + """ + response, _ = await self.gql.update_list(list_id, name, description, is_private) + list_info = find_dict(response, 'list', find_one=True)[0] + return List(self, list_info) + + async def add_list_member(self, list_id: str, user_id: str) -> List: + """ + Adds a user to a list. + + Parameters + ---------- + list_id : :class:`str` + The ID of the list. + user_id : :class:`str` + The ID of the user to add to the list. + + Returns + ------- + :class:`List` + The updated Twitter list. + + Examples + -------- + >>> await client.add_list_member('list id', 'user id') + """ + response, _ = await self.gql.list_add_member(list_id, user_id) + return List(self, response['data']['list']) + + async def remove_list_member(self, list_id: str, user_id: str) -> List: + """ + Removes a user from a list. + + Parameters + ---------- + list_id : :class:`str` + The ID of the list. + user_id : :class:`str` + The ID of the user to remove from the list. + + Returns + ------- + :class:`List` + The updated Twitter list. + + Examples + -------- + >>> await client.remove_list_member('list id', 'user id') + """ + response, _ = await self.gql.list_remove_member(list_id, user_id) + if 'errors' in response: + raise TwitterException(response['errors'][0]['message']) + return List(self, response['data']['list']) + + async def get_lists( + self, count: int = 100, cursor: str = None + ) -> Result[List]: + """ + Retrieves a list of user lists. + + Parameters + ---------- + count : :class:`int` + The number of lists to retrieve. + + Returns + ------- + Result[:class:`List`] + Retrieved lists. + + Examples + -------- + >>> lists = client.get_lists() + >>> for list_ in lists: + ... print(list_) + + + ... + ... + >>> more_lists = lists.next() # Retrieve more lists + """ + response, _ = await self.gql.list_management_pace_timeline(count, cursor) + + entries = find_dict(response, 'entries', find_one=True)[0] + items = find_dict(entries, 'items') + + if len(items) < 2: + return Result([]) + + lists = [] + for list in items[1]: + lists.append(List(self, list['item']['itemContent']['list'])) + + next_cursor = entries[-1]['content']['value'] + + return Result( + lists, + partial(self.get_lists, count, next_cursor), + next_cursor + ) + + async def get_list(self, list_id: str) -> List: + """ + Retrieve list by ID. + + Parameters + ---------- + list_id : :class:`str` + The ID of the list to retrieve. + + Returns + ------- + :class:`List` + List object. + """ + response, _ = await self.gql.list_by_rest_id(list_id) + list_data_ = find_dict(response, 'list', find_one=True) + if not list_data_: + raise ValueError(f'Invalid list id: {list_id}') + return List(self, list_data_[0]) + + async def get_list_tweets( + self, list_id: str, count: int = 20, cursor: str | None = None + ) -> Result[Tweet]: + """ + Retrieves tweets from a list. + + Parameters + ---------- + list_id : :class:`str` + The ID of the list to retrieve tweets from. + count : :class:`int`, default=20 + The number of tweets to retrieve. + cursor : :class:`str`, default=None + The cursor for pagination. + + Returns + ------- + Result[:class:`Tweet`] + A Result object containing the retrieved tweets. + + Examples + -------- + >>> tweets = await client.get_list_tweets('list id') + >>> for tweet in tweets: + ... print(tweet) + + + ... + ... + + >>> more_tweets = await tweets.next() # Retrieve more tweets + >>> for tweet in more_tweets: + ... print(tweet) + + + ... + ... + """ + response, _ = await self.gql.list_latest_tweets_timeline(list_id, count, cursor) + + items_ = find_dict(response, 'entries', find_one=True) + if not items_: + raise ValueError(f'Invalid list id: {list_id}') + items = items_[0] + next_cursor = items[-1]['content']['value'] + + results = [] + for item in items: + if not item['entryId'].startswith('tweet'): + continue + + tweet = tweet_from_data(self, item) + if tweet is not None: + results.append(tweet) + + return Result( + results, + partial(self.get_list_tweets, list_id, count, next_cursor), + next_cursor + ) + + async def _get_list_users(self, f: str, list_id: str, count: int, cursor: str) -> Result[User]: + """ + Base function to retrieve the users associated with a list. + """ + response, _ = await f(list_id, count, cursor) + + items = find_dict(response, 'entries', find_one=True)[0] + results = [] + for item in items: + entry_id = item['entryId'] + if entry_id.startswith('user'): + user_info = find_dict(item, 'result', find_one=True)[0] + results.append(User(self, user_info)) + elif entry_id.startswith('cursor-bottom'): + next_cursor = item['content']['value'] + break + + return Result( + results, + partial(self._get_list_users, f, list_id, count, next_cursor), + next_cursor + ) + + async def get_list_members( + self, list_id: str, count: int = 20, cursor: str | None = None + ) -> Result[User]: + """Retrieves members of a list. + + Parameters + ---------- + list_id : :class:`str` + List ID. + count : int, default=20 + Number of members to retrieve. + + Returns + ------- + Result[:class:`User`] + Members of a list + + Examples + -------- + >>> members = client.get_list_members(123456789) + >>> for member in members: + ... print(member) + + + ... + ... + >>> more_members = members.next() # Retrieve more members + """ + return await self._get_list_users(self.gql.list_members, list_id, count, cursor) + + async def get_list_subscribers( + self, list_id: str, count: int = 20, cursor: str | None = None + ) -> Result[User]: + """Retrieves subscribers of a list. + + Parameters + ---------- + list_id : :class:`str` + List ID. + count : :class:`int`, default=20 + Number of subscribers to retrieve. + + Returns + ------- + Result[:class:`User`] + Subscribers of a list + + Examples + -------- + >>> members = client.get_list_subscribers(123456789) + >>> for subscriber in subscribers: + ... print(subscriber) + + + ... + ... + >>> more_subscribers = members.next() # Retrieve more subscribers + """ + return await self._get_list_users(self.gql.list_subscribers, list_id, count, cursor) + + async def search_list( + self, query: str, count: int = 20, cursor: str | None = None + ) -> Result[List]: + """ + Search for lists based on the provided query. + + Parameters + ---------- + query : :class:`str` + The search query. + count : :class:`int`, default=20 + The number of lists to retrieve. + + Returns + ------- + Result[:class:`List`] + An instance of the `Result` class containing the + search results. + + Examples + -------- + >>> lists = await client.search_list('query') + >>> for list in lists: + ... print(list) + + + ... + + >>> more_lists = await lists.next() # Retrieve more lists + """ + response, _ = await self.gql.search_timeline(query, 'Lists', count, cursor) + entries = find_dict(response, 'entries', find_one=True)[0] + + if cursor is None: + items = entries[0]['content']['items'] + else: + items = find_dict(response, 'moduleItems', find_one=True)[0] + + lists = [] + for item in items: + lists.append(List(self, item['item']['itemContent']['list'])) + next_cursor = entries[-1]['content']['value'] + + return Result( + lists, + partial(self.search_list, query, count, next_cursor), + next_cursor + ) + + async def get_notifications( + self, + type: Literal['All', 'Verified', 'Mentions'], + count: int = 40, + cursor: str | None = None + ) -> Result[Notification]: + """ + Retrieve notifications based on the provided type. + + Parameters + ---------- + type : {'All', 'Verified', 'Mentions'} + Type of notifications to retrieve. + All: All notifications + Verified: Notifications relating to authenticated users + Mentions: Notifications with mentions + count : :class:`int`, default=40 + Number of notifications to retrieve. + + Returns + ------- + Result[:class:`Notification`] + List of retrieved notifications. + + Examples + -------- + >>> notifications = await client.get_notifications('All') + >>> for notification in notifications: + ... print(notification) + + + ... + ... + + >>> # Retrieve more notifications + >>> more_notifications = await notifications.next() + """ + type = type.capitalize() + f = { + 'All': self.v11.notifications_all, + 'Verified': self.v11.notifications_verified, + 'Mentions': self.v11.notifications_mentions + }[type] + response, _ = await f(count, cursor) + + global_objects = response['globalObjects'] + users = { + id: User(self, build_user_data(data)) + for id, data in global_objects.get('users', {}).items() + } + tweets = {} + + for id, tweet_data in global_objects.get('tweets', {}).items(): + user_id = tweet_data['user_id_str'] + user = users[user_id] + tweet = Tweet(self, build_tweet_data(tweet_data), user) + tweets[id] = tweet + + notifications = [] + + for notification in global_objects.get('notifications', {}).values(): + user_actions = notification['template']['aggregateUserActionsV1'] + target_objects = user_actions['targetObjects'] + if target_objects and 'tweet' in target_objects[0]: + tweet_id = target_objects[0]['tweet']['id'] + tweet = tweets[tweet_id] + else: + tweet = None + + from_users = user_actions['fromUsers'] + if from_users and 'user' in from_users[0]: + user_id = from_users[0]['user']['id'] + user = users[user_id] + else: + user = None + + notifications.append(Notification(self, notification, tweet, user)) + + entries = find_dict(response, 'entries', find_one=True)[0] + cursor_bottom_entry = [ + i for i in entries + if i['entryId'].startswith('cursor-bottom') + ] + if cursor_bottom_entry: + next_cursor = find_dict(cursor_bottom_entry[0], 'value', find_one=True)[0] + else: + next_cursor = None + + return Result( + notifications, + partial(self.get_notifications, type, count, next_cursor), + next_cursor + ) + + async def search_community( + self, query: str, cursor: str | None = None + ) -> Result[Community]: + """ + Searchs communities based on the specified query. + + Parameters + ---------- + query : :class:`str` + The search query. + + Returns + ------- + Result[:class:`Community`] + List of retrieved communities. + + Examples + -------- + >>> communities = await client.search_communities('query') + >>> for community in communities: + ... print(community) + + + ... + + >>> # Retrieve more communities + >>> more_communities = await communities.next() + """ + response, _ = await self.gql.search_community(query, cursor) + + items = find_dict(response, 'items_results', find_one=True)[0] + communities = [] + for item in items: + communities.append(Community(self, item['result'])) + next_cursor_ = find_dict(response, 'next_cursor', find_one=True) + next_cursor = next_cursor_[0] if next_cursor_ else None + if next_cursor is None: + fetch_next_result = None + else: + fetch_next_result = partial(self.search_community, query, next_cursor) + return Result( + communities, + fetch_next_result, + next_cursor + ) + + async def get_community(self, community_id: str) -> Community: + """ + Retrieves community by ID. + + Parameters + ---------- + list_id : :class:`str` + The ID of the community to retrieve. + + Returns + ------- + :class:`Community` + Community object. + """ + response, _ = await self.gql.community_query(community_id) + community_data = find_dict(response, 'result', find_one=True)[0] + return Community(self, community_data) + + async def get_community_tweets( + self, + community_id: str, + tweet_type: Literal['Top', 'Latest', 'Media'], + count: int = 40, + cursor: str | None = None + ) -> Result[Tweet]: + """ + Retrieves tweets from a community. + + Parameters + ---------- + community_id : :class:`str` + The ID of the community. + tweet_type : {'Top', 'Latest', 'Media'} + The type of tweets to retrieve. + count : :class:`int`, default=40 + The number of tweets to retrieve. + + Returns + ------- + Result[:class:`Tweet`] + List of retrieved tweets. + + Examples + -------- + >>> community_id = '...' + >>> tweets = await client.get_community_tweets(community_id, 'Latest') + >>> for tweet in tweets: + ... print(tweet) + + + ... + >>> more_tweets = await tweets.next() # Retrieve more tweets + """ + if tweet_type == 'Media': + response, _ = await self.gql.community_media_timeline(community_id, count, cursor) + elif tweet_type == 'Top': + response, _ = await self.gql.community_tweets_timeline(community_id, 'Relevance', count, cursor) + elif tweet_type == 'Latest': + response, _ = await self.gql.community_tweets_timeline(community_id, 'Recency', count, cursor) + else: + raise ValueError(f'Invalid tweet_type: {tweet_type}') + + entries = find_dict(response, 'entries', find_one=True)[0] + if tweet_type == 'Media': + if cursor is None: + items = entries[0]['content']['items'] + next_cursor = entries[-1]['content']['value'] + previous_cursor = entries[-2]['content']['value'] + else: + items = find_dict(response, 'moduleItems', find_one=True)[0] + next_cursor = entries[-1]['content']['value'] + previous_cursor = entries[-2]['content']['value'] + else: + items = entries + next_cursor = items[-1]['content']['value'] + previous_cursor = items[-2]['content']['value'] + + tweets = [] + for item in items: + if not item['entryId'].startswith(('tweet', 'communities-grid')): + continue + + tweet = tweet_from_data(self, item) + if tweet is not None: + tweets.append(tweet) + + return Result( + tweets, + partial(self.get_community_tweets, community_id, tweet_type, count, next_cursor), + next_cursor, + partial(self.get_community_tweets, community_id, tweet_type, count, previous_cursor), + previous_cursor + ) + + async def get_communities_timeline( + self, count: int = 20, cursor: str | None = None + ) -> Result[Tweet]: + """ + Retrieves tweets from communities timeline. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of tweets to retrieve. + + Returns + ------- + Result[:class:`Tweet`] + List of retrieved tweets. + + Examples + -------- + >>> tweets = await client.get_communities_timeline() + >>> for tweet in tweets: + ... print(tweet) + + + ... + >>> more_tweets = await tweets.next() # Retrieve more tweets + """ + response, _ = await self.gql.communities_main_page_timeline(count, cursor) + items = find_dict(response, 'entries', find_one=True)[0] + tweets = [] + for item in items: + if not item['entryId'].startswith('tweet'): + continue + tweet_data = find_dict(item, 'result', find_one=True)[0] + if 'tweet' in tweet_data: + tweet_data = tweet_data['tweet'] + user_data = tweet_data['core']['user_results']['result'] + community_data = tweet_data['community_results']['result'] + community_data['rest_id'] = community_data['id_str'] + community = Community(self, community_data) + tweet = Tweet(self, tweet_data, User(self, user_data)) + tweet.community = community + tweets.append(tweet) + + next_cursor = items[-1]['content']['value'] + previous_cursor = items[-2]['content']['value'] + + return Result( + tweets, + partial(self.get_communities_timeline, count, next_cursor), + next_cursor, + partial(self.get_communities_timeline, count, previous_cursor), + previous_cursor + ) + + async def join_community(self, community_id: str) -> Community: + """ + Join a community. + + Parameters + ---------- + community_id : :class:`str` + The ID of the community to join. + + Returns + ------- + :class:`Community` + The joined community. + """ + response, _ = await self.gql.join_community(community_id) + community_data = response['data']['community_join'] + community_data['rest_id'] = community_data['id_str'] + return Community(self, community_data) + + async def leave_community(self, community_id: str) -> Community: + """ + Leave a community. + + Parameters + ---------- + community_id : :class:`str` + The ID of the community to leave. + + Returns + ------- + :class:`Community` + The left community. + """ + response, _ = await self.gql.leave_community(community_id) + community_data = response['data']['community_leave'] + community_data['rest_id'] = community_data['id_str'] + return Community(self, community_data) + + async def request_to_join_community( + self, community_id: str, answer: str | None = None + ) -> Community: + """ + Request to join a community. + + Parameters + ---------- + community_id : :class:`str` + The ID of the community to request to join. + answer : :class:`str`, default=None + The answer to the join request. + + Returns + ------- + :class:`Community` + The requested community. + """ + response, _ = await self.gql.request_to_join_community(community_id, answer) + community_data = find_dict(response, 'result', find_one=True)[0] + community_data['rest_id'] = community_data['id_str'] + return Community(self, community_data) + + async def _get_community_users(self, f, community_id: str, count: int, cursor: str | None): + """ + Base function to retrieve community users. + """ + response, _ = await f(community_id, count, cursor) + + items = find_dict(response, 'items_results', find_one=True)[0] + users = [] + for item in items: + if 'result' not in item: + continue + if item['result'].get('__typename') != 'User': + continue + users.append(CommunityMember(self, item['result'])) + + next_cursor_ = find_dict(response, 'next_cursor', find_one=True) + next_cursor = next_cursor_[0] if next_cursor_ else None + + if next_cursor is None: + fetch_next_result = None + else: + fetch_next_result = partial(self._get_community_users, f, community_id, count, next_cursor) + return Result( + users, + fetch_next_result, + next_cursor + ) + + async def get_community_members( + self, community_id: str, count: int = 20, cursor: str | None = None + ) -> Result[CommunityMember]: + """ + Retrieves members of a community. + + Parameters + ---------- + community_id : :class:`str` + The ID of the community. + count : :class:`int`, default=20 + The number of members to retrieve. + + Returns + ------- + Result[:class:`CommunityMember`] + List of retrieved members. + """ + return await self._get_community_users( + self.gql.members_slice_timeline_query, community_id, count, cursor + ) + + async def get_community_moderators( + self, community_id: str, count: int = 20, cursor: str | None = None + ) -> Result[CommunityMember]: + """ + Retrieves moderators of a community. + + Parameters + ---------- + community_id : :class:`str` + The ID of the community. + count : :class:`int`, default=20 + The number of moderators to retrieve. + + Returns + ------- + Result[:class:`CommunityMember`] + List of retrieved moderators. + """ + return await self._get_community_users( + self.gql.moderators_slice_timeline_query, community_id, count, cursor + ) + + async def search_community_tweet( + self, + community_id: str, + query: str, + count: int = 20, + cursor: str | None = None + ) -> Result[Tweet]: + """Searchs tweets in a community. + + Parameters + ---------- + community_id : :class:`str` + The ID of the community. + query : :class:`str` + The search query. + count : :class:`int`, default=20 + The number of tweets to retrieve. + + Returns + ------- + Result[:class:`Tweet`] + List of retrieved tweets. + """ + response, _ = await self.gql.community_tweet_search_module_query(community_id, query, count, cursor) + + items = find_dict(response, 'entries', find_one=True)[0] + tweets = [] + for item in items: + if not item['entryId'].startswith('tweet'): + continue + + tweet = tweet_from_data(self, item) + if tweet is not None: + tweets.append(tweet) + + next_cursor = items[-1]['content']['value'] + previous_cursor = items[-2]['content']['value'] + + return Result( + tweets, + partial(self.search_community_tweet, community_id, query, count, next_cursor), + next_cursor, + partial(self.search_community_tweet, community_id, query, count, previous_cursor), + previous_cursor, + ) + + async def _stream(self, topics: set[str]) -> AsyncGenerator[tuple[str, Payload]]: + url = f'https://api.{DOMAIN}/live_pipeline/events' + params = {'topics': ','.join(topics)} + headers = self._base_headers + headers.pop('content-type') + + async with self.http.stream('GET', url, params=params, headers=headers, timeout=None) as response: + self._remove_duplicate_ct0_cookie() + async for line in response.aiter_lines(): + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + payload = _payload_from_data(data['payload']) + yield data.get('topic'), payload + + async def get_streaming_session( + self, topics: set[str], auto_reconnect: bool = True + ) -> StreamingSession: + """ + Returns a session for interacting with the streaming API. + + Parameters + ---------- + topics : set[:class:`str`] + The set of topics to stream. + Topics can be generated using :class:`.Topic`. + auto_reconnect : :class:`bool`, default=True + Whether to automatically reconnect when disconnected. + + Returns + ------- + :class:`.StreamingSession` + A stream session instance. + + Examples + -------- + >>> from twikit.streaming import Topic + >>> + >>> topics = { + ... Topic.tweet_engagement('1739617652'), # Stream tweet engagement + ... Topic.dm_update('17544932482-174455537996'), # Stream DM update + ... Topic.dm_typing('17544932482-174455537996') # Stream DM typing + ... } + >>> session = await client.get_streaming_session(topics) + >>> + >>> async for topic, payload in session: + ... if payload.dm_update: + ... conversation_id = payload.dm_update.conversation_id + ... user_id = payload.dm_update.user_id + ... print(f'{conversation_id}: {user_id} sent a message') + >>> + >>> if payload.dm_typing: + ... conversation_id = payload.dm_typing.conversation_id + ... user_id = payload.dm_typing.user_id + ... print(f'{conversation_id}: {user_id} is typing') + >>> + >>> if payload.tweet_engagement: + ... like = payload.tweet_engagement.like_count + ... retweet = payload.tweet_engagement.retweet_count + ... view = payload.tweet_engagement.view_count + ... print('Tweet engagement updated:' + ... f'likes: {like} retweets: {retweet} views: {view}') + + Topics to stream can be added or deleted using + :attr:`.StreamingSession.update_subscriptions` method. + + >>> subscribe_topics = { + ... Topic.tweet_engagement('1749528513'), + ... Topic.tweet_engagement('1765829534') + ... } + >>> unsubscribe_topics = { + ... Topic.tweet_engagement('1739617652'), + ... Topic.dm_update('17544932482-174455537996'), + ... Topic.dm_update('17544932482-174455537996') + ... } + >>> await session.update_subscriptions( + ... subscribe_topics, unsubscribe_topics + ... ) + + See Also + -------- + .StreamingSession + .StreamingSession.update_subscriptions + .Payload + .Topic + """ + stream = self._stream(topics) + session_id = (await anext(stream))[1].config.session_id + return StreamingSession(self, session_id, stream, topics, auto_reconnect) + + async def _update_subscriptions( + self, + session: StreamingSession, + subscribe: set[str] | None = None, + unsubscribe: set[str] | None = None + ) -> Payload: + if subscribe is None: + subscribe = set() + if unsubscribe is None: + unsubscribe = set() + + response, _ = await self.v11.live_pipeline_update_subscriptions( + session.id, ','.join(subscribe), ','.join(unsubscribe) + ) + session.topics |= subscribe + session.topics -= unsubscribe + + return _payload_from_data(response) + + async def _get_user_state(self) -> Literal['normal', 'bounced', 'suspended']: + response, _ = await self.v11.user_state() + return response['userState'] diff --git a/twikit/client/gql.py b/twikit/client/gql.py new file mode 100644 index 00000000..8fd27e40 --- /dev/null +++ b/twikit/client/gql.py @@ -0,0 +1,693 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..constants import ( + DOMAIN, + BOOKMARK_FOLDER_TIMELINE_FEATURES, + COMMUNITY_NOTE_FEATURES, + COMMUNITY_TWEETS_FEATURES, + FEATURES, + JOIN_COMMUNITY_FEATURES, + LIST_FEATURES, + NOTE_TWEET_FEATURES, + SIMILAR_POSTS_FEATURES, + TWEET_RESULT_BY_REST_ID_FEATURES, + USER_FEATURES, + USER_HIGHLIGHTS_TWEETS_FEATURES +) +from ..utils import flatten_params, get_query_id + +if TYPE_CHECKING: + from ..guest.client import GuestClient + from .client import Client + + ClientType = Client | GuestClient + + +class Endpoint: + @staticmethod + def url(path): + return f'https://{DOMAIN}/i/api/graphql/{path}' + + SEARCH_TIMELINE = url('flaR-PUMshxFWZWPNpq4zA/SearchTimeline') + SIMILAR_POSTS = url('EToazR74i0rJyZYalfVEAQ/SimilarPosts') + CREATE_NOTE_TWEET = url('iCUB42lIfXf9qPKctjE5rQ/CreateNoteTweet') + CREATE_TWEET = url('SiM_cAu83R0wnrpmKQQSEw/CreateTweet') + CREATE_SCHEDULED_TWEET = url('LCVzRQGxOaGnOnYH01NQXg/CreateScheduledTweet') + DELETE_TWEET = url('VaenaVgh5q5ih7kvyVjgtg/DeleteTweet') + USER_BY_SCREEN_NAME = url('NimuplG1OB7Fd2btCLdBOw/UserByScreenName') + USER_BY_REST_ID = url('tD8zKvQzwY3kdx5yz6YmOw/UserByRestId') + TWEET_DETAIL = url('U0HTv-bAWTBYylwEMT7x5A/TweetDetail') + TWEET_RESULT_BY_REST_ID = url('Xl5pC_lBk_gcO2ItU39DQw/TweetResultByRestId') + FETCH_SCHEDULED_TWEETS = url('ITtjAzvlZni2wWXwf295Qg/FetchScheduledTweets') + DELETE_SCHEDULED_TWEET = url('CTOVqej0JBXAZSwkp1US0g/DeleteScheduledTweet') + RETWEETERS = url('X-XEqG5qHQSAwmvy00xfyQ/Retweeters') + FAVORITERS = url('LLkw5EcVutJL6y-2gkz22A/Favoriters') + FETCH_COMMUNITY_NOTE = url('fKWPPj271aTM-AB9Xp48IA/BirdwatchFetchOneNote') + USER_TWEETS = url('QWF3SzpHmykQHsQMixG0cg/UserTweets') + USER_TWEETS_AND_REPLIES = url('vMkJyzx1wdmvOeeNG0n6Wg/UserTweetsAndReplies') + USER_MEDIA = url('2tLOJWwGuCTytDrGBg8VwQ/UserMedia') + USER_LIKES = url('IohM3gxQHfvWePH5E3KuNA/Likes') + USER_HIGHLIGHTS_TWEETS = url('tHFm_XZc_NNi-CfUThwbNw/UserHighlightsTweets') + HOME_TIMELINE = url('-X_hcgQzmHGl29-UXxz4sw/HomeTimeline') + HOME_LATEST_TIMELINE = url('U0cdisy7QFIoTfu3-Okw0A/HomeLatestTimeline') + FAVORITE_TWEET = url('lI07N6Otwv1PhnEgXILM7A/FavoriteTweet') + UNFAVORITE_TWEET = url('ZYKSe-w7KEslx3JhSIk5LA/UnfavoriteTweet') + CREATE_RETWEET = url('ojPdsZsimiJrUGLR1sjUtA/CreateRetweet') + DELETE_RETWEET = url('iQtK4dl5hBmXewYZuEOKVw/DeleteRetweet') + CREATE_BOOKMARK = url('aoDbu3RHznuiSkQ9aNM67Q/CreateBookmark') + BOOKMARK_TO_FOLDER = url('4KHZvvNbHNf07bsgnL9gWA/bookmarkTweetToFolder') + DELETE_BOOKMARK = url('Wlmlj2-xzyS1GN3a6cj-mQ/DeleteBookmark') + BOOKMARKS = url('qToeLeMs43Q8cr7tRYXmaQ/Bookmarks') + BOOKMARK_FOLDER_TIMELINE = url('8HoabOvl7jl9IC1Aixj-vg/BookmarkFolderTimeline') + BOOKMARKS_ALL_DELETE = url('skiACZKC1GDYli-M8RzEPQ/BookmarksAllDelete') + BOOKMARK_FOLDERS_SLICE = url('i78YDd0Tza-dV4SYs58kRg/BookmarkFoldersSlice') + EDIT_BOOKMARK_FOLDER = url('a6kPp1cS1Dgbsjhapz1PNw/EditBookmarkFolder') + DELETE_BOOKMARK_FOLDER = url('2UTTsO-6zs93XqlEUZPsSg/DeleteBookmarkFolder') + CREATE_BOOKMARK_FOLDER = url('6Xxqpq8TM_CREYiuof_h5w/createBookmarkFolder') + FOLLOWERS = url('gC_lyAxZOptAMLCJX5UhWw/Followers') + BLUE_VERIFIED_FOLLOWERS = url('VmIlPJNEDVQ29HfzIhV4mw/BlueVerifiedFollowers') + FOLLOWERS_YOU_KNOW = url('f2tbuGNjfOE8mNUO5itMew/FollowersYouKnow') + FOLLOWING = url('2vUj-_Ek-UmBVDNtd8OnQA/Following') + USER_CREATOR_SUBSCRIPTIONS = url('Wsm5ZTCYtg2eH7mXAXPIgw/UserCreatorSubscriptions') + USER_DM_REACTION_MUTATION_ADD_MUTATION = url('VyDyV9pC2oZEj6g52hgnhA/useDMReactionMutationAddMutation') + USER_DM_REACTION_MUTATION_REMOVE_MUTATION = url('bV_Nim3RYHsaJwMkTXJ6ew/useDMReactionMutationRemoveMutation') + DM_MESSAGE_DELETE_MUTATION = url('BJ6DtxA2llfjnRoRjaiIiw/DMMessageDeleteMutation') + ADD_PARTICIPANTS_MUTATION = url('oBwyQ0_xVbAQ8FAyG0pCRA/AddParticipantsMutation') + CREATE_LIST = url('EYg7JZU3A1eJ-wr2eygPHQ/CreateList') + EDIT_LIST_BANNER = url('t_DsROHldculsB0B9BUAWw/EditListBanner') + DELETE_LIST_BANNER = url('Y90WuxdWugtMRJhkXTdvzg/DeleteListBanner') + UPDATE_LIST = url('dIEI1sbSAuZlxhE0ggrezA/UpdateList') + LIST_ADD_MEMBER = url('lLNsL7mW6gSEQG6rXP7TNw/ListAddMember') + LIST_REMOVE_MEMBER = url('cvDFkG5WjcXV0Qw5nfe1qQ/ListRemoveMember') + LIST_MANAGEMENT_PACE_TIMELINE = url('47170qwZCt5aFo9cBwFoNA/ListsManagementPageTimeline') + LIST_BY_REST_ID = url('9hbYpeVBMq8-yB8slayGWQ/ListByRestId') + LIST_LATEST_TWEETS_TIMELINE = url('HjsWc-nwwHKYwHenbHm-tw/ListLatestTweetsTimeline') + LIST_MEMBERS = url('BQp2IEYkgxuSxqbTAr1e1g/ListMembers') + LIST_SUBSCRIBERS = url('74wGEkaBxrdoXakWTWMxRQ/ListSubscribers') + SEARCH_COMMUNITY = url('daVUkhfHn7-Z8llpYVKJSw/CommunitiesSearchQuery') + COMMUNITY_QUERY = url('lUBKrilodgg9Nikaw3cIiA/CommunityQuery') + COMMUNITY_MEDIA_TIMELINE = url('Ht5K2ckaZYAOuRFmFfbHig/CommunityMediaTimeline') + COMMUNITY_TWEETS_TIMELINE = url('mhwSsmub4JZgHcs0dtsjrw/CommunityTweetsTimeline') + COMMUNITIES_MAIN_PAGE_TIMELINE = url('4-4iuIdaLPpmxKnA3mr2LA/CommunitiesMainPageTimeline') + JOIN_COMMUNITY = url('xZQLbDwbI585YTG0QIpokw/JoinCommunity') + LEAVE_COMMUNITY = url('OoS6Kd4-noNLXPZYHtygeA/LeaveCommunity') + REQUEST_TO_JOIN_COMMUNITY = url('XwWChphD_6g7JnsFus2f2Q/RequestToJoinCommunity') + MEMBERS_SLICE_TIMELINE_QUERY = url('KDAssJ5lafCy-asH4wm1dw/membersSliceTimeline_Query') + MODERATORS_SLICE_TIMELINE_QUERY = url('9KI_r8e-tgp3--N5SZYVjg/moderatorsSliceTimeline_Query') + COMMUNITY_TWEET_SEARCH_MODULE_QUERY = url('5341rmzzvdjqfmPKfoHUBw/CommunityTweetSearchModuleQuery') + + +class GQLClient: + def __init__(self, base: ClientType) -> None: + self.base = base + + async def gql_get( + self, + url: str, + variables: dict, + features: dict | None = None, + headers: dict | None = None, + extra_params: dict | None = None, + **kwargs + ): + params = {'variables': variables} + if features is not None: + params['features'] = features + if extra_params is not None: + params |= extra_params + if headers is None: + headers = self.base._base_headers + return await self.base.get(url, params=flatten_params(params), headers=headers, **kwargs) + + async def gql_post( + self, + url: str, + variables: dict, + features: dict | None = None, + headers: dict | None = None, + extra_data: dict | None = None, + **kwargs + ): + data = {'variables': variables, 'queryId': get_query_id(url)} + if features is not None: + data['features'] = features + if extra_data is not None: + data |= extra_data + if headers is None: + headers = self.base._base_headers + return await self.base.post(url, json=data, headers=headers, **kwargs) + + async def search_timeline( + self, + query: str, + product: str, + count: int, + cursor: str | None + ): + variables = { + 'rawQuery': query, + 'count': count, + 'querySource': 'typed_query', + 'product': product + } + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(Endpoint.SEARCH_TIMELINE, variables, FEATURES) + + async def similar_posts(self, tweet_id: str): + variables = {'tweet_id': tweet_id} + return await self.gql_get( + Endpoint.SIMILAR_POSTS, + variables, + SIMILAR_POSTS_FEATURES + ) + + async def create_tweet( + self, is_note_tweet, text, media_entities, + poll_uri, reply_to, attachment_url, + community_id, share_with_followers, + richtext_options, edit_tweet_id, limit_mode + ): + variables = { + 'tweet_text': text, + 'dark_request': False, + 'media': { + 'media_entities': media_entities, + 'possibly_sensitive': False + }, + 'semantic_annotation_ids': [], + } + + if poll_uri is not None: + variables['card_uri'] = poll_uri + + if reply_to is not None: + variables['reply'] = { + 'in_reply_to_tweet_id': reply_to, + 'exclude_reply_user_ids': [] + } + + if limit_mode is not None: + variables['conversation_control'] = {'mode': limit_mode} + + if attachment_url is not None: + variables['attachment_url'] = attachment_url + + if community_id is not None: + variables['semantic_annotation_ids'] = [{ + 'entity_id': community_id, + 'group_id': '8', + 'domain_id': '31' + }] + variables['broadcast'] = share_with_followers + + if richtext_options is not None: + is_note_tweet = True + variables['richtext_options'] = { + 'richtext_tags': richtext_options + } + if edit_tweet_id is not None: + variables['edit_options'] = { + 'previous_tweet_id': edit_tweet_id + } + + if is_note_tweet: + endpoint = Endpoint.CREATE_NOTE_TWEET + features = NOTE_TWEET_FEATURES + else: + endpoint = Endpoint.CREATE_TWEET + features = FEATURES + return await self.gql_post(endpoint, variables, features) + + async def create_scheduled_tweet(self, scheduled_at, text, media_ids) -> str: + variables = { + 'post_tweet_request': { + 'auto_populate_reply_metadata': False, + 'status': text, + 'exclude_reply_user_ids': [], + 'media_ids': media_ids + }, + 'execute_at': scheduled_at + } + return await self.gql_post(Endpoint.CREATE_SCHEDULED_TWEET, variables) + + async def delete_tweet(self, tweet_id): + variables = { + 'tweet_id': tweet_id, + 'dark_request': False + } + return await self.gql_post(Endpoint.DELETE_TWEET, variables) + + async def user_by_screen_name(self, screen_name): + variables = { + 'screen_name': screen_name, + 'withSafetyModeUserFields': False + } + params = { + 'fieldToggles': {'withAuxiliaryUserLabels': False} + } + return await self.gql_get(Endpoint.USER_BY_SCREEN_NAME, variables, USER_FEATURES, extra_params=params) + + async def user_by_rest_id(self, user_id): + variables = { + 'userId': user_id, + 'withSafetyModeUserFields': True + } + return await self.gql_get(Endpoint.USER_BY_REST_ID, variables, USER_FEATURES) + + async def tweet_detail(self, tweet_id, cursor): + variables = { + 'focalTweetId': tweet_id, + 'with_rux_injections': False, + 'includePromotedContent': True, + 'withCommunity': True, + 'withQuickPromoteEligibilityTweetFields': True, + 'withBirdwatchNotes': True, + 'withVoice': True, + 'withV2Timeline': True + } + if cursor is not None: + variables['cursor'] = cursor + params = { + 'fieldToggles': {'withAuxiliaryUserLabels': False} + } + return await self.gql_get(Endpoint.TWEET_DETAIL, variables, FEATURES, extra_params=params) + + async def fetch_scheduled_tweets(self): + variables = {'ascending': True} + return await self.gql_get(Endpoint.FETCH_SCHEDULED_TWEETS, variables) + + async def delete_scheduled_tweet(self, tweet_id): + variables = {'scheduled_tweet_id': tweet_id} + return await self.gql_post(Endpoint.DELETE_SCHEDULED_TWEET, variables) + + async def tweet_engagements(self, tweet_id, count, cursor, endpoint): + variables = { + 'tweetId': tweet_id, + 'count': count, + 'includePromotedContent': True + } + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(endpoint, variables, FEATURES) + + async def retweeters(self, tweet_id, count, cursor): + return await self.tweet_engagements(tweet_id, count, cursor, Endpoint.RETWEETERS) + + async def favoriters(self, tweet_id, count, cursor): + return await self.tweet_engagements(tweet_id, count, cursor, Endpoint.FAVORITERS) + + async def bird_watch_one_note(self, note_id): + variables = {'note_id': note_id} + return await self.gql_get(Endpoint.FETCH_COMMUNITY_NOTE, variables, COMMUNITY_NOTE_FEATURES) + + async def _get_user_tweets(self, user_id, count, cursor, endpoint): + variables = { + 'userId': user_id, + 'count': count, + 'includePromotedContent': True, + 'withQuickPromoteEligibilityTweetFields': True, + 'withVoice': True, + 'withV2Timeline': True + } + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(endpoint, variables, FEATURES) + + async def user_tweets(self, user_id, count, cursor): + return await self._get_user_tweets(user_id, count, cursor, Endpoint.USER_TWEETS) + + async def user_tweets_and_replies(self, user_id, count, cursor): + return await self._get_user_tweets(user_id, count, cursor, Endpoint.USER_TWEETS_AND_REPLIES) + + async def user_media(self, user_id, count, cursor): + return await self._get_user_tweets(user_id, count, cursor, Endpoint.USER_MEDIA) + + async def user_likes(self, user_id, count, cursor): + return await self._get_user_tweets(user_id, count, cursor, Endpoint.USER_LIKES) + + async def user_highlights_tweets(self, user_id, count, cursor): + variables = { + 'userId': user_id, + 'count': count, + 'includePromotedContent': True, + 'withVoice': True + } + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get( + Endpoint.USER_HIGHLIGHTS_TWEETS, + variables, + USER_HIGHLIGHTS_TWEETS_FEATURES, + self.base._base_headers + ) + + async def home_timeline(self, count, seen_tweet_ids, cursor): + variables = { + 'count': count, + 'includePromotedContent': True, + 'latestControlAvailable': True, + 'requestContext': 'launch', + 'withCommunity': True, + 'seenTweetIds': seen_tweet_ids or [] + } + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_post(Endpoint.HOME_TIMELINE, variables, FEATURES) + + async def home_latest_timeline(self, count, seen_tweet_ids, cursor): + variables = { + 'count': count, + 'includePromotedContent': True, + 'latestControlAvailable': True, + 'requestContext': 'launch', + 'withCommunity': True, + 'seenTweetIds': seen_tweet_ids or [] + } + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_post(Endpoint.HOME_LATEST_TIMELINE, variables, FEATURES) + + async def favorite_tweet(self, tweet_id): + variables = {'tweet_id': tweet_id} + return await self.gql_post(Endpoint.FAVORITE_TWEET, variables) + + async def unfavorite_tweet(self, tweet_id): + variables = {'tweet_id': tweet_id} + return await self.gql_post(Endpoint.UNFAVORITE_TWEET, variables) + + async def retweet(self, tweet_id): + variables = {'tweet_id': tweet_id, 'dark_request': False} + return await self.gql_post(Endpoint.CREATE_RETWEET, variables) + + async def delete_retweet(self, tweet_id): + variables = {'source_tweet_id': tweet_id,'dark_request': False} + return await self.gql_post(Endpoint.DELETE_RETWEET, variables) + + async def create_bookmark(self, tweet_id): + variables = {'tweet_id': tweet_id} + return await self.gql_post(Endpoint.CREATE_BOOKMARK, variables) + + async def bookmark_tweet_to_folder(self, tweet_id, folder_id): + variables = { + 'tweet_id': tweet_id, + 'bookmark_collection_id': folder_id + } + return await self.gql_post(Endpoint.BOOKMARK_TO_FOLDER, variables) + + async def delete_bookmark(self, tweet_id): + variables = {'tweet_id': tweet_id} + return await self.gql_post(Endpoint.DELETE_BOOKMARK, variables) + + async def bookmarks(self, count, cursor): + variables = { + 'count': count, + 'includePromotedContent': True + } + features = FEATURES | { + 'graphql_timeline_v2_bookmark_timeline': True + } + if cursor is not None: + variables['cursor'] = cursor + params = flatten_params({ + 'variables': variables, + 'features': features + }) + return await self.base.get( + Endpoint.BOOKMARKS, + params=params, + headers=self.base._base_headers + ) + + async def bookmark_folder_timeline(self, count, cursor, folder_id): + variables = { + 'count': count, + 'includePromotedContent': True, + 'bookmark_collection_id': folder_id + } + variables['bookmark_collection_id'] = folder_id + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(Endpoint.BOOKMARK_FOLDER_TIMELINE, variables, BOOKMARK_FOLDER_TIMELINE_FEATURES) + + async def delete_all_bookmarks(self): + return await self.gql_post(Endpoint.BOOKMARKS_ALL_DELETE, {}) + + async def bookmark_folders_slice(self, cursor): + variables = {} + if cursor is not None: + variables['cursor'] = cursor + variables = {'variables': variables} + return await self.gql_get(Endpoint.BOOKMARK_FOLDERS_SLICE, variables) + + async def edit_bookmark_folder(self, folder_id, name): + variables = { + 'bookmark_collection_id': folder_id, + 'name': name + } + return await self.gql_post(Endpoint.EDIT_BOOKMARK_FOLDER, variables) + + async def delete_bookmark_folder(self, folder_id): + variables = {'bookmark_collection_id': folder_id} + return await self.gql_post(Endpoint.DELETE_BOOKMARK_FOLDER, variables) + + async def create_bookmark_folder(self, name): + variables = {'name': name} + return await self.gql_post(Endpoint.CREATE_BOOKMARK_FOLDER, variables) + + async def _friendships(self, user_id, count, endpoint, cursor): + variables = { + 'userId': user_id, + 'count': count, + 'includePromotedContent': False + } + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(endpoint, variables, FEATURES) + + async def followers(self, user_id, count, cursor): + return await self._friendships(user_id, count, Endpoint.FOLLOWERS, cursor) + + async def blue_verified_followers(self, user_id, count, cursor): + return await self._friendships(user_id, count, Endpoint.BLUE_VERIFIED_FOLLOWERS, cursor) + + async def followers_you_know(self, user_id, count, cursor): + return await self._friendships(user_id, count, Endpoint.FOLLOWERS_YOU_KNOW, cursor) + + async def following(self, user_id, count, cursor): + return await self._friendships(user_id, count, Endpoint.FOLLOWING, cursor) + + async def user_creator_subscriptions(self, user_id, count, cursor): + return await self._friendships(user_id, count, Endpoint.USER_CREATOR_SUBSCRIPTIONS, cursor) + + async def user_dm_reaction_mutation_add_mutation(self, message_id, conversation_id, emoji): + variables = { + 'messageId': message_id, + 'conversationId': conversation_id, + 'reactionTypes': ['Emoji'], + 'emojiReactions': [emoji] + } + return await self.gql_post(Endpoint.USER_DM_REACTION_MUTATION_ADD_MUTATION, variables) + + async def user_dm_reaction_mutation_remove_mutation(self, message_id, conversation_id, emoji): + variables = { + 'conversationId': conversation_id, + 'messageId': message_id, + 'reactionTypes': ['Emoji'], + 'emojiReactions': [emoji] + } + return await self.gql_post(Endpoint.USER_DM_REACTION_MUTATION_REMOVE_MUTATION, variables) + + async def dm_message_delete_mutation(self, message_id): + variables = {'messageId': message_id} + return await self.gql_post(Endpoint.DM_MESSAGE_DELETE_MUTATION, variables) + + async def add_participants_mutation(self, group_id, user_ids): + variables = { + 'addedParticipants': user_ids, + 'conversationId': group_id + } + return await self.gql_post(Endpoint.ADD_PARTICIPANTS_MUTATION, variables) + + async def create_list(self, name, description, is_private): + variables = { + 'isPrivate': is_private, + 'name': name, + 'description': description + } + return await self.gql_post(Endpoint.CREATE_LIST, variables, LIST_FEATURES) + + async def edit_list_banner(self, list_id, media_id): + variables = { + 'listId': list_id, + 'mediaId': media_id + } + return await self.gql_post(Endpoint.EDIT_LIST_BANNER, variables, LIST_FEATURES) + + async def delete_list_banner(self, list_id): + variables = {'listId': list_id} + return await self.gql_post(Endpoint.DELETE_LIST_BANNER, variables, LIST_FEATURES) + + async def update_list(self, list_id, name, description, is_private): + variables = {'listId': list_id} + if name is not None: + variables['name'] = name + if description is not None: + variables['description'] = description + if is_private is not None: + variables['isPrivate'] = is_private + return await self.gql_post(Endpoint.UPDATE_LIST, variables, LIST_FEATURES) + + async def list_add_member(self, list_id, user_id): + variables = { + 'listId': list_id, + 'userId': user_id + } + return await self.gql_post(Endpoint.LIST_ADD_MEMBER, variables, LIST_FEATURES) + + async def list_remove_member(self, list_id, user_id): + variables = { + 'listId': list_id, + 'userId': user_id + } + return await self.gql_post(Endpoint.LIST_REMOVE_MEMBER, variables, LIST_FEATURES) + + async def list_management_pace_timeline(self, count, cursor): + variables = {'count': count} + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(Endpoint.LIST_MANAGEMENT_PACE_TIMELINE, variables, FEATURES) + + async def list_by_rest_id(self, list_id): + variables = {'listId': list_id} + return await self.gql_get(Endpoint.LIST_BY_REST_ID, variables, LIST_FEATURES) + + async def list_latest_tweets_timeline(self, list_id, count, cursor): + variables = {'listId': list_id, 'count': count} + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(Endpoint.LIST_LATEST_TWEETS_TIMELINE, variables, FEATURES) + + async def _list_users(self, endpoint, list_id, count, cursor): + variables = {'listId': list_id, 'count': count} + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(endpoint, variables, FEATURES) + + async def list_members(self, list_id, count, cursor): + return await self._list_users(Endpoint.LIST_MEMBERS, list_id, count, cursor) + + async def list_subscribers(self, list_id, count, cursor): + return await self._list_users(Endpoint.LIST_SUBSCRIBERS, list_id, count, cursor) + + async def search_community(self, query, cursor): + variables = {'query': query} + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(Endpoint.SEARCH_COMMUNITY, variables) + + async def community_query(self, community_id): + variables = {'communityId': community_id} + features = { + 'c9s_list_members_action_api_enabled': False, + 'c9s_superc9s_indication_enabled': False + } + return await self.gql_get(Endpoint.COMMUNITY_QUERY, variables, features) + + async def community_media_timeline(self, community_id, count, cursor): + variables = { + 'communityId': community_id, + 'count': count, + 'withCommunity': True + } + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(Endpoint.COMMUNITY_MEDIA_TIMELINE, variables, COMMUNITY_TWEETS_FEATURES) + + async def community_tweets_timeline(self, community_id, ranking_mode, count, cursor): + variables = { + 'communityId': community_id, + 'count': count, + 'withCommunity': True, + 'rankingMode': ranking_mode + } + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(Endpoint.COMMUNITY_TWEETS_TIMELINE, variables, COMMUNITY_TWEETS_FEATURES) + + async def communities_main_page_timeline(self, count, cursor): + variables = { + 'count': count, + 'withCommunity': True + } + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(Endpoint.COMMUNITIES_MAIN_PAGE_TIMELINE, variables, COMMUNITY_TWEETS_FEATURES) + + async def join_community(self, community_id): + variables = {'communityId': community_id} + return await self.gql_post(Endpoint.JOIN_COMMUNITY, variables, JOIN_COMMUNITY_FEATURES) + + async def leave_community(self, community_id): + variables = {'communityId': community_id} + return await self.gql_post(Endpoint.LEAVE_COMMUNITY, variables, JOIN_COMMUNITY_FEATURES) + + async def request_to_join_community(self, community_id, answer): + variables = { + 'communityId': community_id, + 'answer': '' if answer is None else answer + } + return await self.gql_post(Endpoint.REQUEST_TO_JOIN_COMMUNITY, variables, JOIN_COMMUNITY_FEATURES) + + async def _get_community_users(self, endpoint, community_id, count, cursor): + variables = {'communityId': community_id, 'count': count} + features = {'responsive_web_graphql_timeline_navigation_enabled': True} + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(endpoint, variables, features) + + async def members_slice_timeline_query(self, community_id, count, cursor): + return await self._get_community_users(Endpoint.MEMBERS_SLICE_TIMELINE_QUERY, community_id, count, cursor) + + async def moderators_slice_timeline_query(self, community_id, count, cursor): + return await self._get_community_users(Endpoint.MODERATORS_SLICE_TIMELINE_QUERY, community_id, count, cursor) + + async def community_tweet_search_module_query(self, community_id, query, count, cursor): + variables = { + 'count': count, + 'query': query, + 'communityId': community_id, + 'includePromotedContent': False, + 'withBirdwatchNotes': True, + 'withVoice': False, + 'isListMemberTargetUserId': '0', + 'withCommunity': False, + 'withSafetyModeUserFields': True + } + if cursor is not None: + variables['cursor'] = cursor + return await self.gql_get(Endpoint.COMMUNITY_TWEET_SEARCH_MODULE_QUERY, variables, COMMUNITY_TWEETS_FEATURES) + + #################### + # For guest client + #################### + + async def tweet_result_by_rest_id(self, tweet_id): + variables = { + 'tweetId': tweet_id, + 'withCommunity': False, + 'includePromotedContent': False, + 'withVoice': False + } + params = { + 'fieldToggles': { + 'withArticleRichContentState': True, + 'withArticlePlainText': False, + 'withGrokAnalyze': False + } + } + return await self.gql_get( + Endpoint.TWEET_RESULT_BY_REST_ID, variables, TWEET_RESULT_BY_REST_ID_FEATURES, extra_params=params + ) diff --git a/twikit/client/v11.py b/twikit/client/v11.py new file mode 100644 index 00000000..d4dbdda1 --- /dev/null +++ b/twikit/client/v11.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..guest.client import GuestClient + from .client import Client + + ClientType = Client | GuestClient + +from ..constants import DOMAIN + +class Endpoint: + GUEST_ACTIVATE = f'https://api.{DOMAIN}/1.1/guest/activate.json' + ONBOARDING_SSO_INIT = f'https://api.{DOMAIN}/1.1/onboarding/sso_init.json' + ACCOUNT_LOGOUT = f'https://api.{DOMAIN}/1.1/account/logout.json' + ONBOARDING_TASK = f'https://api.{DOMAIN}/1.1/onboarding/task.json' + SETTINGS = f'https://api.{DOMAIN}/1.1/account/settings.json' + UPLOAD_MEDIA = f'https://upload.{DOMAIN}/i/media/upload.json' + UPLOAD_MEDIA_2 = f'https://upload.{DOMAIN}/i/media/upload2.json' + CREATE_MEDIA_METADATA = f'https://api.{DOMAIN}/1.1/media/metadata/create.json' + CREATE_CARD = f'https://caps.{DOMAIN}/v2/cards/create.json' + VOTE = f'https://caps.{DOMAIN}/v2/capi/passthrough/1' + REVERSE_GEOCODE = f'https://api.{DOMAIN}/1.1/geo/reverse_geocode.json' + SEARCH_GEO = f'https://api.{DOMAIN}/1.1/geo/search.json' + GET_PLACE = f'https://api.{DOMAIN}/1.1/geo/id/{{}}.json' + CREATE_FRIENDSHIPS = f'https://{DOMAIN}/i/api/1.1/friendships/create.json' + DESTROY_FRIENDSHIPS = f'https://{DOMAIN}/i/api/1.1/friendships/destroy.json' + CREATE_BLOCKS = f'https://{DOMAIN}/i/api/1.1/blocks/create.json' + DESTROY_BLOCKS = f'https://{DOMAIN}/i/api/1.1/blocks/destroy.json' + CREATE_MUTES = f'https://{DOMAIN}/i/api/1.1/mutes/users/create.json' + DESTROY_MUTES = f'https://{DOMAIN}/i/api/1.1/mutes/users/destroy.json' + GUIDE = f'https://{DOMAIN}/i/api/2/guide.json' + AVAILABLE_TRENDS = f'https://api.{DOMAIN}/1.1/trends/available.json' + PLACE_TRENDS = f'https://api.{DOMAIN}/1.1/trends/place.json' + FOLLOWERS_LIST = f'https://api.{DOMAIN}/1.1/followers/list.json' + FRIENDS_LIST = f'https://api.{DOMAIN}/1.1/friends/list.json' + FOLLOWERS_IDS = f'https://api.{DOMAIN}/1.1/followers/ids.json' + FRIENDS_IDS = f'https://api.{DOMAIN}/1.1/friends/ids.json' + DM_NEW = f'https://{DOMAIN}/i/api/1.1/dm/new2.json' + DM_INBOX = f'https://{DOMAIN}/i/api/1.1/dm/inbox_initial_state.json' + DM_CONVERSATION = f'https://{DOMAIN}/i/api/1.1/dm/conversation/{{}}.json' + CONVERSATION_UPDATE_NAME = f'https://{DOMAIN}/i/api/1.1/dm/conversation/{{}}/update_name.json' + NOTIFICATIONS_ALL = f'https://{DOMAIN}/i/api/2/notifications/all.json' + NOTIFICATIONS_VERIFIED = f'https://{DOMAIN}/i/api/2/notifications/verified.json' + NOTIFICATIONS_MENTIONS = f'https://{DOMAIN}/i/api/2/notifications/mentions.json' + LIVE_PIPELINE_EVENTS = f'https://api.{DOMAIN}/live_pipeline/events' + LIVE_PIPELINE_UPDATE_SUBSCRIPTIONS = f'https://api.{DOMAIN}/1.1/live_pipeline/update_subscriptions' + USER_STATE = f'https://api.{DOMAIN}/help-center/forms/api/prod/user_state.json' + + +class V11Client: + def __init__(self, base: ClientType) -> None: + self.base = base + + async def guest_activate(self): + headers = self.base._base_headers + headers.pop('X-Twitter-Active-User', None) + headers.pop('X-Twitter-Auth-Type', None) + return await self.base.post( + Endpoint.GUEST_ACTIVATE, + headers=headers, + data={} + ) + + async def account_logout(self): + return await self.base.post( + Endpoint.ACCOUNT_LOGOUT, + headers=self.base._base_headers + ) + + async def onboarding_task(self, guest_token, token, subtask_inputs, data = None, **kwargs): + if data is None: + data = {} + if token is not None: + data['flow_token'] = token + if subtask_inputs is not None: + data['subtask_inputs'] = subtask_inputs + + headers = { + 'x-guest-token': guest_token, + 'Authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA' + } + + if self.base._get_csrf_token(): + headers["x-csrf-token"] = self.base._get_csrf_token() + headers["x-twitter-auth-type"] = "OAuth2Session" + + return await self.base.post( + Endpoint.ONBOARDING_TASK, + json=data, + headers=headers, + **kwargs + ) + + async def sso_init(self, provider, guest_token): + headers = self.base._base_headers | { + 'x-guest-token': guest_token + } + headers.pop('X-Twitter-Active-User') + headers.pop('X-Twitter-Auth-Type') + return await self.base.post( + Endpoint.ONBOARDING_SSO_INIT, + json={'provider': provider}, + headers=headers + ) + + async def settings(self): + return await self.base.get( + Endpoint.SETTINGS, + headers=self.base._base_headers + ) + + async def upload_media(self, method, is_long_video: bool, *args, **kwargs): + if is_long_video: + endpoint = Endpoint.UPLOAD_MEDIA_2 + else: + endpoint = Endpoint.UPLOAD_MEDIA + return await self.base.request(method, endpoint, *args, **kwargs) + + async def upload_media_init(self, media_type, total_bytes, media_category, is_long_video: bool): + params = { + 'command': 'INIT', + 'total_bytes': total_bytes, + 'media_type': media_type + } + if media_category is not None: + params['media_category'] = media_category + + return await self.upload_media( + 'POST', + is_long_video, + params=params, + headers=self.base._base_headers + ) + + async def upload_media_append(self, is_long_video, media_id, segment_index, chunk_stream): + params = { + 'command': 'APPEND', + 'media_id': media_id, + 'segment_index': segment_index, + } + headers = self.base._base_headers + headers.pop('content-type') + files = { + 'media': ( + 'blob', + chunk_stream, + 'application/octet-stream', + ) + } + return await self.upload_media( + 'POST', + is_long_video, + params=params, + headers=headers, files=files + ) + + async def upload_media_finelize(self, is_long_video, media_id): + params = { + 'command': 'FINALIZE', + 'media_id': media_id, + } + return await self.upload_media( + 'POST', + is_long_video, + params=params, + headers=self.base._base_headers, + ) + + async def upload_media_status(self, is_long_video, media_id): + params = { + 'command': 'STATUS', + 'media_id': media_id, + } + return await self.upload_media( + 'GET', + is_long_video, + params=params, + headers=self.base._base_headers, + ) + + async def create_media_metadata(self, media_id, alt_text, sensitive_warning): + data = {'media_id': media_id} + if alt_text is not None: + data['alt_text'] = {'text': alt_text} + if sensitive_warning is not None: + data['sensitive_media_warning'] = sensitive_warning + return await self.base.post( + Endpoint.CREATE_MEDIA_METADATA, + json=data, + headers=self.base._base_headers + ) + + async def create_card(self, choices, duration_minutes): + card_data = { + 'twitter:card': f'poll{len(choices)}choice_text_only', + 'twitter:api:api:endpoint': '1', + 'twitter:long:duration_minutes': duration_minutes + } + + for i, choice in enumerate(choices, 1): + card_data[f'twitter:string:choice{i}_label'] = choice + + data = {'card_data': json.dumps(card_data)} + headers = self.base._base_headers | {'content-type': 'application/x-www-form-urlencoded'} + return await self.base.post( + Endpoint.CREATE_CARD, + data=data, + headers=headers, + ) + + async def vote(self, selected_choice: str, card_uri: str, tweet_id: str, card_name: str): + data = { + 'twitter:string:card_uri': card_uri, + 'twitter:long:original_tweet_id': tweet_id, + 'twitter:string:response_card_name': card_name, + 'twitter:string:cards_platform': 'Web-12', + 'twitter:string:selected_choice': selected_choice + } + headers = self.base._base_headers | { + 'content-type': 'application/x-www-form-urlencoded' + } + return await self.base.post( + Endpoint.VOTE, + data=data, + headers=headers + ) + + async def reverse_geocode(self, lat, long, accuracy, granularity, max_results): + params = { + 'lat': lat, + 'long': long, + 'accuracy': accuracy, + 'granularity': granularity, + 'max_results': max_results + } + for k, v in tuple(params.items()): + if v is None: + params.pop(k) + return await self.base.get( + Endpoint.REVERSE_GEOCODE, + params=params, + headers=self.base._base_headers + ) + + async def search_geo(self, lat, long, query, ip, granularity, max_results): + params = { + 'lat': lat, + 'long': long, + 'query': query, + 'ip': ip, + 'granularity': granularity, + 'max_results': max_results + } + for k, v in tuple(params.items()): + if v is None: + params.pop(k) + + return await self.base.get( + Endpoint.SEARCH_GEO, + params=params, + headers=self.base._base_headers + ) + + async def get_place(self, id): + return await self.base.get( + Endpoint.GET_PLACE.format(id), + headers=self.base._base_headers + ) + + async def create_friendships(self, user_id): + data = { + 'include_profile_interstitial_type': 1, + 'include_blocking': 1, + 'include_blocked_by': 1, + 'include_followed_by': 1, + 'include_want_retweets': 1, + 'include_mute_edge': 1, + 'include_can_dm': 1, + 'include_can_media_tag': 1, + 'include_ext_is_blue_verified': 1, + 'include_ext_verified_type': 1, + 'include_ext_profile_image_shape': 1, + 'skip_status': 1, + 'user_id': user_id + } + headers = self.base._base_headers | { + 'content-type': 'application/x-www-form-urlencoded' + } + return await self.base.post( + Endpoint.CREATE_FRIENDSHIPS, + data=data, + headers=headers + ) + + async def destroy_friendships(self, user_id): + data = { + 'include_profile_interstitial_type': 1, + 'include_blocking': 1, + 'include_blocked_by': 1, + 'include_followed_by': 1, + 'include_want_retweets': 1, + 'include_mute_edge': 1, + 'include_can_dm': 1, + 'include_can_media_tag': 1, + 'include_ext_is_blue_verified': 1, + 'include_ext_verified_type': 1, + 'include_ext_profile_image_shape': 1, + 'skip_status': 1, + 'user_id': user_id + } + headers = self.base._base_headers | { + 'content-type': 'application/x-www-form-urlencoded' + } + return await self.base.post( + Endpoint.DESTROY_FRIENDSHIPS, + data=data, + headers=headers + ) + + async def create_blocks(self, user_id): + data = {'user_id': user_id} + headers = self.base._base_headers + headers['content-type'] = 'application/x-www-form-urlencoded' + return await self.base.post( + Endpoint.CREATE_BLOCKS, + data=data, + headers=headers + ) + + async def destroy_blocks(self, user_id): + data = {'user_id': user_id} + headers = self.base._base_headers + headers['content-type'] = 'application/x-www-form-urlencoded' + return await self.base.post( + Endpoint.DESTROY_BLOCKS, + data=data, + headers=headers + ) + + async def create_mutes(self, user_id): + data = {'user_id': user_id} + headers = self.base._base_headers + headers['content-type'] = 'application/x-www-form-urlencoded' + return await self.base.post( + Endpoint.CREATE_MUTES, + data=data, + headers=headers + ) + + async def destroy_mutes(self, user_id): + data = {'user_id': user_id} + headers = self.base._base_headers + headers['content-type'] = 'application/x-www-form-urlencoded' + return await self.base.post( + Endpoint.DESTROY_MUTES, + data=data, + headers=headers + ) + + async def guide(self, category, count, additional_request_params): + params = { + 'count': count, + 'include_page_configuration': True, + 'initial_tab_id': category + } + if additional_request_params is not None: + params |= additional_request_params + return await self.base.get( + Endpoint.GUIDE, + params=params, + headers=self.base._base_headers + ) + + async def available_trends(self): + return await self.base.get( + Endpoint.AVAILABLE_TRENDS, + headers=self.base._base_headers + ) + + async def place_trends(self, woeid): + return await self.base.get( + Endpoint.PLACE_TRENDS, + params={'id': woeid}, + headers=self.base._base_headers + ) + + async def _friendships(self, user_id, screen_name, count, endpoint, cursor): + params = {'count': count} + if user_id is not None: + params['user_id'] = user_id + elif screen_name is not None: + params['screen_name'] = screen_name + + if cursor is not None: + params['cursor'] = cursor + + return await self.base.get( + endpoint, + params=params, + headers=self.base._base_headers + ) + + async def followers_list(self, user_id, screen_name, count, cursor): + return await self._friendships(user_id, screen_name, count, Endpoint.FOLLOWERS_LIST, cursor) + + async def friends_list(self, user_id, screen_name, count, cursor): + return await self._friendships(user_id, screen_name, count, Endpoint.FRIENDS_LIST, cursor) + + async def _friendship_ids(self, user_id, screen_name, count, endpoint, cursor): + params = {'count': count} + if user_id is not None: + params['user_id'] = user_id + elif user_id is not None: + params['screen_name'] = screen_name + + if cursor is not None: + params['cursor'] = cursor + + return await self.base.get( + endpoint, + params=params, + headers=self.base._base_headers + ) + + async def followers_ids(self, user_id, screen_name, count, cursor): + return await self._friendship_ids(user_id, screen_name, count, Endpoint.FOLLOWERS_IDS, cursor) + + async def friends_ids(self, user_id, screen_name, count, cursor): + return await self._friendship_ids(user_id, screen_name, count, Endpoint.FRIENDS_IDS, cursor) + + async def dm_new(self, conversation_id, text, media_id, reply_to): + data = { + 'cards_platform': 'Web-12', + 'conversation_id': conversation_id, + 'dm_users': False, + 'include_cards': 1, + 'include_quote_count': True, + 'recipient_ids': False, + 'text': text + } + if media_id is not None: + data['media_id'] = media_id + if reply_to is not None: + data['reply_to_dm_id'] = reply_to + + return await self.base.post( + Endpoint.DM_NEW, + json=data, + headers=self.base._base_headers + ) + + async def dm_conversation(self, conversation_id, max_id): + params = {'context': 'FETCH_DM_CONVERSATION_HISTORY', 'include_conversation_info': True} + if max_id is not None: + params['max_id'] = max_id + + return await self.base.get( + Endpoint.DM_CONVERSATION.format(conversation_id), + params=params, + headers=self.base._base_headers + ) + + async def conversation_update_name(self, group_id, name): + data = {'name': name} + headers = self.base._base_headers + headers['content-type'] = 'application/x-www-form-urlencoded' + return await self.base.post( + Endpoint.CONVERSATION_UPDATE_NAME.format(group_id), + data=data, + headers=headers + ) + + async def _notifications(self, endpoint, count, cursor): + params = {'count': count} + if cursor is not None: + params['cursor'] = cursor + + return await self.base.get( + endpoint, + params=params, + headers=self.base._base_headers + ) + + async def notifications_all(self, count, cursor): + return await self._notifications(Endpoint.NOTIFICATIONS_ALL, count, cursor) + + async def notifications_verified(self, count, cursor): + return await self._notifications(Endpoint.NOTIFICATIONS_VERIFIED, count, cursor) + + async def notifications_mentions(self, count, cursor): + return await self._notifications(Endpoint.NOTIFICATIONS_MENTIONS, count, cursor) + + async def live_pipeline_update_subscriptions(self, session, subscribe, unsubscribe): + data = { + 'sub_topics': subscribe, + 'unsub_topics': unsubscribe + } + headers = self.base._base_headers + headers['content-type'] = 'application/x-www-form-urlencoded' + headers['LivePipeline-Session'] = session + return await self.base.post( + Endpoint.LIVE_PIPELINE_UPDATE_SUBSCRIPTIONS, data=data, headers=headers + ) + + async def user_state(self): + return await self.base.get( + Endpoint.USER_STATE, + headers=self.base._base_headers + ) diff --git a/twikit/community.py b/twikit/community.py new file mode 100644 index 00000000..ce556f46 --- /dev/null +++ b/twikit/community.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, NamedTuple + +from .tweet import Tweet +from .user import User +from .utils import Result, b64_to_str + +if TYPE_CHECKING: + from .client.client import Client + + +class CommunityCreator(NamedTuple): + id: str + screen_name: str + verified: bool + + +class CommunityRule(NamedTuple): + id: str + name: str + + +class CommunityMember: + def __init__(self, client: Client, data: dict) -> None: + self._client = client + self.id: str = data['rest_id'] + + self.community_role: str = data['community_role'] + self.super_following: bool = data['super_following'] + self.super_follow_eligible: bool = data['super_follow_eligible'] + self.super_followed_by: bool = data['super_followed_by'] + self.smart_blocking: bool = data['smart_blocking'] + self.is_blue_verified: bool = data['is_blue_verified'] + + legacy = data['legacy'] + self.screen_name: str = legacy['screen_name'] + self.name: str = legacy['name'] + self.follow_request_sent: bool = legacy['follow_request_sent'] + self.protected: bool = legacy['protected'] + self.following: bool = legacy['following'] + self.followed_by: bool = legacy['followed_by'] + self.blocking: bool = legacy['blocking'] + self.profile_image_url_https: str = legacy['profile_image_url_https'] + self.verified: bool = legacy['verified'] + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, CommunityMember) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value + + def __repr__(self) -> str: + return f'' + + +class Community: + """ + Attributes + ---------- + id : :class:`str` + The ID of the community. + name : :class:`str` + The name of the community. + member_count : :class:`int` + The count of members in the community. + is_nsfw : :class:`bool` + Indicates if the community is NSFW. + members_facepile_results : list[:class:`str`] + The profile image URLs of members. + banner : :class:`dict` + The banner information of the community. + is_member : :class:`bool` + Indicates if the user is a member of the community. + role : :class:`str` + The role of the user in the community. + description : :class:`str` + The description of the community. + creator : :class:`User` | :class:`CommunityCreator` + The creator of the community. + admin : :class:`User` + The admin of the community. + join_policy : :class:`str` + The join policy of the community. + created_at : :class:`int` + The timestamp of the community's creation. + invites_policy : :class:`str` + The invites policy of the community. + is_pinned : :class:`bool` + Indicates if the community is pinned. + rules : list[:class:`CommunityRule`] + The rules of the community. + """ + + def __init__(self, client: Client, data: dict) -> None: + self._client = client + self.id: str = data['rest_id'] + + self.name: str = data['name'] + self.member_count: int = data['member_count'] + self.is_nsfw: bool = data['is_nsfw'] + + self.members_facepile_results: list[str] = [ + i['result']['legacy']['profile_image_url_https'] + for i in data['members_facepile_results'] + ] + self.banner: dict = data['default_banner_media']['media_info'] + + self.is_member: bool = data.get('is_member') + self.role: str = data.get('role') + self.description: str = data.get('description') + + if 'creator_results' in data: + creator = data['creator_results']['result'] + if 'rest_id' in creator: + self.creator = User(client, creator) + else: + self.creator = CommunityCreator( + b64_to_str(creator['id']).removeprefix('User:'), + creator['legacy']['screen_name'], + creator['legacy']['verified'] + ) + else: + self.creator = None + + if 'admin_results' in data: + admin = data['admin_results']['result'] + self.admin = User(client, admin) + else: + self.admin = None + + self.join_policy: str = data.get('join_policy') + self.created_at: int = data.get('created_at') + self.invites_policy: str = data.get('invites_policy') + self.is_pinned: bool = data.get('is_pinned') + + if 'rules' in data: + self.rules: list = [ + CommunityRule(i['rest_id'], i['name']) for i in data['rules'] + ] + else: + self.rules = None + + async def get_tweets( + self, + tweet_type: Literal['Top', 'Latest', 'Media'], + count: int = 40, + cursor: str | None = None + ) -> Result[Tweet]: + """ + Retrieves tweets from the community. + + Parameters + ---------- + tweet_type : {'Top', 'Latest', 'Media'} + The type of tweets to retrieve. + count : :class:`int`, default=40 + The number of tweets to retrieve. + + Returns + ------- + Result[:class:`Tweet`] + List of retrieved tweets. + + Examples + -------- + >>> tweets = await community.get_tweets('Latest') + >>> for tweet in tweets: + ... print(tweet) + + + ... + >>> more_tweets = await tweets.next() # Retrieve more tweets + """ + return await self._client.get_community_tweets( + self.id, + tweet_type, + count, + cursor + ) + + async def join(self) -> Community: + """ + Join the community. + """ + return await self._client.join_community(self.id) + + async def leave(self) -> Community: + """ + Leave the community. + """ + return await self._client.leave_community(self.id) + + async def request_to_join(self, answer: str | None = None) -> Community: + """ + Request to join the community. + """ + return await self._client.request_to_join_community(self.id, answer) + + async def get_members( + self, count: int = 20, cursor: str | None = None + ) -> Result[CommunityMember]: + """ + Retrieves members of the community. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of members to retrieve. + + Returns + ------- + Result[:class:`CommunityMember`] + List of retrieved members. + """ + return await self._client.get_community_members( + self.id, + count, + cursor + ) + + async def get_moderators( + self, count: int = 20, cursor: str | None = None + ) -> Result[CommunityMember]: + """ + Retrieves moderators of the community. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of moderators to retrieve. + + Returns + ------- + Result[:class:`CommunityMember`] + List of retrieved moderators. + """ + return await self._client.get_community_moderators( + self.id, + count, + cursor + ) + + async def search_tweet( + self, + query: str, + count: int = 20, + cursor: str | None = None + )-> Result[Tweet]: + """Searchs tweets in the community. + + Parameters + ---------- + query : :class:`str` + The search query. + count : :class:`int`, default=20 + The number of tweets to retrieve. + + Returns + ------- + Result[:class:`Tweet`] + List of retrieved tweets. + """ + return await self._client.search_community_tweet( + self.id, + query, + count, + cursor + ) + + async def update(self) -> None: + new = await self._client.get_community(self.id) + self.__dict__.update(new.__dict__) + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, Community) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value + + def __repr__(self) -> str: + return f'' diff --git a/twikit/constants.py b/twikit/constants.py new file mode 100644 index 00000000..277be9f3 --- /dev/null +++ b/twikit/constants.py @@ -0,0 +1,229 @@ +# This token is common to all accounts and does not need to be changed. +TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA' + +DOMAIN = 'x.com' + +FEATURES = { + 'creator_subscriptions_tweet_preview_api_enabled': True, + 'c9s_tweet_anatomy_moderator_badge_enabled': True, + 'tweetypie_unmention_optimization_enabled': True, + 'responsive_web_edit_tweet_api_enabled': True, + 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, + 'view_counts_everywhere_api_enabled': True, + 'longform_notetweets_consumption_enabled': True, + 'responsive_web_twitter_article_tweet_consumption_enabled': True, + 'tweet_awards_web_tipping_enabled': False, + 'longform_notetweets_rich_text_read_enabled': True, + 'longform_notetweets_inline_media_enabled': True, + 'rweb_video_timestamps_enabled': True, + 'responsive_web_graphql_exclude_directive_enabled': True, + 'verified_phone_label_enabled': False, + 'freedom_of_speech_not_reach_fetch_enabled': True, + 'standardized_nudges_misinfo': True, + 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, + 'responsive_web_media_download_video_enabled': False, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False, + 'responsive_web_graphql_timeline_navigation_enabled': True, + 'responsive_web_enhance_cards_enabled': False +} + +USER_FEATURES = { + 'hidden_profile_likes_enabled': True, + 'hidden_profile_subscriptions_enabled': True, + 'responsive_web_graphql_exclude_directive_enabled': True, + 'verified_phone_label_enabled': False, + 'subscriptions_verification_info_is_identity_verified_enabled': True, + 'subscriptions_verification_info_verified_since_enabled': True, + 'highlights_tweets_tab_ui_enabled': True, + 'responsive_web_twitter_article_notes_tab_enabled': False, + 'creator_subscriptions_tweet_preview_api_enabled': True, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False, + 'responsive_web_graphql_timeline_navigation_enabled': True +} + +LIST_FEATURES = { + 'responsive_web_graphql_exclude_directive_enabled': True, + 'verified_phone_label_enabled': False, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False, + 'responsive_web_graphql_timeline_navigation_enabled': True +} + +COMMUNITY_NOTE_FEATURES = { + 'responsive_web_birdwatch_media_notes_enabled': True, + 'responsive_web_graphql_timeline_navigation_enabled': True, + 'rweb_tipjar_consumption_enabled': False, + 'responsive_web_graphql_exclude_directive_enabled': True, + 'verified_phone_label_enabled': False, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False +} + +COMMUNITY_TWEETS_FEATURES = { + 'rweb_tipjar_consumption_enabled': True, + 'responsive_web_graphql_exclude_directive_enabled': True, + 'verified_phone_label_enabled': False, + 'creator_subscriptions_tweet_preview_api_enabled': True, + 'responsive_web_graphql_timeline_navigation_enabled': True, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False, + 'communities_web_enable_tweet_community_results_fetch': True, + 'c9s_tweet_anatomy_moderator_badge_enabled': True, + 'tweetypie_unmention_optimization_enabled': True, + 'responsive_web_edit_tweet_api_enabled': True, + 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, + 'view_counts_everywhere_api_enabled': True, + 'longform_notetweets_consumption_enabled': True, + 'responsive_web_twitter_article_tweet_consumption_enabled': True, + 'tweet_awards_web_tipping_enabled': False, + 'creator_subscriptions_quote_tweet_preview_enabled': False, + 'freedom_of_speech_not_reach_fetch_enabled': True, + 'standardized_nudges_misinfo': True, + 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, + 'rweb_video_timestamps_enabled': True, + 'longform_notetweets_rich_text_read_enabled': True, + 'longform_notetweets_inline_media_enabled': True, + 'responsive_web_enhance_cards_enabled': False +} + +JOIN_COMMUNITY_FEATURES = { + 'rweb_tipjar_consumption_enabled': True, + 'responsive_web_graphql_exclude_directive_enabled': True, + 'verified_phone_label_enabled': False, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False, + 'responsive_web_graphql_timeline_navigation_enabled': True +} + +NOTE_TWEET_FEATURES = { + 'communities_web_enable_tweet_community_results_fetch': True, + 'c9s_tweet_anatomy_moderator_badge_enabled': True, + 'tweetypie_unmention_optimization_enabled': True, + 'responsive_web_edit_tweet_api_enabled': True, + 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, + 'view_counts_everywhere_api_enabled': True, + 'longform_notetweets_consumption_enabled': True, + 'responsive_web_twitter_article_tweet_consumption_enabled': True, + 'tweet_awards_web_tipping_enabled': False, + 'creator_subscriptions_quote_tweet_preview_enabled': False, + 'longform_notetweets_rich_text_read_enabled': True, + 'longform_notetweets_inline_media_enabled': True, + 'articles_preview_enabled': False, + 'rweb_video_timestamps_enabled': True, + 'rweb_tipjar_consumption_enabled': True, + 'responsive_web_graphql_exclude_directive_enabled': True, + 'verified_phone_label_enabled': False, + 'freedom_of_speech_not_reach_fetch_enabled': True, + 'standardized_nudges_misinfo': True, + 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, + 'tweet_with_visibility_results_prefer_gql_media_interstitial_enabled': True, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False, + 'responsive_web_graphql_timeline_navigation_enabled': True, + 'responsive_web_enhance_cards_enabled': False +} + +SIMILAR_POSTS_FEATURES = { + 'rweb_tipjar_consumption_enabled': True, + 'responsive_web_graphql_exclude_directive_enabled': True, + 'verified_phone_label_enabled': False, + 'creator_subscriptions_tweet_preview_api_enabled': True, + 'responsive_web_graphql_timeline_navigation_enabled': True, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False, + 'communities_web_enable_tweet_community_results_fetch': True, + 'c9s_tweet_anatomy_moderator_badge_enabled': True, + 'articles_preview_enabled': False, + 'tweetypie_unmention_optimization_enabled': True, + 'responsive_web_edit_tweet_api_enabled': True, + 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, + 'view_counts_everywhere_api_enabled': True, + 'longform_notetweets_consumption_enabled': True, + 'responsive_web_twitter_article_tweet_consumption_enabled': True, + 'tweet_awards_web_tipping_enabled': False, + 'creator_subscriptions_quote_tweet_preview_enabled': False, + 'freedom_of_speech_not_reach_fetch_enabled': True, + 'standardized_nudges_misinfo': True, + 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, + 'tweet_with_visibility_results_prefer_gql_media_interstitial_enabled': True, + 'rweb_video_timestamps_enabled': True, + 'longform_notetweets_rich_text_read_enabled': True, + 'longform_notetweets_inline_media_enabled': True, + 'responsive_web_enhance_cards_enabled': False +} + +BOOKMARK_FOLDER_TIMELINE_FEATURES = { + 'rweb_tipjar_consumption_enabled': True, + 'responsive_web_graphql_exclude_directive_enabled': True, + 'verified_phone_label_enabled': False, + 'creator_subscriptions_tweet_preview_api_enabled': True, + 'responsive_web_graphql_timeline_navigation_enabled': True, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False, + 'communities_web_enable_tweet_community_results_fetch': True, + 'c9s_tweet_anatomy_moderator_badge_enabled': True, + 'articles_preview_enabled': False, + 'tweetypie_unmention_optimization_enabled': True, + 'responsive_web_edit_tweet_api_enabled': True, + 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, + 'view_counts_everywhere_api_enabled': True, + 'longform_notetweets_consumption_enabled': True, + 'responsive_web_twitter_article_tweet_consumption_enabled': True, + 'tweet_awards_web_tipping_enabled': False, + 'creator_subscriptions_quote_tweet_preview_enabled': False, + 'freedom_of_speech_not_reach_fetch_enabled': True, + 'standardized_nudges_misinfo': True, + 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, + 'tweet_with_visibility_results_prefer_gql_media_interstitial_enabled': True, + 'rweb_video_timestamps_enabled': True, + 'longform_notetweets_rich_text_read_enabled': True, + 'longform_notetweets_inline_media_enabled': True, + 'responsive_web_enhance_cards_enabled': False +} + +TWEET_RESULT_BY_REST_ID_FEATURES = { + 'creator_subscriptions_tweet_preview_api_enabled': True, + 'communities_web_enable_tweet_community_results_fetch': True, + 'c9s_tweet_anatomy_moderator_badge_enabled': True, + 'articles_preview_enabled': True, + 'tweetypie_unmention_optimization_enabled': True, + 'responsive_web_edit_tweet_api_enabled': True, + 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, + 'view_counts_everywhere_api_enabled': True, + 'longform_notetweets_consumption_enabled': True, + 'responsive_web_twitter_article_tweet_consumption_enabled': True, + 'tweet_awards_web_tipping_enabled': False, + 'creator_subscriptions_quote_tweet_preview_enabled': False, + 'freedom_of_speech_not_reach_fetch_enabled': True, + 'standardized_nudges_misinfo': True, + 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, + 'rweb_video_timestamps_enabled': True, + 'longform_notetweets_rich_text_read_enabled': True, + 'longform_notetweets_inline_media_enabled': True, + 'rweb_tipjar_consumption_enabled': True, + 'responsive_web_graphql_exclude_directive_enabled': True, + 'verified_phone_label_enabled': False, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False, + 'responsive_web_graphql_timeline_navigation_enabled': True, + 'responsive_web_enhance_cards_enabled': False +} + +USER_HIGHLIGHTS_TWEETS_FEATURES = { + 'rweb_tipjar_consumption_enabled': True, + 'responsive_web_graphql_exclude_directive_enabled': True, + 'verified_phone_label_enabled': False, + 'creator_subscriptions_tweet_preview_api_enabled': True, + 'responsive_web_graphql_timeline_navigation_enabled': True, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False, + 'communities_web_enable_tweet_community_results_fetch': True, + 'c9s_tweet_anatomy_moderator_badge_enabled': True, + 'articles_preview_enabled': True, + 'tweetypie_unmention_optimization_enabled': True, + 'responsive_web_edit_tweet_api_enabled': True, + 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, + 'view_counts_everywhere_api_enabled': True, + 'longform_notetweets_consumption_enabled': True, + 'responsive_web_twitter_article_tweet_consumption_enabled': True, + 'tweet_awards_web_tipping_enabled': False, + 'creator_subscriptions_quote_tweet_preview_enabled': False, + 'freedom_of_speech_not_reach_fetch_enabled': True, + 'standardized_nudges_misinfo': True, + 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, + 'rweb_video_timestamps_enabled': True, + 'longform_notetweets_rich_text_read_enabled': True, + 'longform_notetweets_inline_media_enabled': True, + 'responsive_web_enhance_cards_enabled': False +} diff --git a/twikit/errors.py b/twikit/errors.py new file mode 100644 index 00000000..6518e9e3 --- /dev/null +++ b/twikit/errors.py @@ -0,0 +1,110 @@ +from __future__ import annotations + + +class TwitterException(Exception): + """ + Base class for Twitter API related exceptions. + """ + def __init__(self, *args: object, headers: dict | None = None) -> None: + super().__init__(*args) + if headers is None: + self.headers = None + else: + self.headers = dict(headers) + +class BadRequest(TwitterException): + """ + Exception raised for 400 Bad Request errors. + """ + +class Unauthorized(TwitterException): + """ + Exception raised for 401 Unauthorized errors. + """ + +class Forbidden(TwitterException): + """ + Exception raised for 403 Forbidden errors. + """ + +class NotFound(TwitterException): + """ + Exception raised for 404 Not Found errors. + """ + +class RequestTimeout(TwitterException): + """ + Exception raised for 408 Request Timeout errors. + """ + +class TooManyRequests(TwitterException): + """ + Exception raised for 429 Too Many Requests errors. + """ + def __init__(self, *args, headers: dict | None = None) -> None: + super().__init__(*args, headers=headers) + if headers is not None and 'x-rate-limit-reset' in headers: + self.rate_limit_reset = int(headers.get('x-rate-limit-reset')) + else: + self.rate_limit_reset = None + +class ServerError(TwitterException): + """ + Exception raised for 5xx Server Error responses. + """ + +class CouldNotTweet(TwitterException): + """ + Exception raised when a tweet could not be sent. + """ + +class DuplicateTweet(CouldNotTweet): + """ + Exception raised when a tweet is a duplicate of another. + """ + +class TweetNotAvailable(TwitterException): + """ + Exceptions raised when a tweet is not available. + """ + +class InvalidMedia(TwitterException): + """ + Exception raised when there is a problem with the media ID + sent with the tweet. + """ + +class UserNotFound(TwitterException): + """ + Exception raised when a user does not exsit. + """ + +class UserUnavailable(TwitterException): + """ + Exception raised when a user is unavailable. + """ + +class AccountSuspended(TwitterException): + """ + Exception raised when the account is suspended. + """ + +class AccountLocked(TwitterException): + """ + Exception raised when the account is locked (very likey is Arkose challenge). + """ + +ERROR_CODE_TO_EXCEPTION: dict[int, TwitterException] = { + 187: DuplicateTweet, + 324: InvalidMedia +} + + +def raise_exceptions_from_response(errors: list[dict]): + for error in errors: + code = error.get('code') + if code not in ERROR_CODE_TO_EXCEPTION: + code = error.get('extensions', {}).get('code') + exception = ERROR_CODE_TO_EXCEPTION.get(code) + if exception is not None: + raise exception(error['message']) diff --git a/twikit/geo.py b/twikit/geo.py new file mode 100644 index 00000000..50cd8b77 --- /dev/null +++ b/twikit/geo.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +from .errors import TwitterException + +if TYPE_CHECKING: + from .client.client import Client + + +class Place: + """ + Attributes + ---------- + id : :class:`str` + The ID of the place. + name : :class:`str` + The name of the place. + full_name : :class:`str` + The full name of the place. + country : :class:`str` + The country where the place is located. + country_code : :class:`str` + The ISO 3166-1 alpha-2 country code of the place. + url : :class:`str` + The URL providing more information about the place. + place_type : :class:`str` + The type of place. + attributes : :class:`dict` + bounding_box : :class:`dict` + The bounding box that defines the geographical area of the place. + centroid : list[:class:`float`] | None + The geographical center of the place, represented by latitude and + longitude. + contained_within : list[:class:`.Place`] + A list of places that contain this place. + """ + + def __init__(self, client: Client, data: dict) -> None: + self._client = client + + self.id: str = data['id'] + self.name: str = data['name'] + self.full_name: str = data['full_name'] + self.country: str = data['country'] + self.country_code: str = data['country_code'] + self.url: str = data['url'] + self.place_type: str = data['place_type'] + self.attributes: dict | None = data.get('attributes') + self.bounding_box: dict = data['bounding_box'] + self.centroid: list[float] | None = data.get('centroid') + + self.contained_within: list[Place] = [ + Place(client, place) for place in data.get('contained_within', []) + ] + + async def update(self) -> None: + new = self._client.get_place(self.id) + await self.__dict__.update(new.__dict__) + + def __repr__(self) -> str: + return f'' + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, Place) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value + + +def _places_from_response(client: Client, response: dict) -> list[Place]: + if 'errors' in response: + e = response['errors'][0] + # No data available for the given coordinate. + if e['code'] == 6: + warnings.warn(e['message']) + else: + raise TwitterException(e['message']) + + places = response['result']['places'] if 'result' in response else [] + return [Place(client, place) for place in places] diff --git a/twikit/group.py b/twikit/group.py new file mode 100644 index 00000000..775d6935 --- /dev/null +++ b/twikit/group.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .message import Message +from .user import User +from .utils import build_user_data + +if TYPE_CHECKING: + from httpx import Response + + from .client.client import Client + from .utils import Result + + +class Group: + """ + Represents a group. + + Attributes + ---------- + id : :class:`str` + The ID of the group. + name : :class:`str` | None + The name of the group. + members : list[:class:`str`] + Member IDs + """ + def __init__(self, client: Client, group_id: str, data: dict) -> None: + self._client = client + self.id = group_id + + conversation_timeline = data["conversation_timeline"] + self.name: str | None = ( + conversation_timeline["conversations"][group_id]["name"] + if len(conversation_timeline["conversations"].keys()) > 0 + else None + ) + + members = conversation_timeline["users"].values() + self.members: list[User] = [User(client, build_user_data(i)) for i in members] + + async def get_history( + self, max_id: str | None = None + ) -> Result[GroupMessage]: + """ + Retrieves the DM conversation history in the group. + + Parameters + ---------- + max_id : :class:`str`, default=None + If specified, retrieves messages older than the specified max_id. + + Returns + ------- + Result[:class:`GroupMessage`] + A Result object containing a list of GroupMessage objects + representing the DM conversation history. + + Examples + -------- + >>> messages = await group.get_history() + >>> for message in messages: + >>> print(message) + + + ... + ... + + >>> more_messages = await messages.next() # Retrieve more messages + >>> for message in more_messages: + >>> print(message) + + + ... + ... + """ + return await self._client.get_group_dm_history(self.id, max_id) + + async def add_members(self, user_ids: list[str]) -> Response: + """Adds members to the group. + + Parameters + ---------- + user_ids : list[:class:`str`] + List of IDs of users to be added. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> members = ['...'] + >>> await group.add_members(members) + """ + return await self._client.add_members_to_group(self.id, user_ids) + + async def change_name(self, name: str) -> Response: + """Changes group name + + Parameters + ---------- + name : :class:`str` + New name. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + """ + return await self._client.change_group_name(self.id, name) + + async def send_message( + self, + text: str, + media_id: str | None = None, + reply_to: str | None = None + ) -> GroupMessage: + """ + Sends a message to the group. + + Parameters + ---------- + text : :class:`str` + The text content of the direct message. + media_id : :class:`str`, default=None + The media ID associated with any media content + to be included in the message. + Media ID can be received by using the :func:`.upload_media` method. + reply_to : :class:`str`, default=None + Message ID to reply to. + + Returns + ------- + :class:`GroupMessage` + `Message` object containing information about the message sent. + + Examples + -------- + >>> # send DM with media + >>> group_id = '000000000' + >>> media_id = await client.upload_media('image.png') + >>> message = await group.send_message('text', media_id) + >>> print(message) + + """ + return await self._client.send_dm_to_group( + self.id, text, media_id, reply_to + ) + + async def update(self) -> None: + new = await self._client.get_group(self.id) + self.__dict__.update(new.__dict__) + + def __repr__(self) -> str: + return f'' + + +class GroupMessage(Message): + """ + Represents a direct message. + + Attributes + ---------- + id : :class:`str` + The ID of the message. + time : :class:`str` + The timestamp of the message. + text : :class:`str` + The text content of the message. + attachment : :class:`str` + The media URL associated with any attachment in the message. + group_id : :class:`str` + The ID of the group. + """ + def __init__( + self, + client: Client, + data: dict, + sender_id: str, + group_id: str + ) -> None: + super().__init__(client, data, sender_id, None) + self.group_id = group_id + + async def group(self) -> Group: + """ + Gets the group to which the message was sent. + """ + return await self._client.get_group(self.group_id) + + async def reply( + self, text: str, media_id: str | None = None + ) -> GroupMessage: + """Replies to the message. + + Parameters + ---------- + text : :class:`str` + The text content of the direct message. + media_id : :class:`str`, default=None + The media ID associated with any media content + to be included in the message. + Media ID can be received by using the :func:`.upload_media` method. + + Returns + ------- + :class:`Message` + `GroupMessage` object containing information about + the message sent. + + See Also + -------- + Client.send_dm_to_group + """ + return await self._client.send_dm_to_group( + self.group_id, text, media_id, self.id + ) + + async def add_reaction(self, emoji: str) -> Response: + """ + Adds a reaction to the message. + + Parameters + ---------- + emoji : :class:`str` + The emoji to be added as a reaction. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + """ + return await self._client.add_reaction_to_message( + self.id, self.group_id, emoji + ) + + async def remove_reaction(self, emoji: str) -> Response: + """ + Removes a reaction from the message. + + Parameters + ---------- + emoji : :class:`str` + The emoji to be removed. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + """ + return await self._client.remove_reaction_from_message( + self.id, self.group_id, emoji + ) + + def __repr__(self) -> str: + return f'' \ No newline at end of file diff --git a/twikit/guest/__init__.py b/twikit/guest/__init__.py new file mode 100644 index 00000000..9f982878 --- /dev/null +++ b/twikit/guest/__init__.py @@ -0,0 +1,3 @@ +from .client import GuestClient +from .tweet import Tweet +from .user import User diff --git a/twikit/guest/client.py b/twikit/guest/client.py new file mode 100644 index 00000000..e5ddd7a9 --- /dev/null +++ b/twikit/guest/client.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import json +import warnings +from functools import partial +from typing import Any, Literal + +from httpx import AsyncClient, AsyncHTTPTransport, Response +from httpx._utils import URLPattern + +from ..client.gql import GQLClient +from ..client.v11 import V11Client +from ..constants import TOKEN +from ..errors import ( + BadRequest, + Forbidden, + NotFound, + RequestTimeout, + ServerError, + TooManyRequests, + TwitterException, + Unauthorized +) +from ..utils import Result, find_dict, find_entry_by_type, httpx_transport_to_url +from .tweet import Tweet +from .user import User + + +def tweet_from_data(client: GuestClient, data: dict) -> Tweet: + ':meta private:' + tweet_data_ = find_dict(data, 'result', True) + if not tweet_data_: + return None + tweet_data = tweet_data_[0] + + if tweet_data.get('__typename') == 'TweetTombstone': + return None + if 'tweet' in tweet_data: + tweet_data = tweet_data['tweet'] + if 'core' not in tweet_data: + return None + if 'result' not in tweet_data['core']['user_results']: + return None + if 'legacy' not in tweet_data: + return None + + user_data = tweet_data['core']['user_results']['result'] + return Tweet(client, tweet_data, User(client, user_data)) + + + +class GuestClient: + """ + A client for interacting with the Twitter API as a guest. + This class is used for interacting with the Twitter API + without requiring authentication. + + Parameters + ---------- + language : :class:`str` | None, default=None + The language code to use in API requests. + proxy : :class:`str` | None, default=None + The proxy server URL to use for request + (e.g., 'http://0.0.0.0:0000'). + + Examples + -------- + >>> client = GuestClient() + >>> await client.activate() # Activate the client by generating a guest token. + """ + + def __init__( + self, + language: str | None = None, + proxy: str | None = None, + **kwargs + ) -> None: + if 'proxies' in kwargs: + message = ( + "The 'proxies' argument is now deprecated. Use 'proxy' " + "instead. https://github.com/encode/httpx/pull/2879" + ) + warnings.warn(message) + + self.http = AsyncClient(proxy=proxy, **kwargs) + self.language = language + self.proxy = proxy + + self._token = TOKEN + self._user_agent = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/122.0.0.0 Safari/537.36') + self._guest_token: str | None = None # set when activate method is called + self.gql = GQLClient(self) + self.v11 = V11Client(self) + + async def request( + self, + method: str, + url: str, + raise_exception: bool = True, + **kwargs + ) -> tuple[dict | Any, Response]: + ':meta private:' + response = await self.http.request(method, url, **kwargs) + + try: + response_data = response.json() + except json.decoder.JSONDecodeError: + response_data = response.text + + status_code = response.status_code + + if status_code >= 400 and raise_exception: + message = f'status: {status_code}, message: "{response.text}"' + if status_code == 400: + raise BadRequest(message, headers=response.headers) + elif status_code == 401: + raise Unauthorized(message, headers=response.headers) + elif status_code == 403: + raise Forbidden(message, headers=response.headers) + elif status_code == 404: + raise NotFound(message, headers=response.headers) + elif status_code == 408: + raise RequestTimeout(message, headers=response.headers) + elif status_code == 429: + raise TooManyRequests(message, headers=response.headers) + elif 500 <= status_code < 600: + raise ServerError(message, headers=response.headers) + else: + raise TwitterException(message, headers=response.headers) + + return response_data, response + + async def get(self, url, **kwargs) -> tuple[dict | Any, Response]: + ':meta private:' + return await self.request('GET', url, **kwargs) + + async def post(self, url, **kwargs) -> tuple[dict | Any, Response]: + ':meta private:' + return await self.request('POST', url, **kwargs) + + @property + def proxy(self) -> str: + ':meta private:' + transport: AsyncHTTPTransport = self.http._mounts.get( + URLPattern('all://') + ) + if transport is None: + return None + if not hasattr(transport._pool, '_proxy_url'): + return None + return httpx_transport_to_url(transport) + + @proxy.setter + def proxy(self, url: str) -> None: + self.http._mounts = { + URLPattern('all://'): AsyncHTTPTransport(proxy=url) + } + + @property + def _base_headers(self) -> dict[str, str]: + """ + Base headers for Twitter API requests. + """ + headers = { + 'authorization': f'Bearer {self._token}', + 'content-type': 'application/json', + 'X-Twitter-Active-User': 'yes', + 'Referer': 'https://twitter.com/', + } + + if self.language is not None: + headers['Accept-Language'] = self.language + headers['X-Twitter-Client-Language'] = self.language + + if self._guest_token is not None: + headers['X-Guest-Token'] = self._guest_token + + return headers + + async def activate(self) -> str: + """ + Activate the client by generating a guest token. + """ + response, _ = await self.v11.guest_activate() + self._guest_token = response['guest_token'] + return self._guest_token + + async def get_user_by_screen_name(self, screen_name: str) -> User: + """ + Retrieves a user object based on the provided screen name. + + Parameters + ---------- + screen_name : :class:`str` + The screen name of the user to retrieve. + + Returns + ------- + :class:`.user.User` + An instance of the `User` class containing user details. + + Examples + -------- + >>> user = await client.get_user_by_screen_name('example_user') + >>> print(user) + + """ + response, _ = await self.gql.user_by_screen_name(screen_name) + return User(self, response['data']['user']['result']) + + async def get_user_by_id(self, user_id: str) -> User: + """ + Retrieves a user object based on the provided user ID. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to retrieve. + + Returns + ------- + :class:`.user.User` + An instance of the `User` class + + Examples + -------- + >>> user = await client.get_user_by_id('123456789') + >>> print(user) + + """ + response, _ = await self.gql.user_by_rest_id(user_id) + return User(self, response['data']['user']['result']) + + async def get_user_tweets( + self, + user_id: str, + tweet_type: Literal['Tweets'] = 'Tweets', + count: int = 40, + ) -> list[Tweet]: + """ + Fetches tweets from a specific user's timeline. + + Parameters + ---------- + user_id : :class:`str` + The ID of the Twitter user whose tweets to retrieve. + To get the user id from the screen name, you can use + `get_user_by_screen_name` method. + tweet_type : {'Tweets'}, default='Tweets' + The type of tweets to retrieve. + count : :class:`int`, default=40 + The number of tweets to retrieve. + + Returns + ------- + list[:class:`.tweet.Tweet`] + A Result object containing a list of `Tweet` objects. + + Examples + -------- + >>> user_id = '...' + + If you only have the screen name, you can get the user id as follows: + + >>> screen_name = 'example_user' + >>> user = client.get_user_by_screen_name(screen_name) + >>> user_id = user.id + + >>> tweets = await client.get_user_tweets(user_id) + >>> for tweet in tweets: + ... print(tweet) + + + ... + ... + + See Also + -------- + .get_user_by_screen_name + """ + tweet_type = tweet_type.capitalize() + f = { + 'Tweets': self.gql.user_tweets, + }[tweet_type] + response, _ = await f(user_id, count, None) + instructions_ = find_dict(response, 'instructions', True) + if not instructions_: + return [] + instructions = instructions_[0] + items = find_entry_by_type(instructions, 'TimelineAddEntries')['entries'] + results = [] + + for item in items: + entry_id = item['entryId'] + if not entry_id.startswith(('tweet', 'profile-conversation', 'profile-grid')): + continue + tweet = tweet_from_data(self, item) + if tweet is None: + continue + results.append(tweet) + + return results + + async def get_tweet_by_id(self, tweet_id: str) -> Tweet: + """ + Fetches a tweet by tweet ID. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet. + + Returns + ------- + :class:`.tweet.Tweet` + Tweet object + + Examples + -------- + >>> await client.get_tweet_by_id('123456789') + + """ + response, _ = await self.gql.tweet_result_by_rest_id(tweet_id) + return tweet_from_data(self, response) + + async def get_user_highlights_tweets( + self, + user_id: str, + count: int = 20, + cursor: str | None = None + ) -> Result[Tweet]: + """ + Retrieves highlighted tweets from a user's timeline. + + Parameters + ---------- + user_id : :class:`str` + The user ID + count : :class:`int`, default=20 + The number of tweets to retrieve. + + Returns + ------- + Result[:class:`.tweet.Tweet`] + An instance of the `Result` class containing the highlighted tweets. + + Examples + -------- + >>> result = await client.get_user_highlights_tweets('123456789') + >>> for tweet in result: + ... print(tweet) + + + ... + ... + + >>> more_results = await result.next() # Retrieve more highlighted tweets + >>> for tweet in more_results: + ... print(tweet) + + + ... + ... + """ + response, _ = await self.gql.user_highlights_tweets(user_id, count, cursor) + + instructions = response['data']['user']['result']['timeline']['timeline']['instructions'] + instruction = find_entry_by_type(instructions, 'TimelineAddEntries') + if instruction is None: + return Result.empty() + entries = instruction['entries'] + previous_cursor = None + next_cursor = None + results = [] + + for entry in entries: + entryId = entry['entryId'] + if entryId.startswith('tweet'): + results.append(tweet_from_data(self, entry)) + elif entryId.startswith('cursor-top'): + previous_cursor = entry['content']['value'] + elif entryId.startswith('cursor-bottom'): + next_cursor = entry['content']['value'] + + return Result( + results, + partial(self.get_user_highlights_tweets, user_id, count, next_cursor), + next_cursor, + partial(self.get_user_highlights_tweets, user_id, count, previous_cursor), + previous_cursor + ) diff --git a/twikit/guest/tweet.py b/twikit/guest/tweet.py new file mode 100644 index 00000000..aaa41bf4 --- /dev/null +++ b/twikit/guest/tweet.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..utils import find_dict +from .user import User + +if TYPE_CHECKING: + from .client import GuestClient + + +class Tweet: + """ + Attributes + ---------- + id : :class:`str` + The unique identifier of the tweet. + created_at : :class:`str` + The date and time when the tweet was created. + created_at_datetime : :class:`datetime` + The created_at converted to datetime. + user: :class:`.guest.user.User` + Author of the tweet. + text : :class:`str` + The full text of the tweet. + lang : :class:`str` + The language of the tweet. + in_reply_to : :class:`str` + The tweet ID this tweet is in reply to, if any + is_quote_status : :class:`bool` + Indicates if the tweet is a quote status. + quote : :class:`.guest.tweet.Tweet` | None + The Tweet being quoted (if any) + retweeted_tweet : :class:`.guest.tweet.Tweet` | None + The Tweet being retweeted (if any) + possibly_sensitive : :class:`bool` + Indicates if the tweet content may be sensitive. + possibly_sensitive_editable : :class:`bool` + Indicates if the tweet's sensitivity can be edited. + quote_count : :class:`int` + The count of quotes for the tweet. + media : :class:`list` + A list of media entities associated with the tweet. + reply_count : :class:`int` + The count of replies to the tweet. + favorite_count : :class:`int` + The count of favorites or likes for the tweet. + favorited : :class:`bool` + Indicates if the tweet is favorited. + view_count: :class:`int` | None + The count of views. + view_count_state : :class:`str` | None + The state of the tweet views. + retweet_count : :class:`int` + The count of retweets for the tweet. + place : :class:`.Place` | None + The location associated with the tweet. + editable_until_msecs : :class:`int` + The timestamp until which the tweet is editable. + is_translatable : :class:`bool` + Indicates if the tweet is translatable. + is_edit_eligible : :class:`bool` + Indicates if the tweet is eligible for editing. + edits_remaining : :class:`int` + The remaining number of edits allowed for the tweet. + reply_to: list[:class:`Tweet`] | None + A list of Tweet objects representing the tweets to which to reply. + related_tweets : list[:class:`Tweet`] | None + Related tweets. + hashtags: list[:class:`str`] + Hashtags included in the tweet text. + has_card : :class:`bool` + Indicates if the tweet contains a card. + thumbnail_title : :class:`str` | None + The title of the webpage displayed inside tweet's card. + thumbnail_url : :class:`str` | None + Link to the image displayed in the tweet's card. + urls : :class:`list` + Information about URLs contained in the tweet. + full_text : :class:`str` | None + The full text of the tweet. + """ + + def __init__(self, client: GuestClient, data: dict, user: User = None) -> None: + self._client = client + self._data = data + self.user = user + + self.reply_to: list[Tweet] | None = None + self.related_tweets: list[Tweet] | None = None + self.thread: list[Tweet] | None = None + + self.id: str = data['rest_id'] + legacy = data['legacy'] + self.created_at: str = legacy['created_at'] + self.text: str = legacy['full_text'] + self.lang: str = legacy['lang'] + self.is_quote_status: bool = legacy['is_quote_status'] + self.in_reply_to: str | None = self._data['legacy'].get('in_reply_to_status_id_str') + self.is_quote_status: bool = legacy['is_quote_status'] + self.possibly_sensitive: bool = legacy.get('possibly_sensitive') + self.possibly_sensitive_editable: bool = legacy.get('possibly_sensitive_editable') + self.quote_count: int = legacy['quote_count'] + self.media: list = legacy['entities'].get('media') + self.reply_count: int = legacy['reply_count'] + self.favorite_count: int = legacy['favorite_count'] + self.favorited: bool = legacy['favorited'] + self.retweet_count: int = legacy['retweet_count'] + self._place_data = legacy.get('place') + self.editable_until_msecs: int = data['edit_control'].get('editable_until_msecs') + self.is_translatable: bool = data.get('is_translatable') + self.is_edit_eligible: bool = data['edit_control'].get('is_edit_eligible') + self.edits_remaining: int = data['edit_control'].get('edits_remaining') + self.view_count: str = data['views'].get('count') if 'views' in data else None + self.view_count_state: str = data['views'].get('state') if 'views' in data else None + self.has_community_notes: bool = data.get('has_birdwatch_notes') + + if data.get('quoted_status_result'): + quoted_tweet = data.pop('quoted_status_result')['result'] + if 'tweet' in quoted_tweet: + quoted_tweet = quoted_tweet['tweet'] + if quoted_tweet.get('__typename') != 'TweetTombstone': + quoted_user = User(client, quoted_tweet['core']['user_results']['result']) + self.quote: Tweet = Tweet(client, quoted_tweet, quoted_user) + else: + self.quote = None + + if legacy.get('retweeted_status_result'): + retweeted_tweet = legacy.pop('retweeted_status_result')['result'] + if 'tweet' in retweeted_tweet: + retweeted_tweet = retweeted_tweet['tweet'] + retweeted_user = User( + client, retweeted_tweet['core']['user_results']['result'] + ) + self.retweeted_tweet: Tweet = Tweet( + client, retweeted_tweet, retweeted_user + ) + else: + self.retweeted_tweet = None + + note_tweet_results = find_dict(data, 'note_tweet_results', find_one=True) + self.full_text: str = self.text + if note_tweet_results: + text_list = find_dict(note_tweet_results, 'text', find_one=True) + if text_list: + self.full_text = text_list[0] + + entity_set = note_tweet_results[0]['result']['entity_set'] + self.urls: list = entity_set.get('urls') + hashtags = entity_set.get('hashtags', []) + else: + self.urls: list = legacy['entities'].get('urls') + hashtags = legacy['entities'].get('hashtags', []) + + self.hashtags: list[str] = [ + i['text'] for i in hashtags + ] + + self.community_note = None + if 'birdwatch_pivot' in data: + community_note_data = data['birdwatch_pivot'] + if 'note' in community_note_data: + self.community_note = { + 'id': community_note_data['note']['rest_id'], + 'text': community_note_data['subtitle']['text'] + } + + if ( + 'card' in data and + 'legacy' in data['card'] and + 'name' in data['card']['legacy'] and + data['card']['legacy']['name'].startswith('poll') + ): + self._poll_data = data['card'] + else: + self._poll_data = None + + self.thumbnail_url = None + self.thumbnail_title = None + self.has_card = 'card' in data + if ( + 'card' in data and + 'legacy' in data['card'] and + 'binding_values' in data['card']['legacy'] + ): + card_data = data['card']['legacy']['binding_values'] + + if isinstance(card_data, list): + binding_values = { + i.get('key'): i.get('value') + for i in card_data + } + + if 'title' in binding_values and 'string_value' in binding_values['title']: + self.thumbnail_title = binding_values['title']['string_value'] + + if ( + 'thumbnail_image_original' in binding_values and + 'image_value' in binding_values['thumbnail_image_original'] and + 'url' in binding_values['thumbnail_image_original']['image_value'] + ): + self.thumbnail_url = binding_values['thumbnail_image_original']['image_value']['url'] + + async def update(self) -> None: + new = await self._client.get_tweet_by_id(self.id) + self.__dict__.update(new.__dict__) + + def __repr__(self) -> str: + return f'' + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, Tweet) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value \ No newline at end of file diff --git a/twikit/guest/user.py b/twikit/guest/user.py new file mode 100644 index 00000000..eb873852 --- /dev/null +++ b/twikit/guest/user.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Literal + +from ..utils import Result, timestamp_to_datetime + +if TYPE_CHECKING: + from .client import GuestClient + from .tweet import Tweet + + +class User: + """ + Attributes + ---------- + id : :class:`str` + The unique identifier of the user. + created_at : :class:`str` + The date and time when the user account was created. + name : :class:`str` + The user's name. + screen_name : :class:`str` + The user's screen name. + profile_image_url : :class:`str` + The URL of the user's profile image (HTTPS version). + profile_banner_url : :class:`str` + The URL of the user's profile banner. + url : :class:`str` + The user's URL. + location : :class:`str` + The user's location information. + description : :class:`str` + The user's profile description. + description_urls : :class:`list` + URLs found in the user's profile description. + urls : :class:`list` + URLs associated with the user. + pinned_tweet_ids : :class:`str` + The IDs of tweets that the user has pinned to their profile. + is_blue_verified : :class:`bool` + Indicates if the user is verified with a blue checkmark. + verified : :class:`bool` + Indicates if the user is verified. + possibly_sensitive : :class:`bool` + Indicates if the user's content may be sensitive. + can_media_tag : :class:`bool` + Indicates whether the user can be tagged in media. + want_retweets : :class:`bool` + Indicates if the user wants retweets. + default_profile : :class:`bool` + Indicates if the user has the default profile. + default_profile_image : :class:`bool` + Indicates if the user has the default profile image. + has_custom_timelines : :class:`bool` + Indicates if the user has custom timelines. + followers_count : :class:`int` + The count of followers. + fast_followers_count : :class:`int` + The count of fast followers. + normal_followers_count : :class:`int` + The count of normal followers. + following_count : :class:`int` + The count of users the user is following. + favourites_count : :class:`int` + The count of favorites or likes. + listed_count : :class:`int` + The count of lists the user is a member of. + media_count : :class:`int` + The count of media items associated with the user. + statuses_count : :class:`int` + The count of tweets. + is_translator : :class:`bool` + Indicates if the user is a translator. + translator_type : :class:`str` + The type of translator. + profile_interstitial_type : :class:`str` + The type of profile interstitial. + withheld_in_countries : list[:class:`str`] + Countries where the user's content is withheld. + """ + + def __init__(self, client: GuestClient, data: dict) -> None: + self._client = client + legacy = data['legacy'] + + self.id: str = data['rest_id'] + self.created_at: str = legacy['created_at'] + self.name: str = legacy['name'] + self.screen_name: str = legacy['screen_name'] + self.profile_image_url: str = legacy['profile_image_url_https'] + self.profile_banner_url: str = legacy.get('profile_banner_url') + self.url: str = legacy.get('url') + self.location: str = legacy['location'] + self.description: str = legacy['description'] + self.description_urls: list = legacy['entities']['description']['urls'] + self.urls: list = legacy['entities'].get('url', {}).get('urls') + self.pinned_tweet_ids: list[str] = legacy['pinned_tweet_ids_str'] + self.is_blue_verified: bool = data['is_blue_verified'] + self.verified: bool = legacy['verified'] + self.possibly_sensitive: bool = legacy['possibly_sensitive'] + self.default_profile: bool = legacy['default_profile'] + self.default_profile_image: bool = legacy['default_profile_image'] + self.has_custom_timelines: bool = legacy['has_custom_timelines'] + self.followers_count: int = legacy['followers_count'] + self.fast_followers_count: int = legacy['fast_followers_count'] + self.normal_followers_count: int = legacy['normal_followers_count'] + self.following_count: int = legacy['friends_count'] + self.favourites_count: int = legacy['favourites_count'] + self.listed_count: int = legacy['listed_count'] + self.media_count = legacy['media_count'] + self.statuses_count: int = legacy['statuses_count'] + self.is_translator: bool = legacy['is_translator'] + self.translator_type: str = legacy['translator_type'] + self.withheld_in_countries: list[str] = legacy['withheld_in_countries'] + self.protected: bool = legacy.get('protected', False) + + @property + def created_at_datetime(self) -> datetime: + return timestamp_to_datetime(self.created_at) + + async def get_tweets(self, tweet_type: Literal['Tweets'] = 'Tweets', count: int = 40) -> list[Tweet]: + """ + Retrieves the user's tweets. + + Parameters + ---------- + tweet_type : {'Tweets'}, default='Tweets' + The type of tweets to retrieve. + count : :class:`int`, default=40 + The number of tweets to retrieve. + + Returns + ------- + list[:class:`.tweet.Tweet`] + A list of `Tweet` objects. + + Examples + -------- + >>> user = await client.get_user_by_screen_name('example_user') + >>> tweets = await user.get_tweets() + >>> for tweet in tweets: + ... print(tweet) + + + ... + ... + """ + return await self._client.get_user_tweets(self.id, tweet_type, count) + + async def get_highlights_tweets(self, count: int = 20, cursor: str | None = None) -> Result[Tweet]: + """ + Retrieves highlighted tweets from the user's timeline. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of tweets to retrieve. + + Returns + ------- + Result[:class:`.tweet.Tweet`] + An instance of the `Result` class containing the highlighted tweets. + + Examples + -------- + >>> result = await user.get_highlights_tweets() + >>> for tweet in result: + ... print(tweet) + + + ... + ... + + >>> more_results = await result.next() # Retrieve more highlighted tweets + >>> for tweet in more_results: + ... print(tweet) + + + ... + ... + """ + return await self._client.get_user_highlights_tweets(self.id, count, cursor) + + async def update(self) -> None: + new = await self._client.get_user_by_id(self.id) + self.__dict__.update(new.__dict__) + + def __repr__(self) -> str: + return f'' + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, User) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value diff --git a/twikit/list.py b/twikit/list.py new file mode 100644 index 00000000..9d09bfba --- /dev/null +++ b/twikit/list.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Literal + +from .utils import timestamp_to_datetime + +if TYPE_CHECKING: + from httpx import Response + + from .client.client import Client + from .tweet import Tweet + from .user import User + from .utils import Result + + +class List: + """ + Class representing a Twitter List. + + Attributes + ---------- + id : :class:`str` + The unique identifier of the List. + created_at : :class:`int` + The timestamp when the List was created. + default_banner : :class:`dict` + Information about the default banner of the List. + banner : :class:`dict` + Information about the banner of the List. If custom banner is not set, + it defaults to the default banner. + description : :class:`str` + The description of the List. + following : :class:`bool` + Indicates if the authenticated user is following the List. + is_member : :class:`bool` + Indicates if the authenticated user is a member of the List. + member_count : :class:`int` + The number of members in the List. + mode : {'Private', 'Public'} + The mode of the List, either 'Private' or 'Public'. + muting : :class:`bool` + Indicates if the authenticated user is muting the List. + name : :class:`str` + The name of the List. + pinning : :class:`bool` + Indicates if the List is pinned. + subscriber_count : :class:`int` + The number of subscribers to the List. + """ + def __init__(self, client: Client, data: dict) -> None: + self._client = client + + self.id: str = data['id_str'] + self.created_at: int = data['created_at'] + self.default_banner: dict = data['default_banner_media']['media_info'] + + if 'custom_banner_media' in data: + self.banner: dict = data["custom_banner_media"]["media_info"] + else: + self.banner: dict = self.default_banner + + self.description: str = data['description'] + self.following: bool = data['following'] + self.is_member: bool = data['is_member'] + self.member_count: bool = data['member_count'] + self.mode: Literal['Private', 'Public'] = data['mode'] + self.muting: bool = data['muting'] + self.name: str = data['name'] + self.pinning: bool = data['pinning'] + self.subscriber_count: int = data['subscriber_count'] + + @property + def created_at_datetime(self) -> datetime: + return timestamp_to_datetime(self.created_at) + + async def edit_banner(self, media_id: str) -> Response: + """ + Edit the banner image of the list. + + Parameters + ---------- + media_id : :class:`str` + The ID of the media to use as the new banner image. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> media_id = await client.upload_media('image.png') + >>> await media.edit_banner(media_id) + """ + return await self._client.edit_list_banner(self.id, media_id) + + async def delete_banner(self) -> Response: + """ + Deletes the list banner. + """ + return await self._client.delete_list_banner(self.id) + + async def edit( + self, + name: str | None = None, + description: str | None = None, + is_private: bool | None = None + ) -> List: + """ + Edits list information. + + Parameters + ---------- + name : :class:`str`, default=None + The new name for the list. + description : :class:`str`, default=None + The new description for the list. + is_private : :class:`bool`, default=None + Indicates whether the list should be private + (True) or public (False). + + Returns + ------- + :class:`List` + The updated Twitter list. + + Examples + -------- + >>> await list.edit( + ... 'new name', 'new description', True + ... ) + """ + return await self._client.edit_list( + self.id, name, description, is_private + ) + + async def add_member(self, user_id: str) -> Response: + """ + Adds a member to the list. + """ + return await self._client.add_list_member(self.id, user_id) + + async def remove_member(self, user_id: str) -> Response: + """ + Removes a member from the list. + """ + return await self._client.remove_list_member(self.id, user_id) + + async def get_tweets( + self, count: int = 20, cursor: str | None = None + ) -> Result[Tweet]: + """ + Retrieves tweets from the list. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of tweets to retrieve. + cursor : :class:`str`, default=None + The cursor for pagination. + + Returns + ------- + Result[:class:`Tweet`] + A Result object containing the retrieved tweets. + + Examples + -------- + >>> tweets = await list.get_tweets() + >>> for tweet in tweets: + ... print(tweet) + + + ... + ... + + >>> more_tweets = await tweets.next() # Retrieve more tweets + >>> for tweet in more_tweets: + ... print(tweet) + + + ... + ... + """ + return await self._client.get_list_tweets(self.id, count, cursor) + + async def get_members( + self, count: int = 20, cursor: str | None = None + ) -> Result[User]: + """Retrieves members of the list. + + Parameters + ---------- + count : :class:`int`, default=20 + Number of members to retrieve. + + Returns + ------- + Result[:class:`User`] + Members of the list + + Examples + -------- + >>> members = list_.get_members() + >>> for member in members: + ... print(member) + + + ... + ... + >>> more_members = members.next() # Retrieve more members + """ + return await self._client.get_list_members(self.id, count, cursor) + + async def get_subscribers( + self, count: int = 20, cursor: str | None = None + ) -> Result[User]: + """Retrieves subscribers of the list. + + Parameters + ---------- + count : :class:`int`, default=20 + Number of subscribers to retrieve. + + Returns + ------- + Result[:class:`User`] + Subscribers of the list + + Examples + -------- + >>> subscribers = list_.get_subscribers() + >>> for subscriber in subscribers: + ... print(subscriber) + + + ... + ... + >>> more_subscribers = subscribers.next() # Retrieve more subscribers + """ + return await self._client.get_list_subscribers(self.id, count, cursor) + + async def update(self) -> None: + new = await self._client.get_list(self.id) + self.__dict__.update(new.__dict__) + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, List) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value + + def __repr__(self) -> str: + return f'' diff --git a/twikit/message.py b/twikit/message.py new file mode 100644 index 00000000..bfcaa81c --- /dev/null +++ b/twikit/message.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from httpx import Response + + from .client.client import Client + + +class Message: + """ + Represents a direct message. + + Attributes + ---------- + id : :class:`str` + The ID of the message. + time : :class:`str` + The timestamp of the message. + text : :class:`str` + The text content of the message. + attachment : :class:`dict` + Attachment Information. + """ + def __init__( + self, + client: Client, + data: dict, + sender_id: str, + recipient_id: str + ) -> None: + self._client = client + self.sender_id = sender_id + self.recipient_id = recipient_id + + self.id: str = data['id'] + self.time: str = data['time'] + self.text: str = data['text'] + self.attachment: dict | None = data.get('attachment') + + async def reply(self, text: str, media_id: str | None = None) -> Message: + """Replies to the message. + + Parameters + ---------- + text : :class:`str` + The text content of the direct message. + media_id : :class:`str`, default=None + The media ID associated with any media content + to be included in the message. + Media ID can be received by using the :func:`.upload_media` method. + + Returns + ------- + :class:`Message` + `Message` object containing information about the message sent. + + See Also + -------- + Client.send_dm + """ + user_id = await self._client.user_id() + send_to = ( + self.recipient_id + if user_id == self.sender_id else + self.sender_id + ) + return await self._client.send_dm(send_to, text, media_id, self.id) + + async def add_reaction(self, emoji: str) -> Response: + """ + Adds a reaction to the message. + + Parameters + ---------- + emoji : :class:`str` + The emoji to be added as a reaction. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + """ + user_id = await self._client.user_id() + partner_id = ( + self.recipient_id + if user_id == self.sender_id else + self.sender_id + ) + conversation_id = f'{partner_id}-{user_id}' + return await self._client.add_reaction_to_message( + self.id, conversation_id, emoji + ) + + async def remove_reaction(self, emoji: str) -> Response: + """ + Removes a reaction from the message. + + Parameters + ---------- + emoji : :class:`str` + The emoji to be removed. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + """ + user_id = await self._client.user_id() + partner_id = ( + self.recipient_id + if user_id == self.sender_id else + self.sender_id + ) + conversation_id = f'{partner_id}-{user_id}' + return await self._client.remove_reaction_from_message( + self.id, conversation_id, emoji + ) + + async def delete(self) -> Response: + """ + Deletes the message. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + Client.delete_dm + """ + return await self._client.delete_dm(self.id) + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, Message) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value + + def __repr__(self) -> str: + return f'' diff --git a/twikit/notification.py b/twikit/notification.py new file mode 100644 index 00000000..6f8da274 --- /dev/null +++ b/twikit/notification.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .client.client import Client + from .tweet import Tweet + from .user import User + + +class Notification: + """ + Attributes + ---------- + id : :class:`str` + The unique identifier of the notification. + timestamp_ms : :class:`int` + The timestamp of the notification in milliseconds. + icon : :class:`dict` + Dictionary containing icon data for the notification. + message : :class:`str` + The message text of the notification. + tweet : :class:`.Tweet` + The tweet associated with the notification. + from_user : :class:`.User` + The user who triggered the notification. + """ + def __init__( + self, client: Client, data: dict, tweet: Tweet, from_user: User + ) -> None: + self._client = client + self.tweet = tweet + self.from_user = from_user + + self.id: str = data['id'] + self.timestamp_ms: int = int(data['timestampMs']) + self.icon: dict = data['icon'] + self.message: str = data['message']['text'] + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, Notification) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value + + def __repr__(self) -> str: + return f'' diff --git a/twikit/streaming.py b/twikit/streaming.py new file mode 100644 index 00000000..836b4515 --- /dev/null +++ b/twikit/streaming.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncGenerator, NamedTuple + +if TYPE_CHECKING: + from .client.client import Client + + +class StreamingSession: + """ + Represents a streaming session. + + Attributes + ---------- + id : :class:`str` + The ID or the session. + topics : set[:class:`str`] + The topics to stream. + + See Also + -------- + .Client.get_streaming_session + """ + def __init__( + self, client: Client, session_id: str, + stream: AsyncGenerator[Payload], topics: set[str], auto_reconnect: bool + ) -> None: + self._client = client + self.id = session_id + self._stream = stream + self.topics = topics + self.auto_reconnect = auto_reconnect + + async def reconnect(self) -> tuple[str, Payload]: + """ + Reconnects the session. + """ + stream = self._client._stream(self.topics) + config_event = await anext(stream) + self.id = config_event[1].config.session_id + self._stream = stream + return config_event + + async def update_subscriptions( + self, + subscribe: set[str] | None = None, + unsubscribe: set[str] | None = None + ) -> Payload: + """ + Updates subscriptions for the session. + + Parameters + ---------- + subscribe : set[:class:`str`], default=None + Topics to subscribe to. + unsubscribe : set[:class:`str`], default=None + Topics to unsubscribe from. + + Examples + -------- + >>> from twikit.streaming import Topic + ... + >>> subscribe_topics = { + ... Topic.tweet_engagement('1749528513'), + ... Topic.tweet_engagement('1765829534') + ... } + >>> unsubscribe_topics = { + ... Topic.tweet_engagement('17396176529'), + ... Topic.dm_update('17544932482-174455537996'), + ... Topic.dm_typing('17544932482-174455537996)' + ... } + >>> await session.update_subscriptions( + ... subscribe_topics, unsubscribe_topics + ... ) + + Note + ---- + dm_update and dm_update cannot be added. + + See Also + -------- + .Topic + """ + return await self._client._update_subscriptions( + self, subscribe, unsubscribe + ) + + async def __aiter__(self) -> AsyncGenerator[tuple[str, Payload]]: + while True: + async for event in self._stream: + yield event + if not self.auto_reconnect: + break + yield await self.reconnect() + + def __repr__(self) -> str: + return f'' + + +def _event_from_data(name: str, data: dict) -> StreamEventType: + if name == 'config': + session_id = data['session_id'] + subscription_ttl_millis = data['subscription_ttl_millis'] + heartbeat_millis = data['heartbeat_millis'] + return ConfigEvent( + session_id, subscription_ttl_millis, + heartbeat_millis + ) + + if name == 'subscriptions': + errors = data['errors'] + return SubscriptionsEvent(errors) + + if name == 'tweet_engagement': + like_count = data.get('like_count') + retweet_count = data.get('retweet_count') + quote_count = data.get('quote_count') + reply_count = data.get('reply_count') + view_count = None + view_count_state = None + if 'view_count_info' in data: + view_count = data['view_count_info']['count'] + view_count_state = data['view_count_info']['state'] + return TweetEngagementEvent( + like_count, retweet_count, view_count, + view_count_state, quote_count, reply_count + ) + + if name == 'dm_update': + conversation_id = data['conversation_id'] + user_id = data['user_id'] + return DMUpdateEvent(conversation_id, user_id) + + if name == 'dm_typing': + conversation_id = data['conversation_id'] + user_id = data['user_id'] + return DMTypingEvent(conversation_id, user_id) + + +def _payload_from_data(data: dict) -> Payload: + events = { + name: _event_from_data(name, data) + for (name, data) in data.items() + } + return Payload(**events) + + +class Payload(NamedTuple): + """ + Represents a payload containing several types of events. + """ + config: ConfigEvent | None = None #: The configuration event. + subscriptions: SubscriptionsEvent | None = None #: The subscriptions event. + tweet_engagement: TweetEngagementEvent | None = None #: The tweet engagement event. + dm_update: DMUpdateEvent | None = None #: The direct message update event. + dm_typing: DMTypingEvent | None = None #: The direct message typing event. + + def __repr__(self) -> str: + items = self._asdict().items() + fields = [f'{i[0]}={i[1]}' for i in items if i[1] is not None] + return f'Payload({" ".join(fields)})' + + +class ConfigEvent(NamedTuple): + """ + Event representing configuration data. + """ + session_id: str #: The session ID associated with the configuration. + subscription_ttl_millis: int #: The time to live for the subscription. + heartbeat_millis: int #: The heartbeat interval in milliseconds. + + +class SubscriptionsEvent(NamedTuple): + """ + Event representing subscription status. + """ + errors: list #: A list of errors. + + +class TweetEngagementEvent(NamedTuple): + """ + Event representing tweet engagement metrics. + """ + like_count: str | None #: The number of likes on the tweet. + retweet_count: str | None #: The number of retweets of the tweet. + view_count: str | None #: The number of views of the tweet. + view_count_state: str | None #: The state of view count. + quote_count: int | None #: The number of quotes of the tweet. + reply_count: int | None # The number of Replies of the tweet. + + +class DMUpdateEvent(NamedTuple): + """ + Event representing a (DM) update. + """ + conversation_id: str #: The ID of the conversation associated with the DM. + user_id: str #: ID of the user who sent the DM. + + +class DMTypingEvent(NamedTuple): + """ + Event representing typing indication in a DM conversation. + """ + conversation_id: str #: The conversation where typing indication occurred. + user_id: str #: The ID of the typing user. + +StreamEventType = (ConfigEvent | SubscriptionsEvent | + TweetEngagementEvent | DMTypingEvent | DMTypingEvent) + + +class Topic: + """ + Utility class for generating topic strings for streaming. + """ + @staticmethod + def tweet_engagement(tweet_id: str) -> str: + """ + Generates a topic string for tweet engagement events. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet. + + Returns + ------- + :class:`str` + The topic string for tweet engagement events. + """ + return f'/tweet_engagement/{tweet_id}' + + @staticmethod + def dm_update(conversation_id: str) -> str: + """ + Generates a topic string for direct message update events. + + Parameters + ---------- + conversation_id : :class:`str` + The ID of the conversation. + Group ID (00000000) or partner_ID-your_ID (00000000-00000001) + + Returns + ------- + :class:`str` + The topic string for direct message update events. + """ + return f'/dm_update/{conversation_id}' + + @staticmethod + def dm_typing(conversation_id: str) -> str: + """ + Generates a topic string for direct message typing events. + + Parameters + ---------- + conversation_id : :class:`str` + The ID of the conversation. + Group ID (00000000) or partner_ID-your_ID (00000000-00000001) + + Returns + ------- + :class:`str` + The topic string for direct message typing events. + """ + return f'/dm_typing/{conversation_id}' diff --git a/twikit/trend.py b/twikit/trend.py new file mode 100644 index 00000000..50b92999 --- /dev/null +++ b/twikit/trend.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import TypedDict, TYPE_CHECKING + +if TYPE_CHECKING: + from .client.client import Client + + +class Trend: + """ + Attributes + ---------- + name : :class:`str` + The name of the trending topic. + tweets_count : :class:`int` + The count of tweets associated with the trend. + domain_context : :class:`str` + The context or domain associated with the trend. + grouped_trends : :class:`list`[:class:`str`] + A list of trend names grouped under the main trend. + """ + + def __init__(self, client: Client, data: dict) -> None: + self._client = client + + metadata: dict = data['trendMetadata'] + self.name: str = data['name'] + self.tweets_count: int | None = metadata.get('metaDescription') + self.domain_context: str = metadata.get('domainContext') + self.grouped_trends: list[str] = [ + trend['name'] for trend in data.get('groupedTrends', []) + ] + + def __repr__(self) -> str: + return f'' + + +class PlaceTrends(TypedDict): + trends: list[PlaceTrend] + as_of: str + created_at: str + locations: dict + + +class PlaceTrend: + """ + Attributes + ---------- + name : :class:`str` + The name of the trend. + url : :class:`str` + The URL to view the trend. + query : :class:`str` + The search query corresponding to the trend. + tweet_volume : :class:`int` + The volume of tweets associated with the trend. + """ + def __init__(self, client: Client, data: dict) -> None: + self._client = client + + self.name: str = data['name'] + self.url: str = data['url'] + self.promoted_content: None = data['promoted_content'] + self.query: str = data['query'] + self.tweet_volume: int = data['tweet_volume'] + + def __repr__(self) -> str: + return f'' + + +class Location: + def __init__(self, client: Client, data: dict) -> None: + self._client = client + + self.woeid: int = data['woeid'] + self.country: str = data['country'] + self.country_code: str = data['countryCode'] + self.name: str = data['name'] + self.parentid: int = data['parentid'] + self.placeType: dict = data['placeType'] + self.url: str = data['url'] + + async def get_trends(self) -> PlaceTrends: + return await self._client.get_place_trends(self.woeid) + + def __repr__(self) -> str: + return f'' + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, Location) and self.woeid == __value.woeid + + def __ne__(self, __value: object) -> bool: + return not self == __value \ No newline at end of file diff --git a/twikit/tweet.py b/twikit/tweet.py new file mode 100644 index 00000000..55a271b0 --- /dev/null +++ b/twikit/tweet.py @@ -0,0 +1,684 @@ +from __future__ import annotations + +import re +from datetime import datetime +from typing import TYPE_CHECKING + +from .geo import Place +from .user import User +from .utils import find_dict, timestamp_to_datetime + +if TYPE_CHECKING: + from httpx import Response + + from .client.client import Client + from .utils import Result + + +class Tweet: + """ + Attributes + ---------- + id : :class:`str` + The unique identifier of the tweet. + created_at : :class:`str` + The date and time when the tweet was created. + created_at_datetime : :class:`datetime` + The created_at converted to datetime. + user: :class:`User` + Author of the tweet. + text : :class:`str` + The full text of the tweet. + lang : :class:`str` + The language of the tweet. + in_reply_to : :class:`str` + The tweet ID this tweet is in reply to, if any + is_quote_status : :class:`bool` + Indicates if the tweet is a quote status. + quote : :class:`Tweet` | None + The Tweet being quoted (if any) + retweeted_tweet : :class:`Tweet` | None + The Tweet being retweeted (if any) + possibly_sensitive : :class:`bool` + Indicates if the tweet content may be sensitive. + possibly_sensitive_editable : :class:`bool` + Indicates if the tweet's sensitivity can be edited. + quote_count : :class:`int` + The count of quotes for the tweet. + media : :class:`list` + A list of media entities associated with the tweet. + reply_count : :class:`int` + The count of replies to the tweet. + favorite_count : :class:`int` + The count of favorites or likes for the tweet. + favorited : :class:`bool` + Indicates if the tweet is favorited. + view_count: :class:`int` | None + The count of views. + view_count_state : :class:`str` | None + The state of the tweet views. + retweet_count : :class:`int` + The count of retweets for the tweet. + place : :class:`.Place` | None + The location associated with the tweet. + editable_until_msecs : :class:`int` + The timestamp until which the tweet is editable. + is_translatable : :class:`bool` + Indicates if the tweet is translatable. + is_edit_eligible : :class:`bool` + Indicates if the tweet is eligible for editing. + edits_remaining : :class:`int` + The remaining number of edits allowed for the tweet. + replies: Result[:class:`Tweet`] | None + Replies to the tweet. + reply_to: list[:class:`Tweet`] | None + A list of Tweet objects representing the tweets to which to reply. + related_tweets : list[:class:`Tweet`] | None + Related tweets. + hashtags: list[:class:`str`] + Hashtags included in the tweet text. + has_card : :class:`bool` + Indicates if the tweet contains a card. + thumbnail_title : :class:`str` | None + The title of the webpage displayed inside tweet's card. + thumbnail_url : :class:`str` | None + Link to the image displayed in the tweet's card. + urls : :class:`list` + Information about URLs contained in the tweet. + full_text : :class:`str` | None + The full text of the tweet. + """ + + def __init__(self, client: Client, data: dict, user: User = None) -> None: + self._client = client + self._data = data + self.user = user + + self.replies: Result[Tweet] | None = None + self.reply_to: list[Tweet] | None = None + self.related_tweets: list[Tweet] | None = None + self.thread: list[Tweet] | None = None + + self.id: str = data['rest_id'] + legacy = data['legacy'] + self.created_at: str = legacy['created_at'] + self.text: str = legacy['full_text'] + self.lang: str = legacy['lang'] + self.is_quote_status: bool = legacy['is_quote_status'] + self.in_reply_to: str | None = self._data['legacy'].get('in_reply_to_status_id_str') + self.is_quote_status: bool = legacy['is_quote_status'] + self.possibly_sensitive: bool = legacy.get('possibly_sensitive') + self.possibly_sensitive_editable: bool = legacy.get('possibly_sensitive_editable') + self.quote_count: int = legacy['quote_count'] + self.media: list = legacy['entities'].get('media') + self.reply_count: int = legacy['reply_count'] + self.favorite_count: int = legacy['favorite_count'] + self.favorited: bool = legacy['favorited'] + self.retweet_count: int = legacy['retweet_count'] + self._place_data = legacy.get('place') + self.editable_until_msecs: int = data['edit_control'].get('editable_until_msecs') + self.is_translatable: bool = data.get('is_translatable') + self.is_edit_eligible: bool = data['edit_control'].get('is_edit_eligible') + self.edits_remaining: int = data['edit_control'].get('edits_remaining') + self.view_count: str = data['views'].get('count') if 'views' in data else None + self.view_count_state: str = data['views'].get('state') if 'views' in data else None + self.has_community_notes: bool = data.get('has_birdwatch_notes') + + if data.get('quoted_status_result'): + quoted_tweet = data.pop('quoted_status_result')['result'] + if 'tweet' in quoted_tweet: + quoted_tweet = quoted_tweet['tweet'] + if quoted_tweet.get('__typename') != 'TweetTombstone': + quoted_user = User(client, quoted_tweet['core']['user_results']['result']) + self.quote: Tweet = Tweet(client, quoted_tweet, quoted_user) + else: + self.quote = None + + if legacy.get('retweeted_status_result'): + retweeted_tweet = legacy.pop('retweeted_status_result')['result'] + if 'tweet' in retweeted_tweet: + retweeted_tweet = retweeted_tweet['tweet'] + retweeted_user = User( + client, retweeted_tweet['core']['user_results']['result'] + ) + self.retweeted_tweet: Tweet = Tweet( + client, retweeted_tweet, retweeted_user + ) + else: + self.retweeted_tweet = None + + note_tweet_results = find_dict(data, 'note_tweet_results', find_one=True) + self.full_text: str = self.text + if note_tweet_results: + text_list = find_dict(note_tweet_results, 'text', find_one=True) + if text_list: + self.full_text = text_list[0] + + entity_set = note_tweet_results[0]['result']['entity_set'] + self.urls: list = entity_set.get('urls') + hashtags = entity_set.get('hashtags', []) + else: + self.urls: list = legacy['entities'].get('urls') + hashtags = legacy['entities'].get('hashtags', []) + + self.hashtags: list[str] = [ + i['text'] for i in hashtags + ] + + self.community_note = None + if 'birdwatch_pivot' in data: + community_note_data = data['birdwatch_pivot'] + if 'note' in community_note_data: + self.community_note = { + 'id': community_note_data['note']['rest_id'], + 'text': community_note_data['subtitle']['text'] + } + + if ( + 'card' in data and + 'legacy' in data['card'] and + 'name' in data['card']['legacy'] and + data['card']['legacy']['name'].startswith('poll') + ): + self._poll_data = data['card'] + else: + self._poll_data = None + + self.thumbnail_url = None + self.thumbnail_title = None + self.has_card = 'card' in data + if ( + 'card' in data and + 'legacy' in data['card'] and + 'binding_values' in data['card']['legacy'] + ): + card_data = data['card']['legacy']['binding_values'] + + if isinstance(card_data, list): + binding_values = { + i.get('key'): i.get('value') + for i in card_data + } + + if 'title' in binding_values and 'string_value' in binding_values['title']: + self.thumbnail_title = binding_values['title']['string_value'] + + if ( + 'thumbnail_image_original' in binding_values and + 'image_value' in binding_values['thumbnail_image_original'] and + 'url' in binding_values['thumbnail_image_original' + ]['image_value'] + ): + self.thumbnail_url = binding_values['thumbnail_image_original' + ]['image_value']['url'] + + @property + def created_at_datetime(self) -> datetime: + return timestamp_to_datetime(self.created_at) + + @property + def poll(self) -> Poll: + return self._poll_data and Poll(self._client, self._poll_data, self) + + @property + def place(self) -> Place: + return self._place_data and Place(self._client, self._place_data) + + async def delete(self) -> Response: + """Deletes the tweet. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + Examples + -------- + >>> await tweet.delete() + """ + return await self._client.delete_tweet(self.id) + + async def favorite(self) -> Response: + """ + Favorites the tweet. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + Client.favorite_tweet + """ + return await self._client.favorite_tweet(self.id) + + async def unfavorite(self) -> Response: + """ + Favorites the tweet. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + Client.unfavorite_tweet + """ + return await self._client.unfavorite_tweet(self.id) + + async def retweet(self) -> Response: + """ + Retweets the tweet. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + Client.retweet + """ + return await self._client.retweet(self.id) + + async def delete_retweet(self) -> Response: + """ + Deletes the retweet. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + Client.delete_retweet + """ + return await self._client.delete_retweet(self.id) + + async def bookmark(self) -> Response: + """ + Adds the tweet to bookmarks. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + Client.bookmark_tweet + """ + return await self._client.bookmark_tweet(self.id) + + async def delete_bookmark(self) -> Response: + """ + Removes the tweet from bookmarks. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + Client.delete_bookmark + """ + return await self._client.delete_bookmark(self.id) + + async def reply( + self, + text: str = '', + media_ids: list[str] | None = None, + **kwargs + ) -> Tweet: + """ + Replies to the tweet. + + Parameters + ---------- + text : :class:`str`, default='' + The text content of the reply. + media_ids : list[:class:`str`], default=None + A list of media IDs or URIs to attach to the reply. + Media IDs can be obtained by using the `upload_media` method. + + Returns + ------- + :class:`Tweet` + The created tweet. + + Examples + -------- + >>> tweet_text = 'Example text' + >>> media_ids = [ + ... client.upload_media('image1.png'), + ... client.upload_media('image2.png') + ... ] + >>> await tweet.reply( + ... tweet_text, + ... media_ids=media_ids + ... ) + + See Also + -------- + `Client.upload_media` + """ + return await self._client.create_tweet( + text, media_ids, reply_to=self.id, **kwargs + ) + + async def get_retweeters( + self, count: str = 40, cursor: str | None = None + ) -> Result[User]: + """ + Retrieve users who retweeted the tweet. + + Parameters + ---------- + count : :class:`int`, default=40 + The maximum number of users to retrieve. + cursor : :class:`str`, default=None + A string indicating the position of the cursor for pagination. + + Returns + ------- + Result[:class:`User`] + A list of users who retweeted the tweet. + + Examples + -------- + >>> tweet_id = '...' + >>> retweeters = tweet.get_retweeters() + >>> print(retweeters) + [, , ..., ] + + >>> more_retweeters = retweeters.next() # Retrieve more retweeters. + >>> print(more_retweeters) + [, , ..., ] + """ + return await self._client.get_retweeters(self.id, count, cursor) + + async def get_favoriters( + self, count: str = 40, cursor: str | None = None + ) -> Result[User]: + """ + Retrieve users who favorited a specific tweet. + + Parameters + ---------- + tweet_id : :class:`str` + The ID of the tweet. + count : :class:`int`, default=40 + The maximum number of users to retrieve. + cursor : :class:`str`, default=None + A string indicating the position of the cursor for pagination. + + Returns + ------- + Result[:class:`User`] + A list of users who favorited the tweet. + + Examples + -------- + >>> tweet_id = '...' + >>> favoriters = tweet.get_favoriters() + >>> print(favoriters) + [, , ..., ] + + >>> more_favoriters = favoriters.next() # Retrieve more favoriters. + >>> print(more_favoriters) + [, , ..., ] + """ + return await self._client.get_favoriters(self.id, count, cursor) + + async def get_similar_tweets(self) -> list[Tweet]: + """ + Retrieves tweets similar to the tweet (Twitter premium only). + + Returns + ------- + list[:class:`Tweet`] + A list of Tweet objects representing tweets + similar to the tweet. + """ + return await self._client.get_similar_tweets(self.id) + + async def update(self) -> None: + new = await self._client.get_tweet_by_id(self.id) + self.__dict__.update(new.__dict__) + + def __repr__(self) -> str: + return f'' + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, Tweet) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value + + +def tweet_from_data(client: Client, data: dict) -> Tweet: + ':meta private:' + tweet_data_ = find_dict(data, 'result', True) + if not tweet_data_: + return None + tweet_data = tweet_data_[0] + + if tweet_data.get('__typename') == 'TweetTombstone': + return None + if 'tweet' in tweet_data: + tweet_data = tweet_data['tweet'] + if 'core' not in tweet_data: + return None + if 'result' not in tweet_data['core']['user_results']: + return None + if 'legacy' not in tweet_data: + return None + + user_data = tweet_data['core']['user_results']['result'] + return Tweet(client, tweet_data, User(client, user_data)) + + +class ScheduledTweet: + def __init__(self, client: Client, data: dict) -> None: + self._client = client + + self.id = data['rest_id'] + self.execute_at: int = data['scheduling_info']['execute_at'] + self.state: str = data['scheduling_info']['state'] + self.type: str = data['tweet_create_request']['type'] + self.text: str = data['tweet_create_request']['status'] + self.media = [i['media_info'] for i in data.get('media_entities', [])] + + async def delete(self) -> Response: + """ + Delete the scheduled tweet. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + """ + return await self._client.delete_scheduled_tweet(self.id) + + def __repr__(self) -> str: + return f'' + + +class TweetTombstone: + def __init__(self, client: Client, tweet_id: str, data: dict) -> None: + self._client = client + self.id = tweet_id + self.text: str = data['text']['text'] + + def __repr__(self) -> str: + return f'' + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, TweetTombstone) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value + + +class Poll: + """Represents a poll associated with a tweet. + Attributes + ---------- + tweet : :class:`Tweet` + The tweet associated with the poll. + id : :class:`str` + The unique identifier of the poll. + name : :class:`str` + The name of the poll. + choices : list[:class:`dict`] + A list containing dictionaries representing poll choices. + Each dictionary contains 'label' and 'count' keys + for choice label and count. + duration_minutes : :class:`int` + The duration of the poll in minutes. + end_datetime_utc : :class:`str` + The end date and time of the poll in UTC format. + last_updated_datetime_utc : :class:`str` + The last updated date and time of the poll in UTC format. + selected_choice : :class:`str` | None + Number of the selected choice. + """ + + def __init__( + self, client: Client, data: dict, tweet: Tweet | None = None + ) -> None: + self._client = client + self.tweet = tweet + + legacy = data['legacy'] + binding_values = legacy['binding_values'] + + if isinstance(legacy['binding_values'], list): + binding_values = { + i.get('key'): i.get('value') + for i in legacy['binding_values'] + } + + self.id: str = data['rest_id'] + self.name: str = legacy['name'] + + choices_number = int(re.findall( + r'poll(\d)choice_text_only', self.name + )[0]) + choices = [] + + for i in range(1, choices_number + 1): + choice_label = binding_values[f'choice{i}_label'] + choice_count = binding_values.get(f'choice{i}_count', {}) + choices.append({ + 'number': str(i), + 'label': choice_label['string_value'], + 'count': choice_count.get('string_value', '0') + }) + + self.choices = choices + + self.duration_minutes = int(binding_values['duration_minutes']['string_value']) + self.end_datetime_utc: str = binding_values['end_datetime_utc']['string_value'] + updated = binding_values['last_updated_datetime_utc']['string_value'] + self.last_updated_datetime_utc: str = updated + + self.counts_are_final: bool = binding_values['counts_are_final']['boolean_value'] + + if 'selected_choice' in binding_values: + self.selected_choice: str = binding_values['selected_choice']['string_value'] + else: + self.selected_choice = None + + async def vote(self, selected_choice: str) -> Poll: + """ + Vote on the poll with the specified selected choice. + Parameters + ---------- + selected_choice : :class:`str` + The label of the selected choice for the vote. + Returns + ------- + :class:`Poll` + The Poll object representing the updated poll after voting. + """ + return await self._client.vote( + selected_choice, + self.id, + self.tweet.id, + self.name + ) + + def __repr__(self) -> str: + return f'' + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, Poll) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value + + +class CommunityNote: + """Represents a community note. + + Attributes + ---------- + id : :class:`str` + The ID of the community note. + text : :class:`str` + The text content of the community note. + misleading_tags : list[:class:`str`] + A list of tags indicating misleading information. + trustworthy_sources : :class:`bool` + Indicates if the sources are trustworthy. + helpful_tags : list[:class:`str`] + A list of tags indicating helpful information. + created_at : :class:`int` + The timestamp when the note was created. + can_appeal : :class:`bool` + Indicates if the note can be appealed. + appeal_status : :class:`str` + The status of the appeal. + is_media_note : :class:`bool` + Indicates if the note is related to media content. + media_note_matches : :class:`str` + Matches related to media content. + birdwatch_profile : :class:`dict` + Birdwatch profile associated with the note. + tweet_id : :class:`str` + The ID of the tweet associated with the note. + """ + def __init__(self, client: Client, data: dict) -> None: + self._client = client + self.id: str = data['rest_id'] + + data_v1 = data['data_v1'] + self.text: str = data_v1['summary']['text'] + self.misleading_tags: list[str] = data_v1.get('misleading_tags') + self.trustworthy_sources: bool = data_v1.get('trustworthy_sources') + self.helpful_tags: list[str] = data.get('helpful_tags') + self.created_at: int = data.get('created_at') + self.can_appeal: bool = data.get('can_appeal') + self.appeal_status: str = data.get('appeal_status') + self.is_media_note: bool = data.get('is_media_note') + self.media_note_matches: str = data.get('media_note_matches') + self.birdwatch_profile: dict = data.get('birdwatch_profile') + self.tweet_id: str = data['tweet_results']['result']['rest_id'] + + async def update(self) -> None: + new = await self._client.get_community_note(self.id) + self.__dict__.update(new.__dict__) + + def __repr__(self) -> str: + return f'' + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, CommunityNote) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value diff --git a/twikit/user.py b/twikit/user.py new file mode 100644 index 00000000..5535a346 --- /dev/null +++ b/twikit/user.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Literal + +from .utils import timestamp_to_datetime + +if TYPE_CHECKING: + from httpx import Response + + from .client.client import Client + from .message import Message + from .tweet import Tweet + from .utils import Result + + +class User: + """ + Attributes + ---------- + id : :class:`str` + The unique identifier of the user. + created_at : :class:`str` + The date and time when the user account was created. + name : :class:`str` + The user's name. + screen_name : :class:`str` + The user's screen name. + profile_image_url : :class:`str` + The URL of the user's profile image (HTTPS version). + profile_banner_url : :class:`str` + The URL of the user's profile banner. + url : :class:`str` + The user's URL. + location : :class:`str` + The user's location information. + description : :class:`str` + The user's profile description. + description_urls : :class:`list` + URLs found in the user's profile description. + urls : :class:`list` + URLs associated with the user. + pinned_tweet_ids : :class:`str` + The IDs of tweets that the user has pinned to their profile. + is_blue_verified : :class:`bool` + Indicates if the user is verified with a blue checkmark. + verified : :class:`bool` + Indicates if the user is verified. + possibly_sensitive : :class:`bool` + Indicates if the user's content may be sensitive. + can_dm : :class:`bool` + Indicates whether the user can receive direct messages. + can_media_tag : :class:`bool` + Indicates whether the user can be tagged in media. + want_retweets : :class:`bool` + Indicates if the user wants retweets. + default_profile : :class:`bool` + Indicates if the user has the default profile. + default_profile_image : :class:`bool` + Indicates if the user has the default profile image. + has_custom_timelines : :class:`bool` + Indicates if the user has custom timelines. + followers_count : :class:`int` + The count of followers. + fast_followers_count : :class:`int` + The count of fast followers. + normal_followers_count : :class:`int` + The count of normal followers. + following_count : :class:`int` + The count of users the user is following. + favourites_count : :class:`int` + The count of favorites or likes. + listed_count : :class:`int` + The count of lists the user is a member of. + media_count : :class:`int` + The count of media items associated with the user. + statuses_count : :class:`int` + The count of tweets. + is_translator : :class:`bool` + Indicates if the user is a translator. + translator_type : :class:`str` + The type of translator. + profile_interstitial_type : :class:`str` + The type of profile interstitial. + withheld_in_countries : list[:class:`str`] + Countries where the user's content is withheld. + """ + + def __init__(self, client: Client, data: dict) -> None: + self._client = client + legacy = data['legacy'] + + self.id: str = data['rest_id'] + self.created_at: str = legacy['created_at'] + self.name: str = legacy['name'] + self.screen_name: str = legacy['screen_name'] + self.profile_image_url: str = legacy['profile_image_url_https'] + self.profile_banner_url: str = legacy.get('profile_banner_url') + self.url: str = legacy.get('url') + self.location: str = legacy['location'] + self.description: str = legacy['description'] + self.description_urls: list = legacy['entities']['description']['urls'] + self.urls: list = legacy['entities'].get('url', {}).get('urls') + self.pinned_tweet_ids: list[str] = legacy['pinned_tweet_ids_str'] + self.is_blue_verified: bool = data['is_blue_verified'] + self.verified: bool = legacy['verified'] + self.possibly_sensitive: bool = legacy['possibly_sensitive'] + self.can_dm: bool = legacy['can_dm'] + self.can_media_tag: bool = legacy['can_media_tag'] + self.want_retweets: bool = legacy['want_retweets'] + self.default_profile: bool = legacy['default_profile'] + self.default_profile_image: bool = legacy['default_profile_image'] + self.has_custom_timelines: bool = legacy['has_custom_timelines'] + self.followers_count: int = legacy['followers_count'] + self.fast_followers_count: int = legacy['fast_followers_count'] + self.normal_followers_count: int = legacy['normal_followers_count'] + self.following_count: int = legacy['friends_count'] + self.favourites_count: int = legacy['favourites_count'] + self.listed_count: int = legacy['listed_count'] + self.media_count = legacy['media_count'] + self.statuses_count: int = legacy['statuses_count'] + self.is_translator: bool = legacy['is_translator'] + self.translator_type: str = legacy['translator_type'] + self.withheld_in_countries: list[str] = legacy['withheld_in_countries'] + self.protected: bool = legacy.get('protected', False) + + @property + def created_at_datetime(self) -> datetime: + return timestamp_to_datetime(self.created_at) + + async def get_tweets( + self, + tweet_type: Literal['Tweets', 'Replies', 'Media', 'Likes'], + count: int = 40, + ) -> Result[Tweet]: + """ + Retrieves the user's tweets. + + Parameters + ---------- + tweet_type : {'Tweets', 'Replies', 'Media', 'Likes'} + The type of tweets to retrieve. + count : :class:`int`, default=40 + The number of tweets to retrieve. + + Returns + ------- + Result[:class:`Tweet`] + A Result object containing a list of `Tweet` objects. + + Examples + -------- + >>> user = await client.get_user_by_screen_name('example_user') + >>> tweets = await user.get_tweets('Tweets', count=20) + >>> for tweet in tweets: + ... print(tweet) + + + ... + ... + + >>> more_tweets = await tweets.next() # Retrieve more tweets + >>> for tweet in more_tweets: + ... print(tweet) + + + ... + ... + """ + return await self._client.get_user_tweets(self.id, tweet_type, count) + + async def follow(self) -> Response: + """ + Follows the user. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + Client.follow_user + """ + return await self._client.follow_user(self.id) + + async def unfollow(self) -> Response: + """ + Unfollows the user. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + Client.unfollow_user + """ + return await self._client.unfollow_user(self.id) + + async def block(self) -> Response: + """ + Blocks a user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to block. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + .unblock + """ + return await self._client.block_user(self.id) + + async def unblock(self) -> Response: + """ + Unblocks a user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to unblock. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + .block + """ + return await self._client.unblock_user(self.id) + + async def mute(self) -> Response: + """ + Mutes a user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to mute. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + .unmute + """ + return await self._client.mute_user(self.id) + + async def unmute(self) -> Response: + """ + Unmutes a user. + + Parameters + ---------- + user_id : :class:`str` + The ID of the user to unmute. + + Returns + ------- + :class:`httpx.Response` + Response returned from twitter api. + + See Also + -------- + .mute + """ + return await self._client.unmute_user(self.id) + + async def get_followers(self, count: int = 20) -> Result[User]: + """ + Retrieves a list of followers for the user. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of followers to retrieve. + + Returns + ------- + Result[:class:`User`] + A list of User objects representing the followers. + + See Also + -------- + Client.get_user_followers + """ + return await self._client.get_user_followers(self.id, count) + + async def get_verified_followers(self, count: int = 20) -> Result[User]: + """ + Retrieves a list of verified followers for the user. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of verified followers to retrieve. + + Returns + ------- + Result[:class:`User`] + A list of User objects representing the verified followers. + + See Also + -------- + Client.get_user_verified_followers + """ + return await self._client.get_user_verified_followers(self.id, count) + + async def get_followers_you_know(self, count: int = 20) -> Result[User]: + """ + Retrieves a list of followers whom the user might know. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of followers you might know to retrieve. + + Returns + ------- + Result[:class:`User`] + A list of User objects representing the followers you might know. + + See Also + -------- + Client.get_user_followers_you_know + """ + return await self._client.get_user_followers_you_know(self.id, count) + + async def get_following(self, count: int = 20) -> Result[User]: + """ + Retrieves a list of users whom the user is following. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of following users to retrieve. + + Returns + ------- + Result[:class:`User`] + A list of User objects representing the users being followed. + + See Also + -------- + Client.get_user_following + """ + return await self._client.get_user_following(self.id, count) + + async def get_subscriptions(self, count: int = 20) -> Result[User]: + """ + Retrieves a list of users whom the user is subscribed to. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of subscriptions to retrieve. + + Returns + ------- + Result[:class:`User`] + A list of User objects representing the subscribed users. + + See Also + -------- + Client.get_user_subscriptions + """ + return await self._client.get_user_subscriptions(self.id, count) + + async def get_latest_followers( + self, count: int | None = None, cursor: str | None = None + ) -> Result[User]: + """ + Retrieves the latest followers. + Max count : 200 + """ + return await self._client.get_latest_followers( + self.id, count=count, cursor=cursor + ) + + async def get_latest_friends( + self, count: int | None = None, cursor: str | None = None + ) -> Result[User]: + """ + Retrieves the latest friends (following users). + Max count : 200 + """ + return await self._client.get_latest_friends( + self.id, count=count, cursor=cursor + ) + + async def send_dm( + self, text: str, media_id: str = None, reply_to = None + ) -> Message: + """ + Send a direct message to the user. + + Parameters + ---------- + text : :class:`str` + The text content of the direct message. + media_id : :class:`str`, default=None + The media ID associated with any media content + to be included in the message. + Media ID can be received by using the :func:`.upload_media` method. + reply_to : :class:`str`, default=None + Message ID to reply to. + + Returns + ------- + :class:`Message` + `Message` object containing information about the message sent. + + Examples + -------- + >>> # send DM with media + >>> media_id = await client.upload_media('image.png') + >>> message = await user.send_dm('text', media_id) + >>> print(message) + + + See Also + -------- + Client.upload_media + Client.send_dm + """ + return await self._client.send_dm(self.id, text, media_id, reply_to) + + async def get_dm_history(self, max_id: str = None) -> Result[Message]: + """ + Retrieves the DM conversation history with the user. + + Parameters + ---------- + max_id : :class:`str`, default=None + If specified, retrieves messages older than the specified max_id. + + Returns + ------- + Result[:class:`Message`] + A Result object containing a list of Message objects representing + the DM conversation history. + + Examples + -------- + >>> messages = await user.get_dm_history() + >>> for message in messages: + >>> print(message) + + + ... + ... + + >>> more_messages = await messages.next() # Retrieve more messages + >>> for message in more_messages: + >>> print(message) + + + ... + ... + """ + return await self._client.get_dm_history(self.id, max_id) + + async def get_highlights_tweets(self, count: int = 20, cursor: str | None = None) -> Result[Tweet]: + """ + Retrieves highlighted tweets from the user's timeline. + + Parameters + ---------- + count : :class:`int`, default=20 + The number of tweets to retrieve. + + Returns + ------- + Result[:class:`Tweet`] + An instance of the `Result` class containing the highlighted tweets. + + Examples + -------- + >>> result = await user.get_highlights_tweets() + >>> for tweet in result: + ... print(tweet) + + + ... + ... + + >>> more_results = await result.next() # Retrieve more highlighted tweets + >>> for tweet in more_results: + ... print(tweet) + + + ... + ... + """ + return await self._client.get_user_highlights_tweets(self.id, count, cursor) + + async def update(self) -> None: + new = await self._client.get_user_by_id(self.id) + self.__dict__.update(new.__dict__) + + def __repr__(self) -> str: + return f'' + + def __eq__(self, __value: object) -> bool: + return isinstance(__value, User) and self.id == __value.id + + def __ne__(self, __value: object) -> bool: + return not self == __value diff --git a/twikit/utils.py b/twikit/utils.py new file mode 100644 index 00000000..62cfa752 --- /dev/null +++ b/twikit/utils.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +import base64 +import json +from datetime import datetime +from httpx import AsyncHTTPTransport +from typing import TYPE_CHECKING, Any, Awaitable, Generic, Iterator, Literal, TypedDict, TypeVar + +if TYPE_CHECKING: + from .client.client import Client + +T = TypeVar('T') + + +class Result(Generic[T]): + """ + This class is for storing multiple results. + The `next` method can be used to retrieve further results. + As with a regular list, you can access elements by + specifying indexes and iterate over elements using a for loop. + + Attributes + ---------- + next_cursor : :class:`str` + Cursor used to obtain the next result. + previous_cursor : :class:`str` + Cursor used to obtain the previous result. + token : :class:`str` + Alias of `next_cursor`. + cursor : :class:`str` + Alias of `next_cursor`. + """ + + def __init__( + self, + results: list[T], + fetch_next_result: Awaitable | None = None, + next_cursor: str | None = None, + fetch_previous_result: Awaitable | None = None, + previous_cursor: str | None = None + ) -> None: + self.__results = results + self.next_cursor = next_cursor + self.__fetch_next_result = fetch_next_result + self.previous_cursor = previous_cursor + self.__fetch_previous_result = fetch_previous_result + + async def next(self) -> Result[T]: + """ + The next result. + """ + if self.__fetch_next_result is None: + return Result([]) + return await self.__fetch_next_result() + + async def previous(self) -> Result[T]: + """ + The previous result. + """ + if self.__fetch_previous_result is None: + return Result([]) + return await self.__fetch_previous_result() + + @classmethod + def empty(cls): + return cls([]) + + def __iter__(self) -> Iterator[T]: + yield from self.__results + + def __getitem__(self, index: int) -> T: + return self.__results[index] + + def __len__(self) -> int: + return len(self.__results) + + def __repr__(self) -> str: + return self.__results.__repr__() + + +class Flow: + def __init__(self, client: Client, guest_token: str) -> None: + self._client = client + self.guest_token = guest_token + self.response = None + + async def execute_task(self, *subtask_inputs, **kwargs) -> None: + response, _ = await self._client.v11.onboarding_task( + self.guest_token, self.token, list(subtask_inputs), **kwargs + ) + self.response = response + + async def sso_init(self, provider: str) -> None: + await self._client.v11.sso_init(provider, self.guest_token) + + @property + def token(self) -> str | None: + if self.response is None: + return None + return self.response.get('flow_token') + + @property + def task_id(self) -> str | None: + if self.response is None: + return None + if len(self.response['subtasks']) <= 0: + return None + return self.response['subtasks'][0]['subtask_id'] + + +def find_dict(obj: list | dict, key: str | int, find_one: bool = False) -> list[Any]: + """ + Retrieves elements from a nested dictionary. + """ + results = [] + if isinstance(obj, dict): + if key in obj: + results.append(obj.get(key)) + if find_one: + return results + if isinstance(obj, (list, dict)): + for elem in (obj if isinstance(obj, list) else obj.values()): + r = find_dict(elem, key, find_one) + results += r + if r and find_one: + return results + return results + + +def httpx_transport_to_url(transport: AsyncHTTPTransport) -> str: + url = transport._pool._proxy_url + scheme = url.scheme.decode() + host = url.host.decode() + port = url.port + auth = None + if transport._pool._proxy_headers: + auth_header = dict(transport._pool._proxy_headers)[b'Proxy-Authorization'].decode() + auth = base64.b64decode(auth_header.split()[1]).decode() + + url_str = f'{scheme}://' + if auth is not None: + url_str += auth + '@' + url_str += host + if port is not None: + url_str += f':{port}' + return url_str + + +def get_query_id(url: str) -> str: + """ + Extracts the identifier from a URL. + + Examples + -------- + >>> get_query_id('https://twitter.com/i/api/graphql/queryid/...') + 'queryid' + """ + return url.rsplit('/', 2)[-2] + + +def timestamp_to_datetime(timestamp: str) -> datetime: + return datetime.strptime(timestamp, '%a %b %d %H:%M:%S %z %Y') + + +def build_tweet_data(raw_data: dict) -> dict: + return { + **raw_data, + 'rest_id': raw_data['id'], + 'is_translatable': None, + 'views': {}, + 'edit_control': {}, + 'legacy': { + 'created_at': raw_data.get('created_at'), + 'full_text': raw_data.get('full_text') or raw_data.get('text'), + 'lang': raw_data.get('lang'), + 'is_quote_status': raw_data.get('is_quote_status'), + 'in_reply_to_status_id_str': raw_data.get('in_reply_to_status_id_str'), + 'retweeted_status_result': raw_data.get('retweeted_status_result'), + 'possibly_sensitive': raw_data.get('possibly_sensitive'), + 'possibly_sensitive_editable': raw_data.get('possibly_sensitive_editable'), + 'quote_count': raw_data.get('quote_count'), + 'entities': raw_data.get('entities'), + 'reply_count': raw_data.get('reply_count'), + 'favorite_count': raw_data.get('favorite_count'), + 'favorited': raw_data.get('favorited'), + 'retweet_count': raw_data.get('retweet_count') + } + } + + +def build_user_data(raw_data: dict) -> dict: + return { + **raw_data, + 'rest_id': raw_data['id'], + 'is_blue_verified': raw_data.get('ext_is_blue_verified'), + 'legacy': { + 'created_at': raw_data.get('created_at'), + 'name': raw_data.get('name'), + 'screen_name': raw_data.get('screen_name'), + 'profile_image_url_https': raw_data.get('profile_image_url_https'), + 'location': raw_data.get('location'), + 'description': raw_data.get('description'), + 'entities': raw_data.get('entities'), + 'pinned_tweet_ids_str': raw_data.get('pinned_tweet_ids_str'), + 'verified': raw_data.get('verified'), + 'possibly_sensitive': raw_data.get('possibly_sensitive'), + 'can_dm': raw_data.get('can_dm'), + 'can_media_tag': raw_data.get('can_media_tag'), + 'want_retweets': raw_data.get('want_retweets'), + 'default_profile': raw_data.get('default_profile'), + 'default_profile_image': raw_data.get('default_profile_image'), + 'has_custom_timelines': raw_data.get('has_custom_timelines'), + 'followers_count': raw_data.get('followers_count'), + 'fast_followers_count': raw_data.get('fast_followers_count'), + 'normal_followers_count': raw_data.get('normal_followers_count'), + 'friends_count': raw_data.get('friends_count'), + 'favourites_count': raw_data.get('favourites_count'), + 'listed_count': raw_data.get('listed_count'), + 'media_count': raw_data.get('media_count'), + 'statuses_count': raw_data.get('statuses_count'), + 'is_translator': raw_data.get('is_translator'), + 'translator_type': raw_data.get('translator_type'), + 'withheld_in_countries': raw_data.get('withheld_in_countries'), + 'url': raw_data.get('url'), + 'profile_banner_url': raw_data.get('profile_banner_url') + } + } + + +def flatten_params(params: dict) -> dict: + flattened_params = {} + for key, value in params.items(): + if isinstance(value, (list, dict)): + value = json.dumps(value) + flattened_params[key] = value + return flattened_params + + +def b64_to_str(b64: str) -> str: + return base64.b64decode(b64).decode() + + +def find_entry_by_type(entries, type_filter): + for entry in entries: + if entry.get('type') == type_filter: + return entry + return None + + +FILTERS = Literal[ + 'media', + 'retweets', + 'native_video', + 'periscope', + 'vine', + 'images', + 'twimg', + 'links' +] + + +class SearchOptions(TypedDict): + exact_phrases: list[str] + or_keywords: list[str] + exclude_keywords: list[str] + hashtags: list[str] + from_user: str + to_user: str + mentioned_users: list[str] + filters: list[FILTERS] + exclude_filters: list[FILTERS] + urls: list[str] + since: str + until: str + positive: bool + negative: bool + question: bool + + +def build_query(text: str, options: SearchOptions) -> str: + """ + Builds a search query based on the given text and search options. + + Parameters + ---------- + text : str + The base text of the search query. + options : SearchOptions + A dictionary containing various search options. + - exact_phrases: list[str] + List of exact phrases to include in the search query. + - or_keywords: list[str] + List of keywords where tweets must contain at least + one of these keywords. + - exclude_keywords: list[str] + A list of keywords that the tweet must contain these keywords. + - hashtags: list[str] + List of hashtags to include in the search query. + - from_user: str + Specify a username. Only tweets from this user will + be includedin the search. + - to_user: str + Specify a username. Only tweets sent to this user will + be included in the search. + - mentioned_users: list[str] + List of usernames. Only tweets mentioning these users will + be included in the search. + - filters: list[FILTERS] + List of tweet filters to include in the search query. + - exclude_filters: list[FILTERS] + List of tweet filters to exclude from the search query. + - urls: list[str] + List of URLs. Only tweets containing these URLs will be + included in the search. + - since: str + Specify a date (formatted as 'YYYY-MM-DD'). Only tweets since + this date will be included in the search. + - until: str + Specify a date (formatted as 'YYYY-MM-DD'). Only tweets until + this date will be included in the search. + - positive: bool + Include positive sentiment in the search. + - negative: bool + Include negative sentiment in the search. + - question: bool + Search for tweets in questionable form. + + https://developer.twitter.com/en/docs/twitter-api/v1/rules-and-filtering/search-operators + + Returns + ------- + str + The constructed Twitter search query. + """ + if exact_phrases := options.get('exact_phrases'): + text += ' ' + ' '.join( + [f'"{i}"' for i in exact_phrases] + ) + + if or_keywords := options.get('or_keywords'): + text += ' ' + ' OR '.join(or_keywords) + + if exclude_keywords := options.get('exclude_keywords'): + text += ' ' + ' '.join( + [f'-"{i}"' for i in exclude_keywords] + ) + + if hashtags := options.get('hashtags'): + text += ' ' + ' '.join( + [f'#{i}' for i in hashtags] + ) + + if from_user := options.get('from_user'): + text +=f' from:{from_user}' + + if to_user := options.get('to_user'): + text += f' to:{to_user}' + + if mentioned_users := options.get('mentioned_users'): + text += ' ' + ' '.join( + [f'@{i}' for i in mentioned_users] + ) + + if filters := options.get('filters'): + text += ' ' + ' '.join( + [f'filter:{i}' for i in filters] + ) + + if exclude_filters := options.get('exclude_filters'): + text += ' ' + ' '.join( + [f'-filter:{i}' for i in exclude_filters] + ) + + if urls := options.get('urls'): + text += ' ' + ' '.join( + [f'url:{i}' for i in urls] + ) + + if since := options.get('since'): + text += f' since:{since}' + + if until := options.get('until'): + text += f' until:{until}' + + if options.get('positive') is True: + text += ' :)' + + if options.get('negative') is True: + text += ' :(' + + if options.get('question') is True: + text += ' ?' + + return text diff --git a/twikit/x_client_transaction/__init__.py b/twikit/x_client_transaction/__init__.py new file mode 100644 index 00000000..416c8034 --- /dev/null +++ b/twikit/x_client_transaction/__init__.py @@ -0,0 +1 @@ +from .transaction import ClientTransaction diff --git a/twikit/x_client_transaction/cubic_curve.py b/twikit/x_client_transaction/cubic_curve.py new file mode 100644 index 00000000..13fcc2ab --- /dev/null +++ b/twikit/x_client_transaction/cubic_curve.py @@ -0,0 +1,48 @@ +from typing import Union, List + + +class Cubic: + def __init__(self, curves: List[Union[float, int]]): + self.curves = curves + + def get_value(self, time: Union[float, int]): + start_gradient = end_gradient = start = mid = 0.0 + end = 1.0 + + if time <= 0.0: + if self.curves[0] > 0.0: + start_gradient = self.curves[1] / self.curves[0] + elif self.curves[1] == 0.0 and self.curves[2] > 0.0: + start_gradient = self.curves[3] / self.curves[2] + return start_gradient * time + + if time >= 1.0: + if self.curves[2] < 1.0: + end_gradient = (self.curves[3] - 1.0) / (self.curves[2] - 1.0) + elif self.curves[2] == 1.0 and self.curves[0] < 1.0: + end_gradient = (self.curves[1] - 1.0) / (self.curves[0] - 1.0) + return 1.0 + end_gradient * (time - 1.0) + + while start < end: + mid = (start + end) / 2 + x_est = self.calculate(self.curves[0], self.curves[2], mid) + if abs(time - x_est) < 0.00001: + return self.calculate(self.curves[1], self.curves[3], mid) + if x_est < time: + start = mid + else: + end = mid + return self.calculate(self.curves[1], self.curves[3], mid) + + @staticmethod + def calculate(a, b, m): + return 3.0 * a * (1 - m) * (1 - m) * m + 3.0 * b * (1 - m) * m * m + m * m * m + +# Example usage: +# cubic_instance = Cubic([0.1, 0.2, 0.3, 0.4]) +# value = cubic_instance.get_value(0.5) +# print(value) + + +if __name__ == "__main__": + pass diff --git a/twikit/x_client_transaction/interpolate.py b/twikit/x_client_transaction/interpolate.py new file mode 100644 index 00000000..4a879a2d --- /dev/null +++ b/twikit/x_client_transaction/interpolate.py @@ -0,0 +1,23 @@ +from typing import Union, List + + +def interpolate(from_list: List[Union[float, int]], to_list: List[Union[float, int]], f: Union[float, int]): + if len(from_list) != len(to_list): + raise Exception( + f"Mismatched interpolation arguments {from_list}: {to_list}") + out = [] + for i in range(len(from_list)): + out.append(interpolate_num(from_list[i], to_list[i], f)) + return out + + +def interpolate_num(from_val: List[Union[float, int]], to_val: List[Union[float, int]], f: Union[float, int]): + if all([isinstance(number, (int, float)) for number in [from_val, to_val]]): + return from_val * (1 - f) + to_val * f + + if all([isinstance(number, bool) for number in [from_val, to_val]]): + return from_val if f < 0.5 else to_val + + +if __name__ == "__main__": + pass diff --git a/twikit/x_client_transaction/rotation.py b/twikit/x_client_transaction/rotation.py new file mode 100644 index 00000000..c27f5f4f --- /dev/null +++ b/twikit/x_client_transaction/rotation.py @@ -0,0 +1,27 @@ +import math +from typing import Union + + +def convert_rotation_to_matrix(rotation: Union[float, int]): + rad = math.radians(rotation) + return [math.cos(rad), -math.sin(rad), math.sin(rad), math.cos(rad)] + + +def convertRotationToMatrix(degrees: Union[float, int]): + # first convert degrees to radians + radians = degrees * math.pi / 180 + # now we do this: + """ + [cos(r), -sin(r), 0] + [sin(r), cos(r), 0] + + in this order: + [cos(r), sin(r), -sin(r), cos(r), 0, 0] + """ + cos = math.cos(radians) + sin = math.sin(radians) + return [cos, sin, -sin, cos, 0, 0] + + +if __name__ == "__main__": + pass diff --git a/twikit/x_client_transaction/transaction.py b/twikit/x_client_transaction/transaction.py new file mode 100644 index 00000000..523dc7c0 --- /dev/null +++ b/twikit/x_client_transaction/transaction.py @@ -0,0 +1,164 @@ +import re +import bs4 +import math +import time +import random +import base64 +import hashlib +import requests +from typing import Union, List +from functools import reduce +from .cubic_curve import Cubic +from .interpolate import interpolate +from .rotation import convert_rotation_to_matrix +from .utils import float_to_hex, is_odd, base64_encode, handle_x_migration + +ON_DEMAND_FILE_REGEX = re.compile( + r"""['|\"]{1}ondemand\.s['|\"]{1}:\s*['|\"]{1}([\w]*)['|\"]{1}""", flags=(re.VERBOSE | re.MULTILINE)) +INDICES_REGEX = re.compile( + r"""(\(\w{1}\[(\d{1,2})\],\s*16\))+""", flags=(re.VERBOSE | re.MULTILINE)) + + +class ClientTransaction: + ADDITIONAL_RANDOM_NUMBER = 3 + DEFAULT_KEYWORD = "obfiowerehiring" + DEFAULT_ROW_INDEX = None + DEFAULT_KEY_BYTES_INDICES = None + + def __init__(self): + self.home_page_response = None + + async def init(self, session, headers): + home_page_response = await handle_x_migration(session, headers) + + self.home_page_response = self.validate_response(home_page_response) + self.DEFAULT_ROW_INDEX, self.DEFAULT_KEY_BYTES_INDICES = await self.get_indices( + self.home_page_response, session, headers) + self.key = self.get_key(response=self.home_page_response) + self.key_bytes = self.get_key_bytes(key=self.key) + self.animation_key = self.get_animation_key( + key_bytes=self.key_bytes, response=self.home_page_response) + + async def get_indices(self, home_page_response, session, headers): + key_byte_indices = [] + response = self.validate_response( + home_page_response) or self.home_page_response + on_demand_file = ON_DEMAND_FILE_REGEX.search(str(response)) + if on_demand_file: + on_demand_file_url = f"https://abs.twimg.com/responsive-web/client-web/ondemand.s.{on_demand_file.group(1)}a.js" + on_demand_file_response = await session.request(method="GET", url=on_demand_file_url, headers=headers) + key_byte_indices_match = INDICES_REGEX.finditer( + str(on_demand_file_response.text)) + for item in key_byte_indices_match: + key_byte_indices.append(item.group(2)) + if not key_byte_indices: + raise Exception("Couldn't get KEY_BYTE indices") + key_byte_indices = list(map(int, key_byte_indices)) + return key_byte_indices[0], key_byte_indices[1:] + + def validate_response(self, response: Union[bs4.BeautifulSoup, requests.models.Response]): + if not isinstance(response, (bs4.BeautifulSoup, requests.models.Response)): + raise Exception("invalid response") + return response if isinstance(response, bs4.BeautifulSoup) else bs4.BeautifulSoup(response.content, 'lxml') + + def get_key(self, response=None): + response = self.validate_response(response) or self.home_page_response + # + element = response.select_one("[name='twitter-site-verification']") + if not element: + raise Exception("Couldn't get key from the page source") + return element.get("content") + + def get_key_bytes(self, key: str): + return list(base64.b64decode(bytes(key, 'utf-8'))) + + def get_frames(self, response=None): + # loading-x-anim-0...loading-x-anim-3 + response = self.validate_response(response) or self.home_page_response + return response.select("[id^='loading-x-anim']") + + def get_2d_array(self, key_bytes: List[Union[float, int]], response, frames: bs4.ResultSet = None): + if not frames: + frames = self.get_frames(response) + # return list(list(frames[key[5] % 4].children)[0].children)[1].get("d")[9:].split("C") + return [[int(x) for x in re.sub(r"[^\d]+", " ", item).strip().split()] for item in list(list(frames[key_bytes[5] % 4].children)[0].children)[1].get("d")[9:].split("C")] + + def solve(self, value, min_val, max_val, rounding: bool): + result = value * (max_val-min_val) / 255 + min_val + return math.floor(result) if rounding else round(result, 2) + + def animate(self, frames, target_time): + # from_color = f"#{''.join(['{:x}'.format(digit) for digit in frames[:3]])}" + # to_color = f"#{''.join(['{:x}'.format(digit) for digit in frames[3:6]])}" + # from_rotation = "rotate(0deg)" + # to_rotation = f"rotate({solve(frames[6], 60, 360, True)}deg)" + # easing_values = [solve(value, -1 if count % 2 else 0, 1, False) + # for count, value in enumerate(frames[7:])] + # easing = f"cubic-bezier({','.join([str(value) for value in easing_values])})" + # current_time = round(target_time / 10) * 10 + + from_color = [float(item) for item in [*frames[:3], 1]] + to_color = [float(item) for item in [*frames[3:6], 1]] + from_rotation = [0.0] + to_rotation = [self.solve(float(frames[6]), 60.0, 360.0, True)] + frames = frames[7:] + curves = [self.solve(float(item), is_odd(counter), 1.0, False) + for counter, item in enumerate(frames)] + cubic = Cubic(curves) + val = cubic.get_value(target_time) + color = interpolate(from_color, to_color, val) + color = [value if value > 0 else 0 for value in color] + rotation = interpolate(from_rotation, to_rotation, val) + matrix = convert_rotation_to_matrix(rotation[0]) + # str_arr = [format(int(round(color[i])), '02x') for i in range(len(color) - 1)] + # str_arr = [format(int(round(color[i])), 'x') for i in range(len(color) - 1)] + str_arr = [format(round(value), 'x') for value in color[:-1]] + for value in matrix: + rounded = round(value, 2) + if rounded < 0: + rounded = -rounded + hex_value = float_to_hex(rounded) + str_arr.append(f"0{hex_value}".lower() if hex_value.startswith( + ".") else hex_value if hex_value else '0') + str_arr.extend(["0", "0"]) + animation_key = re.sub(r"[.-]", "", "".join(str_arr)) + return animation_key + + def get_animation_key(self, key_bytes, response): + total_time = 4096 + # row_index, frame_time = [key_bytes[2] % 16, key_bytes[12] % 16 * (key_bytes[14] % 16) * (key_bytes[7] % 16)] + # row_index, frame_time = [key_bytes[2] % 16, key_bytes[2] % 16 * (key_bytes[42] % 16) * (key_bytes[45] % 16)] + + row_index = key_bytes[self.DEFAULT_ROW_INDEX] % 16 + frame_time = reduce(lambda num1, num2: num1*num2, + [key_bytes[index] % 16 for index in self.DEFAULT_KEY_BYTES_INDICES]) + arr = self.get_2d_array(key_bytes, response) + frame_row = arr[row_index] + + target_time = float(frame_time) / total_time + animation_key = self.animate(frame_row, target_time) + return animation_key + + def generate_transaction_id(self, method: str, path: str, response=None, key=None, animation_key=None, time_now=None): + time_now = time_now or math.floor( + (time.time() * 1000 - 1682924400 * 1000) / 1000) + time_now_bytes = [(time_now >> (i * 8)) & 0xFF for i in range(4)] + key = key or self.key or self.get_key(response) + key_bytes = self.get_key_bytes(key) + animation_key = animation_key or self.animation_key or self.get_animation_key( + key_bytes, response) + # hash_val = hashlib.sha256(f"{method}!{path}!{time_now}bird{animation_key}".encode()).digest() + hash_val = hashlib.sha256( + f"{method}!{path}!{time_now}{self.DEFAULT_KEYWORD}{animation_key}".encode()).digest() + # hash_bytes = [int(hash_val[i]) for i in range(len(hash_val))] + hash_bytes = list(hash_val) + random_num = random.randint(0, 255) + bytes_arr = [*key_bytes, *time_now_bytes, * + hash_bytes[:16], self.ADDITIONAL_RANDOM_NUMBER] + out = bytearray( + [random_num, *[item ^ random_num for item in bytes_arr]]) + return base64_encode(out).strip("=") + + +if __name__ == "__main__": + pass diff --git a/twikit/x_client_transaction/utils.py b/twikit/x_client_transaction/utils.py new file mode 100644 index 00000000..9fe0131e --- /dev/null +++ b/twikit/x_client_transaction/utils.py @@ -0,0 +1,84 @@ +import re +import bs4 +import base64 +from typing import Union + + +async def handle_x_migration(session, headers): + home_page = None + migration_redirection_regex = re.compile( + r"""(http(?:s)?://(?:www\.)?(twitter|x){1}\.com(/x)?/migrate([/?])?tok=[a-zA-Z0-9%\-_]+)+""", re.VERBOSE) + response = await session.request(method="GET", url="https://x.com", headers=headers) + home_page = bs4.BeautifulSoup(response.content, 'lxml') + migration_url = home_page.select_one("meta[http-equiv='refresh']") + migration_redirection_url = re.search(migration_redirection_regex, str( + migration_url)) or re.search(migration_redirection_regex, str(response.content)) + if migration_redirection_url: + response = await session.request(method="GET", url=migration_redirection_url.group(0), headers=headers) + home_page = bs4.BeautifulSoup(response.content, 'lxml') + migration_form = home_page.select_one("form[name='f']") or home_page.select_one(f"form[action='https://x.com/x/migrate']") + if migration_form: + url = migration_form.attrs.get("action", "https://x.com/x/migrate") + "/?mx=2" + method = migration_form.attrs.get("method", "POST") + request_payload = {input_field.get("name"): input_field.get("value") for input_field in migration_form.select("input")} + response = await session.request(method=method, url=url, data=request_payload, headers=headers) + home_page = bs4.BeautifulSoup(response.content, 'lxml') + return home_page + + +def float_to_hex(x): + result = [] + quotient = int(x) + fraction = x - quotient + + while quotient > 0: + quotient = int(x / 16) + remainder = int(x - (float(quotient) * 16)) + + if remainder > 9: + result.insert(0, chr(remainder + 55)) + else: + result.insert(0, str(remainder)) + + x = float(quotient) + + if fraction == 0: + return ''.join(result) + + result.append('.') + + while fraction > 0: + fraction *= 16 + integer = int(fraction) + fraction -= float(integer) + + if integer > 9: + result.append(chr(integer + 55)) + else: + result.append(str(integer)) + + return ''.join(result) + + +def is_odd(num: Union[int, float]): + if num % 2: + return -1.0 + return 0.0 + + +def base64_encode(string): + string = string.encode() if isinstance(string, str) else string + return base64.b64encode(string).decode() + + +def base64_decode(input): + try: + data = base64.b64decode(input) + return data.decode() + except Exception: + # return bytes(input, "utf-8") + return list(bytes(input, "utf-8")) + + +if __name__ == "__main__": + pass