xselfbot 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xselfbot/__init__.py +34 -0
- xselfbot/_captcha/__init__.py +2 -0
- xselfbot/_captcha/base.py +111 -0
- xselfbot/_captcha/capsolver.py +95 -0
- xselfbot/bookmark.py +64 -0
- xselfbot/client/client.py +4527 -0
- xselfbot/client/gql.py +730 -0
- xselfbot/client/v11.py +512 -0
- xselfbot/community.py +282 -0
- xselfbot/constants.py +268 -0
- xselfbot/content_disclosure.py +104 -0
- xselfbot/errors.py +110 -0
- xselfbot/geo.py +82 -0
- xselfbot/group.py +259 -0
- xselfbot/guest/__init__.py +3 -0
- xselfbot/guest/client.py +411 -0
- xselfbot/guest/tweet.py +235 -0
- xselfbot/guest/user.py +196 -0
- xselfbot/list.py +255 -0
- xselfbot/media.py +346 -0
- xselfbot/message.py +143 -0
- xselfbot/notification.py +47 -0
- xselfbot/py.typed +0 -0
- xselfbot/streaming.py +266 -0
- xselfbot/trend.py +93 -0
- xselfbot/tweet.py +790 -0
- xselfbot/ui_metrics/__init__.py +25 -0
- xselfbot/ui_metrics/dom.py +52 -0
- xselfbot/user.py +532 -0
- xselfbot/utils.py +427 -0
- xselfbot/x_client_transaction/__init__.py +8 -0
- xselfbot/x_client_transaction/cubic_curve.py +48 -0
- xselfbot/x_client_transaction/interpolate.py +23 -0
- xselfbot/x_client_transaction/rotation.py +27 -0
- xselfbot/x_client_transaction/transaction.py +170 -0
- xselfbot/x_client_transaction/utils.py +84 -0
- xselfbot-4.0.0.dist-info/METADATA +138 -0
- xselfbot-4.0.0.dist-info/RECORD +41 -0
- xselfbot-4.0.0.dist-info/WHEEL +5 -0
- xselfbot-4.0.0.dist-info/licenses/LICENSE +21 -0
- xselfbot-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,4527 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
from functools import partial
|
|
11
|
+
from typing import Any, AsyncGenerator, Literal
|
|
12
|
+
from urllib.parse import unquote, urlparse
|
|
13
|
+
|
|
14
|
+
import filetype
|
|
15
|
+
import pyotp
|
|
16
|
+
from httpx import AsyncClient, AsyncHTTPTransport, Response
|
|
17
|
+
from httpx._utils import URLPattern
|
|
18
|
+
|
|
19
|
+
from .._captcha import Capsolver
|
|
20
|
+
from ..bookmark import BookmarkFolder
|
|
21
|
+
from ..community import Community, CommunityMember
|
|
22
|
+
from ..constants import TOKEN, DOMAIN
|
|
23
|
+
from ..content_disclosure import build_content_disclosure
|
|
24
|
+
from ..errors import (
|
|
25
|
+
AccountLocked,
|
|
26
|
+
AccountSuspended,
|
|
27
|
+
BadRequest,
|
|
28
|
+
CouldNotTweet,
|
|
29
|
+
Forbidden,
|
|
30
|
+
InvalidMedia,
|
|
31
|
+
NotFound,
|
|
32
|
+
RequestTimeout,
|
|
33
|
+
ServerError,
|
|
34
|
+
TooManyRequests,
|
|
35
|
+
TweetNotAvailable,
|
|
36
|
+
TwitterException,
|
|
37
|
+
Unauthorized,
|
|
38
|
+
UserNotFound,
|
|
39
|
+
UserUnavailable,
|
|
40
|
+
raise_exceptions_from_response
|
|
41
|
+
)
|
|
42
|
+
from ..geo import Place, _places_from_response
|
|
43
|
+
from ..group import Group, GroupMessage
|
|
44
|
+
from ..list import List
|
|
45
|
+
from ..message import Message
|
|
46
|
+
from ..notification import Notification
|
|
47
|
+
from ..streaming import Payload, StreamingSession, _payload_from_data
|
|
48
|
+
from ..trend import Location, PlaceTrend, PlaceTrends, Trend
|
|
49
|
+
from ..tweet import CommunityNote, Poll, ScheduledTweet, Tweet, tweet_from_data
|
|
50
|
+
from ..ui_metrics import solve_ui_metrics
|
|
51
|
+
from ..user import User
|
|
52
|
+
from ..utils import (
|
|
53
|
+
Flow,
|
|
54
|
+
Result,
|
|
55
|
+
build_tweet_data,
|
|
56
|
+
build_user_data,
|
|
57
|
+
find_dict,
|
|
58
|
+
find_entry_by_type,
|
|
59
|
+
httpx_transport_to_url,
|
|
60
|
+
timeline_cursor_value
|
|
61
|
+
)
|
|
62
|
+
from ..x_client_transaction.utils import handle_x_migration
|
|
63
|
+
from ..x_client_transaction import ClientTransaction
|
|
64
|
+
from .gql import GQLClient
|
|
65
|
+
from .v11 import V11Client
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _json_string(raw: str) -> str:
|
|
69
|
+
':meta private:'
|
|
70
|
+
try:
|
|
71
|
+
return json.loads('"' + raw + '"')
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
return raw
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Client:
|
|
77
|
+
"""
|
|
78
|
+
A client for interacting with the Twitter API.
|
|
79
|
+
Since this class is for asynchronous use,
|
|
80
|
+
methods must be executed using await.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
language : :class:`str` | None, default=None
|
|
85
|
+
The language code to use in API requests.
|
|
86
|
+
proxy : :class:`str` | None, default=None
|
|
87
|
+
The proxy server URL to use for request
|
|
88
|
+
(e.g., 'http://0.0.0.0:0000').
|
|
89
|
+
captcha_solver : :class:`.Capsolver` | None, default=None
|
|
90
|
+
See :class:`.Capsolver`.
|
|
91
|
+
|
|
92
|
+
Examples
|
|
93
|
+
--------
|
|
94
|
+
>>> client = Client(language='en-US')
|
|
95
|
+
|
|
96
|
+
>>> await client.login(
|
|
97
|
+
... auth_info_1='example_user',
|
|
98
|
+
... auth_info_2='email@example.com',
|
|
99
|
+
... password='00000000'
|
|
100
|
+
... )
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
language: str = 'en-US',
|
|
106
|
+
proxy: str | None = None,
|
|
107
|
+
captcha_solver: Capsolver | None = None,
|
|
108
|
+
user_agent: str | None = None,
|
|
109
|
+
**kwargs
|
|
110
|
+
) -> None:
|
|
111
|
+
if 'proxies' in kwargs:
|
|
112
|
+
message = (
|
|
113
|
+
"The 'proxies' argument is now deprecated. Use 'proxy' "
|
|
114
|
+
"instead. https://github.com/encode/httpx/pull/2879"
|
|
115
|
+
)
|
|
116
|
+
warnings.warn(message)
|
|
117
|
+
|
|
118
|
+
self.http = AsyncClient(proxy=proxy, **kwargs)
|
|
119
|
+
self.language = language
|
|
120
|
+
self.proxy = proxy
|
|
121
|
+
self.captcha_solver = captcha_solver
|
|
122
|
+
if captcha_solver is not None:
|
|
123
|
+
captcha_solver.client = self
|
|
124
|
+
self.client_transaction = ClientTransaction()
|
|
125
|
+
|
|
126
|
+
self._token = TOKEN
|
|
127
|
+
self._user_id = None
|
|
128
|
+
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'
|
|
129
|
+
self._act_as = None
|
|
130
|
+
|
|
131
|
+
self.gql = GQLClient(self)
|
|
132
|
+
self.v11 = V11Client(self)
|
|
133
|
+
|
|
134
|
+
async def request(
|
|
135
|
+
self,
|
|
136
|
+
method: str,
|
|
137
|
+
url: str,
|
|
138
|
+
auto_unlock: bool = True,
|
|
139
|
+
raise_exception: bool = True,
|
|
140
|
+
**kwargs
|
|
141
|
+
) -> tuple[dict | Any, Response]:
|
|
142
|
+
':meta private:'
|
|
143
|
+
headers = kwargs.pop('headers', {})
|
|
144
|
+
|
|
145
|
+
if not self.client_transaction.home_page_response:
|
|
146
|
+
cookies_backup = self.get_cookies().copy()
|
|
147
|
+
ct_headers = {
|
|
148
|
+
'Accept-Language': f'{self.language},{self.language.split("-")[0]};q=0.9',
|
|
149
|
+
'Cache-Control': 'no-cache',
|
|
150
|
+
'Referer': f'https://{DOMAIN}',
|
|
151
|
+
'User-Agent': self._user_agent
|
|
152
|
+
}
|
|
153
|
+
await self.client_transaction.init(self.http, ct_headers)
|
|
154
|
+
self.set_cookies(cookies_backup, clear_cookies=True)
|
|
155
|
+
|
|
156
|
+
tid = self.client_transaction.generate_transaction_id(method=method, path=urlparse(url).path)
|
|
157
|
+
headers['X-Client-Transaction-Id'] = tid
|
|
158
|
+
|
|
159
|
+
cookies_backup = self.get_cookies().copy()
|
|
160
|
+
response = await self.http.request(method, url, headers=headers, **kwargs)
|
|
161
|
+
self._remove_duplicate_ct0_cookie()
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
response_data = response.json()
|
|
165
|
+
except json.decoder.JSONDecodeError:
|
|
166
|
+
response_data = response.text
|
|
167
|
+
|
|
168
|
+
if isinstance(response_data, dict) and 'errors' in response_data:
|
|
169
|
+
error_code = response_data['errors'][0]['code']
|
|
170
|
+
error_message = response_data['errors'][0].get('message')
|
|
171
|
+
if error_code in (37, 64):
|
|
172
|
+
# Account suspended
|
|
173
|
+
raise AccountSuspended(error_message)
|
|
174
|
+
|
|
175
|
+
if error_code == 326:
|
|
176
|
+
# Account unlocking
|
|
177
|
+
if self.captcha_solver is None:
|
|
178
|
+
raise AccountLocked(
|
|
179
|
+
'Your account is locked. Visit '
|
|
180
|
+
f'https://{DOMAIN}/account/access to unlock it.'
|
|
181
|
+
)
|
|
182
|
+
if auto_unlock:
|
|
183
|
+
await self.unlock()
|
|
184
|
+
self.set_cookies(cookies_backup, clear_cookies=True)
|
|
185
|
+
response = await self.http.request(method, url, **kwargs)
|
|
186
|
+
self._remove_duplicate_ct0_cookie()
|
|
187
|
+
try:
|
|
188
|
+
response_data = response.json()
|
|
189
|
+
except json.decoder.JSONDecodeError:
|
|
190
|
+
response_data = response.text
|
|
191
|
+
|
|
192
|
+
status_code = response.status_code
|
|
193
|
+
|
|
194
|
+
if status_code >= 400 and raise_exception:
|
|
195
|
+
message = f'status: {status_code}, message: "{response.text}"'
|
|
196
|
+
if status_code == 400:
|
|
197
|
+
raise BadRequest(message, headers=response.headers)
|
|
198
|
+
elif status_code == 401:
|
|
199
|
+
raise Unauthorized(message, headers=response.headers)
|
|
200
|
+
elif status_code == 403:
|
|
201
|
+
raise Forbidden(message, headers=response.headers)
|
|
202
|
+
elif status_code == 404:
|
|
203
|
+
raise NotFound(message, headers=response.headers)
|
|
204
|
+
elif status_code == 408:
|
|
205
|
+
raise RequestTimeout(message, headers=response.headers)
|
|
206
|
+
elif status_code == 429:
|
|
207
|
+
if await self._get_user_state() == 'suspended':
|
|
208
|
+
raise AccountSuspended(message, headers=response.headers)
|
|
209
|
+
raise TooManyRequests(message, headers=response.headers)
|
|
210
|
+
elif 500 <= status_code < 600:
|
|
211
|
+
raise ServerError(message, headers=response.headers)
|
|
212
|
+
else:
|
|
213
|
+
raise TwitterException(message, headers=response.headers)
|
|
214
|
+
|
|
215
|
+
if status_code == 200:
|
|
216
|
+
return response_data, response
|
|
217
|
+
|
|
218
|
+
return response_data, response
|
|
219
|
+
|
|
220
|
+
async def get(self, url, **kwargs) -> tuple[dict | Any, Response]:
|
|
221
|
+
':meta private:'
|
|
222
|
+
return await self.request('GET', url, **kwargs)
|
|
223
|
+
|
|
224
|
+
async def post(self, url, **kwargs) -> tuple[dict | Any, Response]:
|
|
225
|
+
':meta private:'
|
|
226
|
+
return await self.request('POST', url, **kwargs)
|
|
227
|
+
|
|
228
|
+
def _remove_duplicate_ct0_cookie(self) -> None:
|
|
229
|
+
"""Collapse the jar to one cookie per name.
|
|
230
|
+
|
|
231
|
+
httpx keeps host and domain variants of the same cookie (`twid` and `__cuid`
|
|
232
|
+
are set both ways by x.com), and ``dict(cookies)`` raises ``CookieConflict``
|
|
233
|
+
as soon as a name appears twice. The last write wins.
|
|
234
|
+
"""
|
|
235
|
+
cookies = {}
|
|
236
|
+
for cookie in self.http.cookies.jar:
|
|
237
|
+
cookies[cookie.name] = cookie.value
|
|
238
|
+
self.http.cookies = list(cookies.items())
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def proxy(self) -> str:
|
|
242
|
+
':meta private:'
|
|
243
|
+
transport: AsyncHTTPTransport = self.http._mounts.get(URLPattern('all://'))
|
|
244
|
+
if transport is None:
|
|
245
|
+
return None
|
|
246
|
+
if not hasattr(transport._pool, '_proxy_url'):
|
|
247
|
+
return None
|
|
248
|
+
return httpx_transport_to_url(transport)
|
|
249
|
+
|
|
250
|
+
@proxy.setter
|
|
251
|
+
def proxy(self, url: str) -> None:
|
|
252
|
+
self.http._mounts = {URLPattern('all://'): AsyncHTTPTransport(proxy=url)}
|
|
253
|
+
|
|
254
|
+
def _get_csrf_token(self) -> str:
|
|
255
|
+
"""
|
|
256
|
+
Retrieves the Cross-Site Request Forgery (CSRF) token from the
|
|
257
|
+
current session's cookies.
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
:class:`str`
|
|
262
|
+
The CSRF token as a string.
|
|
263
|
+
"""
|
|
264
|
+
return self.http.cookies.get('ct0')
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def _base_headers(self) -> dict[str, str]:
|
|
268
|
+
"""
|
|
269
|
+
Base headers for Twitter API requests.
|
|
270
|
+
"""
|
|
271
|
+
headers = {
|
|
272
|
+
'authorization': f'Bearer {self._token}',
|
|
273
|
+
'content-type': 'application/json',
|
|
274
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
275
|
+
'X-Twitter-Active-User': 'yes',
|
|
276
|
+
'Referer': f'https://{DOMAIN}/',
|
|
277
|
+
'User-Agent': self._user_agent,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if self.language is not None:
|
|
281
|
+
headers['Accept-Language'] = self.language
|
|
282
|
+
headers['X-Twitter-Client-Language'] = self.language
|
|
283
|
+
|
|
284
|
+
csrf_token = self._get_csrf_token()
|
|
285
|
+
if csrf_token is not None:
|
|
286
|
+
headers['X-Csrf-Token'] = csrf_token
|
|
287
|
+
if self._act_as is not None:
|
|
288
|
+
headers['X-Act-As-User-Id'] = self._act_as
|
|
289
|
+
return headers
|
|
290
|
+
|
|
291
|
+
async def _get_guest_token(self) -> str:
|
|
292
|
+
response, _ = await self.v11.guest_activate()
|
|
293
|
+
guest_token = response['guest_token']
|
|
294
|
+
return guest_token
|
|
295
|
+
|
|
296
|
+
async def _ui_metrics(self) -> str:
|
|
297
|
+
response, _ = await self.get(f'https://twitter.com/i/js_inst?c_name=ui_metrics') # keep twitter.com here
|
|
298
|
+
return response
|
|
299
|
+
|
|
300
|
+
async def login(
|
|
301
|
+
self,
|
|
302
|
+
*,
|
|
303
|
+
auth_info_1: str,
|
|
304
|
+
auth_info_2: str | None = None,
|
|
305
|
+
password: str,
|
|
306
|
+
totp_secret: str | None = None,
|
|
307
|
+
cookies_file: str | None = None,
|
|
308
|
+
enable_ui_metrics: bool = True
|
|
309
|
+
) -> dict:
|
|
310
|
+
"""
|
|
311
|
+
Logs into the account using the specified login information.
|
|
312
|
+
`auth_info_1` and `password` are required parameters.
|
|
313
|
+
`auth_info_2` is optional and can be omitted, but it is
|
|
314
|
+
recommended to provide if available.
|
|
315
|
+
The order in which you specify authentication information
|
|
316
|
+
(auth_info_1 and auth_info_2) is flexible.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
auth_info_1 : :class:`str`
|
|
321
|
+
The first piece of authentication information,
|
|
322
|
+
which can be a username, email address, or phone number.
|
|
323
|
+
auth_info_2 : :class:`str`, default=None
|
|
324
|
+
The second piece of authentication information,
|
|
325
|
+
which is optional but recommended to provide.
|
|
326
|
+
It can be a username, email address, or phone number.
|
|
327
|
+
password : :class:`str`
|
|
328
|
+
The password associated with the account.
|
|
329
|
+
totp_secret : :class:`str`, default=None
|
|
330
|
+
The TOTP (Time-Based One-Time Password) secret key used for
|
|
331
|
+
two-factor authentication (2FA).
|
|
332
|
+
cookies_file : :class:`str`, default=None
|
|
333
|
+
The file path used for storing and loading cookies.
|
|
334
|
+
If the specified file exists, cookies will be loaded from it, potentially bypassing the login process.
|
|
335
|
+
After a successful login, cookies will be saved to this file for future use.
|
|
336
|
+
enable_ui_metrics : :class:`bool`, default=True
|
|
337
|
+
If set to True, obfuscated ui_metrics function will be executed using js2py,
|
|
338
|
+
and the result will be sent to the API. Enabling this may reduce the risk of account suspension.
|
|
339
|
+
|
|
340
|
+
Examples
|
|
341
|
+
--------
|
|
342
|
+
>>> await client.login(
|
|
343
|
+
... auth_info_1='example_user',
|
|
344
|
+
... auth_info_2='email@example.com',
|
|
345
|
+
... password='00000000'
|
|
346
|
+
... )
|
|
347
|
+
"""
|
|
348
|
+
self.http.cookies.clear()
|
|
349
|
+
|
|
350
|
+
if cookies_file and os.path.exists(cookies_file):
|
|
351
|
+
self.load_cookies(cookies_file)
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
guest_token = await self._get_guest_token()
|
|
355
|
+
|
|
356
|
+
flow = Flow(self, guest_token)
|
|
357
|
+
|
|
358
|
+
await flow.execute_task(params={'flow_name': 'login'}, data={
|
|
359
|
+
'input_flow_data': {
|
|
360
|
+
'flow_context': {
|
|
361
|
+
'debug_overrides': {},
|
|
362
|
+
'start_location': {
|
|
363
|
+
'location': 'splash_screen'
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
'subtask_versions': {
|
|
368
|
+
'action_list': 2,
|
|
369
|
+
'alert_dialog': 1,
|
|
370
|
+
'app_download_cta': 1,
|
|
371
|
+
'check_logged_in_account': 1,
|
|
372
|
+
'choice_selection': 3,
|
|
373
|
+
'contacts_live_sync_permission_prompt': 0,
|
|
374
|
+
'cta': 7,
|
|
375
|
+
'email_verification': 2,
|
|
376
|
+
'end_flow': 1,
|
|
377
|
+
'enter_date': 1,
|
|
378
|
+
'enter_email': 2,
|
|
379
|
+
'enter_password': 5,
|
|
380
|
+
'enter_phone': 2,
|
|
381
|
+
'enter_recaptcha': 1,
|
|
382
|
+
'enter_text': 5,
|
|
383
|
+
'enter_username': 2,
|
|
384
|
+
'generic_urt': 3,
|
|
385
|
+
'in_app_notification': 1,
|
|
386
|
+
'interest_picker': 3,
|
|
387
|
+
'js_instrumentation': 1,
|
|
388
|
+
'menu_dialog': 1,
|
|
389
|
+
'notifications_permission_prompt': 2,
|
|
390
|
+
'open_account': 2,
|
|
391
|
+
'open_home_timeline': 1,
|
|
392
|
+
'open_link': 1,
|
|
393
|
+
'phone_verification': 4,
|
|
394
|
+
'privacy_options': 1,
|
|
395
|
+
'security_key': 3,
|
|
396
|
+
'select_avatar': 4,
|
|
397
|
+
'select_banner': 2,
|
|
398
|
+
'settings_list': 7,
|
|
399
|
+
'show_code': 1,
|
|
400
|
+
'sign_up': 2,
|
|
401
|
+
'sign_up_review': 4,
|
|
402
|
+
'tweet_selection_urt': 1,
|
|
403
|
+
'update_users': 1,
|
|
404
|
+
'upload_media': 1,
|
|
405
|
+
'user_recommendations_list': 4,
|
|
406
|
+
'user_recommendations_urt': 1,
|
|
407
|
+
'wait_spinner': 3,
|
|
408
|
+
'web_modal': 1
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
await flow.sso_init('apple')
|
|
412
|
+
|
|
413
|
+
if enable_ui_metrics:
|
|
414
|
+
ui_metrics_response = solve_ui_metrics(
|
|
415
|
+
await self._ui_metrics()
|
|
416
|
+
)
|
|
417
|
+
else:
|
|
418
|
+
ui_metrics_response = ''
|
|
419
|
+
|
|
420
|
+
await flow.execute_task({
|
|
421
|
+
'subtask_id': 'LoginJsInstrumentationSubtask',
|
|
422
|
+
'js_instrumentation': {
|
|
423
|
+
'response': ui_metrics_response,
|
|
424
|
+
'link': 'next_link'
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
await flow.execute_task({
|
|
428
|
+
'subtask_id': 'LoginEnterUserIdentifierSSO',
|
|
429
|
+
'settings_list': {
|
|
430
|
+
'setting_responses': [
|
|
431
|
+
{
|
|
432
|
+
'key': 'user_identifier',
|
|
433
|
+
'response_data': {
|
|
434
|
+
'text_data': {'result': auth_info_1}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
],
|
|
438
|
+
'link': 'next_link'
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
if flow.task_id == 'LoginEnterAlternateIdentifierSubtask':
|
|
443
|
+
await flow.execute_task({
|
|
444
|
+
'subtask_id': 'LoginEnterAlternateIdentifierSubtask',
|
|
445
|
+
'enter_text': {
|
|
446
|
+
'text': auth_info_2,
|
|
447
|
+
'link': 'next_link'
|
|
448
|
+
}
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
if flow.task_id == 'DenyLoginSubtask':
|
|
452
|
+
raise TwitterException(flow.response['subtasks'][0]['cta']['secondary_text']['text'])
|
|
453
|
+
|
|
454
|
+
await flow.execute_task({
|
|
455
|
+
'subtask_id': 'LoginEnterPassword',
|
|
456
|
+
'enter_password': {
|
|
457
|
+
'password': password,
|
|
458
|
+
'link': 'next_link'
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
if flow.task_id == 'DenyLoginSubtask':
|
|
463
|
+
raise TwitterException(flow.response['subtasks'][0]['cta']['secondary_text']['text'])
|
|
464
|
+
|
|
465
|
+
if flow.task_id == 'LoginAcid':
|
|
466
|
+
print(find_dict(flow.response, 'secondary_text', find_one=True)[0]['text'])
|
|
467
|
+
|
|
468
|
+
await flow.execute_task({
|
|
469
|
+
'subtask_id': 'LoginAcid',
|
|
470
|
+
'enter_text': {
|
|
471
|
+
'text': input('>>> '),
|
|
472
|
+
'link': 'next_link'
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
return flow.response
|
|
476
|
+
|
|
477
|
+
if flow.task_id == 'LoginTwoFactorAuthChallenge':
|
|
478
|
+
if totp_secret is None:
|
|
479
|
+
print(find_dict(flow.response, 'secondary_text', find_one=True)[0]['text'])
|
|
480
|
+
totp_code = input('>>>')
|
|
481
|
+
else:
|
|
482
|
+
totp_code = pyotp.TOTP(totp_secret).now()
|
|
483
|
+
|
|
484
|
+
await flow.execute_task({
|
|
485
|
+
'subtask_id': 'LoginTwoFactorAuthChallenge',
|
|
486
|
+
'enter_text': {
|
|
487
|
+
'text': totp_code,
|
|
488
|
+
'link': 'next_link'
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
await flow.execute_task({
|
|
493
|
+
'subtask_id': 'AccountDuplicationCheck',
|
|
494
|
+
'check_logged_in_account': {
|
|
495
|
+
'link': 'AccountDuplicationCheck_false'
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
if cookies_file:
|
|
500
|
+
self.save_cookies(cookies_file)
|
|
501
|
+
|
|
502
|
+
if not flow.response['subtasks']:
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
self._user_id = find_dict(flow.response, 'id_str', find_one=True)[0]
|
|
506
|
+
return flow.response
|
|
507
|
+
|
|
508
|
+
async def logout(self) -> Response:
|
|
509
|
+
"""
|
|
510
|
+
Logs out of the currently logged-in account.
|
|
511
|
+
"""
|
|
512
|
+
response, _ = await self.v11.account_logout()
|
|
513
|
+
return response
|
|
514
|
+
|
|
515
|
+
async def unlock(self) -> None:
|
|
516
|
+
"""
|
|
517
|
+
Unlocks the account using the provided CAPTCHA solver.
|
|
518
|
+
|
|
519
|
+
See Also
|
|
520
|
+
--------
|
|
521
|
+
.capsolver
|
|
522
|
+
"""
|
|
523
|
+
if self.captcha_solver is None:
|
|
524
|
+
raise ValueError('Captcha solver is not provided.')
|
|
525
|
+
|
|
526
|
+
response, html = await self.captcha_solver.get_unlock_html()
|
|
527
|
+
|
|
528
|
+
if html.delete_button:
|
|
529
|
+
response, html = await self.captcha_solver.confirm_unlock(
|
|
530
|
+
html.authenticity_token,
|
|
531
|
+
html.assignment_token,
|
|
532
|
+
ui_metrics=True
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
if html.start_button or html.finish_button:
|
|
536
|
+
response, html = await self.captcha_solver.confirm_unlock(
|
|
537
|
+
html.authenticity_token,
|
|
538
|
+
html.assignment_token,
|
|
539
|
+
ui_metrics=True
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
cookies_backup = self.get_cookies().copy()
|
|
543
|
+
max_unlock_attempts = self.captcha_solver.max_attempts
|
|
544
|
+
attempt = 0
|
|
545
|
+
while attempt < max_unlock_attempts:
|
|
546
|
+
attempt += 1
|
|
547
|
+
|
|
548
|
+
if html.authenticity_token is None:
|
|
549
|
+
response, html = await self.captcha_solver.get_unlock_html()
|
|
550
|
+
|
|
551
|
+
result = self.captcha_solver.solve_funcaptcha(html.blob)
|
|
552
|
+
if result['errorId'] == 1:
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
self.set_cookies(cookies_backup, clear_cookies=True)
|
|
556
|
+
response, html = await self.captcha_solver.confirm_unlock(
|
|
557
|
+
html.authenticity_token,
|
|
558
|
+
html.assignment_token,
|
|
559
|
+
result['solution']['token'],
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
if html.finish_button:
|
|
563
|
+
response, html = await self.captcha_solver.confirm_unlock(
|
|
564
|
+
html.authenticity_token,
|
|
565
|
+
html.assignment_token,
|
|
566
|
+
ui_metrics=True
|
|
567
|
+
)
|
|
568
|
+
finished = (
|
|
569
|
+
response.next_request is not None and
|
|
570
|
+
response.next_request.url.path == '/'
|
|
571
|
+
)
|
|
572
|
+
if finished:
|
|
573
|
+
return
|
|
574
|
+
raise Exception('could not unlock the account.')
|
|
575
|
+
|
|
576
|
+
def get_cookies(self) -> dict:
|
|
577
|
+
"""
|
|
578
|
+
Get the cookies.
|
|
579
|
+
You can skip the login procedure by loading the saved cookies
|
|
580
|
+
using the :func:`set_cookies` method.
|
|
581
|
+
|
|
582
|
+
Examples
|
|
583
|
+
--------
|
|
584
|
+
>>> client.get_cookies()
|
|
585
|
+
|
|
586
|
+
See Also
|
|
587
|
+
--------
|
|
588
|
+
.set_cookies
|
|
589
|
+
.load_cookies
|
|
590
|
+
.save_cookies
|
|
591
|
+
"""
|
|
592
|
+
cookies = {}
|
|
593
|
+
for cookie in self.http.cookies.jar:
|
|
594
|
+
cookies[cookie.name] = cookie.value
|
|
595
|
+
return cookies
|
|
596
|
+
|
|
597
|
+
def save_cookies(self, path: str) -> None:
|
|
598
|
+
"""
|
|
599
|
+
Save cookies to file in json format.
|
|
600
|
+
You can skip the login procedure by loading the saved cookies
|
|
601
|
+
using the :func:`load_cookies` method.
|
|
602
|
+
|
|
603
|
+
Parameters
|
|
604
|
+
----------
|
|
605
|
+
path : :class:`str`
|
|
606
|
+
The path to the file where the cookie will be stored.
|
|
607
|
+
|
|
608
|
+
Examples
|
|
609
|
+
--------
|
|
610
|
+
>>> client.save_cookies('cookies.json')
|
|
611
|
+
|
|
612
|
+
See Also
|
|
613
|
+
--------
|
|
614
|
+
.load_cookies
|
|
615
|
+
.get_cookies
|
|
616
|
+
.set_cookies
|
|
617
|
+
"""
|
|
618
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
619
|
+
json.dump(self.get_cookies(), f)
|
|
620
|
+
|
|
621
|
+
def set_cookies(self, cookies: dict, clear_cookies: bool = False) -> None:
|
|
622
|
+
"""
|
|
623
|
+
Sets cookies.
|
|
624
|
+
You can skip the login procedure by loading a saved cookies.
|
|
625
|
+
|
|
626
|
+
Parameters
|
|
627
|
+
----------
|
|
628
|
+
cookies : :class:`dict`
|
|
629
|
+
The cookies to be set as key value pair.
|
|
630
|
+
|
|
631
|
+
Examples
|
|
632
|
+
--------
|
|
633
|
+
>>> with open('cookies.json', 'r', encoding='utf-8') as f:
|
|
634
|
+
... client.set_cookies(json.load(f))
|
|
635
|
+
|
|
636
|
+
See Also
|
|
637
|
+
--------
|
|
638
|
+
.get_cookies
|
|
639
|
+
.load_cookies
|
|
640
|
+
.save_cookies
|
|
641
|
+
"""
|
|
642
|
+
if clear_cookies:
|
|
643
|
+
self.http.cookies.clear()
|
|
644
|
+
self.http.cookies.update(cookies)
|
|
645
|
+
self._remove_duplicate_ct0_cookie()
|
|
646
|
+
|
|
647
|
+
def load_cookies(self, path: str) -> None:
|
|
648
|
+
"""
|
|
649
|
+
Loads cookies from a file.
|
|
650
|
+
You can skip the login procedure by loading a saved cookies.
|
|
651
|
+
|
|
652
|
+
Parameters
|
|
653
|
+
----------
|
|
654
|
+
path : :class:`str`
|
|
655
|
+
Path to the file where the cookie is stored.
|
|
656
|
+
|
|
657
|
+
Examples
|
|
658
|
+
--------
|
|
659
|
+
>>> client.load_cookies('cookies.json')
|
|
660
|
+
|
|
661
|
+
See Also
|
|
662
|
+
--------
|
|
663
|
+
.get_cookies
|
|
664
|
+
.save_cookies
|
|
665
|
+
.set_cookies
|
|
666
|
+
"""
|
|
667
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
668
|
+
self.set_cookies(json.load(f))
|
|
669
|
+
|
|
670
|
+
def set_delegate_account(self, user_id: str | None) -> None:
|
|
671
|
+
"""
|
|
672
|
+
Sets the account to act as.
|
|
673
|
+
|
|
674
|
+
Parameters
|
|
675
|
+
----------
|
|
676
|
+
user_id : :class:`str` | None
|
|
677
|
+
The user ID of the account to act as.
|
|
678
|
+
Set to None to clear the delegated account.
|
|
679
|
+
"""
|
|
680
|
+
self._act_as = user_id
|
|
681
|
+
|
|
682
|
+
async def user_id(self) -> str:
|
|
683
|
+
"""
|
|
684
|
+
Retrieves the user ID associated with the authenticated account.
|
|
685
|
+
"""
|
|
686
|
+
if self._user_id is not None:
|
|
687
|
+
return self._user_id
|
|
688
|
+
user_id = self._user_id_from_cookies()
|
|
689
|
+
if user_id is None:
|
|
690
|
+
user_id = self._user_id_from_home_page()
|
|
691
|
+
if user_id is not None:
|
|
692
|
+
self._user_id = user_id
|
|
693
|
+
return user_id
|
|
694
|
+
# legacy path: 1.1/account/settings.json, removed from x.com in 2025
|
|
695
|
+
response, _ = await self.v11.settings()
|
|
696
|
+
screen_name = response['screen_name']
|
|
697
|
+
self._user_id = (await self.get_user_by_screen_name(screen_name)).id
|
|
698
|
+
return self._user_id
|
|
699
|
+
|
|
700
|
+
def _user_id_from_cookies(self) -> str | None:
|
|
701
|
+
"""x.com keeps the signed-in user id in the `twid` cookie as ``u%3D<id>``."""
|
|
702
|
+
raw = self.http.cookies.get('twid')
|
|
703
|
+
if not raw:
|
|
704
|
+
return None
|
|
705
|
+
value = unquote(raw)
|
|
706
|
+
candidate = value.rsplit('=', 1)[1].strip() if '=' in value else ''
|
|
707
|
+
return candidate if candidate.isdigit() else None
|
|
708
|
+
|
|
709
|
+
@property
|
|
710
|
+
def home_page(self):
|
|
711
|
+
':meta private:'
|
|
712
|
+
return self.client_transaction.home_page_response
|
|
713
|
+
|
|
714
|
+
def _user_id_from_home_page(self) -> str | None:
|
|
715
|
+
':meta private:'
|
|
716
|
+
if self.home_page is None:
|
|
717
|
+
return None
|
|
718
|
+
match = re.search(
|
|
719
|
+
r'"(\d{5,25})":\{"name":"(?:[^"\\]|\\.)*","screen_name":"',
|
|
720
|
+
str(self.home_page),
|
|
721
|
+
)
|
|
722
|
+
return match.group(1) if match else None
|
|
723
|
+
|
|
724
|
+
def identity(self) -> dict | None:
|
|
725
|
+
"""The signed-in account, read from the home page bootstrap JSON.
|
|
726
|
+
|
|
727
|
+
x.com dropped ``1.1/account/settings.json`` and the ``UserByRestId`` graphql id,
|
|
728
|
+
so the user entity embedded in the home page is the reliable source.
|
|
729
|
+
|
|
730
|
+
Returns
|
|
731
|
+
-------
|
|
732
|
+
:class:`dict` | None
|
|
733
|
+
``{'user_id', 'screen_name', 'name', 'avatar'}``, or None when the page
|
|
734
|
+
carries no user entity (stale ``auth_token``).
|
|
735
|
+
"""
|
|
736
|
+
':meta private:'
|
|
737
|
+
page = self.home_page
|
|
738
|
+
if page is None:
|
|
739
|
+
return None
|
|
740
|
+
html = str(page)
|
|
741
|
+
user_id = self._user_id_from_cookies()
|
|
742
|
+
pattern = re.compile(
|
|
743
|
+
r'"(\d{5,25})":\{"name":"((?:[^"\\]|\\.)*)","screen_name":"((?:[^"\\]|\\.)*)"'
|
|
744
|
+
)
|
|
745
|
+
for match in pattern.finditer(html):
|
|
746
|
+
if user_id and match.group(1) != user_id:
|
|
747
|
+
continue
|
|
748
|
+
if not match.group(3):
|
|
749
|
+
continue
|
|
750
|
+
window = html[match.end():match.end() + 4000]
|
|
751
|
+
avatar = re.search(r'"profile_image_url_https":"(https:[^"]+)"', window)
|
|
752
|
+
return {
|
|
753
|
+
'user_id': match.group(1),
|
|
754
|
+
'name': _json_string(match.group(2)),
|
|
755
|
+
'screen_name': _json_string(match.group(3)),
|
|
756
|
+
'avatar': avatar.group(1) if avatar else None,
|
|
757
|
+
}
|
|
758
|
+
return None
|
|
759
|
+
|
|
760
|
+
async def user(self) -> User:
|
|
761
|
+
"""
|
|
762
|
+
Retrieve detailed information about the authenticated user.
|
|
763
|
+
"""
|
|
764
|
+
user_id = await self.user_id()
|
|
765
|
+
try:
|
|
766
|
+
return await self.get_user_by_id(user_id)
|
|
767
|
+
except TwitterException:
|
|
768
|
+
# UserByRestId is refused by the current build (an edge-level 403, not a
|
|
769
|
+
# stale query id). Rebuild a User from the bootstrap entity rather than
|
|
770
|
+
# failing on a working session. The entity carries no counts, so the
|
|
771
|
+
# result is marked `partial` — see User.partial.
|
|
772
|
+
identity = self.identity()
|
|
773
|
+
if identity is None:
|
|
774
|
+
raise
|
|
775
|
+
return User(self, {
|
|
776
|
+
'rest_id': identity['user_id'],
|
|
777
|
+
'core': {'name': identity['name'], 'screen_name': identity['screen_name']},
|
|
778
|
+
'legacy': {
|
|
779
|
+
'name': identity['name'],
|
|
780
|
+
'screen_name': identity['screen_name'],
|
|
781
|
+
'profile_image_url_https': identity['avatar'],
|
|
782
|
+
},
|
|
783
|
+
}, partial=True)
|
|
784
|
+
|
|
785
|
+
async def search_tweet(
|
|
786
|
+
self,
|
|
787
|
+
query: str,
|
|
788
|
+
product: Literal['Top', 'Latest', 'Media'],
|
|
789
|
+
count: int = 20,
|
|
790
|
+
cursor: str | None = None
|
|
791
|
+
) -> Result[Tweet]:
|
|
792
|
+
"""
|
|
793
|
+
Searches for tweets based on the specified query and
|
|
794
|
+
product type.
|
|
795
|
+
|
|
796
|
+
Parameters
|
|
797
|
+
----------
|
|
798
|
+
query : :class:`str`
|
|
799
|
+
The search query.
|
|
800
|
+
product : {'Top', 'Latest', 'Media'}
|
|
801
|
+
The type of tweets to retrieve.
|
|
802
|
+
count : :class:`int`, default=20
|
|
803
|
+
The number of tweets to retrieve, between 1 and 20.
|
|
804
|
+
cursor : :class:`str`, default=20
|
|
805
|
+
Token to retrieve more tweets.
|
|
806
|
+
|
|
807
|
+
Returns
|
|
808
|
+
-------
|
|
809
|
+
Result[:class:`Tweet`]
|
|
810
|
+
An instance of the `Result` class containing the
|
|
811
|
+
search results.
|
|
812
|
+
|
|
813
|
+
Examples
|
|
814
|
+
--------
|
|
815
|
+
>>> tweets = await client.search_tweet('query', 'Top')
|
|
816
|
+
>>> for tweet in tweets:
|
|
817
|
+
... print(tweet)
|
|
818
|
+
<Tweet id="...">
|
|
819
|
+
<Tweet id="...">
|
|
820
|
+
...
|
|
821
|
+
...
|
|
822
|
+
|
|
823
|
+
>>> more_tweets = await tweets.next() # Retrieve more tweets
|
|
824
|
+
>>> for tweet in more_tweets:
|
|
825
|
+
... print(tweet)
|
|
826
|
+
<Tweet id="...">
|
|
827
|
+
<Tweet id="...">
|
|
828
|
+
...
|
|
829
|
+
...
|
|
830
|
+
|
|
831
|
+
>>> # Retrieve previous tweets
|
|
832
|
+
>>> previous_tweets = await tweets.previous()
|
|
833
|
+
"""
|
|
834
|
+
product = product.capitalize()
|
|
835
|
+
|
|
836
|
+
response, _ = await self.gql.search_timeline(query, product, count, cursor)
|
|
837
|
+
instructions = find_dict(response, 'instructions', find_one=True)
|
|
838
|
+
if not instructions:
|
|
839
|
+
return Result([])
|
|
840
|
+
instructions = instructions[0]
|
|
841
|
+
|
|
842
|
+
if product == 'Media' and cursor is not None:
|
|
843
|
+
items = find_dict(instructions, 'moduleItems', find_one=True)[0]
|
|
844
|
+
else:
|
|
845
|
+
items_ = find_dict(instructions, 'entries', find_one=True)
|
|
846
|
+
if items_:
|
|
847
|
+
items = items_[0]
|
|
848
|
+
else:
|
|
849
|
+
items = []
|
|
850
|
+
if product == 'Media':
|
|
851
|
+
if 'items' in items[0]['content']:
|
|
852
|
+
items = items[0]['content']['items']
|
|
853
|
+
else:
|
|
854
|
+
items = []
|
|
855
|
+
|
|
856
|
+
next_cursor = None
|
|
857
|
+
previous_cursor = None
|
|
858
|
+
|
|
859
|
+
results = []
|
|
860
|
+
for item in items:
|
|
861
|
+
if item['entryId'].startswith('cursor-bottom'):
|
|
862
|
+
next_cursor = item['content']['value']
|
|
863
|
+
if item['entryId'].startswith('cursor-top'):
|
|
864
|
+
previous_cursor = item['content']['value']
|
|
865
|
+
if not item['entryId'].startswith(('tweet', 'search-grid')):
|
|
866
|
+
continue
|
|
867
|
+
|
|
868
|
+
try:
|
|
869
|
+
tweet = tweet_from_data(self, item)
|
|
870
|
+
except KeyError:
|
|
871
|
+
tweet = None
|
|
872
|
+
|
|
873
|
+
if tweet is not None:
|
|
874
|
+
results.append(tweet)
|
|
875
|
+
|
|
876
|
+
if next_cursor is None:
|
|
877
|
+
if product == 'Media':
|
|
878
|
+
entries = find_dict(instructions, 'entries', find_one=True)[0]
|
|
879
|
+
next_cursor = entries[-1]['content']['value']
|
|
880
|
+
previous_cursor = entries[-2]['content']['value']
|
|
881
|
+
else:
|
|
882
|
+
next_cursor = instructions[-1]['entry']['content']['value']
|
|
883
|
+
previous_cursor = instructions[-2]['entry']['content']['value']
|
|
884
|
+
|
|
885
|
+
return Result(
|
|
886
|
+
results,
|
|
887
|
+
partial(self.search_tweet, query, product, count, next_cursor),
|
|
888
|
+
next_cursor,
|
|
889
|
+
partial(self.search_tweet, query, product, count, previous_cursor),
|
|
890
|
+
previous_cursor
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
async def search_user(
|
|
894
|
+
self,
|
|
895
|
+
query: str,
|
|
896
|
+
count: int = 20,
|
|
897
|
+
cursor: str | None = None
|
|
898
|
+
) -> Result[User]:
|
|
899
|
+
"""
|
|
900
|
+
Searches for users based on the provided query.
|
|
901
|
+
|
|
902
|
+
Parameters
|
|
903
|
+
----------
|
|
904
|
+
query : :class:`str`
|
|
905
|
+
The search query for finding users.
|
|
906
|
+
count : :class:`int`, default=20
|
|
907
|
+
The number of users to retrieve in each request.
|
|
908
|
+
cursor : :class:`str`, default=None
|
|
909
|
+
Token to retrieve more users.
|
|
910
|
+
|
|
911
|
+
Returns
|
|
912
|
+
-------
|
|
913
|
+
Result[:class:`User`]
|
|
914
|
+
An instance of the `Result` class containing the
|
|
915
|
+
search results.
|
|
916
|
+
|
|
917
|
+
Examples
|
|
918
|
+
--------
|
|
919
|
+
>>> result = await client.search_user('query')
|
|
920
|
+
>>> for user in result:
|
|
921
|
+
... print(user)
|
|
922
|
+
<User id="...">
|
|
923
|
+
<User id="...">
|
|
924
|
+
...
|
|
925
|
+
...
|
|
926
|
+
|
|
927
|
+
>>> more_results = await result.next() # Retrieve more search results
|
|
928
|
+
>>> for user in more_results:
|
|
929
|
+
... print(user)
|
|
930
|
+
<User id="...">
|
|
931
|
+
<User id="...">
|
|
932
|
+
...
|
|
933
|
+
...
|
|
934
|
+
"""
|
|
935
|
+
response, _ = await self.gql.search_timeline(query, 'People', count, cursor)
|
|
936
|
+
items = find_dict(response, 'entries', find_one=True)[0]
|
|
937
|
+
next_cursor = items[-1]['content']['value']
|
|
938
|
+
|
|
939
|
+
results = []
|
|
940
|
+
for item in items:
|
|
941
|
+
if 'itemContent' not in item['content']:
|
|
942
|
+
continue
|
|
943
|
+
user_info = find_dict(item, 'result', find_one=True)[0]
|
|
944
|
+
results.append(User(self, user_info))
|
|
945
|
+
|
|
946
|
+
return Result(
|
|
947
|
+
results,
|
|
948
|
+
partial(self.search_user, query, count, next_cursor),
|
|
949
|
+
next_cursor
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
async def get_similar_tweets(self, tweet_id: str) -> list[Tweet]:
|
|
953
|
+
"""
|
|
954
|
+
Retrieves tweets similar to the specified tweet (Twitter premium only).
|
|
955
|
+
|
|
956
|
+
Parameters
|
|
957
|
+
----------
|
|
958
|
+
tweet_id : :class:`str`
|
|
959
|
+
The ID of the tweet for which similar tweets are to be retrieved.
|
|
960
|
+
|
|
961
|
+
Returns
|
|
962
|
+
-------
|
|
963
|
+
list[:class:`Tweet`]
|
|
964
|
+
A list of Tweet objects representing tweets
|
|
965
|
+
similar to the specified tweet.
|
|
966
|
+
"""
|
|
967
|
+
response, _ = await self.gql.similar_posts(tweet_id)
|
|
968
|
+
items_ = find_dict(response, 'entries', find_one=True)
|
|
969
|
+
results = []
|
|
970
|
+
if not items_:
|
|
971
|
+
return results
|
|
972
|
+
|
|
973
|
+
for item in items_[0]:
|
|
974
|
+
if not item['entryId'].startswith('tweet'):
|
|
975
|
+
continue
|
|
976
|
+
|
|
977
|
+
tweet = tweet_from_data(self, item)
|
|
978
|
+
if tweet is not None:
|
|
979
|
+
results.append(tweet)
|
|
980
|
+
|
|
981
|
+
return results
|
|
982
|
+
|
|
983
|
+
async def get_user_highlights_tweets(
|
|
984
|
+
self,
|
|
985
|
+
user_id: str,
|
|
986
|
+
count: int = 20,
|
|
987
|
+
cursor: str | None = None
|
|
988
|
+
) -> Result[Tweet]:
|
|
989
|
+
"""
|
|
990
|
+
Retrieves highlighted tweets from a user's timeline.
|
|
991
|
+
|
|
992
|
+
Parameters
|
|
993
|
+
----------
|
|
994
|
+
user_id : :class:`str`
|
|
995
|
+
The user ID
|
|
996
|
+
count : :class:`int`, default=20
|
|
997
|
+
The number of tweets to retrieve.
|
|
998
|
+
|
|
999
|
+
Returns
|
|
1000
|
+
-------
|
|
1001
|
+
Result[:class:`Tweet`]
|
|
1002
|
+
An instance of the `Result` class containing the highlighted tweets.
|
|
1003
|
+
|
|
1004
|
+
Examples
|
|
1005
|
+
--------
|
|
1006
|
+
>>> result = await client.get_user_highlights_tweets('123456789')
|
|
1007
|
+
>>> for tweet in result:
|
|
1008
|
+
... print(tweet)
|
|
1009
|
+
<Tweet id="...">
|
|
1010
|
+
<Tweet id="...">
|
|
1011
|
+
...
|
|
1012
|
+
...
|
|
1013
|
+
|
|
1014
|
+
>>> more_results = await result.next() # Retrieve more highlighted tweets
|
|
1015
|
+
>>> for tweet in more_results:
|
|
1016
|
+
... print(tweet)
|
|
1017
|
+
<Tweet id="...">
|
|
1018
|
+
<Tweet id="...">
|
|
1019
|
+
...
|
|
1020
|
+
...
|
|
1021
|
+
"""
|
|
1022
|
+
response, _ = await self.gql.user_highlights_tweets(user_id, count, cursor)
|
|
1023
|
+
|
|
1024
|
+
instructions = response['data']['user']['result']['timeline']['timeline']['instructions']
|
|
1025
|
+
instruction = find_entry_by_type(instructions, 'TimelineAddEntries')
|
|
1026
|
+
if instruction is None:
|
|
1027
|
+
return Result.empty()
|
|
1028
|
+
entries = instruction['entries']
|
|
1029
|
+
previous_cursor = None
|
|
1030
|
+
next_cursor = None
|
|
1031
|
+
results = []
|
|
1032
|
+
|
|
1033
|
+
for entry in entries:
|
|
1034
|
+
entryId = entry['entryId']
|
|
1035
|
+
if entryId.startswith('tweet'):
|
|
1036
|
+
results.append(tweet_from_data(self, entry))
|
|
1037
|
+
elif entryId.startswith('cursor-top'):
|
|
1038
|
+
previous_cursor = entry['content']['value']
|
|
1039
|
+
elif entryId.startswith('cursor-bottom'):
|
|
1040
|
+
next_cursor = entry['content']['value']
|
|
1041
|
+
|
|
1042
|
+
return Result(
|
|
1043
|
+
results,
|
|
1044
|
+
partial(self.get_user_highlights_tweets, user_id, count, next_cursor),
|
|
1045
|
+
next_cursor,
|
|
1046
|
+
partial(self.get_user_highlights_tweets, user_id, count, previous_cursor),
|
|
1047
|
+
previous_cursor
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
async def upload_media(
|
|
1051
|
+
self,
|
|
1052
|
+
source: str | bytes,
|
|
1053
|
+
wait_for_completion: bool = False,
|
|
1054
|
+
status_check_interval: float | None = None,
|
|
1055
|
+
media_type: str | None = None,
|
|
1056
|
+
media_category: str | None = None,
|
|
1057
|
+
is_long_video: bool = False
|
|
1058
|
+
) -> str:
|
|
1059
|
+
"""
|
|
1060
|
+
Uploads media to twitter.
|
|
1061
|
+
|
|
1062
|
+
Parameters
|
|
1063
|
+
----------
|
|
1064
|
+
source : :class:`str` | :class:`bytes`
|
|
1065
|
+
The source of the media to be uploaded.
|
|
1066
|
+
It can be either a file path or bytes of the media content.
|
|
1067
|
+
wait_for_completion : :class:`bool`, default=False
|
|
1068
|
+
Whether to wait for the completion of the media upload process.
|
|
1069
|
+
status_check_interval : :class:`float`, default=1.0
|
|
1070
|
+
The interval (in seconds) to check the status of the
|
|
1071
|
+
media upload process.
|
|
1072
|
+
media_type : :class:`str`, default=None
|
|
1073
|
+
The MIME type of the media.
|
|
1074
|
+
If not specified, it will be guessed from the source.
|
|
1075
|
+
media_category : :class:`str`, default=None
|
|
1076
|
+
The media category.
|
|
1077
|
+
is_long_video : :class:`bool`, default=False
|
|
1078
|
+
If this is True, videos longer than 2:20 can be uploaded.
|
|
1079
|
+
(Twitter Premium only)
|
|
1080
|
+
|
|
1081
|
+
Returns
|
|
1082
|
+
-------
|
|
1083
|
+
:class:`str`
|
|
1084
|
+
The media ID of the uploaded media.
|
|
1085
|
+
|
|
1086
|
+
Examples
|
|
1087
|
+
--------
|
|
1088
|
+
Videos, images and gifs can be uploaded.
|
|
1089
|
+
|
|
1090
|
+
>>> media_id_1 = await client.upload_media(
|
|
1091
|
+
... 'media1.jpg',
|
|
1092
|
+
... )
|
|
1093
|
+
|
|
1094
|
+
>>> media_id_2 = await client.upload_media(
|
|
1095
|
+
... 'media2.mp4',
|
|
1096
|
+
... wait_for_completion=True
|
|
1097
|
+
... )
|
|
1098
|
+
|
|
1099
|
+
>>> media_id_3 = await client.upload_media(
|
|
1100
|
+
... 'media3.gif',
|
|
1101
|
+
... wait_for_completion=True,
|
|
1102
|
+
... media_category='tweet_gif' # media_category must be specified
|
|
1103
|
+
... )
|
|
1104
|
+
"""
|
|
1105
|
+
if not isinstance(wait_for_completion, bool):
|
|
1106
|
+
raise TypeError(
|
|
1107
|
+
'wait_for_completion must be bool,'
|
|
1108
|
+
f' not {wait_for_completion.__class__.__name__}'
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
if isinstance(source, str):
|
|
1112
|
+
# If the source is a path
|
|
1113
|
+
with open(source, 'rb') as file:
|
|
1114
|
+
binary = file.read()
|
|
1115
|
+
elif isinstance(source, bytes):
|
|
1116
|
+
# If the source is bytes
|
|
1117
|
+
binary = source
|
|
1118
|
+
|
|
1119
|
+
if media_type is None:
|
|
1120
|
+
# Guess mimetype if not specified
|
|
1121
|
+
media_type = filetype.guess(binary).mime
|
|
1122
|
+
|
|
1123
|
+
if wait_for_completion:
|
|
1124
|
+
if media_type == 'image/gif':
|
|
1125
|
+
if media_category is None:
|
|
1126
|
+
raise TwitterException(
|
|
1127
|
+
"`media_category` must be specified to check the "
|
|
1128
|
+
"upload status of gif images ('dm_gif' or 'tweet_gif')"
|
|
1129
|
+
)
|
|
1130
|
+
elif media_type.startswith('image'):
|
|
1131
|
+
# Checking the upload status of an image is impossible.
|
|
1132
|
+
wait_for_completion = False
|
|
1133
|
+
|
|
1134
|
+
total_bytes = len(binary)
|
|
1135
|
+
|
|
1136
|
+
# ============ INIT =============
|
|
1137
|
+
response, _ = await self.v11.upload_media_init(
|
|
1138
|
+
media_type, total_bytes, media_category, is_long_video
|
|
1139
|
+
)
|
|
1140
|
+
media_id = response['media_id']
|
|
1141
|
+
# =========== APPEND ============
|
|
1142
|
+
segment_index = 0
|
|
1143
|
+
bytes_sent = 0
|
|
1144
|
+
MAX_SEGMENT_SIZE = 8 * 1024 * 1024 # The maximum segment size is 8 MB
|
|
1145
|
+
append_tasks = []
|
|
1146
|
+
chunk_streams: list[io.BytesIO] = []
|
|
1147
|
+
|
|
1148
|
+
while bytes_sent < total_bytes:
|
|
1149
|
+
chunk = binary[bytes_sent:bytes_sent + MAX_SEGMENT_SIZE]
|
|
1150
|
+
chunk_stream = io.BytesIO(chunk)
|
|
1151
|
+
coro = self.v11.upload_media_append(is_long_video, media_id, segment_index, chunk_stream)
|
|
1152
|
+
append_tasks.append(asyncio.create_task(coro))
|
|
1153
|
+
chunk_streams.append(chunk_stream)
|
|
1154
|
+
|
|
1155
|
+
segment_index += 1
|
|
1156
|
+
bytes_sent += len(chunk)
|
|
1157
|
+
|
|
1158
|
+
append_gather = asyncio.gather(*append_tasks)
|
|
1159
|
+
await append_gather
|
|
1160
|
+
|
|
1161
|
+
# Close chunk streams
|
|
1162
|
+
for chunk_stream in chunk_streams:
|
|
1163
|
+
chunk_stream.close()
|
|
1164
|
+
|
|
1165
|
+
# ========== FINALIZE ===========
|
|
1166
|
+
await self.v11.upload_media_finelize(is_long_video, media_id)
|
|
1167
|
+
# ===============================
|
|
1168
|
+
|
|
1169
|
+
if wait_for_completion:
|
|
1170
|
+
while True:
|
|
1171
|
+
state = await self.check_media_status(media_id, is_long_video)
|
|
1172
|
+
processing_info = state['processing_info']
|
|
1173
|
+
if 'error' in processing_info:
|
|
1174
|
+
raise InvalidMedia(processing_info['error'].get('message'))
|
|
1175
|
+
if processing_info['state'] == 'succeeded':
|
|
1176
|
+
break
|
|
1177
|
+
await asyncio.sleep(status_check_interval or processing_info['check_after_secs'])
|
|
1178
|
+
|
|
1179
|
+
return media_id
|
|
1180
|
+
|
|
1181
|
+
async def check_media_status(
|
|
1182
|
+
self, media_id: str, is_long_video: bool = False
|
|
1183
|
+
) -> dict:
|
|
1184
|
+
"""
|
|
1185
|
+
Check the status of uploaded media.
|
|
1186
|
+
|
|
1187
|
+
Parameters
|
|
1188
|
+
----------
|
|
1189
|
+
media_id : :class:`str`
|
|
1190
|
+
The media ID of the uploaded media.
|
|
1191
|
+
|
|
1192
|
+
Returns
|
|
1193
|
+
-------
|
|
1194
|
+
dict
|
|
1195
|
+
A dictionary containing information about the status of
|
|
1196
|
+
the uploaded media.
|
|
1197
|
+
"""
|
|
1198
|
+
response, _ = await self.v11.upload_media_status(is_long_video, media_id)
|
|
1199
|
+
return response
|
|
1200
|
+
|
|
1201
|
+
async def create_media_metadata(
|
|
1202
|
+
self,
|
|
1203
|
+
media_id: str,
|
|
1204
|
+
alt_text: str | None = None,
|
|
1205
|
+
sensitive_warning: list[Literal['adult_content', 'graphic_violence', 'other']] = None
|
|
1206
|
+
) -> Response:
|
|
1207
|
+
"""
|
|
1208
|
+
Adds metadata to uploaded media.
|
|
1209
|
+
|
|
1210
|
+
Parameters
|
|
1211
|
+
----------
|
|
1212
|
+
media_id : :class:`str`
|
|
1213
|
+
The media id for which to create metadata.
|
|
1214
|
+
alt_text : :class:`str` | None, default=None
|
|
1215
|
+
Alternative text for the media.
|
|
1216
|
+
sensitive_warning : list{'adult_content', 'graphic_violence', 'other'}
|
|
1217
|
+
A list of sensitive content warnings for the media.
|
|
1218
|
+
|
|
1219
|
+
Returns
|
|
1220
|
+
-------
|
|
1221
|
+
:class:`httpx.Response`
|
|
1222
|
+
Response returned from twitter api.
|
|
1223
|
+
|
|
1224
|
+
Examples
|
|
1225
|
+
--------
|
|
1226
|
+
>>> media_id = await client.upload_media('media.jpg')
|
|
1227
|
+
>>> await client.create_media_metadata(
|
|
1228
|
+
... media_id,
|
|
1229
|
+
... alt_text='This is a sample media',
|
|
1230
|
+
... sensitive_warning=['other']
|
|
1231
|
+
... )
|
|
1232
|
+
>>> await client.create_tweet(media_ids=[media_id])
|
|
1233
|
+
"""
|
|
1234
|
+
_, response = await self.v11.create_media_metadata(media_id, alt_text, sensitive_warning)
|
|
1235
|
+
return response
|
|
1236
|
+
|
|
1237
|
+
async def create_poll(
|
|
1238
|
+
self,
|
|
1239
|
+
choices: list[str],
|
|
1240
|
+
duration_minutes: int
|
|
1241
|
+
) -> str:
|
|
1242
|
+
"""
|
|
1243
|
+
Creates a poll and returns card-uri.
|
|
1244
|
+
|
|
1245
|
+
Parameters
|
|
1246
|
+
----------
|
|
1247
|
+
choices : list[:class:`str`]
|
|
1248
|
+
A list of choices for the poll. Maximum of 4 choices.
|
|
1249
|
+
duration_minutes : :class:`int`
|
|
1250
|
+
The duration of the poll in minutes.
|
|
1251
|
+
|
|
1252
|
+
Returns
|
|
1253
|
+
-------
|
|
1254
|
+
:class:`str`
|
|
1255
|
+
The URI of the created poll card.
|
|
1256
|
+
|
|
1257
|
+
Examples
|
|
1258
|
+
--------
|
|
1259
|
+
Create a poll with three choices lasting for 60 minutes:
|
|
1260
|
+
|
|
1261
|
+
>>> choices = ['Option A', 'Option B', 'Option C']
|
|
1262
|
+
>>> duration_minutes = 60
|
|
1263
|
+
>>> card_uri = await client.create_poll(choices, duration_minutes)
|
|
1264
|
+
>>> print(card_uri)
|
|
1265
|
+
'card://0000000000000000000'
|
|
1266
|
+
"""
|
|
1267
|
+
response, _ = await self.v11.create_card(choices, duration_minutes)
|
|
1268
|
+
return response['card_uri']
|
|
1269
|
+
|
|
1270
|
+
async def vote(
|
|
1271
|
+
self,
|
|
1272
|
+
selected_choice: str,
|
|
1273
|
+
card_uri: str,
|
|
1274
|
+
tweet_id: str,
|
|
1275
|
+
card_name: str
|
|
1276
|
+
) -> Poll:
|
|
1277
|
+
"""
|
|
1278
|
+
Vote on a poll with the selected choice.
|
|
1279
|
+
Parameters
|
|
1280
|
+
----------
|
|
1281
|
+
selected_choice : :class:`str`
|
|
1282
|
+
The label of the selected choice for the vote.
|
|
1283
|
+
card_uri : :class:`str`
|
|
1284
|
+
The URI of the poll card.
|
|
1285
|
+
tweet_id : :class:`str`
|
|
1286
|
+
The ID of the original tweet containing the poll.
|
|
1287
|
+
card_name : :class:`str`
|
|
1288
|
+
The name of the poll card.
|
|
1289
|
+
Returns
|
|
1290
|
+
-------
|
|
1291
|
+
:class:`Poll`
|
|
1292
|
+
The Poll object representing the updated poll after voting.
|
|
1293
|
+
"""
|
|
1294
|
+
response, _ = await self.v11.vote(selected_choice, card_uri, tweet_id, card_name)
|
|
1295
|
+
card_data = {
|
|
1296
|
+
'rest_id': response['card']['url'],
|
|
1297
|
+
'legacy': response['card']
|
|
1298
|
+
}
|
|
1299
|
+
return Poll(self, card_data, None)
|
|
1300
|
+
|
|
1301
|
+
async def create_tweet(
|
|
1302
|
+
self,
|
|
1303
|
+
text: str = '',
|
|
1304
|
+
media_ids: list[str] | None = None,
|
|
1305
|
+
poll_uri: str | None = None,
|
|
1306
|
+
reply_to: str | None = None,
|
|
1307
|
+
conversation_control: Literal['followers', 'verified', 'mentioned'] | None = None,
|
|
1308
|
+
attachment_url: str | None = None,
|
|
1309
|
+
community_id: str | None = None,
|
|
1310
|
+
share_with_followers: bool = False,
|
|
1311
|
+
is_note_tweet: bool = False,
|
|
1312
|
+
richtext_options: list[dict] = None,
|
|
1313
|
+
edit_tweet_id: str | None = None,
|
|
1314
|
+
paid_partnership: bool = False,
|
|
1315
|
+
made_with_ai: bool = False
|
|
1316
|
+
) -> Tweet:
|
|
1317
|
+
"""
|
|
1318
|
+
Creates a new tweet on Twitter with the specified
|
|
1319
|
+
text, media, and poll.
|
|
1320
|
+
|
|
1321
|
+
Parameters
|
|
1322
|
+
----------
|
|
1323
|
+
text : :class:`str`, default=''
|
|
1324
|
+
The text content of the tweet.
|
|
1325
|
+
media_ids : list[:class:`str`], default=None
|
|
1326
|
+
A list of media IDs or URIs to attach to the tweet.
|
|
1327
|
+
media IDs can be obtained by using the `upload_media` method.
|
|
1328
|
+
poll_uri : :class:`str`, default=None
|
|
1329
|
+
The URI of a Twitter poll card to attach to the tweet.
|
|
1330
|
+
Poll URIs can be obtained by using the `create_poll` method.
|
|
1331
|
+
reply_to : :class:`str`, default=None
|
|
1332
|
+
The ID of the tweet to which this tweet is a reply.
|
|
1333
|
+
conversation_control : {'followers', 'verified', 'mentioned'}
|
|
1334
|
+
The type of conversation control for the tweet:
|
|
1335
|
+
- 'followers': Limits replies to followers only.
|
|
1336
|
+
- 'verified': Limits replies to verified accounts only.
|
|
1337
|
+
- 'mentioned': Limits replies to mentioned accounts only.
|
|
1338
|
+
attachment_url : :class:`str`
|
|
1339
|
+
URL of the tweet to be quoted.
|
|
1340
|
+
is_note_tweet : :class:`bool`, default=False
|
|
1341
|
+
If this option is set to True, tweets longer than 280 characters
|
|
1342
|
+
can be posted (Twitter Premium only).
|
|
1343
|
+
richtext_options : list[:class:`dict`], default=None
|
|
1344
|
+
Options for decorating text (Twitter Premium only).
|
|
1345
|
+
edit_tweet_id : :class:`str` | None, default=None
|
|
1346
|
+
ID of the tweet to edit (Twitter Premium only).
|
|
1347
|
+
paid_partnership : :class:`bool`, default=False
|
|
1348
|
+
Adds the "Paid partnership" disclosure to the post
|
|
1349
|
+
(``advertising_disclosure.is_paid_promotion``).
|
|
1350
|
+
made_with_ai : :class:`bool`, default=False
|
|
1351
|
+
Adds the "Made with AI" disclosure to the post
|
|
1352
|
+
(``ai_generated_disclosure``, source ``UserDeclared``).
|
|
1353
|
+
|
|
1354
|
+
Raises
|
|
1355
|
+
------
|
|
1356
|
+
:exc:`DuplicateTweet` : If the tweet is a duplicate of another tweet.
|
|
1357
|
+
|
|
1358
|
+
Returns
|
|
1359
|
+
-------
|
|
1360
|
+
:class:`Tweet`
|
|
1361
|
+
The Created Tweet.
|
|
1362
|
+
|
|
1363
|
+
Examples
|
|
1364
|
+
--------
|
|
1365
|
+
Create a tweet with media:
|
|
1366
|
+
|
|
1367
|
+
>>> tweet_text = 'Example text'
|
|
1368
|
+
>>> media_ids = [
|
|
1369
|
+
... await client.upload_media('image1.png'),
|
|
1370
|
+
... await client.upload_media('image2.png')
|
|
1371
|
+
... ]
|
|
1372
|
+
>>> await client.create_tweet(
|
|
1373
|
+
... tweet_text,
|
|
1374
|
+
... media_ids=media_ids
|
|
1375
|
+
... )
|
|
1376
|
+
|
|
1377
|
+
Create a tweet with a poll:
|
|
1378
|
+
|
|
1379
|
+
>>> tweet_text = 'Example text'
|
|
1380
|
+
>>> poll_choices = ['Option A', 'Option B', 'Option C']
|
|
1381
|
+
>>> duration_minutes = 60
|
|
1382
|
+
>>> poll_uri = await client.create_poll(poll_choices, duration_minutes)
|
|
1383
|
+
>>> await client.create_tweet(
|
|
1384
|
+
... tweet_text,
|
|
1385
|
+
... poll_uri=poll_uri
|
|
1386
|
+
... )
|
|
1387
|
+
|
|
1388
|
+
Create a labelled post:
|
|
1389
|
+
|
|
1390
|
+
>>> await client.create_tweet('Rendered overnight', made_with_ai=True)
|
|
1391
|
+
|
|
1392
|
+
See Also
|
|
1393
|
+
--------
|
|
1394
|
+
.upload_media
|
|
1395
|
+
.create_poll
|
|
1396
|
+
.add_content_disclosure
|
|
1397
|
+
"""
|
|
1398
|
+
media_entities = [
|
|
1399
|
+
{'media_id': media_id, 'tagged_users': []}
|
|
1400
|
+
for media_id in (media_ids or [])
|
|
1401
|
+
]
|
|
1402
|
+
limit_mode = None
|
|
1403
|
+
if conversation_control is not None:
|
|
1404
|
+
conversation_control = conversation_control.lower()
|
|
1405
|
+
limit_mode = {
|
|
1406
|
+
'followers': 'Community',
|
|
1407
|
+
'verified': 'Verified',
|
|
1408
|
+
'mentioned': 'ByInvitation'
|
|
1409
|
+
}[conversation_control]
|
|
1410
|
+
|
|
1411
|
+
content_disclosure = build_content_disclosure(paid_partnership, made_with_ai)
|
|
1412
|
+
|
|
1413
|
+
response, _ = await self.gql.create_tweet(
|
|
1414
|
+
is_note_tweet, text, media_entities, poll_uri,
|
|
1415
|
+
reply_to, attachment_url, community_id, share_with_followers,
|
|
1416
|
+
richtext_options, edit_tweet_id, limit_mode,
|
|
1417
|
+
content_disclosure=content_disclosure
|
|
1418
|
+
)
|
|
1419
|
+
if 'errors' in response:
|
|
1420
|
+
raise_exceptions_from_response(response['errors'])
|
|
1421
|
+
detail = (response['errors'] or [{}])[0]
|
|
1422
|
+
message = detail.get('message') if isinstance(detail, dict) else str(detail)
|
|
1423
|
+
raise CouldNotTweet(message or 'Failed to post a tweet.')
|
|
1424
|
+
if is_note_tweet:
|
|
1425
|
+
_result = response['data']['notetweet_create']['tweet_results']
|
|
1426
|
+
else:
|
|
1427
|
+
_result = response['data']['create_tweet']['tweet_results']
|
|
1428
|
+
tweet = tweet_from_data(self, _result)
|
|
1429
|
+
if tweet is None:
|
|
1430
|
+
# x.com can answer without an `errors` block and without a parseable
|
|
1431
|
+
# tweet — a tombstone, or a partially-populated result. Returning None
|
|
1432
|
+
# from a create call loses the one thing the caller needs, so name the
|
|
1433
|
+
# shape that came back instead.
|
|
1434
|
+
typename = None
|
|
1435
|
+
node = _result.get('result') if isinstance(_result, dict) else None
|
|
1436
|
+
if isinstance(node, dict):
|
|
1437
|
+
typename = node.get('__typename')
|
|
1438
|
+
detail = f' ({typename})' if typename else ''
|
|
1439
|
+
raise CouldNotTweet(
|
|
1440
|
+
f'x.com returned no tweet for this create request{detail}.'
|
|
1441
|
+
)
|
|
1442
|
+
return tweet
|
|
1443
|
+
|
|
1444
|
+
async def add_content_disclosure(
|
|
1445
|
+
self,
|
|
1446
|
+
tweet_id: str,
|
|
1447
|
+
paid_partnership: bool = False,
|
|
1448
|
+
made_with_ai: bool = False
|
|
1449
|
+
) -> dict:
|
|
1450
|
+
"""
|
|
1451
|
+
Labels an existing post as a paid partnership and/or AI-generated content.
|
|
1452
|
+
|
|
1453
|
+
The composer sends the same object inside :func:`create_tweet`; this is the
|
|
1454
|
+
separate mutation behind a post's "Add/remove content disclosure" menu item.
|
|
1455
|
+
|
|
1456
|
+
Parameters
|
|
1457
|
+
----------
|
|
1458
|
+
tweet_id : :class:`str`
|
|
1459
|
+
ID of the post to label.
|
|
1460
|
+
paid_partnership : :class:`bool`, default=False
|
|
1461
|
+
Mark the post as promoting a brand or business.
|
|
1462
|
+
made_with_ai : :class:`bool`, default=False
|
|
1463
|
+
Mark the post as containing synthetically generated content.
|
|
1464
|
+
|
|
1465
|
+
Returns
|
|
1466
|
+
-------
|
|
1467
|
+
:class:`dict`
|
|
1468
|
+
The GraphQL response data.
|
|
1469
|
+
|
|
1470
|
+
Examples
|
|
1471
|
+
--------
|
|
1472
|
+
>>> await client.add_content_disclosure('0000000000', made_with_ai=True)
|
|
1473
|
+
"""
|
|
1474
|
+
content_disclosure = build_content_disclosure(paid_partnership, made_with_ai)
|
|
1475
|
+
if content_disclosure is None:
|
|
1476
|
+
raise ValueError(
|
|
1477
|
+
'Nothing to disclose: pass paid_partnership=True and/or made_with_ai=True.'
|
|
1478
|
+
)
|
|
1479
|
+
response, _ = await self.gql.add_content_disclosure(tweet_id, content_disclosure)
|
|
1480
|
+
if 'errors' in response:
|
|
1481
|
+
raise_exceptions_from_response(response['errors'])
|
|
1482
|
+
return response
|
|
1483
|
+
|
|
1484
|
+
async def delete_content_disclosure(self, tweet_id: str) -> dict:
|
|
1485
|
+
"""
|
|
1486
|
+
Removes the content disclosure from a post.
|
|
1487
|
+
|
|
1488
|
+
Parameters
|
|
1489
|
+
----------
|
|
1490
|
+
tweet_id : :class:`str`
|
|
1491
|
+
ID of the post to clear.
|
|
1492
|
+
|
|
1493
|
+
Returns
|
|
1494
|
+
-------
|
|
1495
|
+
:class:`dict`
|
|
1496
|
+
The GraphQL response data.
|
|
1497
|
+
|
|
1498
|
+
Examples
|
|
1499
|
+
--------
|
|
1500
|
+
>>> await client.delete_content_disclosure('0000000000')
|
|
1501
|
+
"""
|
|
1502
|
+
response, _ = await self.gql.delete_content_disclosure(tweet_id)
|
|
1503
|
+
if 'errors' in response:
|
|
1504
|
+
raise_exceptions_from_response(response['errors'])
|
|
1505
|
+
return response
|
|
1506
|
+
|
|
1507
|
+
async def create_scheduled_tweet(
|
|
1508
|
+
self,
|
|
1509
|
+
scheduled_at: int,
|
|
1510
|
+
text: str = '',
|
|
1511
|
+
media_ids: list[str] | None = None,
|
|
1512
|
+
) -> str:
|
|
1513
|
+
"""
|
|
1514
|
+
Schedules a tweet to be posted at a specified timestamp.
|
|
1515
|
+
|
|
1516
|
+
Parameters
|
|
1517
|
+
----------
|
|
1518
|
+
scheduled_at : :class:`int`
|
|
1519
|
+
The timestamp when the tweet should be scheduled for posting.
|
|
1520
|
+
text : :class:`str`, default=''
|
|
1521
|
+
The text content of the tweet, by default an empty string.
|
|
1522
|
+
media_ids : list[:class:`str`], default=None
|
|
1523
|
+
A list of media IDs to be attached to the tweet, by default None.
|
|
1524
|
+
|
|
1525
|
+
Returns
|
|
1526
|
+
-------
|
|
1527
|
+
:class:`str`
|
|
1528
|
+
The ID of the scheduled tweet.
|
|
1529
|
+
|
|
1530
|
+
Examples
|
|
1531
|
+
--------
|
|
1532
|
+
Create a tweet with media:
|
|
1533
|
+
|
|
1534
|
+
>>> scheduled_time = int(time.time()) + 3600 # One hour from now
|
|
1535
|
+
>>> tweet_text = 'Example text'
|
|
1536
|
+
>>> media_ids = [
|
|
1537
|
+
... await client.upload_media('image1.png'),
|
|
1538
|
+
... await client.upload_media('image2.png')
|
|
1539
|
+
... ]
|
|
1540
|
+
>>> await client.create_scheduled_tweet(
|
|
1541
|
+
... scheduled_time
|
|
1542
|
+
... tweet_text,
|
|
1543
|
+
... media_ids=media_ids
|
|
1544
|
+
... )
|
|
1545
|
+
"""
|
|
1546
|
+
response, _ = await self.gql.create_scheduled_tweet(scheduled_at, text, media_ids)
|
|
1547
|
+
return response['data']['tweet']['rest_id']
|
|
1548
|
+
|
|
1549
|
+
async def delete_tweet(self, tweet_id: str) -> Response:
|
|
1550
|
+
"""Deletes a tweet.
|
|
1551
|
+
|
|
1552
|
+
Parameters
|
|
1553
|
+
----------
|
|
1554
|
+
tweet_id : :class:`str`
|
|
1555
|
+
ID of the tweet to be deleted.
|
|
1556
|
+
|
|
1557
|
+
Returns
|
|
1558
|
+
-------
|
|
1559
|
+
:class:`httpx.Response`
|
|
1560
|
+
Response returned from twitter api.
|
|
1561
|
+
|
|
1562
|
+
Examples
|
|
1563
|
+
--------
|
|
1564
|
+
>>> tweet_id = '0000000000'
|
|
1565
|
+
>>> await delete_tweet(tweet_id)
|
|
1566
|
+
"""
|
|
1567
|
+
_, response = await self.gql.delete_tweet(tweet_id)
|
|
1568
|
+
return response
|
|
1569
|
+
|
|
1570
|
+
async def get_user_by_screen_name(self, screen_name: str) -> User:
|
|
1571
|
+
"""
|
|
1572
|
+
Fetches a user by screen name.
|
|
1573
|
+
|
|
1574
|
+
Parameter
|
|
1575
|
+
---------
|
|
1576
|
+
screen_name : :class:`str`
|
|
1577
|
+
The screen name of the Twitter user.
|
|
1578
|
+
|
|
1579
|
+
Returns
|
|
1580
|
+
-------
|
|
1581
|
+
:class:`User`
|
|
1582
|
+
An instance of the User class representing the
|
|
1583
|
+
Twitter user.
|
|
1584
|
+
|
|
1585
|
+
Examples
|
|
1586
|
+
--------
|
|
1587
|
+
>>> target_screen_name = 'example_user'
|
|
1588
|
+
>>> user = await client.get_user_by_name(target_screen_name)
|
|
1589
|
+
>>> print(user)
|
|
1590
|
+
<User id="...">
|
|
1591
|
+
"""
|
|
1592
|
+
response, _ = await self.gql.user_by_screen_name(screen_name)
|
|
1593
|
+
|
|
1594
|
+
if 'user' not in response['data']:
|
|
1595
|
+
raise UserNotFound('The user does not exist.')
|
|
1596
|
+
user_data = response['data']['user']['result']
|
|
1597
|
+
if user_data.get('__typename') == 'UserUnavailable':
|
|
1598
|
+
raise UserUnavailable(user_data.get('message'))
|
|
1599
|
+
|
|
1600
|
+
return User(self, user_data)
|
|
1601
|
+
|
|
1602
|
+
async def get_user_by_id(self, user_id: str) -> User:
|
|
1603
|
+
"""
|
|
1604
|
+
Fetches a user by ID
|
|
1605
|
+
|
|
1606
|
+
Parameter
|
|
1607
|
+
---------
|
|
1608
|
+
user_id : :class:`str`
|
|
1609
|
+
The ID of the Twitter user.
|
|
1610
|
+
|
|
1611
|
+
Returns
|
|
1612
|
+
-------
|
|
1613
|
+
:class:`User`
|
|
1614
|
+
An instance of the User class representing the
|
|
1615
|
+
Twitter user.
|
|
1616
|
+
|
|
1617
|
+
Examples
|
|
1618
|
+
--------
|
|
1619
|
+
>>> target_screen_name = '000000000'
|
|
1620
|
+
>>> user = await client.get_user_by_id(target_screen_name)
|
|
1621
|
+
>>> print(user)
|
|
1622
|
+
<User id="000000000">
|
|
1623
|
+
"""
|
|
1624
|
+
response, _ = await self.gql.user_by_rest_id(user_id)
|
|
1625
|
+
if 'result' not in response['data']['user']:
|
|
1626
|
+
raise TwitterException(f'Invalid user id: {user_id}')
|
|
1627
|
+
user_data = response['data']['user']['result']
|
|
1628
|
+
if user_data.get('__typename') == 'UserUnavailable':
|
|
1629
|
+
raise UserUnavailable(user_data.get('message'))
|
|
1630
|
+
return User(self, user_data)
|
|
1631
|
+
|
|
1632
|
+
async def reverse_geocode(
|
|
1633
|
+
self, lat: float, long: float, accuracy: str | float | None = None,
|
|
1634
|
+
granularity: str | None = None, max_results: int | None = None
|
|
1635
|
+
) -> list[Place]:
|
|
1636
|
+
"""
|
|
1637
|
+
Given a latitude and a longitude, searches for up to 20 places that
|
|
1638
|
+
|
|
1639
|
+
Parameters
|
|
1640
|
+
----------
|
|
1641
|
+
lat : :class:`float`
|
|
1642
|
+
The latitude to search around.
|
|
1643
|
+
long : :class:`float`
|
|
1644
|
+
The longitude to search around.
|
|
1645
|
+
accuracy : :class:`str` | :class:`float` None, default=None
|
|
1646
|
+
A hint on the "region" in which to search.
|
|
1647
|
+
granularity : :class:`str` | None, default=None
|
|
1648
|
+
This is the minimal granularity of place types to return and must
|
|
1649
|
+
be one of: `neighborhood`, `city`, `admin` or `country`.
|
|
1650
|
+
max_results : :class:`int` | None, default=None
|
|
1651
|
+
A hint as to the number of results to return.
|
|
1652
|
+
|
|
1653
|
+
Returns
|
|
1654
|
+
-------
|
|
1655
|
+
list[:class:`.Place`]
|
|
1656
|
+
"""
|
|
1657
|
+
response, _ = await self.v11.reverse_geocode(lat, long, accuracy, granularity, max_results)
|
|
1658
|
+
return _places_from_response(self, response)
|
|
1659
|
+
|
|
1660
|
+
async def search_geo(
|
|
1661
|
+
self, lat: float | None = None, long: float | None = None,
|
|
1662
|
+
query: str | None = None, ip: str | None = None,
|
|
1663
|
+
granularity: str | None = None, max_results: int | None = None
|
|
1664
|
+
) -> list[Place]:
|
|
1665
|
+
"""
|
|
1666
|
+
Search for places that can be attached to a Tweet via POST
|
|
1667
|
+
statuses/update.
|
|
1668
|
+
|
|
1669
|
+
Parameters
|
|
1670
|
+
----------
|
|
1671
|
+
lat : :class:`float` | None
|
|
1672
|
+
The latitude to search around.
|
|
1673
|
+
long : :class:`float` | None
|
|
1674
|
+
The longitude to search around.
|
|
1675
|
+
query : :class:`str` | None
|
|
1676
|
+
Free-form text to match against while executing a geo-based query,
|
|
1677
|
+
best suited for finding nearby locations by name.
|
|
1678
|
+
Remember to URL encode the query.
|
|
1679
|
+
ip : :class:`str` | None
|
|
1680
|
+
An IP address. Used when attempting to
|
|
1681
|
+
fix geolocation based off of the user's IP address.
|
|
1682
|
+
granularity : :class:`str` | None
|
|
1683
|
+
This is the minimal granularity of place types to return and must
|
|
1684
|
+
be one of: `neighborhood`, `city`, `admin` or `country`.
|
|
1685
|
+
max_results : :class:`int` | None
|
|
1686
|
+
A hint as to the number of results to return.
|
|
1687
|
+
|
|
1688
|
+
Returns
|
|
1689
|
+
-------
|
|
1690
|
+
list[:class:`.Place`]
|
|
1691
|
+
"""
|
|
1692
|
+
response, _ = await self.v11.search_geo(lat, long, query, ip, granularity, max_results)
|
|
1693
|
+
return _places_from_response(self, response)
|
|
1694
|
+
|
|
1695
|
+
async def get_place(self, id: str) -> Place:
|
|
1696
|
+
"""
|
|
1697
|
+
Parameters
|
|
1698
|
+
----------
|
|
1699
|
+
id : :class:`str`
|
|
1700
|
+
The ID of the place.
|
|
1701
|
+
|
|
1702
|
+
Returns
|
|
1703
|
+
-------
|
|
1704
|
+
:class:`.Place`
|
|
1705
|
+
"""
|
|
1706
|
+
response, _ = await self.v11.get_place(id)
|
|
1707
|
+
return Place(self, response)
|
|
1708
|
+
|
|
1709
|
+
async def _get_more_replies(
|
|
1710
|
+
self, tweet_id: str, cursor: str
|
|
1711
|
+
) -> Result[Tweet]:
|
|
1712
|
+
response, _ = await self.gql.tweet_detail(tweet_id, cursor)
|
|
1713
|
+
entries = find_dict(response, 'entries', find_one=True)[0]
|
|
1714
|
+
|
|
1715
|
+
results = []
|
|
1716
|
+
for entry in entries:
|
|
1717
|
+
if entry['entryId'].startswith(('cursor', 'label')):
|
|
1718
|
+
continue
|
|
1719
|
+
tweet = tweet_from_data(self, entry)
|
|
1720
|
+
if tweet is not None:
|
|
1721
|
+
results.append(tweet)
|
|
1722
|
+
|
|
1723
|
+
if entries and entries[-1]['entryId'].startswith('cursor'):
|
|
1724
|
+
next_cursor = timeline_cursor_value(entries[-1])
|
|
1725
|
+
_fetch_next_result = (
|
|
1726
|
+
partial(self._get_more_replies, tweet_id, next_cursor)
|
|
1727
|
+
if next_cursor else None
|
|
1728
|
+
)
|
|
1729
|
+
else:
|
|
1730
|
+
next_cursor = None
|
|
1731
|
+
_fetch_next_result = None
|
|
1732
|
+
|
|
1733
|
+
return Result(
|
|
1734
|
+
results,
|
|
1735
|
+
_fetch_next_result,
|
|
1736
|
+
next_cursor
|
|
1737
|
+
)
|
|
1738
|
+
|
|
1739
|
+
async def _show_more_replies(
|
|
1740
|
+
self, tweet_id: str, cursor: str
|
|
1741
|
+
) -> Result[Tweet]:
|
|
1742
|
+
response, _ = await self.gql.tweet_detail(tweet_id, cursor)
|
|
1743
|
+
items = find_dict(response, 'moduleItems', find_one=True)[0]
|
|
1744
|
+
results = []
|
|
1745
|
+
for item in items:
|
|
1746
|
+
if 'tweet' not in item['entryId']:
|
|
1747
|
+
continue
|
|
1748
|
+
tweet = tweet_from_data(self, item)
|
|
1749
|
+
if tweet is not None:
|
|
1750
|
+
results.append(tweet)
|
|
1751
|
+
return Result(results)
|
|
1752
|
+
|
|
1753
|
+
async def get_tweet_by_id(
|
|
1754
|
+
self, tweet_id: str, cursor: str | None = None
|
|
1755
|
+
) -> Tweet:
|
|
1756
|
+
"""
|
|
1757
|
+
Fetches a tweet by tweet ID.
|
|
1758
|
+
|
|
1759
|
+
Parameters
|
|
1760
|
+
----------
|
|
1761
|
+
tweet_id : :class:`str`
|
|
1762
|
+
The ID of the tweet.
|
|
1763
|
+
|
|
1764
|
+
Returns
|
|
1765
|
+
-------
|
|
1766
|
+
:class:`Tweet`
|
|
1767
|
+
A Tweet object representing the fetched tweet.
|
|
1768
|
+
|
|
1769
|
+
Examples
|
|
1770
|
+
--------
|
|
1771
|
+
>>> target_tweet_id = '...'
|
|
1772
|
+
>>> tweet = client.get_tweet_by_id(target_tweet_id)
|
|
1773
|
+
>>> print(tweet)
|
|
1774
|
+
<Tweet id="...">
|
|
1775
|
+
"""
|
|
1776
|
+
response, _ = await self.gql.tweet_detail(tweet_id, cursor)
|
|
1777
|
+
|
|
1778
|
+
if 'errors' in response:
|
|
1779
|
+
raise TweetNotAvailable(response['errors'][0]['message'])
|
|
1780
|
+
|
|
1781
|
+
entries = find_dict(response, 'entries', find_one=True)[0]
|
|
1782
|
+
reply_to = []
|
|
1783
|
+
replies_list = []
|
|
1784
|
+
related_tweets = []
|
|
1785
|
+
tweet = None
|
|
1786
|
+
|
|
1787
|
+
for entry in entries:
|
|
1788
|
+
if entry['entryId'].startswith('cursor'):
|
|
1789
|
+
continue
|
|
1790
|
+
tweet_object = tweet_from_data(self, entry)
|
|
1791
|
+
if tweet_object is None:
|
|
1792
|
+
continue
|
|
1793
|
+
|
|
1794
|
+
if entry['entryId'].startswith('tweetdetailrelatedtweets'):
|
|
1795
|
+
related_tweets.append(tweet_object)
|
|
1796
|
+
continue
|
|
1797
|
+
|
|
1798
|
+
if entry['entryId'] == f'tweet-{tweet_id}':
|
|
1799
|
+
tweet = tweet_object
|
|
1800
|
+
else:
|
|
1801
|
+
if tweet is None:
|
|
1802
|
+
reply_to.append(tweet_object)
|
|
1803
|
+
else:
|
|
1804
|
+
replies = []
|
|
1805
|
+
sr_cursor = None
|
|
1806
|
+
show_replies = None
|
|
1807
|
+
|
|
1808
|
+
for reply in entry['content']['items'][1:]:
|
|
1809
|
+
if 'tweetcomposer' in reply['entryId']:
|
|
1810
|
+
continue
|
|
1811
|
+
if 'tweet' in reply.get('entryId'):
|
|
1812
|
+
rpl = tweet_from_data(self, reply)
|
|
1813
|
+
if rpl is None:
|
|
1814
|
+
continue
|
|
1815
|
+
replies.append(rpl)
|
|
1816
|
+
if 'cursor' in reply.get('entryId'):
|
|
1817
|
+
sr_cursor = timeline_cursor_value(reply)
|
|
1818
|
+
show_replies = partial(
|
|
1819
|
+
self._show_more_replies,
|
|
1820
|
+
tweet_id,
|
|
1821
|
+
sr_cursor
|
|
1822
|
+
) if sr_cursor else None
|
|
1823
|
+
tweet_object.replies = Result(
|
|
1824
|
+
replies,
|
|
1825
|
+
show_replies,
|
|
1826
|
+
sr_cursor
|
|
1827
|
+
)
|
|
1828
|
+
replies_list.append(tweet_object)
|
|
1829
|
+
|
|
1830
|
+
display_type = find_dict(entry, 'tweetDisplayType', True)
|
|
1831
|
+
if display_type and display_type[0] == 'SelfThread':
|
|
1832
|
+
tweet.thread = [tweet_object, *replies]
|
|
1833
|
+
|
|
1834
|
+
if entries and entries[-1]['entryId'].startswith('cursor'):
|
|
1835
|
+
# if has more replies
|
|
1836
|
+
reply_next_cursor = timeline_cursor_value(entries[-1])
|
|
1837
|
+
_fetch_more_replies = (
|
|
1838
|
+
partial(self._get_more_replies, tweet_id, reply_next_cursor)
|
|
1839
|
+
if reply_next_cursor else None
|
|
1840
|
+
)
|
|
1841
|
+
else:
|
|
1842
|
+
reply_next_cursor = None
|
|
1843
|
+
_fetch_more_replies = None
|
|
1844
|
+
|
|
1845
|
+
tweet.replies = Result(
|
|
1846
|
+
replies_list,
|
|
1847
|
+
_fetch_more_replies,
|
|
1848
|
+
reply_next_cursor
|
|
1849
|
+
)
|
|
1850
|
+
tweet.reply_to = reply_to
|
|
1851
|
+
tweet.related_tweets = related_tweets
|
|
1852
|
+
|
|
1853
|
+
return tweet
|
|
1854
|
+
|
|
1855
|
+
async def get_tweets_by_ids(self, ids: list[str]) -> list[Tweet]:
|
|
1856
|
+
"""
|
|
1857
|
+
Retrieve multiple tweets by IDs.
|
|
1858
|
+
|
|
1859
|
+
Parameters
|
|
1860
|
+
----------
|
|
1861
|
+
ids : list[:class:`str`]
|
|
1862
|
+
A list of tweet IDs to retrieve.
|
|
1863
|
+
|
|
1864
|
+
Returns
|
|
1865
|
+
-------
|
|
1866
|
+
list[:class:`Tweet`]
|
|
1867
|
+
List of tweets.
|
|
1868
|
+
|
|
1869
|
+
Examples
|
|
1870
|
+
--------
|
|
1871
|
+
>>> tweet_ids = ['1111111111', '1111111112', '111111113']
|
|
1872
|
+
>>> tweets = await client.get_tweets_by_ids(tweet_ids)
|
|
1873
|
+
>>> print(tweets)
|
|
1874
|
+
[<Tweet id="1111111111">, <Tweet id="1111111112">, <Tweet id="111111113">]
|
|
1875
|
+
"""
|
|
1876
|
+
response, _ = await self.gql.tweet_results_by_rest_ids(ids)
|
|
1877
|
+
tweet_results = response['data']['tweetResult']
|
|
1878
|
+
results = []
|
|
1879
|
+
for tweet_result in tweet_results:
|
|
1880
|
+
results.append(tweet_from_data(self, tweet_result))
|
|
1881
|
+
return results
|
|
1882
|
+
|
|
1883
|
+
async def get_scheduled_tweets(self) -> list[ScheduledTweet]:
|
|
1884
|
+
"""
|
|
1885
|
+
Retrieves scheduled tweets.
|
|
1886
|
+
|
|
1887
|
+
Returns
|
|
1888
|
+
-------
|
|
1889
|
+
list[:class:`ScheduledTweet`]
|
|
1890
|
+
List of ScheduledTweet objects representing the scheduled tweets.
|
|
1891
|
+
"""
|
|
1892
|
+
response, _ = await self.gql.fetch_scheduled_tweets()
|
|
1893
|
+
tweets = find_dict(response, 'scheduled_tweet_list', find_one=True)[0]
|
|
1894
|
+
return [ScheduledTweet(self, tweet) for tweet in tweets]
|
|
1895
|
+
|
|
1896
|
+
async def delete_scheduled_tweet(self, tweet_id: str) -> Response:
|
|
1897
|
+
"""
|
|
1898
|
+
Delete a scheduled tweet.
|
|
1899
|
+
|
|
1900
|
+
Parameters
|
|
1901
|
+
----------
|
|
1902
|
+
tweet_id : :class:`str`
|
|
1903
|
+
The ID of the scheduled tweet to delete.
|
|
1904
|
+
|
|
1905
|
+
Returns
|
|
1906
|
+
-------
|
|
1907
|
+
:class:`httpx.Response`
|
|
1908
|
+
Response returned from twitter api.
|
|
1909
|
+
"""
|
|
1910
|
+
_, response = await self.gql.delete_scheduled_tweet(tweet_id)
|
|
1911
|
+
return response
|
|
1912
|
+
|
|
1913
|
+
async def _get_tweet_engagements(
|
|
1914
|
+
self, tweet_id: str, count: int, cursor: str, f
|
|
1915
|
+
) -> Result[User]:
|
|
1916
|
+
"""
|
|
1917
|
+
Base function to get tweet engagements.
|
|
1918
|
+
type0: retweeters
|
|
1919
|
+
type1: favoriters
|
|
1920
|
+
"""
|
|
1921
|
+
response, _ = await f(tweet_id, count, cursor)
|
|
1922
|
+
items_ = find_dict(response, 'entries', True)
|
|
1923
|
+
if not items_:
|
|
1924
|
+
return Result([])
|
|
1925
|
+
items = items_[0]
|
|
1926
|
+
next_cursor = items[-1]['content']['value']
|
|
1927
|
+
previous_cursor = items[-2]['content']['value']
|
|
1928
|
+
|
|
1929
|
+
results = []
|
|
1930
|
+
for item in items:
|
|
1931
|
+
if not item['entryId'].startswith('user'):
|
|
1932
|
+
continue
|
|
1933
|
+
user_info_ = find_dict(item, 'result', True)
|
|
1934
|
+
if not user_info_:
|
|
1935
|
+
continue
|
|
1936
|
+
user_info = user_info_[0]
|
|
1937
|
+
results.append(User(self, user_info))
|
|
1938
|
+
|
|
1939
|
+
return Result(
|
|
1940
|
+
results,
|
|
1941
|
+
partial(self._get_tweet_engagements, tweet_id, count, next_cursor, f),
|
|
1942
|
+
next_cursor,
|
|
1943
|
+
partial(self._get_tweet_engagements, tweet_id, count, previous_cursor, f),
|
|
1944
|
+
previous_cursor
|
|
1945
|
+
)
|
|
1946
|
+
|
|
1947
|
+
async def get_retweeters(
|
|
1948
|
+
self, tweet_id: str, count: int = 40, cursor: str | None = None
|
|
1949
|
+
) -> Result[User]:
|
|
1950
|
+
"""
|
|
1951
|
+
Retrieve users who retweeted a specific tweet.
|
|
1952
|
+
|
|
1953
|
+
Parameters
|
|
1954
|
+
----------
|
|
1955
|
+
tweet_id : :class:`str`
|
|
1956
|
+
The ID of the tweet.
|
|
1957
|
+
count : :class:`int`, default=40
|
|
1958
|
+
The maximum number of users to retrieve.
|
|
1959
|
+
cursor : :class:`str`, default=None
|
|
1960
|
+
A string indicating the position of the cursor for pagination.
|
|
1961
|
+
|
|
1962
|
+
Returns
|
|
1963
|
+
-------
|
|
1964
|
+
Result[:class:`User`]
|
|
1965
|
+
A list of users who retweeted the tweet.
|
|
1966
|
+
|
|
1967
|
+
Examples
|
|
1968
|
+
--------
|
|
1969
|
+
>>> tweet_id = '...'
|
|
1970
|
+
>>> retweeters = client.get_retweeters(tweet_id)
|
|
1971
|
+
>>> print(retweeters)
|
|
1972
|
+
[<User id="...">, <User id="...">, ..., <User id="...">]
|
|
1973
|
+
|
|
1974
|
+
>>> more_retweeters = retweeters.next() # Retrieve more retweeters.
|
|
1975
|
+
>>> print(more_retweeters)
|
|
1976
|
+
[<User id="...">, <User id="...">, ..., <User id="...">]
|
|
1977
|
+
"""
|
|
1978
|
+
return await self._get_tweet_engagements(tweet_id, count, cursor, self.gql.retweeters)
|
|
1979
|
+
|
|
1980
|
+
async def get_favoriters(
|
|
1981
|
+
self, tweet_id: str, count: int = 40, cursor: str | None = None
|
|
1982
|
+
) -> Result[User]:
|
|
1983
|
+
"""
|
|
1984
|
+
Retrieve users who favorited a specific tweet.
|
|
1985
|
+
|
|
1986
|
+
Parameters
|
|
1987
|
+
----------
|
|
1988
|
+
tweet_id : :class:`str`
|
|
1989
|
+
The ID of the tweet.
|
|
1990
|
+
count : int, default=40
|
|
1991
|
+
The maximum number of users to retrieve.
|
|
1992
|
+
cursor : :class:`str`, default=None
|
|
1993
|
+
A string indicating the position of the cursor for pagination.
|
|
1994
|
+
|
|
1995
|
+
Returns
|
|
1996
|
+
-------
|
|
1997
|
+
Result[:class:`User`]
|
|
1998
|
+
A list of users who favorited the tweet.
|
|
1999
|
+
|
|
2000
|
+
Examples
|
|
2001
|
+
--------
|
|
2002
|
+
>>> tweet_id = '...'
|
|
2003
|
+
>>> favoriters = await client.get_favoriters(tweet_id)
|
|
2004
|
+
>>> print(favoriters)
|
|
2005
|
+
[<User id="...">, <User id="...">, ..., <User id="...">]
|
|
2006
|
+
|
|
2007
|
+
>>> # Retrieve more favoriters.
|
|
2008
|
+
>>> more_favoriters = await favoriters.next()
|
|
2009
|
+
>>> print(more_favoriters)
|
|
2010
|
+
[<User id="...">, <User id="...">, ..., <User id="...">]
|
|
2011
|
+
"""
|
|
2012
|
+
return await self._get_tweet_engagements(tweet_id, count, cursor, self.gql.favoriters)
|
|
2013
|
+
|
|
2014
|
+
async def get_community_note(self, note_id: str) -> CommunityNote:
|
|
2015
|
+
"""
|
|
2016
|
+
Fetches a community note by ID.
|
|
2017
|
+
|
|
2018
|
+
Parameters
|
|
2019
|
+
----------
|
|
2020
|
+
note_id : :class:`str`
|
|
2021
|
+
The ID of the community note.
|
|
2022
|
+
|
|
2023
|
+
Returns
|
|
2024
|
+
-------
|
|
2025
|
+
:class:`CommunityNote`
|
|
2026
|
+
A CommunityNote object representing the fetched community note.
|
|
2027
|
+
|
|
2028
|
+
Raises
|
|
2029
|
+
------
|
|
2030
|
+
:exc:`TwitterException`
|
|
2031
|
+
Invalid note ID.
|
|
2032
|
+
|
|
2033
|
+
Examples
|
|
2034
|
+
--------
|
|
2035
|
+
>>> note_id = '...'
|
|
2036
|
+
>>> note = client.get_community_note(note_id)
|
|
2037
|
+
>>> print(note)
|
|
2038
|
+
<CommunityNote id="...">
|
|
2039
|
+
"""
|
|
2040
|
+
response, _ = await self.gql.bird_watch_one_note(note_id)
|
|
2041
|
+
note_data = response['data']['birdwatch_note_by_rest_id']
|
|
2042
|
+
if 'data_v1' not in note_data:
|
|
2043
|
+
raise TwitterException(f'Invalid note id: {note_id}')
|
|
2044
|
+
return CommunityNote(self, note_data)
|
|
2045
|
+
|
|
2046
|
+
async def get_user_tweets(
|
|
2047
|
+
self,
|
|
2048
|
+
user_id: str,
|
|
2049
|
+
tweet_type: Literal['Tweets', 'Replies', 'Media', 'Likes'],
|
|
2050
|
+
count: int = 40,
|
|
2051
|
+
cursor: str | None = None
|
|
2052
|
+
) -> Result[Tweet]:
|
|
2053
|
+
"""
|
|
2054
|
+
Fetches tweets from a specific user's timeline.
|
|
2055
|
+
|
|
2056
|
+
Parameters
|
|
2057
|
+
----------
|
|
2058
|
+
user_id : :class:`str`
|
|
2059
|
+
The ID of the Twitter user whose tweets to retrieve.
|
|
2060
|
+
To get the user id from the screen name, you can use
|
|
2061
|
+
`get_user_by_screen_name` method.
|
|
2062
|
+
tweet_type : {'Tweets', 'Replies', 'Media', 'Likes'}
|
|
2063
|
+
The type of tweets to retrieve.
|
|
2064
|
+
count : :class:`int`, default=40
|
|
2065
|
+
The number of tweets to retrieve.
|
|
2066
|
+
cursor : :class:`str`, default=None
|
|
2067
|
+
The cursor for fetching the next set of results.
|
|
2068
|
+
|
|
2069
|
+
Returns
|
|
2070
|
+
-------
|
|
2071
|
+
Result[:class:`Tweet`]
|
|
2072
|
+
A Result object containing a list of `Tweet` objects.
|
|
2073
|
+
|
|
2074
|
+
Examples
|
|
2075
|
+
--------
|
|
2076
|
+
>>> user_id = '...'
|
|
2077
|
+
|
|
2078
|
+
If you only have the screen name, you can get the user id as follows:
|
|
2079
|
+
|
|
2080
|
+
>>> screen_name = 'example_user'
|
|
2081
|
+
>>> user = client.get_user_by_screen_name(screen_name)
|
|
2082
|
+
>>> user_id = user.id
|
|
2083
|
+
|
|
2084
|
+
>>> tweets = await client.get_user_tweets(user_id, 'Tweets', count=20)
|
|
2085
|
+
>>> for tweet in tweets:
|
|
2086
|
+
... print(tweet)
|
|
2087
|
+
<Tweet id="...">
|
|
2088
|
+
<Tweet id="...">
|
|
2089
|
+
...
|
|
2090
|
+
...
|
|
2091
|
+
|
|
2092
|
+
>>> more_tweets = await tweets.next() # Retrieve more tweets
|
|
2093
|
+
>>> for tweet in more_tweets:
|
|
2094
|
+
... print(tweet)
|
|
2095
|
+
<Tweet id="...">
|
|
2096
|
+
<Tweet id="...">
|
|
2097
|
+
...
|
|
2098
|
+
...
|
|
2099
|
+
|
|
2100
|
+
>>> # Retrieve previous tweets
|
|
2101
|
+
>>> previous_tweets = await tweets.previous()
|
|
2102
|
+
|
|
2103
|
+
See Also
|
|
2104
|
+
--------
|
|
2105
|
+
.get_user_by_screen_name
|
|
2106
|
+
"""
|
|
2107
|
+
tweet_type = tweet_type.capitalize()
|
|
2108
|
+
f = {
|
|
2109
|
+
'Tweets': self.gql.user_tweets,
|
|
2110
|
+
'Replies': self.gql.user_tweets_and_replies,
|
|
2111
|
+
'Media': self.gql.user_media,
|
|
2112
|
+
'Likes': self.gql.user_likes,
|
|
2113
|
+
}[tweet_type]
|
|
2114
|
+
response, _ = await f(user_id, count, cursor)
|
|
2115
|
+
|
|
2116
|
+
instructions_ = find_dict(response, 'instructions', True)
|
|
2117
|
+
if not instructions_:
|
|
2118
|
+
return Result([])
|
|
2119
|
+
instructions = instructions_[0]
|
|
2120
|
+
|
|
2121
|
+
items = instructions[-1]['entries']
|
|
2122
|
+
next_cursor = items[-1]['content']['value']
|
|
2123
|
+
previous_cursor = items[-2]['content']['value']
|
|
2124
|
+
|
|
2125
|
+
if tweet_type == 'Media':
|
|
2126
|
+
if cursor is None:
|
|
2127
|
+
items = items[0]['content']['items']
|
|
2128
|
+
else:
|
|
2129
|
+
items = instructions[0]['moduleItems']
|
|
2130
|
+
|
|
2131
|
+
results = []
|
|
2132
|
+
for item in items:
|
|
2133
|
+
entry_id = item['entryId']
|
|
2134
|
+
|
|
2135
|
+
if not entry_id.startswith(('tweet', 'profile-conversation', 'profile-grid')):
|
|
2136
|
+
continue
|
|
2137
|
+
|
|
2138
|
+
if entry_id.startswith('profile-conversation'):
|
|
2139
|
+
tweets = item['content']['items']
|
|
2140
|
+
replies = []
|
|
2141
|
+
for reply in tweets[1:]:
|
|
2142
|
+
tweet_object = tweet_from_data(self, reply)
|
|
2143
|
+
if tweet_object is None:
|
|
2144
|
+
continue
|
|
2145
|
+
replies.append(tweet_object)
|
|
2146
|
+
item = tweets[0]
|
|
2147
|
+
else:
|
|
2148
|
+
replies = None
|
|
2149
|
+
|
|
2150
|
+
tweet = tweet_from_data(self, item)
|
|
2151
|
+
if tweet is None:
|
|
2152
|
+
continue
|
|
2153
|
+
tweet.replies = replies
|
|
2154
|
+
results.append(tweet)
|
|
2155
|
+
|
|
2156
|
+
return Result(
|
|
2157
|
+
results,
|
|
2158
|
+
partial(self.get_user_tweets, user_id, tweet_type, count, next_cursor),
|
|
2159
|
+
next_cursor,
|
|
2160
|
+
partial(self.get_user_tweets, user_id, tweet_type, count, previous_cursor),
|
|
2161
|
+
previous_cursor
|
|
2162
|
+
)
|
|
2163
|
+
|
|
2164
|
+
async def get_timeline(
|
|
2165
|
+
self,
|
|
2166
|
+
count: int = 20,
|
|
2167
|
+
seen_tweet_ids: list[str] | None = None,
|
|
2168
|
+
cursor: str | None = None
|
|
2169
|
+
) -> Result[Tweet]:
|
|
2170
|
+
"""
|
|
2171
|
+
Retrieves the timeline.
|
|
2172
|
+
Retrieves tweets from Home -> For You.
|
|
2173
|
+
|
|
2174
|
+
Parameters
|
|
2175
|
+
----------
|
|
2176
|
+
count : :class:`int`, default=20
|
|
2177
|
+
The number of tweets to retrieve.
|
|
2178
|
+
seen_tweet_ids : list[:class:`str`], default=None
|
|
2179
|
+
A list of tweet IDs that have been seen.
|
|
2180
|
+
cursor : :class:`str`, default=None
|
|
2181
|
+
A cursor for pagination.
|
|
2182
|
+
|
|
2183
|
+
Returns
|
|
2184
|
+
-------
|
|
2185
|
+
Result[:class:`Tweet`]
|
|
2186
|
+
A Result object containing a list of Tweet objects.
|
|
2187
|
+
|
|
2188
|
+
Example
|
|
2189
|
+
-------
|
|
2190
|
+
>>> tweets = await client.get_timeline()
|
|
2191
|
+
>>> for tweet in tweets:
|
|
2192
|
+
... print(tweet)
|
|
2193
|
+
<Tweet id="...">
|
|
2194
|
+
<Tweet id="...">
|
|
2195
|
+
...
|
|
2196
|
+
...
|
|
2197
|
+
>>> more_tweets = await tweets.next() # Retrieve more tweets
|
|
2198
|
+
>>> for tweet in more_tweets:
|
|
2199
|
+
... print(tweet)
|
|
2200
|
+
<Tweet id="...">
|
|
2201
|
+
<Tweet id="...">
|
|
2202
|
+
...
|
|
2203
|
+
...
|
|
2204
|
+
"""
|
|
2205
|
+
response, _ = await self.gql.home_timeline(count, seen_tweet_ids, cursor)
|
|
2206
|
+
items = find_dict(response, 'entries', find_one=True)[0]
|
|
2207
|
+
next_cursor = items[-1]['content']['value']
|
|
2208
|
+
results = []
|
|
2209
|
+
|
|
2210
|
+
for item in items:
|
|
2211
|
+
if 'itemContent' not in item['content']:
|
|
2212
|
+
continue
|
|
2213
|
+
tweet = tweet_from_data(self, item)
|
|
2214
|
+
if tweet is None:
|
|
2215
|
+
continue
|
|
2216
|
+
results.append(tweet)
|
|
2217
|
+
|
|
2218
|
+
return Result(
|
|
2219
|
+
results,
|
|
2220
|
+
partial(self.get_timeline, count, seen_tweet_ids, next_cursor),
|
|
2221
|
+
next_cursor
|
|
2222
|
+
)
|
|
2223
|
+
|
|
2224
|
+
async def get_latest_timeline(
|
|
2225
|
+
self,
|
|
2226
|
+
count: int = 20,
|
|
2227
|
+
seen_tweet_ids: list[str] | None = None,
|
|
2228
|
+
cursor: str | None = None
|
|
2229
|
+
) -> Result[Tweet]:
|
|
2230
|
+
"""
|
|
2231
|
+
Retrieves the timeline.
|
|
2232
|
+
Retrieves tweets from Home -> Following.
|
|
2233
|
+
|
|
2234
|
+
Parameters
|
|
2235
|
+
----------
|
|
2236
|
+
count : :class:`int`, default=20
|
|
2237
|
+
The number of tweets to retrieve.
|
|
2238
|
+
seen_tweet_ids : list[:class:`str`], default=None
|
|
2239
|
+
A list of tweet IDs that have been seen.
|
|
2240
|
+
cursor : :class:`str`, default=None
|
|
2241
|
+
A cursor for pagination.
|
|
2242
|
+
|
|
2243
|
+
Returns
|
|
2244
|
+
-------
|
|
2245
|
+
Result[:class:`Tweet`]
|
|
2246
|
+
A Result object containing a list of Tweet objects.
|
|
2247
|
+
|
|
2248
|
+
Example
|
|
2249
|
+
-------
|
|
2250
|
+
>>> tweets = await client.get_latest_timeline()
|
|
2251
|
+
>>> for tweet in tweets:
|
|
2252
|
+
... print(tweet)
|
|
2253
|
+
<Tweet id="...">
|
|
2254
|
+
<Tweet id="...">
|
|
2255
|
+
...
|
|
2256
|
+
...
|
|
2257
|
+
>>> more_tweets = await tweets.next() # Retrieve more tweets
|
|
2258
|
+
>>> for tweet in more_tweets:
|
|
2259
|
+
... print(tweet)
|
|
2260
|
+
<Tweet id="...">
|
|
2261
|
+
<Tweet id="...">
|
|
2262
|
+
...
|
|
2263
|
+
...
|
|
2264
|
+
"""
|
|
2265
|
+
response, _ = await self.gql.home_latest_timeline(count, seen_tweet_ids, cursor)
|
|
2266
|
+
items = find_dict(response, 'entries', find_one=True)[0]
|
|
2267
|
+
next_cursor = items[-1]['content']['value']
|
|
2268
|
+
results = []
|
|
2269
|
+
|
|
2270
|
+
for item in items:
|
|
2271
|
+
if 'itemContent' not in item['content']:
|
|
2272
|
+
continue
|
|
2273
|
+
tweet = tweet_from_data(self, item)
|
|
2274
|
+
if tweet is None:
|
|
2275
|
+
continue
|
|
2276
|
+
results.append(tweet)
|
|
2277
|
+
|
|
2278
|
+
return Result(
|
|
2279
|
+
results,
|
|
2280
|
+
partial(self.get_latest_timeline, count, seen_tweet_ids, next_cursor),
|
|
2281
|
+
next_cursor
|
|
2282
|
+
)
|
|
2283
|
+
|
|
2284
|
+
async def favorite_tweet(self, tweet_id: str) -> Response:
|
|
2285
|
+
"""
|
|
2286
|
+
Favorites a tweet.
|
|
2287
|
+
|
|
2288
|
+
Parameters
|
|
2289
|
+
----------
|
|
2290
|
+
tweet_id : :class:`str`
|
|
2291
|
+
The ID of the tweet to be liked.
|
|
2292
|
+
|
|
2293
|
+
Returns
|
|
2294
|
+
-------
|
|
2295
|
+
:class:`httpx.Response`
|
|
2296
|
+
Response returned from twitter api.
|
|
2297
|
+
|
|
2298
|
+
Examples
|
|
2299
|
+
--------
|
|
2300
|
+
>>> tweet_id = '...'
|
|
2301
|
+
>>> await client.favorite_tweet(tweet_id)
|
|
2302
|
+
|
|
2303
|
+
See Also
|
|
2304
|
+
--------
|
|
2305
|
+
.unfavorite_tweet
|
|
2306
|
+
"""
|
|
2307
|
+
_, response = await self.gql.favorite_tweet(tweet_id)
|
|
2308
|
+
return response
|
|
2309
|
+
|
|
2310
|
+
async def unfavorite_tweet(self, tweet_id: str) -> Response:
|
|
2311
|
+
"""
|
|
2312
|
+
Unfavorites a tweet.
|
|
2313
|
+
|
|
2314
|
+
Parameters
|
|
2315
|
+
----------
|
|
2316
|
+
tweet_id : :class:`str`
|
|
2317
|
+
The ID of the tweet to be unliked.
|
|
2318
|
+
|
|
2319
|
+
Returns
|
|
2320
|
+
-------
|
|
2321
|
+
:class:`httpx.Response`
|
|
2322
|
+
Response returned from twitter api.
|
|
2323
|
+
|
|
2324
|
+
Examples
|
|
2325
|
+
--------
|
|
2326
|
+
>>> tweet_id = '...'
|
|
2327
|
+
>>> await client.unfavorite_tweet(tweet_id)
|
|
2328
|
+
|
|
2329
|
+
See Also
|
|
2330
|
+
--------
|
|
2331
|
+
.favorite_tweet
|
|
2332
|
+
"""
|
|
2333
|
+
_, response = await self.gql.unfavorite_tweet(tweet_id)
|
|
2334
|
+
return response
|
|
2335
|
+
|
|
2336
|
+
async def retweet(self, tweet_id: str) -> Response:
|
|
2337
|
+
"""
|
|
2338
|
+
Retweets a tweet.
|
|
2339
|
+
|
|
2340
|
+
Parameters
|
|
2341
|
+
----------
|
|
2342
|
+
tweet_id : :class:`str`
|
|
2343
|
+
The ID of the tweet to be retweeted.
|
|
2344
|
+
|
|
2345
|
+
Returns
|
|
2346
|
+
-------
|
|
2347
|
+
:class:`httpx.Response`
|
|
2348
|
+
Response returned from twitter api.
|
|
2349
|
+
|
|
2350
|
+
Examples
|
|
2351
|
+
--------
|
|
2352
|
+
>>> tweet_id = '...'
|
|
2353
|
+
>>> await client.retweet(tweet_id)
|
|
2354
|
+
|
|
2355
|
+
See Also
|
|
2356
|
+
--------
|
|
2357
|
+
.delete_retweet
|
|
2358
|
+
"""
|
|
2359
|
+
_, response = await self.gql.retweet(tweet_id)
|
|
2360
|
+
return response
|
|
2361
|
+
|
|
2362
|
+
async def delete_retweet(self, tweet_id: str) -> Response:
|
|
2363
|
+
"""
|
|
2364
|
+
Deletes the retweet.
|
|
2365
|
+
|
|
2366
|
+
Parameters
|
|
2367
|
+
----------
|
|
2368
|
+
tweet_id : :class:`str`
|
|
2369
|
+
The ID of the retweeted tweet to be unretweeted.
|
|
2370
|
+
|
|
2371
|
+
Returns
|
|
2372
|
+
-------
|
|
2373
|
+
:class:`httpx.Response`
|
|
2374
|
+
Response returned from twitter api.
|
|
2375
|
+
|
|
2376
|
+
Examples
|
|
2377
|
+
--------
|
|
2378
|
+
>>> tweet_id = '...'
|
|
2379
|
+
>>> await client.delete_retweet(tweet_id)
|
|
2380
|
+
|
|
2381
|
+
See Also
|
|
2382
|
+
--------
|
|
2383
|
+
.retweet
|
|
2384
|
+
"""
|
|
2385
|
+
_, response = await self.gql.delete_retweet(tweet_id)
|
|
2386
|
+
return response
|
|
2387
|
+
|
|
2388
|
+
async def bookmark_tweet(
|
|
2389
|
+
self, tweet_id: str, folder_id: str | None = None
|
|
2390
|
+
) -> Response:
|
|
2391
|
+
"""
|
|
2392
|
+
Adds the tweet to bookmarks.
|
|
2393
|
+
|
|
2394
|
+
Parameters
|
|
2395
|
+
----------
|
|
2396
|
+
tweet_id : :class:`str`
|
|
2397
|
+
The ID of the tweet to be bookmarked.
|
|
2398
|
+
folder_id : :class:`str` | None, default=None
|
|
2399
|
+
The ID of the folder to add the bookmark to.
|
|
2400
|
+
|
|
2401
|
+
Returns
|
|
2402
|
+
-------
|
|
2403
|
+
:class:`httpx.Response`
|
|
2404
|
+
Response returned from twitter api.
|
|
2405
|
+
|
|
2406
|
+
Examples
|
|
2407
|
+
--------
|
|
2408
|
+
>>> tweet_id = '...'
|
|
2409
|
+
>>> await client.bookmark_tweet(tweet_id)
|
|
2410
|
+
"""
|
|
2411
|
+
if folder_id is None:
|
|
2412
|
+
_, response = await self.gql.create_bookmark(tweet_id)
|
|
2413
|
+
else:
|
|
2414
|
+
_, response = await self.gql.bookmark_tweet_to_folder(tweet_id, folder_id)
|
|
2415
|
+
return response
|
|
2416
|
+
|
|
2417
|
+
async def delete_bookmark(self, tweet_id: str) -> Response:
|
|
2418
|
+
"""
|
|
2419
|
+
Removes the tweet from bookmarks.
|
|
2420
|
+
|
|
2421
|
+
Parameters
|
|
2422
|
+
----------
|
|
2423
|
+
tweet_id : :class:`str`
|
|
2424
|
+
The ID of the tweet to be removed from bookmarks.
|
|
2425
|
+
|
|
2426
|
+
Returns
|
|
2427
|
+
-------
|
|
2428
|
+
:class:`httpx.Response`
|
|
2429
|
+
Response returned from twitter api.
|
|
2430
|
+
|
|
2431
|
+
Examples
|
|
2432
|
+
--------
|
|
2433
|
+
>>> tweet_id = '...'
|
|
2434
|
+
>>> await client.delete_bookmark(tweet_id)
|
|
2435
|
+
|
|
2436
|
+
See Also
|
|
2437
|
+
--------
|
|
2438
|
+
.bookmark_tweet
|
|
2439
|
+
"""
|
|
2440
|
+
_, response = await self.gql.delete_bookmark(tweet_id)
|
|
2441
|
+
return response
|
|
2442
|
+
|
|
2443
|
+
async def get_bookmarks(
|
|
2444
|
+
self, count: int = 20,
|
|
2445
|
+
cursor: str | None = None, folder_id: str | None = None
|
|
2446
|
+
) -> Result[Tweet]:
|
|
2447
|
+
"""
|
|
2448
|
+
Retrieves bookmarks from the authenticated user's Twitter account.
|
|
2449
|
+
|
|
2450
|
+
Parameters
|
|
2451
|
+
----------
|
|
2452
|
+
count : :class:`int`, default=20
|
|
2453
|
+
The number of bookmarks to retrieve.
|
|
2454
|
+
folder_id : :class:`str` | None, default=None
|
|
2455
|
+
Folder to retrieve bookmarks.
|
|
2456
|
+
|
|
2457
|
+
Returns
|
|
2458
|
+
-------
|
|
2459
|
+
Result[:class:`Tweet`]
|
|
2460
|
+
A Result object containing a list of Tweet objects
|
|
2461
|
+
representing bookmarks.
|
|
2462
|
+
|
|
2463
|
+
Example
|
|
2464
|
+
-------
|
|
2465
|
+
>>> bookmarks = await client.get_bookmarks()
|
|
2466
|
+
>>> for bookmark in bookmarks:
|
|
2467
|
+
... print(bookmark)
|
|
2468
|
+
<Tweet id="...">
|
|
2469
|
+
<Tweet id="...">
|
|
2470
|
+
|
|
2471
|
+
>>> # # To retrieve more bookmarks
|
|
2472
|
+
>>> more_bookmarks = await bookmarks.next()
|
|
2473
|
+
>>> for bookmark in more_bookmarks:
|
|
2474
|
+
... print(bookmark)
|
|
2475
|
+
<Tweet id="...">
|
|
2476
|
+
<Tweet id="...">
|
|
2477
|
+
"""
|
|
2478
|
+
if folder_id is None:
|
|
2479
|
+
response, _ = await self.gql.bookmarks(count, cursor)
|
|
2480
|
+
else:
|
|
2481
|
+
response, _ = await self.gql.bookmark_folder_timeline(count, cursor, folder_id)
|
|
2482
|
+
|
|
2483
|
+
items_ = find_dict(response, 'entries', find_one=True)
|
|
2484
|
+
if not items_:
|
|
2485
|
+
return Result([])
|
|
2486
|
+
items = items_[0]
|
|
2487
|
+
next_cursor = items[-1]['content']['value']
|
|
2488
|
+
if folder_id is None:
|
|
2489
|
+
previous_cursor = items[-2]['content']['value']
|
|
2490
|
+
fetch_previous_result = partial(self.get_bookmarks, count, previous_cursor, folder_id)
|
|
2491
|
+
else:
|
|
2492
|
+
previous_cursor = None
|
|
2493
|
+
fetch_previous_result = None
|
|
2494
|
+
|
|
2495
|
+
results = []
|
|
2496
|
+
for item in items:
|
|
2497
|
+
tweet = tweet_from_data(self, item)
|
|
2498
|
+
if tweet is None:
|
|
2499
|
+
continue
|
|
2500
|
+
results.append(tweet)
|
|
2501
|
+
|
|
2502
|
+
return Result(
|
|
2503
|
+
results,
|
|
2504
|
+
partial(self.get_bookmarks, count, next_cursor, folder_id),
|
|
2505
|
+
next_cursor,
|
|
2506
|
+
fetch_previous_result,
|
|
2507
|
+
previous_cursor
|
|
2508
|
+
)
|
|
2509
|
+
|
|
2510
|
+
async def delete_all_bookmarks(self) -> Response:
|
|
2511
|
+
"""
|
|
2512
|
+
Deleted all bookmarks.
|
|
2513
|
+
|
|
2514
|
+
Returns
|
|
2515
|
+
-------
|
|
2516
|
+
:class:`httpx.Response`
|
|
2517
|
+
Response returned from twitter api.
|
|
2518
|
+
|
|
2519
|
+
Examples
|
|
2520
|
+
--------
|
|
2521
|
+
>>> await client.delete_all_bookmarks()
|
|
2522
|
+
"""
|
|
2523
|
+
_, response = await self.gql.delete_all_bookmarks()
|
|
2524
|
+
return response
|
|
2525
|
+
|
|
2526
|
+
async def get_bookmark_folders(self, cursor: str | None = None) -> Result[BookmarkFolder]:
|
|
2527
|
+
"""
|
|
2528
|
+
Retrieves bookmark folders.
|
|
2529
|
+
|
|
2530
|
+
Returns
|
|
2531
|
+
-------
|
|
2532
|
+
Result[:class:`BookmarkFolder`]
|
|
2533
|
+
Result object containing a list of bookmark folders.
|
|
2534
|
+
|
|
2535
|
+
Examples
|
|
2536
|
+
--------
|
|
2537
|
+
>>> folders = await client.get_bookmark_folders()
|
|
2538
|
+
>>> print(folders)
|
|
2539
|
+
[<BookmarkFolder id="...">, ..., <BookmarkFolder id="...">]
|
|
2540
|
+
>>> more_folders = await folders.next() # Retrieve more folders
|
|
2541
|
+
"""
|
|
2542
|
+
response, _ = await self.gql.bookmark_folders_slice(cursor)
|
|
2543
|
+
|
|
2544
|
+
slice = find_dict(response, 'bookmark_collections_slice', find_one=True)[0]
|
|
2545
|
+
results = []
|
|
2546
|
+
for item in slice['items']:
|
|
2547
|
+
results.append(BookmarkFolder(self, item))
|
|
2548
|
+
|
|
2549
|
+
if 'next_cursor' in slice['slice_info']:
|
|
2550
|
+
next_cursor = slice['slice_info']['next_cursor']
|
|
2551
|
+
fetch_next_result = partial(self.get_bookmark_folders, next_cursor)
|
|
2552
|
+
else:
|
|
2553
|
+
next_cursor = None
|
|
2554
|
+
fetch_next_result = None
|
|
2555
|
+
|
|
2556
|
+
return Result(
|
|
2557
|
+
results,
|
|
2558
|
+
fetch_next_result,
|
|
2559
|
+
next_cursor
|
|
2560
|
+
)
|
|
2561
|
+
|
|
2562
|
+
async def edit_bookmark_folder(
|
|
2563
|
+
self, folder_id: str, name: str
|
|
2564
|
+
) -> BookmarkFolder:
|
|
2565
|
+
"""
|
|
2566
|
+
Edits a bookmark folder.
|
|
2567
|
+
|
|
2568
|
+
Parameters
|
|
2569
|
+
----------
|
|
2570
|
+
folder_id : :class:`str`
|
|
2571
|
+
ID of the folder to edit.
|
|
2572
|
+
name : :class:`str`
|
|
2573
|
+
New name for the folder.
|
|
2574
|
+
|
|
2575
|
+
Returns
|
|
2576
|
+
-------
|
|
2577
|
+
:class:`BookmarkFolder`
|
|
2578
|
+
Updated bookmark folder.
|
|
2579
|
+
|
|
2580
|
+
Examples
|
|
2581
|
+
--------
|
|
2582
|
+
>>> await client.edit_bookmark_folder('123456789', 'MyFolder')
|
|
2583
|
+
"""
|
|
2584
|
+
response, _ = await self.gql.edit_bookmark_folder(folder_id, name)
|
|
2585
|
+
return BookmarkFolder(self, response['data']['bookmark_collection_update'])
|
|
2586
|
+
|
|
2587
|
+
async def delete_bookmark_folder(self, folder_id: str) -> Response:
|
|
2588
|
+
"""
|
|
2589
|
+
Deletes a bookmark folder.
|
|
2590
|
+
|
|
2591
|
+
Parameters
|
|
2592
|
+
----------
|
|
2593
|
+
folder_id : :class:`str`
|
|
2594
|
+
ID of the folder to delete.
|
|
2595
|
+
|
|
2596
|
+
Returns
|
|
2597
|
+
-------
|
|
2598
|
+
:class:`httpx.Response`
|
|
2599
|
+
Response returned from twitter api.
|
|
2600
|
+
"""
|
|
2601
|
+
_, response = await self.gql.delete_bookmark_folder(folder_id)
|
|
2602
|
+
return response
|
|
2603
|
+
|
|
2604
|
+
async def create_bookmark_folder(self, name: str) -> BookmarkFolder:
|
|
2605
|
+
"""Creates a bookmark folder.
|
|
2606
|
+
|
|
2607
|
+
Parameters
|
|
2608
|
+
----------
|
|
2609
|
+
name : :class:`str`
|
|
2610
|
+
Name of the folder.
|
|
2611
|
+
|
|
2612
|
+
Returns
|
|
2613
|
+
-------
|
|
2614
|
+
:class:`BookmarkFolder`
|
|
2615
|
+
Newly created bookmark folder.
|
|
2616
|
+
"""
|
|
2617
|
+
response, _ = await self.gql.create_bookmark_folder(name)
|
|
2618
|
+
return BookmarkFolder(self, response['data']['bookmark_collection_create'])
|
|
2619
|
+
|
|
2620
|
+
async def follow_user(self, user_id: str) -> User:
|
|
2621
|
+
"""
|
|
2622
|
+
Follows a user.
|
|
2623
|
+
|
|
2624
|
+
Parameters
|
|
2625
|
+
----------
|
|
2626
|
+
user_id : :class:`str`
|
|
2627
|
+
The ID of the user to follow.
|
|
2628
|
+
|
|
2629
|
+
Returns
|
|
2630
|
+
-------
|
|
2631
|
+
:class:`User`
|
|
2632
|
+
The followed user.
|
|
2633
|
+
|
|
2634
|
+
Examples
|
|
2635
|
+
--------
|
|
2636
|
+
>>> user_id = '...'
|
|
2637
|
+
>>> await client.follow_user(user_id)
|
|
2638
|
+
|
|
2639
|
+
See Also
|
|
2640
|
+
--------
|
|
2641
|
+
.unfollow_user
|
|
2642
|
+
"""
|
|
2643
|
+
response, _ = await self.v11.create_friendships(user_id)
|
|
2644
|
+
return User(self, build_user_data(response))
|
|
2645
|
+
|
|
2646
|
+
async def unfollow_user(self, user_id: str) -> User:
|
|
2647
|
+
"""
|
|
2648
|
+
Unfollows a user.
|
|
2649
|
+
|
|
2650
|
+
Parameters
|
|
2651
|
+
----------
|
|
2652
|
+
user_id : :class:`str`
|
|
2653
|
+
The ID of the user to unfollow.
|
|
2654
|
+
|
|
2655
|
+
Returns
|
|
2656
|
+
-------
|
|
2657
|
+
:class:`User`
|
|
2658
|
+
The unfollowed user.
|
|
2659
|
+
|
|
2660
|
+
Examples
|
|
2661
|
+
--------
|
|
2662
|
+
>>> user_id = '...'
|
|
2663
|
+
>>> await client.unfollow_user(user_id)
|
|
2664
|
+
|
|
2665
|
+
See Also
|
|
2666
|
+
--------
|
|
2667
|
+
.follow_user
|
|
2668
|
+
"""
|
|
2669
|
+
response, _ = await self.v11.destroy_friendships(user_id)
|
|
2670
|
+
return User(self, build_user_data(response))
|
|
2671
|
+
|
|
2672
|
+
async def block_user(self, user_id: str) -> User:
|
|
2673
|
+
"""
|
|
2674
|
+
Blocks a user.
|
|
2675
|
+
|
|
2676
|
+
Parameters
|
|
2677
|
+
----------
|
|
2678
|
+
user_id : :class:`str`
|
|
2679
|
+
The ID of the user to block.
|
|
2680
|
+
|
|
2681
|
+
Returns
|
|
2682
|
+
-------
|
|
2683
|
+
:class:`User`
|
|
2684
|
+
The blocked user.
|
|
2685
|
+
|
|
2686
|
+
See Also
|
|
2687
|
+
--------
|
|
2688
|
+
.unblock_user
|
|
2689
|
+
"""
|
|
2690
|
+
response, _ = await self.v11.create_blocks(user_id)
|
|
2691
|
+
return User(self, build_user_data(response))
|
|
2692
|
+
|
|
2693
|
+
async def unblock_user(self, user_id: str) -> User:
|
|
2694
|
+
"""
|
|
2695
|
+
Unblocks a user.
|
|
2696
|
+
|
|
2697
|
+
Parameters
|
|
2698
|
+
----------
|
|
2699
|
+
user_id : :class:`str`
|
|
2700
|
+
The ID of the user to unblock.
|
|
2701
|
+
|
|
2702
|
+
Returns
|
|
2703
|
+
-------
|
|
2704
|
+
:class:`User`
|
|
2705
|
+
The unblocked user.
|
|
2706
|
+
|
|
2707
|
+
See Also
|
|
2708
|
+
--------
|
|
2709
|
+
.block_user
|
|
2710
|
+
"""
|
|
2711
|
+
response, _ = await self.v11.destroy_blocks(user_id)
|
|
2712
|
+
return User(self, build_user_data(response))
|
|
2713
|
+
|
|
2714
|
+
async def mute_user(self, user_id: str) -> User:
|
|
2715
|
+
"""
|
|
2716
|
+
Mutes a user.
|
|
2717
|
+
|
|
2718
|
+
Parameters
|
|
2719
|
+
----------
|
|
2720
|
+
user_id : :class:`str`
|
|
2721
|
+
The ID of the user to mute.
|
|
2722
|
+
|
|
2723
|
+
Returns
|
|
2724
|
+
-------
|
|
2725
|
+
:class:`User`
|
|
2726
|
+
The muted user.
|
|
2727
|
+
|
|
2728
|
+
See Also
|
|
2729
|
+
--------
|
|
2730
|
+
.unmute_user
|
|
2731
|
+
"""
|
|
2732
|
+
response, _ = await self.v11.create_mutes(user_id)
|
|
2733
|
+
return User(self, build_user_data(response))
|
|
2734
|
+
|
|
2735
|
+
async def unmute_user(self, user_id: str) -> User:
|
|
2736
|
+
"""
|
|
2737
|
+
Unmutes a user.
|
|
2738
|
+
|
|
2739
|
+
Parameters
|
|
2740
|
+
----------
|
|
2741
|
+
user_id : :class:`str`
|
|
2742
|
+
The ID of the user to unmute.
|
|
2743
|
+
|
|
2744
|
+
Returns
|
|
2745
|
+
-------
|
|
2746
|
+
:class:`User`
|
|
2747
|
+
The unmuted user.
|
|
2748
|
+
|
|
2749
|
+
See Also
|
|
2750
|
+
--------
|
|
2751
|
+
.mute_user
|
|
2752
|
+
"""
|
|
2753
|
+
response, _ = await self.v11.destroy_mutes(user_id)
|
|
2754
|
+
return User(self, build_user_data(response))
|
|
2755
|
+
|
|
2756
|
+
async def get_trends(
|
|
2757
|
+
self,
|
|
2758
|
+
category: Literal['trending', 'for-you', 'news', 'sports', 'entertainment'],
|
|
2759
|
+
count: int = 20,
|
|
2760
|
+
retry: bool = True,
|
|
2761
|
+
additional_request_params: dict | None = None
|
|
2762
|
+
) -> list[Trend]:
|
|
2763
|
+
"""
|
|
2764
|
+
Retrieves trending topics on Twitter.
|
|
2765
|
+
|
|
2766
|
+
Parameters
|
|
2767
|
+
----------
|
|
2768
|
+
category : {'trending', 'for-you', 'news', 'sports', 'entertainment'}
|
|
2769
|
+
The category of trends to retrieve. Valid options include:
|
|
2770
|
+
- 'trending': General trending topics.
|
|
2771
|
+
- 'for-you': Trends personalized for the user.
|
|
2772
|
+
- 'news': News-related trends.
|
|
2773
|
+
- 'sports': Sports-related trends.
|
|
2774
|
+
- 'entertainment': Entertainment-related trends.
|
|
2775
|
+
count : :class:`int`, default=20
|
|
2776
|
+
The number of trends to retrieve.
|
|
2777
|
+
retry : :class:`bool`, default=True
|
|
2778
|
+
If no trends are fetched continuously retry to fetch trends.
|
|
2779
|
+
additional_request_params : :class:`dict`, default=None
|
|
2780
|
+
Parameters to be added on top of the existing trends API
|
|
2781
|
+
parameters. Typically, it is used as `additional_request_params =
|
|
2782
|
+
{'candidate_source': 'trends'}` when this function doesn't work
|
|
2783
|
+
otherwise.
|
|
2784
|
+
|
|
2785
|
+
Returns
|
|
2786
|
+
-------
|
|
2787
|
+
list[:class:`Trend`]
|
|
2788
|
+
A list of Trend objects representing the retrieved trends.
|
|
2789
|
+
|
|
2790
|
+
Examples
|
|
2791
|
+
--------
|
|
2792
|
+
>>> trends = await client.get_trends('trending')
|
|
2793
|
+
>>> for trend in trends:
|
|
2794
|
+
... print(trend)
|
|
2795
|
+
<Trend name="...">
|
|
2796
|
+
<Trend name="...">
|
|
2797
|
+
...
|
|
2798
|
+
"""
|
|
2799
|
+
category = category.lower()
|
|
2800
|
+
if category in ['news', 'sports', 'entertainment']:
|
|
2801
|
+
category += '_unified'
|
|
2802
|
+
response, _ = await self.v11.guide(category, count, additional_request_params)
|
|
2803
|
+
|
|
2804
|
+
entry_id_prefix = 'trends' if category == 'trending' else 'Guide'
|
|
2805
|
+
entries = [
|
|
2806
|
+
i for i in find_dict(response, 'entries', find_one=True)[0]
|
|
2807
|
+
if i['entryId'].startswith(entry_id_prefix)
|
|
2808
|
+
]
|
|
2809
|
+
|
|
2810
|
+
if not entries:
|
|
2811
|
+
if not retry:
|
|
2812
|
+
return []
|
|
2813
|
+
# Recall the method again, as the trend information
|
|
2814
|
+
# may not be returned due to a Twitter error.
|
|
2815
|
+
return await self.get_trends(category, count, retry, additional_request_params)
|
|
2816
|
+
|
|
2817
|
+
items = entries[-1]['content']['timelineModule']['items']
|
|
2818
|
+
|
|
2819
|
+
results = []
|
|
2820
|
+
for item in items:
|
|
2821
|
+
trend_info = item['item']['content']['trend']
|
|
2822
|
+
results.append(Trend(self, trend_info))
|
|
2823
|
+
|
|
2824
|
+
return results
|
|
2825
|
+
|
|
2826
|
+
async def get_available_locations(self) -> list[Location]:
|
|
2827
|
+
"""
|
|
2828
|
+
Retrieves locations where trends can be retrieved.
|
|
2829
|
+
|
|
2830
|
+
Returns
|
|
2831
|
+
-------
|
|
2832
|
+
list[:class:`.Location`]
|
|
2833
|
+
"""
|
|
2834
|
+
response, _ = await self.v11.available_trends()
|
|
2835
|
+
return [Location(self, data) for data in response]
|
|
2836
|
+
|
|
2837
|
+
async def get_place_trends(self, woeid: int) -> PlaceTrends:
|
|
2838
|
+
"""
|
|
2839
|
+
Retrieves the top 50 trending topics for a specific id.
|
|
2840
|
+
You can get available woeid using
|
|
2841
|
+
:attr:`.Client.get_available_locations`.
|
|
2842
|
+
"""
|
|
2843
|
+
response, _ = await self.v11.place_trends(woeid)
|
|
2844
|
+
trend_data = response[0]
|
|
2845
|
+
trends = [PlaceTrend(self, data) for data in trend_data['trends']]
|
|
2846
|
+
trend_data['trends'] = trends
|
|
2847
|
+
return trend_data
|
|
2848
|
+
|
|
2849
|
+
async def _get_user_friendship(
|
|
2850
|
+
self,
|
|
2851
|
+
user_id: str,
|
|
2852
|
+
count: int,
|
|
2853
|
+
f,
|
|
2854
|
+
cursor: str | None
|
|
2855
|
+
) -> Result[User]:
|
|
2856
|
+
"""
|
|
2857
|
+
Base function to get friendship.
|
|
2858
|
+
"""
|
|
2859
|
+
response, _ = await f(user_id, count, cursor)
|
|
2860
|
+
|
|
2861
|
+
items_ = find_dict(response, 'entries', find_one=True)
|
|
2862
|
+
if not items_:
|
|
2863
|
+
return Result.empty()
|
|
2864
|
+
items = items_[0]
|
|
2865
|
+
results = []
|
|
2866
|
+
for item in items:
|
|
2867
|
+
entry_id = item['entryId']
|
|
2868
|
+
if entry_id.startswith('user'):
|
|
2869
|
+
user_info = find_dict(item, 'result', find_one=True)
|
|
2870
|
+
if not user_info:
|
|
2871
|
+
warnings.warn(
|
|
2872
|
+
'Some followers are excluded because '
|
|
2873
|
+
'"Quality Filter" is enabled. To get all followers, '
|
|
2874
|
+
'turn off it in the Twitter settings.'
|
|
2875
|
+
)
|
|
2876
|
+
continue
|
|
2877
|
+
if user_info[0].get('__typename') == 'UserUnavailable':
|
|
2878
|
+
continue
|
|
2879
|
+
results.append(User(self, user_info[0]))
|
|
2880
|
+
elif entry_id.startswith('cursor-bottom'):
|
|
2881
|
+
next_cursor = item['content']['value']
|
|
2882
|
+
|
|
2883
|
+
return Result(
|
|
2884
|
+
results,
|
|
2885
|
+
partial(self._get_user_friendship, user_id, count, f, next_cursor),
|
|
2886
|
+
next_cursor
|
|
2887
|
+
)
|
|
2888
|
+
|
|
2889
|
+
async def _get_user_friendship_2(
|
|
2890
|
+
self, user_id: str, screen_name: str,
|
|
2891
|
+
count: int, f, cursor: str
|
|
2892
|
+
) -> Result[User]:
|
|
2893
|
+
response, _ = await f(user_id, screen_name, count, cursor)
|
|
2894
|
+
users = response['users']
|
|
2895
|
+
results = []
|
|
2896
|
+
for user in users:
|
|
2897
|
+
results.append(User(self, build_user_data(user)))
|
|
2898
|
+
|
|
2899
|
+
previous_cursor = response['previous_cursor']
|
|
2900
|
+
next_cursor = response['next_cursor']
|
|
2901
|
+
|
|
2902
|
+
return Result(
|
|
2903
|
+
results,
|
|
2904
|
+
partial(self._get_user_friendship_2, user_id, screen_name, count, f, next_cursor),
|
|
2905
|
+
next_cursor,
|
|
2906
|
+
partial(self._get_user_friendship_2, user_id, screen_name, count, f, previous_cursor),
|
|
2907
|
+
previous_cursor
|
|
2908
|
+
)
|
|
2909
|
+
|
|
2910
|
+
async def get_user_followers(
|
|
2911
|
+
self, user_id: str, count: int = 20, cursor: str | None = None
|
|
2912
|
+
) -> Result[User]:
|
|
2913
|
+
"""
|
|
2914
|
+
Retrieves a list of followers for a given user.
|
|
2915
|
+
|
|
2916
|
+
Parameters
|
|
2917
|
+
----------
|
|
2918
|
+
user_id : :class:`str`
|
|
2919
|
+
The ID of the user for whom to retrieve followers.
|
|
2920
|
+
count : int, default=20
|
|
2921
|
+
The number of followers to retrieve.
|
|
2922
|
+
|
|
2923
|
+
Returns
|
|
2924
|
+
-------
|
|
2925
|
+
Result[:class:`User`]
|
|
2926
|
+
A list of User objects representing the followers.
|
|
2927
|
+
"""
|
|
2928
|
+
return await self._get_user_friendship(
|
|
2929
|
+
user_id, count, self.gql.followers, cursor
|
|
2930
|
+
)
|
|
2931
|
+
|
|
2932
|
+
async def get_latest_followers(
|
|
2933
|
+
self, user_id: str | None = None, screen_name: str | None = None,
|
|
2934
|
+
count: int = 200, cursor: str | None = None
|
|
2935
|
+
) -> Result[User]:
|
|
2936
|
+
"""
|
|
2937
|
+
Retrieves the latest followers.
|
|
2938
|
+
Max count : 200
|
|
2939
|
+
"""
|
|
2940
|
+
return await self._get_user_friendship_2(
|
|
2941
|
+
user_id, screen_name, count, self.v11.followers_list, cursor
|
|
2942
|
+
)
|
|
2943
|
+
|
|
2944
|
+
async def get_latest_friends(
|
|
2945
|
+
self, user_id: str | None = None, screen_name: str | None = None,
|
|
2946
|
+
count: int = 200, cursor: str | None = None
|
|
2947
|
+
) -> Result[User]:
|
|
2948
|
+
"""
|
|
2949
|
+
Retrieves the latest friends (following users).
|
|
2950
|
+
Max count : 200
|
|
2951
|
+
"""
|
|
2952
|
+
return await self._get_user_friendship_2(
|
|
2953
|
+
user_id, screen_name, count, self.v11.friends_list, cursor
|
|
2954
|
+
)
|
|
2955
|
+
|
|
2956
|
+
async def get_user_verified_followers(
|
|
2957
|
+
self, user_id: str, count: int = 20, cursor: str | None = None
|
|
2958
|
+
) -> Result[User]:
|
|
2959
|
+
"""
|
|
2960
|
+
Retrieves a list of verified followers for a given user.
|
|
2961
|
+
|
|
2962
|
+
Parameters
|
|
2963
|
+
----------
|
|
2964
|
+
user_id : :class:`str`
|
|
2965
|
+
The ID of the user for whom to retrieve verified followers.
|
|
2966
|
+
count : :class:`int`, default=20
|
|
2967
|
+
The number of verified followers to retrieve.
|
|
2968
|
+
|
|
2969
|
+
Returns
|
|
2970
|
+
-------
|
|
2971
|
+
Result[:class:`User`]
|
|
2972
|
+
A list of User objects representing the verified followers.
|
|
2973
|
+
"""
|
|
2974
|
+
return await self._get_user_friendship(
|
|
2975
|
+
user_id, count, self.gql.blue_verified_followers, cursor
|
|
2976
|
+
)
|
|
2977
|
+
|
|
2978
|
+
async def get_user_followers_you_know(
|
|
2979
|
+
self, user_id: str, count: int = 20, cursor: str | None = None
|
|
2980
|
+
) -> Result[User]:
|
|
2981
|
+
"""
|
|
2982
|
+
Retrieves a list of common followers.
|
|
2983
|
+
|
|
2984
|
+
Parameters
|
|
2985
|
+
----------
|
|
2986
|
+
user_id : :class:`str`
|
|
2987
|
+
The ID of the user for whom to retrieve followers you might know.
|
|
2988
|
+
count : :class:`int`, default=20
|
|
2989
|
+
The number of followers you might know to retrieve.
|
|
2990
|
+
|
|
2991
|
+
Returns
|
|
2992
|
+
-------
|
|
2993
|
+
Result[:class:`User`]
|
|
2994
|
+
A list of User objects representing the followers you might know.
|
|
2995
|
+
"""
|
|
2996
|
+
return await self._get_user_friendship(
|
|
2997
|
+
user_id, count, self.gql.followers_you_know, cursor
|
|
2998
|
+
)
|
|
2999
|
+
|
|
3000
|
+
async def get_user_following(
|
|
3001
|
+
self, user_id: str, count: int = 20, cursor: str | None = None
|
|
3002
|
+
) -> Result[User]:
|
|
3003
|
+
"""
|
|
3004
|
+
Retrieves a list of users whom the given user is following.
|
|
3005
|
+
|
|
3006
|
+
Parameters
|
|
3007
|
+
----------
|
|
3008
|
+
user_id : :class:`str`
|
|
3009
|
+
The ID of the user for whom to retrieve the following users.
|
|
3010
|
+
count : :class:`int`, default=20
|
|
3011
|
+
The number of following users to retrieve.
|
|
3012
|
+
|
|
3013
|
+
Returns
|
|
3014
|
+
-------
|
|
3015
|
+
Result[:class:`User`]
|
|
3016
|
+
A list of User objects representing the users being followed.
|
|
3017
|
+
"""
|
|
3018
|
+
return await self._get_user_friendship(
|
|
3019
|
+
user_id, count, self.gql.following, cursor
|
|
3020
|
+
)
|
|
3021
|
+
|
|
3022
|
+
async def get_user_subscriptions(
|
|
3023
|
+
self, user_id: str, count: int = 20, cursor: str | None = None
|
|
3024
|
+
) -> Result[User]:
|
|
3025
|
+
"""
|
|
3026
|
+
Retrieves a list of users to which the specified user is subscribed.
|
|
3027
|
+
|
|
3028
|
+
Parameters
|
|
3029
|
+
----------
|
|
3030
|
+
user_id : :class:`str`
|
|
3031
|
+
The ID of the user for whom to retrieve subscriptions.
|
|
3032
|
+
count : :class:`int`, default=20
|
|
3033
|
+
The number of subscriptions to retrieve.
|
|
3034
|
+
|
|
3035
|
+
Returns
|
|
3036
|
+
-------
|
|
3037
|
+
Result[:class:`User`]
|
|
3038
|
+
A list of User objects representing the subscribed users.
|
|
3039
|
+
"""
|
|
3040
|
+
return await self._get_user_friendship(
|
|
3041
|
+
user_id, count, self.gql.user_creator_subscriptions, cursor
|
|
3042
|
+
)
|
|
3043
|
+
|
|
3044
|
+
async def _get_friendship_ids(
|
|
3045
|
+
self,
|
|
3046
|
+
user_id: str | None,
|
|
3047
|
+
screen_name: str | None,
|
|
3048
|
+
count: int,
|
|
3049
|
+
f,
|
|
3050
|
+
cursor: str | None
|
|
3051
|
+
) -> Result[int]:
|
|
3052
|
+
response, _ = await f(user_id, screen_name, count, cursor)
|
|
3053
|
+
previous_cursor = response['previous_cursor']
|
|
3054
|
+
next_cursor = response['next_cursor']
|
|
3055
|
+
|
|
3056
|
+
return Result(
|
|
3057
|
+
response['ids'],
|
|
3058
|
+
partial(self._get_friendship_ids, user_id, screen_name, count, f, next_cursor),
|
|
3059
|
+
next_cursor,
|
|
3060
|
+
partial(self._get_friendship_ids, user_id, screen_name, count, f, previous_cursor),
|
|
3061
|
+
previous_cursor
|
|
3062
|
+
)
|
|
3063
|
+
|
|
3064
|
+
async def get_followers_ids(
|
|
3065
|
+
self,
|
|
3066
|
+
user_id: str | None = None,
|
|
3067
|
+
screen_name: str | None = None,
|
|
3068
|
+
count: int = 5000,
|
|
3069
|
+
cursor: str | None = None
|
|
3070
|
+
) -> Result[int]:
|
|
3071
|
+
"""
|
|
3072
|
+
Fetches the IDs of the followers of a specified user.
|
|
3073
|
+
|
|
3074
|
+
Parameters
|
|
3075
|
+
----------
|
|
3076
|
+
user_id : :class:`str` | None, default=None
|
|
3077
|
+
The ID of the user for whom to return results.
|
|
3078
|
+
screen_name : :class:`str` | None, default=None
|
|
3079
|
+
The screen name of the user for whom to return results.
|
|
3080
|
+
count : :class:`int`, default=5000
|
|
3081
|
+
The maximum number of IDs to retrieve.
|
|
3082
|
+
|
|
3083
|
+
Returns
|
|
3084
|
+
-------
|
|
3085
|
+
:class:`Result`[:class:`int`]
|
|
3086
|
+
A Result object containing the IDs of the followers.
|
|
3087
|
+
"""
|
|
3088
|
+
return await self._get_friendship_ids(user_id, screen_name, count, self.v11.followers_ids, cursor)
|
|
3089
|
+
|
|
3090
|
+
async def get_friends_ids(
|
|
3091
|
+
self,
|
|
3092
|
+
user_id: str | None = None,
|
|
3093
|
+
screen_name: str | None = None,
|
|
3094
|
+
count: int = 5000,
|
|
3095
|
+
cursor: str | None = None
|
|
3096
|
+
) -> Result[int]:
|
|
3097
|
+
"""
|
|
3098
|
+
Fetches the IDs of the friends (following users) of a specified user.
|
|
3099
|
+
|
|
3100
|
+
Parameters
|
|
3101
|
+
----------
|
|
3102
|
+
user_id : :class:`str` | None, default=None
|
|
3103
|
+
The ID of the user for whom to return results.
|
|
3104
|
+
screen_name : :class:`str` | None, default=None
|
|
3105
|
+
The screen name of the user for whom to return results.
|
|
3106
|
+
count : :class:`int`, default=5000
|
|
3107
|
+
The maximum number of IDs to retrieve.
|
|
3108
|
+
|
|
3109
|
+
Returns
|
|
3110
|
+
-------
|
|
3111
|
+
:class:`Result`[:class:`int`]
|
|
3112
|
+
A Result object containing the IDs of the friends.
|
|
3113
|
+
"""
|
|
3114
|
+
return await self._get_friendship_ids(
|
|
3115
|
+
user_id, screen_name, count, self.v11.friends_ids, cursor
|
|
3116
|
+
)
|
|
3117
|
+
|
|
3118
|
+
async def _send_dm(
|
|
3119
|
+
self,
|
|
3120
|
+
conversation_id: str,
|
|
3121
|
+
text: str,
|
|
3122
|
+
media_id: str | None,
|
|
3123
|
+
reply_to: str | None
|
|
3124
|
+
) -> dict:
|
|
3125
|
+
"""
|
|
3126
|
+
Base function to send dm.
|
|
3127
|
+
"""
|
|
3128
|
+
response, _ = await self.v11.dm_new(conversation_id, text, media_id, reply_to)
|
|
3129
|
+
return response
|
|
3130
|
+
|
|
3131
|
+
async def _get_dm_history(
|
|
3132
|
+
self,
|
|
3133
|
+
conversation_id: str,
|
|
3134
|
+
max_id: str | None = None
|
|
3135
|
+
) -> dict:
|
|
3136
|
+
"""
|
|
3137
|
+
Base function to get dm history.
|
|
3138
|
+
"""
|
|
3139
|
+
response, _ = await self.v11.dm_conversation(conversation_id, max_id)
|
|
3140
|
+
return response
|
|
3141
|
+
|
|
3142
|
+
async def send_dm(
|
|
3143
|
+
self,
|
|
3144
|
+
user_id: str,
|
|
3145
|
+
text: str,
|
|
3146
|
+
media_id: str | None = None,
|
|
3147
|
+
reply_to: str | None = None
|
|
3148
|
+
) -> Message:
|
|
3149
|
+
"""
|
|
3150
|
+
Send a direct message to a user.
|
|
3151
|
+
|
|
3152
|
+
Parameters
|
|
3153
|
+
----------
|
|
3154
|
+
user_id : :class:`str`
|
|
3155
|
+
The ID of the user to whom the direct message will be sent.
|
|
3156
|
+
text : :class:`str`
|
|
3157
|
+
The text content of the direct message.
|
|
3158
|
+
media_id : :class:`str`, default=None
|
|
3159
|
+
The media ID associated with any media content
|
|
3160
|
+
to be included in the message.
|
|
3161
|
+
Media ID can be received by using the :func:`.upload_media` method.
|
|
3162
|
+
reply_to : :class:`str`, default=None
|
|
3163
|
+
Message ID to reply to.
|
|
3164
|
+
|
|
3165
|
+
Returns
|
|
3166
|
+
-------
|
|
3167
|
+
:class:`Message`
|
|
3168
|
+
`Message` object containing information about the message sent.
|
|
3169
|
+
|
|
3170
|
+
Examples
|
|
3171
|
+
--------
|
|
3172
|
+
>>> # send DM with media
|
|
3173
|
+
>>> user_id = '000000000'
|
|
3174
|
+
>>> media_id = await client.upload_media('image.png')
|
|
3175
|
+
>>> message = await client.send_dm(user_id, 'text', media_id)
|
|
3176
|
+
>>> print(message)
|
|
3177
|
+
<Message id='...'>
|
|
3178
|
+
|
|
3179
|
+
See Also
|
|
3180
|
+
--------
|
|
3181
|
+
.upload_media
|
|
3182
|
+
.delete_dm
|
|
3183
|
+
"""
|
|
3184
|
+
response = await self._send_dm(
|
|
3185
|
+
f'{user_id}-{await self.user_id()}', text, media_id, reply_to
|
|
3186
|
+
)
|
|
3187
|
+
|
|
3188
|
+
message_data = find_dict(response, 'message_data', find_one=True)[0]
|
|
3189
|
+
users = list(response['users'].values())
|
|
3190
|
+
return Message(
|
|
3191
|
+
self,
|
|
3192
|
+
message_data,
|
|
3193
|
+
users[0]['id_str'],
|
|
3194
|
+
users[1]['id_str'] if len(users) == 2 else users[0]['id_str']
|
|
3195
|
+
)
|
|
3196
|
+
|
|
3197
|
+
async def add_reaction_to_message(
|
|
3198
|
+
self, message_id: str, conversation_id: str, emoji: str
|
|
3199
|
+
) -> Response:
|
|
3200
|
+
"""
|
|
3201
|
+
Adds a reaction emoji to a specific message in a conversation.
|
|
3202
|
+
|
|
3203
|
+
Parameters
|
|
3204
|
+
----------
|
|
3205
|
+
message_id : :class:`str`
|
|
3206
|
+
The ID of the message to which the reaction emoji will be added.
|
|
3207
|
+
Group ID ('00000000') or partner_ID-your_ID ('00000000-00000001')
|
|
3208
|
+
conversation_id : :class:`str`
|
|
3209
|
+
The ID of the conversation containing the message.
|
|
3210
|
+
emoji : :class:`str`
|
|
3211
|
+
The emoji to be added as a reaction.
|
|
3212
|
+
|
|
3213
|
+
Returns
|
|
3214
|
+
-------
|
|
3215
|
+
:class:`httpx.Response`
|
|
3216
|
+
Response returned from twitter api.
|
|
3217
|
+
|
|
3218
|
+
Examples
|
|
3219
|
+
--------
|
|
3220
|
+
>>> message_id = '00000000'
|
|
3221
|
+
>>> conversation_id = f'00000001-{await client.user_id()}'
|
|
3222
|
+
>>> await client.add_reaction_to_message(
|
|
3223
|
+
... message_id, conversation_id, 'Emoji here'
|
|
3224
|
+
... )
|
|
3225
|
+
"""
|
|
3226
|
+
_, response = await self.gql.user_dm_reaction_mutation_add_mutation(
|
|
3227
|
+
message_id, conversation_id, emoji
|
|
3228
|
+
)
|
|
3229
|
+
return response
|
|
3230
|
+
|
|
3231
|
+
async def remove_reaction_from_message(
|
|
3232
|
+
self, message_id: str, conversation_id: str, emoji: str
|
|
3233
|
+
) -> Response:
|
|
3234
|
+
"""
|
|
3235
|
+
Remove a reaction from a message.
|
|
3236
|
+
|
|
3237
|
+
Parameters
|
|
3238
|
+
----------
|
|
3239
|
+
message_id : :class:`str`
|
|
3240
|
+
The ID of the message from which to remove the reaction.
|
|
3241
|
+
conversation_id : :class:`str`
|
|
3242
|
+
The ID of the conversation where the message is located.
|
|
3243
|
+
Group ID ('00000000') or partner_ID-your_ID ('00000000-00000001')
|
|
3244
|
+
emoji : :class:`str`
|
|
3245
|
+
The emoji to remove as a reaction.
|
|
3246
|
+
|
|
3247
|
+
Returns
|
|
3248
|
+
-------
|
|
3249
|
+
:class:`httpx.Response`
|
|
3250
|
+
Response returned from twitter api.
|
|
3251
|
+
|
|
3252
|
+
Examples
|
|
3253
|
+
--------
|
|
3254
|
+
>>> message_id = '00000000'
|
|
3255
|
+
>>> conversation_id = f'00000001-{await client.user_id()}'
|
|
3256
|
+
>>> await client.remove_reaction_from_message(
|
|
3257
|
+
... message_id, conversation_id, 'Emoji here'
|
|
3258
|
+
... )
|
|
3259
|
+
"""
|
|
3260
|
+
_, response = await self.gql.user_dm_reaction_mutation_remove_mutation(
|
|
3261
|
+
message_id, conversation_id, emoji
|
|
3262
|
+
)
|
|
3263
|
+
return response
|
|
3264
|
+
|
|
3265
|
+
async def delete_dm(self, message_id: str) -> Response:
|
|
3266
|
+
"""
|
|
3267
|
+
Deletes a direct message with the specified message ID.
|
|
3268
|
+
|
|
3269
|
+
Parameters
|
|
3270
|
+
----------
|
|
3271
|
+
message_id : :class:`str`
|
|
3272
|
+
The ID of the direct message to be deleted.
|
|
3273
|
+
|
|
3274
|
+
Returns
|
|
3275
|
+
-------
|
|
3276
|
+
:class:`httpx.Response`
|
|
3277
|
+
Response returned from twitter api.
|
|
3278
|
+
|
|
3279
|
+
Examples
|
|
3280
|
+
--------
|
|
3281
|
+
>>> await client.delete_dm('0000000000')
|
|
3282
|
+
"""
|
|
3283
|
+
_, response = await self.gql.dm_message_delete_mutation(message_id)
|
|
3284
|
+
return response
|
|
3285
|
+
|
|
3286
|
+
async def get_dm_history(
|
|
3287
|
+
self,
|
|
3288
|
+
user_id: str,
|
|
3289
|
+
max_id: str | None = None
|
|
3290
|
+
) -> Result[Message]:
|
|
3291
|
+
"""
|
|
3292
|
+
Retrieves the DM conversation history with a specific user.
|
|
3293
|
+
|
|
3294
|
+
Parameters
|
|
3295
|
+
----------
|
|
3296
|
+
user_id : :class:`str`
|
|
3297
|
+
The ID of the user with whom the DM conversation
|
|
3298
|
+
history will be retrieved.
|
|
3299
|
+
max_id : :class:`str`, default=None
|
|
3300
|
+
If specified, retrieves messages older than the specified max_id.
|
|
3301
|
+
|
|
3302
|
+
Returns
|
|
3303
|
+
-------
|
|
3304
|
+
Result[:class:`Message`]
|
|
3305
|
+
A Result object containing a list of Message objects representing
|
|
3306
|
+
the DM conversation history.
|
|
3307
|
+
|
|
3308
|
+
Examples
|
|
3309
|
+
--------
|
|
3310
|
+
>>> messages = await client.get_dm_history('0000000000')
|
|
3311
|
+
>>> for message in messages:
|
|
3312
|
+
>>> print(message)
|
|
3313
|
+
<Message id="...">
|
|
3314
|
+
<Message id="...">
|
|
3315
|
+
...
|
|
3316
|
+
...
|
|
3317
|
+
|
|
3318
|
+
>>> more_messages = await messages.next() # Retrieve more messages
|
|
3319
|
+
>>> for message in more_messages:
|
|
3320
|
+
>>> print(message)
|
|
3321
|
+
<Message id="...">
|
|
3322
|
+
<Message id="...">
|
|
3323
|
+
...
|
|
3324
|
+
...
|
|
3325
|
+
"""
|
|
3326
|
+
response = await self._get_dm_history(
|
|
3327
|
+
f'{user_id}-{await self.user_id()}', max_id
|
|
3328
|
+
)
|
|
3329
|
+
|
|
3330
|
+
if 'entries' not in response['conversation_timeline']:
|
|
3331
|
+
return Result([])
|
|
3332
|
+
items = response['conversation_timeline']['entries']
|
|
3333
|
+
|
|
3334
|
+
messages = []
|
|
3335
|
+
for item in items:
|
|
3336
|
+
message_info = item['message']['message_data']
|
|
3337
|
+
messages.append(Message(
|
|
3338
|
+
self,
|
|
3339
|
+
message_info,
|
|
3340
|
+
message_info['sender_id'],
|
|
3341
|
+
message_info['recipient_id']
|
|
3342
|
+
))
|
|
3343
|
+
|
|
3344
|
+
return Result(
|
|
3345
|
+
messages,
|
|
3346
|
+
partial(self.get_dm_history, user_id, messages[-1].id),
|
|
3347
|
+
messages[-1].id
|
|
3348
|
+
)
|
|
3349
|
+
|
|
3350
|
+
async def send_dm_to_group(
|
|
3351
|
+
self,
|
|
3352
|
+
group_id: str,
|
|
3353
|
+
text: str,
|
|
3354
|
+
media_id: str | None = None,
|
|
3355
|
+
reply_to: str | None = None
|
|
3356
|
+
) -> GroupMessage:
|
|
3357
|
+
"""
|
|
3358
|
+
Sends a message to a group.
|
|
3359
|
+
|
|
3360
|
+
Parameters
|
|
3361
|
+
----------
|
|
3362
|
+
group_id : :class:`str`
|
|
3363
|
+
The ID of the group in which the direct message will be sent.
|
|
3364
|
+
text : :class:`str`
|
|
3365
|
+
The text content of the direct message.
|
|
3366
|
+
media_id : :class:`str`, default=None
|
|
3367
|
+
The media ID associated with any media content
|
|
3368
|
+
to be included in the message.
|
|
3369
|
+
Media ID can be received by using the :func:`.upload_media` method.
|
|
3370
|
+
reply_to : :class:`str`, default=None
|
|
3371
|
+
Message ID to reply to.
|
|
3372
|
+
|
|
3373
|
+
Returns
|
|
3374
|
+
-------
|
|
3375
|
+
:class:`GroupMessage`
|
|
3376
|
+
`GroupMessage` object containing information about
|
|
3377
|
+
the message sent.
|
|
3378
|
+
|
|
3379
|
+
Examples
|
|
3380
|
+
--------
|
|
3381
|
+
>>> # send DM with media
|
|
3382
|
+
>>> group_id = '000000000'
|
|
3383
|
+
>>> media_id = await client.upload_media('image.png')
|
|
3384
|
+
>>> message = await client.send_dm_to_group(group_id, 'text', media_id)
|
|
3385
|
+
>>> print(message)
|
|
3386
|
+
<GroupMessage id='...'>
|
|
3387
|
+
|
|
3388
|
+
See Also
|
|
3389
|
+
--------
|
|
3390
|
+
.upload_media
|
|
3391
|
+
.delete_dm
|
|
3392
|
+
"""
|
|
3393
|
+
response = await self._send_dm(group_id, text, media_id, reply_to)
|
|
3394
|
+
|
|
3395
|
+
message_data = find_dict(response, 'message_data', find_one=True)[0]
|
|
3396
|
+
users = list(response['users'].values())
|
|
3397
|
+
return GroupMessage(
|
|
3398
|
+
self,
|
|
3399
|
+
message_data,
|
|
3400
|
+
users[0]['id_str'],
|
|
3401
|
+
group_id
|
|
3402
|
+
)
|
|
3403
|
+
|
|
3404
|
+
async def get_group_dm_history(
|
|
3405
|
+
self,
|
|
3406
|
+
group_id: str,
|
|
3407
|
+
max_id: str | None = None
|
|
3408
|
+
) -> Result[GroupMessage]:
|
|
3409
|
+
"""
|
|
3410
|
+
Retrieves the DM conversation history in a group.
|
|
3411
|
+
|
|
3412
|
+
Parameters
|
|
3413
|
+
----------
|
|
3414
|
+
group_id : :class:`str`
|
|
3415
|
+
The ID of the group in which the DM conversation
|
|
3416
|
+
history will be retrieved.
|
|
3417
|
+
max_id : :class:`str`, default=None
|
|
3418
|
+
If specified, retrieves messages older than the specified max_id.
|
|
3419
|
+
|
|
3420
|
+
Returns
|
|
3421
|
+
-------
|
|
3422
|
+
Result[:class:`GroupMessage`]
|
|
3423
|
+
A Result object containing a list of GroupMessage objects
|
|
3424
|
+
representing the DM conversation history.
|
|
3425
|
+
|
|
3426
|
+
Examples
|
|
3427
|
+
--------
|
|
3428
|
+
>>> messages = await client.get_group_dm_history('0000000000')
|
|
3429
|
+
>>> for message in messages:
|
|
3430
|
+
>>> print(message)
|
|
3431
|
+
<GroupMessage id="...">
|
|
3432
|
+
<GroupMessage id="...">
|
|
3433
|
+
...
|
|
3434
|
+
...
|
|
3435
|
+
|
|
3436
|
+
>>> more_messages = await messages.next() # Retrieve more messages
|
|
3437
|
+
>>> for message in more_messages:
|
|
3438
|
+
>>> print(message)
|
|
3439
|
+
<GroupMessage id="...">
|
|
3440
|
+
<GroupMessage id="...">
|
|
3441
|
+
...
|
|
3442
|
+
...
|
|
3443
|
+
"""
|
|
3444
|
+
response = await self._get_dm_history(group_id, max_id)
|
|
3445
|
+
if 'entries' not in response['conversation_timeline']:
|
|
3446
|
+
return Result([])
|
|
3447
|
+
|
|
3448
|
+
items = response['conversation_timeline']['entries']
|
|
3449
|
+
messages = []
|
|
3450
|
+
for item in items:
|
|
3451
|
+
if 'message' not in item:
|
|
3452
|
+
continue
|
|
3453
|
+
message_info = item['message']['message_data']
|
|
3454
|
+
messages.append(GroupMessage(
|
|
3455
|
+
self,
|
|
3456
|
+
message_info,
|
|
3457
|
+
message_info['sender_id'],
|
|
3458
|
+
group_id
|
|
3459
|
+
))
|
|
3460
|
+
|
|
3461
|
+
return Result(
|
|
3462
|
+
messages,
|
|
3463
|
+
partial(self.get_group_dm_history, group_id, messages[-1].id),
|
|
3464
|
+
messages[-1].id
|
|
3465
|
+
)
|
|
3466
|
+
|
|
3467
|
+
async def get_group(self, group_id: str) -> Group:
|
|
3468
|
+
"""
|
|
3469
|
+
Fetches a guild by ID.
|
|
3470
|
+
|
|
3471
|
+
Parameters
|
|
3472
|
+
----------
|
|
3473
|
+
group_id : :class:`str`
|
|
3474
|
+
The ID of the group to retrieve information for.
|
|
3475
|
+
|
|
3476
|
+
Returns
|
|
3477
|
+
-------
|
|
3478
|
+
:class:`Group`
|
|
3479
|
+
An object representing the retrieved group.
|
|
3480
|
+
"""
|
|
3481
|
+
response = await self._get_dm_history(group_id)
|
|
3482
|
+
return Group(self, group_id, response)
|
|
3483
|
+
|
|
3484
|
+
async def add_members_to_group(
|
|
3485
|
+
self, group_id: str, user_ids: list[str]
|
|
3486
|
+
) -> Response:
|
|
3487
|
+
"""Adds members to a group.
|
|
3488
|
+
|
|
3489
|
+
Parameters
|
|
3490
|
+
----------
|
|
3491
|
+
group_id : :class:`str`
|
|
3492
|
+
ID of the group to which the member is to be added.
|
|
3493
|
+
user_ids : list[:class:`str`]
|
|
3494
|
+
List of IDs of users to be added.
|
|
3495
|
+
|
|
3496
|
+
Returns
|
|
3497
|
+
-------
|
|
3498
|
+
:class:`httpx.Response`
|
|
3499
|
+
Response returned from twitter api.
|
|
3500
|
+
|
|
3501
|
+
Examples
|
|
3502
|
+
--------
|
|
3503
|
+
>>> group_id = '...'
|
|
3504
|
+
>>> members = ['...']
|
|
3505
|
+
>>> await client.add_members_to_group(group_id, members)
|
|
3506
|
+
"""
|
|
3507
|
+
_, response = await self.gql.add_participants_mutation(group_id, user_ids)
|
|
3508
|
+
return response
|
|
3509
|
+
|
|
3510
|
+
async def change_group_name(self, group_id: str, name: str) -> Response:
|
|
3511
|
+
"""Changes group name
|
|
3512
|
+
|
|
3513
|
+
Parameters
|
|
3514
|
+
----------
|
|
3515
|
+
group_id : :class:`str`
|
|
3516
|
+
ID of the group to be renamed.
|
|
3517
|
+
name : :class:`str`
|
|
3518
|
+
New name.
|
|
3519
|
+
|
|
3520
|
+
Returns
|
|
3521
|
+
-------
|
|
3522
|
+
:class:`httpx.Response`
|
|
3523
|
+
Response returned from twitter api.
|
|
3524
|
+
"""
|
|
3525
|
+
_, response = await self.v11.conversation_update_name(group_id, name)
|
|
3526
|
+
return response
|
|
3527
|
+
|
|
3528
|
+
async def create_list(
|
|
3529
|
+
self, name: str, description: str = '', is_private: bool = False
|
|
3530
|
+
) -> List:
|
|
3531
|
+
"""
|
|
3532
|
+
Creates a list.
|
|
3533
|
+
|
|
3534
|
+
Parameters
|
|
3535
|
+
----------
|
|
3536
|
+
name : :class:`str`
|
|
3537
|
+
The name of the list.
|
|
3538
|
+
description : :class:`str`, default=''
|
|
3539
|
+
The description of the list.
|
|
3540
|
+
is_private : :class:`bool`, default=False
|
|
3541
|
+
Indicates whether the list is private (True) or public (False).
|
|
3542
|
+
|
|
3543
|
+
Returns
|
|
3544
|
+
-------
|
|
3545
|
+
:class:`List`
|
|
3546
|
+
The created list.
|
|
3547
|
+
|
|
3548
|
+
Examples
|
|
3549
|
+
--------
|
|
3550
|
+
>>> list = await client.create_list(
|
|
3551
|
+
... 'list name',
|
|
3552
|
+
... 'list description',
|
|
3553
|
+
... is_private=True
|
|
3554
|
+
... )
|
|
3555
|
+
>>> print(list)
|
|
3556
|
+
<List id="...">
|
|
3557
|
+
"""
|
|
3558
|
+
response, _ = await self.gql.create_list(name, description, is_private)
|
|
3559
|
+
list_info = find_dict(response, 'list', find_one=True)[0]
|
|
3560
|
+
return List(self, list_info)
|
|
3561
|
+
|
|
3562
|
+
async def edit_list_banner(self, list_id: str, media_id: str) -> Response:
|
|
3563
|
+
"""
|
|
3564
|
+
Edit the banner image of a list.
|
|
3565
|
+
|
|
3566
|
+
Parameters
|
|
3567
|
+
----------
|
|
3568
|
+
list_id : :class:`str`
|
|
3569
|
+
The ID of the list.
|
|
3570
|
+
media_id : :class:`str`
|
|
3571
|
+
The ID of the media to use as the new banner image.
|
|
3572
|
+
|
|
3573
|
+
Returns
|
|
3574
|
+
-------
|
|
3575
|
+
:class:`httpx.Response`
|
|
3576
|
+
Response returned from twitter api.
|
|
3577
|
+
|
|
3578
|
+
Examples
|
|
3579
|
+
--------
|
|
3580
|
+
>>> list_id = '...'
|
|
3581
|
+
>>> media_id = await client.upload_media('image.png')
|
|
3582
|
+
>>> await client.edit_list_banner(list_id, media_id)
|
|
3583
|
+
"""
|
|
3584
|
+
_, response = await self.gql.edit_list_banner(list_id, media_id)
|
|
3585
|
+
return response
|
|
3586
|
+
|
|
3587
|
+
async def delete_list_banner(self, list_id: str) -> Response:
|
|
3588
|
+
"""Deletes list banner.
|
|
3589
|
+
|
|
3590
|
+
Parameters
|
|
3591
|
+
----------
|
|
3592
|
+
list_id : :class:`str`
|
|
3593
|
+
ID of the list from which the banner is to be removed.
|
|
3594
|
+
|
|
3595
|
+
Returns
|
|
3596
|
+
-------
|
|
3597
|
+
:class:`httpx.Response`
|
|
3598
|
+
Response returned from twitter api.
|
|
3599
|
+
"""
|
|
3600
|
+
_, response = await self.gql.delete_list_banner(list_id)
|
|
3601
|
+
return response
|
|
3602
|
+
|
|
3603
|
+
async def edit_list(
|
|
3604
|
+
self,
|
|
3605
|
+
list_id: str,
|
|
3606
|
+
name: str | None = None,
|
|
3607
|
+
description: str | None = None,
|
|
3608
|
+
is_private: bool | None = None
|
|
3609
|
+
) -> List:
|
|
3610
|
+
"""
|
|
3611
|
+
Edits list information.
|
|
3612
|
+
|
|
3613
|
+
Parameters
|
|
3614
|
+
----------
|
|
3615
|
+
list_id : :class:`str`
|
|
3616
|
+
The ID of the list to edit.
|
|
3617
|
+
name : :class:`str`, default=None
|
|
3618
|
+
The new name for the list.
|
|
3619
|
+
description : :class:`str`, default=None
|
|
3620
|
+
The new description for the list.
|
|
3621
|
+
is_private : :class:`bool`, default=None
|
|
3622
|
+
Indicates whether the list should be private
|
|
3623
|
+
(True) or public (False).
|
|
3624
|
+
|
|
3625
|
+
Returns
|
|
3626
|
+
-------
|
|
3627
|
+
:class:`List`
|
|
3628
|
+
The updated Twitter list.
|
|
3629
|
+
|
|
3630
|
+
Examples
|
|
3631
|
+
--------
|
|
3632
|
+
>>> await client.edit_list(
|
|
3633
|
+
... 'new name', 'new description', True
|
|
3634
|
+
... )
|
|
3635
|
+
"""
|
|
3636
|
+
response, _ = await self.gql.update_list(list_id, name, description, is_private)
|
|
3637
|
+
list_info = find_dict(response, 'list', find_one=True)[0]
|
|
3638
|
+
return List(self, list_info)
|
|
3639
|
+
|
|
3640
|
+
async def add_list_member(self, list_id: str, user_id: str) -> List:
|
|
3641
|
+
"""
|
|
3642
|
+
Adds a user to a list.
|
|
3643
|
+
|
|
3644
|
+
Parameters
|
|
3645
|
+
----------
|
|
3646
|
+
list_id : :class:`str`
|
|
3647
|
+
The ID of the list.
|
|
3648
|
+
user_id : :class:`str`
|
|
3649
|
+
The ID of the user to add to the list.
|
|
3650
|
+
|
|
3651
|
+
Returns
|
|
3652
|
+
-------
|
|
3653
|
+
:class:`List`
|
|
3654
|
+
The updated Twitter list.
|
|
3655
|
+
|
|
3656
|
+
Examples
|
|
3657
|
+
--------
|
|
3658
|
+
>>> await client.add_list_member('list id', 'user id')
|
|
3659
|
+
"""
|
|
3660
|
+
response, _ = await self.gql.list_add_member(list_id, user_id)
|
|
3661
|
+
return List(self, response['data']['list'])
|
|
3662
|
+
|
|
3663
|
+
async def remove_list_member(self, list_id: str, user_id: str) -> List:
|
|
3664
|
+
"""
|
|
3665
|
+
Removes a user from a list.
|
|
3666
|
+
|
|
3667
|
+
Parameters
|
|
3668
|
+
----------
|
|
3669
|
+
list_id : :class:`str`
|
|
3670
|
+
The ID of the list.
|
|
3671
|
+
user_id : :class:`str`
|
|
3672
|
+
The ID of the user to remove from the list.
|
|
3673
|
+
|
|
3674
|
+
Returns
|
|
3675
|
+
-------
|
|
3676
|
+
:class:`List`
|
|
3677
|
+
The updated Twitter list.
|
|
3678
|
+
|
|
3679
|
+
Examples
|
|
3680
|
+
--------
|
|
3681
|
+
>>> await client.remove_list_member('list id', 'user id')
|
|
3682
|
+
"""
|
|
3683
|
+
response, _ = await self.gql.list_remove_member(list_id, user_id)
|
|
3684
|
+
if 'errors' in response:
|
|
3685
|
+
raise TwitterException(response['errors'][0]['message'])
|
|
3686
|
+
return List(self, response['data']['list'])
|
|
3687
|
+
|
|
3688
|
+
async def get_lists(
|
|
3689
|
+
self, count: int = 100, cursor: str = None
|
|
3690
|
+
) -> Result[List]:
|
|
3691
|
+
"""
|
|
3692
|
+
Retrieves a list of user lists.
|
|
3693
|
+
|
|
3694
|
+
Parameters
|
|
3695
|
+
----------
|
|
3696
|
+
count : :class:`int`
|
|
3697
|
+
The number of lists to retrieve.
|
|
3698
|
+
|
|
3699
|
+
Returns
|
|
3700
|
+
-------
|
|
3701
|
+
Result[:class:`List`]
|
|
3702
|
+
Retrieved lists.
|
|
3703
|
+
|
|
3704
|
+
Examples
|
|
3705
|
+
--------
|
|
3706
|
+
>>> lists = client.get_lists()
|
|
3707
|
+
>>> for list_ in lists:
|
|
3708
|
+
... print(list_)
|
|
3709
|
+
<List id="...">
|
|
3710
|
+
<List id="...">
|
|
3711
|
+
...
|
|
3712
|
+
...
|
|
3713
|
+
>>> more_lists = lists.next() # Retrieve more lists
|
|
3714
|
+
"""
|
|
3715
|
+
response, _ = await self.gql.list_management_pace_timeline(count, cursor)
|
|
3716
|
+
|
|
3717
|
+
entries = find_dict(response, 'entries', find_one=True)[0]
|
|
3718
|
+
items = find_dict(entries, 'items')
|
|
3719
|
+
|
|
3720
|
+
if len(items) < 2:
|
|
3721
|
+
return Result([])
|
|
3722
|
+
|
|
3723
|
+
lists = []
|
|
3724
|
+
for list in items[1]:
|
|
3725
|
+
lists.append(List(self, list['item']['itemContent']['list']))
|
|
3726
|
+
|
|
3727
|
+
next_cursor = entries[-1]['content']['value']
|
|
3728
|
+
|
|
3729
|
+
return Result(
|
|
3730
|
+
lists,
|
|
3731
|
+
partial(self.get_lists, count, next_cursor),
|
|
3732
|
+
next_cursor
|
|
3733
|
+
)
|
|
3734
|
+
|
|
3735
|
+
async def get_list(self, list_id: str) -> List:
|
|
3736
|
+
"""
|
|
3737
|
+
Retrieve list by ID.
|
|
3738
|
+
|
|
3739
|
+
Parameters
|
|
3740
|
+
----------
|
|
3741
|
+
list_id : :class:`str`
|
|
3742
|
+
The ID of the list to retrieve.
|
|
3743
|
+
|
|
3744
|
+
Returns
|
|
3745
|
+
-------
|
|
3746
|
+
:class:`List`
|
|
3747
|
+
List object.
|
|
3748
|
+
"""
|
|
3749
|
+
response, _ = await self.gql.list_by_rest_id(list_id)
|
|
3750
|
+
list_data_ = find_dict(response, 'list', find_one=True)
|
|
3751
|
+
if not list_data_:
|
|
3752
|
+
raise ValueError(f'Invalid list id: {list_id}')
|
|
3753
|
+
return List(self, list_data_[0])
|
|
3754
|
+
|
|
3755
|
+
async def get_list_tweets(
|
|
3756
|
+
self, list_id: str, count: int = 20, cursor: str | None = None
|
|
3757
|
+
) -> Result[Tweet]:
|
|
3758
|
+
"""
|
|
3759
|
+
Retrieves tweets from a list.
|
|
3760
|
+
|
|
3761
|
+
Parameters
|
|
3762
|
+
----------
|
|
3763
|
+
list_id : :class:`str`
|
|
3764
|
+
The ID of the list to retrieve tweets from.
|
|
3765
|
+
count : :class:`int`, default=20
|
|
3766
|
+
The number of tweets to retrieve.
|
|
3767
|
+
cursor : :class:`str`, default=None
|
|
3768
|
+
The cursor for pagination.
|
|
3769
|
+
|
|
3770
|
+
Returns
|
|
3771
|
+
-------
|
|
3772
|
+
Result[:class:`Tweet`]
|
|
3773
|
+
A Result object containing the retrieved tweets.
|
|
3774
|
+
|
|
3775
|
+
Examples
|
|
3776
|
+
--------
|
|
3777
|
+
>>> tweets = await client.get_list_tweets('list id')
|
|
3778
|
+
>>> for tweet in tweets:
|
|
3779
|
+
... print(tweet)
|
|
3780
|
+
<Tweet id="...">
|
|
3781
|
+
<Tweet id="...">
|
|
3782
|
+
...
|
|
3783
|
+
...
|
|
3784
|
+
|
|
3785
|
+
>>> more_tweets = await tweets.next() # Retrieve more tweets
|
|
3786
|
+
>>> for tweet in more_tweets:
|
|
3787
|
+
... print(tweet)
|
|
3788
|
+
<Tweet id="...">
|
|
3789
|
+
<Tweet id="...">
|
|
3790
|
+
...
|
|
3791
|
+
...
|
|
3792
|
+
"""
|
|
3793
|
+
response, _ = await self.gql.list_latest_tweets_timeline(list_id, count, cursor)
|
|
3794
|
+
|
|
3795
|
+
items_ = find_dict(response, 'entries', find_one=True)
|
|
3796
|
+
if not items_:
|
|
3797
|
+
raise ValueError(f'Invalid list id: {list_id}')
|
|
3798
|
+
items = items_[0]
|
|
3799
|
+
next_cursor = items[-1]['content']['value']
|
|
3800
|
+
|
|
3801
|
+
results = []
|
|
3802
|
+
for item in items:
|
|
3803
|
+
if not item['entryId'].startswith('tweet'):
|
|
3804
|
+
continue
|
|
3805
|
+
|
|
3806
|
+
tweet = tweet_from_data(self, item)
|
|
3807
|
+
if tweet is not None:
|
|
3808
|
+
results.append(tweet)
|
|
3809
|
+
|
|
3810
|
+
return Result(
|
|
3811
|
+
results,
|
|
3812
|
+
partial(self.get_list_tweets, list_id, count, next_cursor),
|
|
3813
|
+
next_cursor
|
|
3814
|
+
)
|
|
3815
|
+
|
|
3816
|
+
async def _get_list_users(self, f: str, list_id: str, count: int, cursor: str) -> Result[User]:
|
|
3817
|
+
"""
|
|
3818
|
+
Base function to retrieve the users associated with a list.
|
|
3819
|
+
"""
|
|
3820
|
+
response, _ = await f(list_id, count, cursor)
|
|
3821
|
+
|
|
3822
|
+
items = find_dict(response, 'entries', find_one=True)[0]
|
|
3823
|
+
results = []
|
|
3824
|
+
for item in items:
|
|
3825
|
+
entry_id = item['entryId']
|
|
3826
|
+
if entry_id.startswith('user'):
|
|
3827
|
+
user_info = find_dict(item, 'result', find_one=True)[0]
|
|
3828
|
+
results.append(User(self, user_info))
|
|
3829
|
+
elif entry_id.startswith('cursor-bottom'):
|
|
3830
|
+
next_cursor = item['content']['value']
|
|
3831
|
+
break
|
|
3832
|
+
|
|
3833
|
+
return Result(
|
|
3834
|
+
results,
|
|
3835
|
+
partial(self._get_list_users, f, list_id, count, next_cursor),
|
|
3836
|
+
next_cursor
|
|
3837
|
+
)
|
|
3838
|
+
|
|
3839
|
+
async def get_list_members(
|
|
3840
|
+
self, list_id: str, count: int = 20, cursor: str | None = None
|
|
3841
|
+
) -> Result[User]:
|
|
3842
|
+
"""Retrieves members of a list.
|
|
3843
|
+
|
|
3844
|
+
Parameters
|
|
3845
|
+
----------
|
|
3846
|
+
list_id : :class:`str`
|
|
3847
|
+
List ID.
|
|
3848
|
+
count : int, default=20
|
|
3849
|
+
Number of members to retrieve.
|
|
3850
|
+
|
|
3851
|
+
Returns
|
|
3852
|
+
-------
|
|
3853
|
+
Result[:class:`User`]
|
|
3854
|
+
Members of a list
|
|
3855
|
+
|
|
3856
|
+
Examples
|
|
3857
|
+
--------
|
|
3858
|
+
>>> members = client.get_list_members(123456789)
|
|
3859
|
+
>>> for member in members:
|
|
3860
|
+
... print(member)
|
|
3861
|
+
<User id="...">
|
|
3862
|
+
<User id="...">
|
|
3863
|
+
...
|
|
3864
|
+
...
|
|
3865
|
+
>>> more_members = members.next() # Retrieve more members
|
|
3866
|
+
"""
|
|
3867
|
+
return await self._get_list_users(self.gql.list_members, list_id, count, cursor)
|
|
3868
|
+
|
|
3869
|
+
async def get_list_subscribers(
|
|
3870
|
+
self, list_id: str, count: int = 20, cursor: str | None = None
|
|
3871
|
+
) -> Result[User]:
|
|
3872
|
+
"""Retrieves subscribers of a list.
|
|
3873
|
+
|
|
3874
|
+
Parameters
|
|
3875
|
+
----------
|
|
3876
|
+
list_id : :class:`str`
|
|
3877
|
+
List ID.
|
|
3878
|
+
count : :class:`int`, default=20
|
|
3879
|
+
Number of subscribers to retrieve.
|
|
3880
|
+
|
|
3881
|
+
Returns
|
|
3882
|
+
-------
|
|
3883
|
+
Result[:class:`User`]
|
|
3884
|
+
Subscribers of a list
|
|
3885
|
+
|
|
3886
|
+
Examples
|
|
3887
|
+
--------
|
|
3888
|
+
>>> members = client.get_list_subscribers(123456789)
|
|
3889
|
+
>>> for subscriber in subscribers:
|
|
3890
|
+
... print(subscriber)
|
|
3891
|
+
<User id="...">
|
|
3892
|
+
<User id="...">
|
|
3893
|
+
...
|
|
3894
|
+
...
|
|
3895
|
+
>>> more_subscribers = members.next() # Retrieve more subscribers
|
|
3896
|
+
"""
|
|
3897
|
+
return await self._get_list_users(self.gql.list_subscribers, list_id, count, cursor)
|
|
3898
|
+
|
|
3899
|
+
async def search_list(
|
|
3900
|
+
self, query: str, count: int = 20, cursor: str | None = None
|
|
3901
|
+
) -> Result[List]:
|
|
3902
|
+
"""
|
|
3903
|
+
Search for lists based on the provided query.
|
|
3904
|
+
|
|
3905
|
+
Parameters
|
|
3906
|
+
----------
|
|
3907
|
+
query : :class:`str`
|
|
3908
|
+
The search query.
|
|
3909
|
+
count : :class:`int`, default=20
|
|
3910
|
+
The number of lists to retrieve.
|
|
3911
|
+
|
|
3912
|
+
Returns
|
|
3913
|
+
-------
|
|
3914
|
+
Result[:class:`List`]
|
|
3915
|
+
An instance of the `Result` class containing the
|
|
3916
|
+
search results.
|
|
3917
|
+
|
|
3918
|
+
Examples
|
|
3919
|
+
--------
|
|
3920
|
+
>>> lists = await client.search_list('query')
|
|
3921
|
+
>>> for list in lists:
|
|
3922
|
+
... print(list)
|
|
3923
|
+
<List id="...">
|
|
3924
|
+
<List id="...">
|
|
3925
|
+
...
|
|
3926
|
+
|
|
3927
|
+
>>> more_lists = await lists.next() # Retrieve more lists
|
|
3928
|
+
"""
|
|
3929
|
+
response, _ = await self.gql.search_timeline(query, 'Lists', count, cursor)
|
|
3930
|
+
entries = find_dict(response, 'entries', find_one=True)[0]
|
|
3931
|
+
|
|
3932
|
+
if cursor is None:
|
|
3933
|
+
items = entries[0]['content']['items']
|
|
3934
|
+
else:
|
|
3935
|
+
items = find_dict(response, 'moduleItems', find_one=True)[0]
|
|
3936
|
+
|
|
3937
|
+
lists = []
|
|
3938
|
+
for item in items:
|
|
3939
|
+
lists.append(List(self, item['item']['itemContent']['list']))
|
|
3940
|
+
next_cursor = entries[-1]['content']['value']
|
|
3941
|
+
|
|
3942
|
+
return Result(
|
|
3943
|
+
lists,
|
|
3944
|
+
partial(self.search_list, query, count, next_cursor),
|
|
3945
|
+
next_cursor
|
|
3946
|
+
)
|
|
3947
|
+
|
|
3948
|
+
async def get_notifications(
|
|
3949
|
+
self,
|
|
3950
|
+
type: Literal['All', 'Verified', 'Mentions'],
|
|
3951
|
+
count: int = 40,
|
|
3952
|
+
cursor: str | None = None
|
|
3953
|
+
) -> Result[Notification]:
|
|
3954
|
+
"""
|
|
3955
|
+
Retrieve notifications based on the provided type.
|
|
3956
|
+
|
|
3957
|
+
Parameters
|
|
3958
|
+
----------
|
|
3959
|
+
type : {'All', 'Verified', 'Mentions'}
|
|
3960
|
+
Type of notifications to retrieve.
|
|
3961
|
+
All: All notifications
|
|
3962
|
+
Verified: Notifications relating to authenticated users
|
|
3963
|
+
Mentions: Notifications with mentions
|
|
3964
|
+
count : :class:`int`, default=40
|
|
3965
|
+
Number of notifications to retrieve.
|
|
3966
|
+
|
|
3967
|
+
Returns
|
|
3968
|
+
-------
|
|
3969
|
+
Result[:class:`Notification`]
|
|
3970
|
+
List of retrieved notifications.
|
|
3971
|
+
|
|
3972
|
+
Examples
|
|
3973
|
+
--------
|
|
3974
|
+
>>> notifications = await client.get_notifications('All')
|
|
3975
|
+
>>> for notification in notifications:
|
|
3976
|
+
... print(notification)
|
|
3977
|
+
<Notification id="...">
|
|
3978
|
+
<Notification id="...">
|
|
3979
|
+
...
|
|
3980
|
+
...
|
|
3981
|
+
|
|
3982
|
+
>>> # Retrieve more notifications
|
|
3983
|
+
>>> more_notifications = await notifications.next()
|
|
3984
|
+
"""
|
|
3985
|
+
type = type.capitalize()
|
|
3986
|
+
f = {
|
|
3987
|
+
'All': self.v11.notifications_all,
|
|
3988
|
+
'Verified': self.v11.notifications_verified,
|
|
3989
|
+
'Mentions': self.v11.notifications_mentions
|
|
3990
|
+
}[type]
|
|
3991
|
+
response, _ = await f(count, cursor)
|
|
3992
|
+
|
|
3993
|
+
global_objects = response['globalObjects']
|
|
3994
|
+
users = {
|
|
3995
|
+
id: User(self, build_user_data(data))
|
|
3996
|
+
for id, data in global_objects.get('users', {}).items()
|
|
3997
|
+
}
|
|
3998
|
+
tweets = {}
|
|
3999
|
+
|
|
4000
|
+
for id, tweet_data in global_objects.get('tweets', {}).items():
|
|
4001
|
+
user_id = tweet_data['user_id_str']
|
|
4002
|
+
user = users[user_id]
|
|
4003
|
+
tweet = Tweet(self, build_tweet_data(tweet_data), user)
|
|
4004
|
+
tweets[id] = tweet
|
|
4005
|
+
|
|
4006
|
+
notifications = []
|
|
4007
|
+
|
|
4008
|
+
for notification in global_objects.get('notifications', {}).values():
|
|
4009
|
+
user_actions = notification['template']['aggregateUserActionsV1']
|
|
4010
|
+
target_objects = user_actions['targetObjects']
|
|
4011
|
+
if target_objects and 'tweet' in target_objects[0]:
|
|
4012
|
+
tweet_id = target_objects[0]['tweet']['id']
|
|
4013
|
+
tweet = tweets[tweet_id]
|
|
4014
|
+
else:
|
|
4015
|
+
tweet = None
|
|
4016
|
+
|
|
4017
|
+
from_users = user_actions['fromUsers']
|
|
4018
|
+
if from_users and 'user' in from_users[0]:
|
|
4019
|
+
user_id = from_users[0]['user']['id']
|
|
4020
|
+
user = users[user_id]
|
|
4021
|
+
else:
|
|
4022
|
+
user = None
|
|
4023
|
+
|
|
4024
|
+
notifications.append(Notification(self, notification, tweet, user))
|
|
4025
|
+
|
|
4026
|
+
entries = find_dict(response, 'entries', find_one=True)[0]
|
|
4027
|
+
cursor_bottom_entry = [
|
|
4028
|
+
i for i in entries
|
|
4029
|
+
if i['entryId'].startswith('cursor-bottom')
|
|
4030
|
+
]
|
|
4031
|
+
if cursor_bottom_entry:
|
|
4032
|
+
next_cursor = find_dict(cursor_bottom_entry[0], 'value', find_one=True)[0]
|
|
4033
|
+
else:
|
|
4034
|
+
next_cursor = None
|
|
4035
|
+
|
|
4036
|
+
return Result(
|
|
4037
|
+
notifications,
|
|
4038
|
+
partial(self.get_notifications, type, count, next_cursor),
|
|
4039
|
+
next_cursor
|
|
4040
|
+
)
|
|
4041
|
+
|
|
4042
|
+
async def search_community(
|
|
4043
|
+
self, query: str, cursor: str | None = None
|
|
4044
|
+
) -> Result[Community]:
|
|
4045
|
+
"""
|
|
4046
|
+
Searchs communities based on the specified query.
|
|
4047
|
+
|
|
4048
|
+
Parameters
|
|
4049
|
+
----------
|
|
4050
|
+
query : :class:`str`
|
|
4051
|
+
The search query.
|
|
4052
|
+
|
|
4053
|
+
Returns
|
|
4054
|
+
-------
|
|
4055
|
+
Result[:class:`Community`]
|
|
4056
|
+
List of retrieved communities.
|
|
4057
|
+
|
|
4058
|
+
Examples
|
|
4059
|
+
--------
|
|
4060
|
+
>>> communities = await client.search_communities('query')
|
|
4061
|
+
>>> for community in communities:
|
|
4062
|
+
... print(community)
|
|
4063
|
+
<Community id="...">
|
|
4064
|
+
<Community id="...">
|
|
4065
|
+
...
|
|
4066
|
+
|
|
4067
|
+
>>> # Retrieve more communities
|
|
4068
|
+
>>> more_communities = await communities.next()
|
|
4069
|
+
"""
|
|
4070
|
+
response, _ = await self.gql.search_community(query, cursor)
|
|
4071
|
+
|
|
4072
|
+
items = find_dict(response, 'items_results', find_one=True)[0]
|
|
4073
|
+
communities = []
|
|
4074
|
+
for item in items:
|
|
4075
|
+
communities.append(Community(self, item['result']))
|
|
4076
|
+
next_cursor_ = find_dict(response, 'next_cursor', find_one=True)
|
|
4077
|
+
next_cursor = next_cursor_[0] if next_cursor_ else None
|
|
4078
|
+
if next_cursor is None:
|
|
4079
|
+
fetch_next_result = None
|
|
4080
|
+
else:
|
|
4081
|
+
fetch_next_result = partial(self.search_community, query, next_cursor)
|
|
4082
|
+
return Result(
|
|
4083
|
+
communities,
|
|
4084
|
+
fetch_next_result,
|
|
4085
|
+
next_cursor
|
|
4086
|
+
)
|
|
4087
|
+
|
|
4088
|
+
async def get_community(self, community_id: str) -> Community:
|
|
4089
|
+
"""
|
|
4090
|
+
Retrieves community by ID.
|
|
4091
|
+
|
|
4092
|
+
Parameters
|
|
4093
|
+
----------
|
|
4094
|
+
list_id : :class:`str`
|
|
4095
|
+
The ID of the community to retrieve.
|
|
4096
|
+
|
|
4097
|
+
Returns
|
|
4098
|
+
-------
|
|
4099
|
+
:class:`Community`
|
|
4100
|
+
Community object.
|
|
4101
|
+
"""
|
|
4102
|
+
response, _ = await self.gql.community_query(community_id)
|
|
4103
|
+
community_data = find_dict(response, 'result', find_one=True)[0]
|
|
4104
|
+
return Community(self, community_data)
|
|
4105
|
+
|
|
4106
|
+
async def get_community_tweets(
|
|
4107
|
+
self,
|
|
4108
|
+
community_id: str,
|
|
4109
|
+
tweet_type: Literal['Top', 'Latest', 'Media'],
|
|
4110
|
+
count: int = 40,
|
|
4111
|
+
cursor: str | None = None
|
|
4112
|
+
) -> Result[Tweet]:
|
|
4113
|
+
"""
|
|
4114
|
+
Retrieves tweets from a community.
|
|
4115
|
+
|
|
4116
|
+
Parameters
|
|
4117
|
+
----------
|
|
4118
|
+
community_id : :class:`str`
|
|
4119
|
+
The ID of the community.
|
|
4120
|
+
tweet_type : {'Top', 'Latest', 'Media'}
|
|
4121
|
+
The type of tweets to retrieve.
|
|
4122
|
+
count : :class:`int`, default=40
|
|
4123
|
+
The number of tweets to retrieve.
|
|
4124
|
+
|
|
4125
|
+
Returns
|
|
4126
|
+
-------
|
|
4127
|
+
Result[:class:`Tweet`]
|
|
4128
|
+
List of retrieved tweets.
|
|
4129
|
+
|
|
4130
|
+
Examples
|
|
4131
|
+
--------
|
|
4132
|
+
>>> community_id = '...'
|
|
4133
|
+
>>> tweets = await client.get_community_tweets(community_id, 'Latest')
|
|
4134
|
+
>>> for tweet in tweets:
|
|
4135
|
+
... print(tweet)
|
|
4136
|
+
<Tweet id="...">
|
|
4137
|
+
<Tweet id="...">
|
|
4138
|
+
...
|
|
4139
|
+
>>> more_tweets = await tweets.next() # Retrieve more tweets
|
|
4140
|
+
"""
|
|
4141
|
+
if tweet_type == 'Media':
|
|
4142
|
+
response, _ = await self.gql.community_media_timeline(community_id, count, cursor)
|
|
4143
|
+
elif tweet_type == 'Top':
|
|
4144
|
+
response, _ = await self.gql.community_tweets_timeline(community_id, 'Relevance', count, cursor)
|
|
4145
|
+
elif tweet_type == 'Latest':
|
|
4146
|
+
response, _ = await self.gql.community_tweets_timeline(community_id, 'Recency', count, cursor)
|
|
4147
|
+
else:
|
|
4148
|
+
raise ValueError(f'Invalid tweet_type: {tweet_type}')
|
|
4149
|
+
|
|
4150
|
+
entries = find_dict(response, 'entries', find_one=True)[0]
|
|
4151
|
+
if tweet_type == 'Media':
|
|
4152
|
+
if cursor is None:
|
|
4153
|
+
items = entries[0]['content']['items']
|
|
4154
|
+
next_cursor = entries[-1]['content']['value']
|
|
4155
|
+
previous_cursor = entries[-2]['content']['value']
|
|
4156
|
+
else:
|
|
4157
|
+
items = find_dict(response, 'moduleItems', find_one=True)[0]
|
|
4158
|
+
next_cursor = entries[-1]['content']['value']
|
|
4159
|
+
previous_cursor = entries[-2]['content']['value']
|
|
4160
|
+
else:
|
|
4161
|
+
items = entries
|
|
4162
|
+
next_cursor = items[-1]['content']['value']
|
|
4163
|
+
previous_cursor = items[-2]['content']['value']
|
|
4164
|
+
|
|
4165
|
+
tweets = []
|
|
4166
|
+
for item in items:
|
|
4167
|
+
if not item['entryId'].startswith(('tweet', 'communities-grid')):
|
|
4168
|
+
continue
|
|
4169
|
+
|
|
4170
|
+
tweet = tweet_from_data(self, item)
|
|
4171
|
+
if tweet is not None:
|
|
4172
|
+
tweets.append(tweet)
|
|
4173
|
+
|
|
4174
|
+
return Result(
|
|
4175
|
+
tweets,
|
|
4176
|
+
partial(self.get_community_tweets, community_id, tweet_type, count, next_cursor),
|
|
4177
|
+
next_cursor,
|
|
4178
|
+
partial(self.get_community_tweets, community_id, tweet_type, count, previous_cursor),
|
|
4179
|
+
previous_cursor
|
|
4180
|
+
)
|
|
4181
|
+
|
|
4182
|
+
async def get_communities_timeline(
|
|
4183
|
+
self, count: int = 20, cursor: str | None = None
|
|
4184
|
+
) -> Result[Tweet]:
|
|
4185
|
+
"""
|
|
4186
|
+
Retrieves tweets from communities timeline.
|
|
4187
|
+
|
|
4188
|
+
Parameters
|
|
4189
|
+
----------
|
|
4190
|
+
count : :class:`int`, default=20
|
|
4191
|
+
The number of tweets to retrieve.
|
|
4192
|
+
|
|
4193
|
+
Returns
|
|
4194
|
+
-------
|
|
4195
|
+
Result[:class:`Tweet`]
|
|
4196
|
+
List of retrieved tweets.
|
|
4197
|
+
|
|
4198
|
+
Examples
|
|
4199
|
+
--------
|
|
4200
|
+
>>> tweets = await client.get_communities_timeline()
|
|
4201
|
+
>>> for tweet in tweets:
|
|
4202
|
+
... print(tweet)
|
|
4203
|
+
<Tweet id="...">
|
|
4204
|
+
<Tweet id="...">
|
|
4205
|
+
...
|
|
4206
|
+
>>> more_tweets = await tweets.next() # Retrieve more tweets
|
|
4207
|
+
"""
|
|
4208
|
+
response, _ = await self.gql.communities_main_page_timeline(count, cursor)
|
|
4209
|
+
items = find_dict(response, 'entries', find_one=True)[0]
|
|
4210
|
+
tweets = []
|
|
4211
|
+
for item in items:
|
|
4212
|
+
if not item['entryId'].startswith('tweet'):
|
|
4213
|
+
continue
|
|
4214
|
+
tweet_data = find_dict(item, 'result', find_one=True)[0]
|
|
4215
|
+
if 'tweet' in tweet_data:
|
|
4216
|
+
tweet_data = tweet_data['tweet']
|
|
4217
|
+
user_data = tweet_data['core']['user_results']['result']
|
|
4218
|
+
community_data = tweet_data['community_results']['result']
|
|
4219
|
+
community_data['rest_id'] = community_data['id_str']
|
|
4220
|
+
community = Community(self, community_data)
|
|
4221
|
+
tweet = Tweet(self, tweet_data, User(self, user_data))
|
|
4222
|
+
tweet.community = community
|
|
4223
|
+
tweets.append(tweet)
|
|
4224
|
+
|
|
4225
|
+
next_cursor = items[-1]['content']['value']
|
|
4226
|
+
previous_cursor = items[-2]['content']['value']
|
|
4227
|
+
|
|
4228
|
+
return Result(
|
|
4229
|
+
tweets,
|
|
4230
|
+
partial(self.get_communities_timeline, count, next_cursor),
|
|
4231
|
+
next_cursor,
|
|
4232
|
+
partial(self.get_communities_timeline, count, previous_cursor),
|
|
4233
|
+
previous_cursor
|
|
4234
|
+
)
|
|
4235
|
+
|
|
4236
|
+
async def join_community(self, community_id: str) -> Community:
|
|
4237
|
+
"""
|
|
4238
|
+
Join a community.
|
|
4239
|
+
|
|
4240
|
+
Parameters
|
|
4241
|
+
----------
|
|
4242
|
+
community_id : :class:`str`
|
|
4243
|
+
The ID of the community to join.
|
|
4244
|
+
|
|
4245
|
+
Returns
|
|
4246
|
+
-------
|
|
4247
|
+
:class:`Community`
|
|
4248
|
+
The joined community.
|
|
4249
|
+
"""
|
|
4250
|
+
response, _ = await self.gql.join_community(community_id)
|
|
4251
|
+
community_data = response['data']['community_join']
|
|
4252
|
+
community_data['rest_id'] = community_data['id_str']
|
|
4253
|
+
return Community(self, community_data)
|
|
4254
|
+
|
|
4255
|
+
async def leave_community(self, community_id: str) -> Community:
|
|
4256
|
+
"""
|
|
4257
|
+
Leave a community.
|
|
4258
|
+
|
|
4259
|
+
Parameters
|
|
4260
|
+
----------
|
|
4261
|
+
community_id : :class:`str`
|
|
4262
|
+
The ID of the community to leave.
|
|
4263
|
+
|
|
4264
|
+
Returns
|
|
4265
|
+
-------
|
|
4266
|
+
:class:`Community`
|
|
4267
|
+
The left community.
|
|
4268
|
+
"""
|
|
4269
|
+
response, _ = await self.gql.leave_community(community_id)
|
|
4270
|
+
community_data = response['data']['community_leave']
|
|
4271
|
+
community_data['rest_id'] = community_data['id_str']
|
|
4272
|
+
return Community(self, community_data)
|
|
4273
|
+
|
|
4274
|
+
async def request_to_join_community(
|
|
4275
|
+
self, community_id: str, answer: str | None = None
|
|
4276
|
+
) -> Community:
|
|
4277
|
+
"""
|
|
4278
|
+
Request to join a community.
|
|
4279
|
+
|
|
4280
|
+
Parameters
|
|
4281
|
+
----------
|
|
4282
|
+
community_id : :class:`str`
|
|
4283
|
+
The ID of the community to request to join.
|
|
4284
|
+
answer : :class:`str`, default=None
|
|
4285
|
+
The answer to the join request.
|
|
4286
|
+
|
|
4287
|
+
Returns
|
|
4288
|
+
-------
|
|
4289
|
+
:class:`Community`
|
|
4290
|
+
The requested community.
|
|
4291
|
+
"""
|
|
4292
|
+
response, _ = await self.gql.request_to_join_community(community_id, answer)
|
|
4293
|
+
community_data = find_dict(response, 'result', find_one=True)[0]
|
|
4294
|
+
community_data['rest_id'] = community_data['id_str']
|
|
4295
|
+
return Community(self, community_data)
|
|
4296
|
+
|
|
4297
|
+
async def _get_community_users(self, f, community_id: str, count: int, cursor: str | None):
|
|
4298
|
+
"""
|
|
4299
|
+
Base function to retrieve community users.
|
|
4300
|
+
"""
|
|
4301
|
+
response, _ = await f(community_id, count, cursor)
|
|
4302
|
+
|
|
4303
|
+
items = find_dict(response, 'items_results', find_one=True)[0]
|
|
4304
|
+
users = []
|
|
4305
|
+
for item in items:
|
|
4306
|
+
if 'result' not in item:
|
|
4307
|
+
continue
|
|
4308
|
+
if item['result'].get('__typename') != 'User':
|
|
4309
|
+
continue
|
|
4310
|
+
users.append(CommunityMember(self, item['result']))
|
|
4311
|
+
|
|
4312
|
+
next_cursor_ = find_dict(response, 'next_cursor', find_one=True)
|
|
4313
|
+
next_cursor = next_cursor_[0] if next_cursor_ else None
|
|
4314
|
+
|
|
4315
|
+
if next_cursor is None:
|
|
4316
|
+
fetch_next_result = None
|
|
4317
|
+
else:
|
|
4318
|
+
fetch_next_result = partial(self._get_community_users, f, community_id, count, next_cursor)
|
|
4319
|
+
return Result(
|
|
4320
|
+
users,
|
|
4321
|
+
fetch_next_result,
|
|
4322
|
+
next_cursor
|
|
4323
|
+
)
|
|
4324
|
+
|
|
4325
|
+
async def get_community_members(
|
|
4326
|
+
self, community_id: str, count: int = 20, cursor: str | None = None
|
|
4327
|
+
) -> Result[CommunityMember]:
|
|
4328
|
+
"""
|
|
4329
|
+
Retrieves members of a community.
|
|
4330
|
+
|
|
4331
|
+
Parameters
|
|
4332
|
+
----------
|
|
4333
|
+
community_id : :class:`str`
|
|
4334
|
+
The ID of the community.
|
|
4335
|
+
count : :class:`int`, default=20
|
|
4336
|
+
The number of members to retrieve.
|
|
4337
|
+
|
|
4338
|
+
Returns
|
|
4339
|
+
-------
|
|
4340
|
+
Result[:class:`CommunityMember`]
|
|
4341
|
+
List of retrieved members.
|
|
4342
|
+
"""
|
|
4343
|
+
return await self._get_community_users(
|
|
4344
|
+
self.gql.members_slice_timeline_query, community_id, count, cursor
|
|
4345
|
+
)
|
|
4346
|
+
|
|
4347
|
+
async def get_community_moderators(
|
|
4348
|
+
self, community_id: str, count: int = 20, cursor: str | None = None
|
|
4349
|
+
) -> Result[CommunityMember]:
|
|
4350
|
+
"""
|
|
4351
|
+
Retrieves moderators of a community.
|
|
4352
|
+
|
|
4353
|
+
Parameters
|
|
4354
|
+
----------
|
|
4355
|
+
community_id : :class:`str`
|
|
4356
|
+
The ID of the community.
|
|
4357
|
+
count : :class:`int`, default=20
|
|
4358
|
+
The number of moderators to retrieve.
|
|
4359
|
+
|
|
4360
|
+
Returns
|
|
4361
|
+
-------
|
|
4362
|
+
Result[:class:`CommunityMember`]
|
|
4363
|
+
List of retrieved moderators.
|
|
4364
|
+
"""
|
|
4365
|
+
return await self._get_community_users(
|
|
4366
|
+
self.gql.moderators_slice_timeline_query, community_id, count, cursor
|
|
4367
|
+
)
|
|
4368
|
+
|
|
4369
|
+
async def search_community_tweet(
|
|
4370
|
+
self,
|
|
4371
|
+
community_id: str,
|
|
4372
|
+
query: str,
|
|
4373
|
+
count: int = 20,
|
|
4374
|
+
cursor: str | None = None
|
|
4375
|
+
) -> Result[Tweet]:
|
|
4376
|
+
"""Searchs tweets in a community.
|
|
4377
|
+
|
|
4378
|
+
Parameters
|
|
4379
|
+
----------
|
|
4380
|
+
community_id : :class:`str`
|
|
4381
|
+
The ID of the community.
|
|
4382
|
+
query : :class:`str`
|
|
4383
|
+
The search query.
|
|
4384
|
+
count : :class:`int`, default=20
|
|
4385
|
+
The number of tweets to retrieve.
|
|
4386
|
+
|
|
4387
|
+
Returns
|
|
4388
|
+
-------
|
|
4389
|
+
Result[:class:`Tweet`]
|
|
4390
|
+
List of retrieved tweets.
|
|
4391
|
+
"""
|
|
4392
|
+
response, _ = await self.gql.community_tweet_search_module_query(community_id, query, count, cursor)
|
|
4393
|
+
|
|
4394
|
+
items = find_dict(response, 'entries', find_one=True)[0]
|
|
4395
|
+
tweets = []
|
|
4396
|
+
for item in items:
|
|
4397
|
+
if not item['entryId'].startswith('tweet'):
|
|
4398
|
+
continue
|
|
4399
|
+
|
|
4400
|
+
tweet = tweet_from_data(self, item)
|
|
4401
|
+
if tweet is not None:
|
|
4402
|
+
tweets.append(tweet)
|
|
4403
|
+
|
|
4404
|
+
next_cursor = items[-1]['content']['value']
|
|
4405
|
+
previous_cursor = items[-2]['content']['value']
|
|
4406
|
+
|
|
4407
|
+
return Result(
|
|
4408
|
+
tweets,
|
|
4409
|
+
partial(self.search_community_tweet, community_id, query, count, next_cursor),
|
|
4410
|
+
next_cursor,
|
|
4411
|
+
partial(self.search_community_tweet, community_id, query, count, previous_cursor),
|
|
4412
|
+
previous_cursor,
|
|
4413
|
+
)
|
|
4414
|
+
|
|
4415
|
+
async def _stream(self, topics: set[str]) -> AsyncGenerator[tuple[str, Payload]]:
|
|
4416
|
+
url = f'https://api.{DOMAIN}/live_pipeline/events'
|
|
4417
|
+
params = {'topics': ','.join(topics)}
|
|
4418
|
+
headers = self._base_headers
|
|
4419
|
+
headers.pop('content-type')
|
|
4420
|
+
|
|
4421
|
+
async with self.http.stream('GET', url, params=params, headers=headers, timeout=None) as response:
|
|
4422
|
+
self._remove_duplicate_ct0_cookie()
|
|
4423
|
+
async for line in response.aiter_lines():
|
|
4424
|
+
try:
|
|
4425
|
+
data = json.loads(line)
|
|
4426
|
+
except json.JSONDecodeError:
|
|
4427
|
+
continue
|
|
4428
|
+
payload = _payload_from_data(data['payload'])
|
|
4429
|
+
yield data.get('topic'), payload
|
|
4430
|
+
|
|
4431
|
+
async def get_streaming_session(
|
|
4432
|
+
self, topics: set[str], auto_reconnect: bool = True
|
|
4433
|
+
) -> StreamingSession:
|
|
4434
|
+
"""
|
|
4435
|
+
Returns a session for interacting with the streaming API.
|
|
4436
|
+
|
|
4437
|
+
Parameters
|
|
4438
|
+
----------
|
|
4439
|
+
topics : set[:class:`str`]
|
|
4440
|
+
The set of topics to stream.
|
|
4441
|
+
Topics can be generated using :class:`.Topic`.
|
|
4442
|
+
auto_reconnect : :class:`bool`, default=True
|
|
4443
|
+
Whether to automatically reconnect when disconnected.
|
|
4444
|
+
|
|
4445
|
+
Returns
|
|
4446
|
+
-------
|
|
4447
|
+
:class:`.StreamingSession`
|
|
4448
|
+
A stream session instance.
|
|
4449
|
+
|
|
4450
|
+
Examples
|
|
4451
|
+
--------
|
|
4452
|
+
>>> from xselfbot.streaming import Topic
|
|
4453
|
+
>>>
|
|
4454
|
+
>>> topics = {
|
|
4455
|
+
... Topic.tweet_engagement('1739617652'), # Stream tweet engagement
|
|
4456
|
+
... Topic.dm_update('17544932482-174455537996'), # Stream DM update
|
|
4457
|
+
... Topic.dm_typing('17544932482-174455537996') # Stream DM typing
|
|
4458
|
+
... }
|
|
4459
|
+
>>> session = await client.get_streaming_session(topics)
|
|
4460
|
+
>>>
|
|
4461
|
+
>>> async for topic, payload in session:
|
|
4462
|
+
... if payload.dm_update:
|
|
4463
|
+
... conversation_id = payload.dm_update.conversation_id
|
|
4464
|
+
... user_id = payload.dm_update.user_id
|
|
4465
|
+
... print(f'{conversation_id}: {user_id} sent a message')
|
|
4466
|
+
>>>
|
|
4467
|
+
>>> if payload.dm_typing:
|
|
4468
|
+
... conversation_id = payload.dm_typing.conversation_id
|
|
4469
|
+
... user_id = payload.dm_typing.user_id
|
|
4470
|
+
... print(f'{conversation_id}: {user_id} is typing')
|
|
4471
|
+
>>>
|
|
4472
|
+
>>> if payload.tweet_engagement:
|
|
4473
|
+
... like = payload.tweet_engagement.like_count
|
|
4474
|
+
... retweet = payload.tweet_engagement.retweet_count
|
|
4475
|
+
... view = payload.tweet_engagement.view_count
|
|
4476
|
+
... print('Tweet engagement updated:'
|
|
4477
|
+
... f'likes: {like} retweets: {retweet} views: {view}')
|
|
4478
|
+
|
|
4479
|
+
Topics to stream can be added or deleted using
|
|
4480
|
+
:attr:`.StreamingSession.update_subscriptions` method.
|
|
4481
|
+
|
|
4482
|
+
>>> subscribe_topics = {
|
|
4483
|
+
... Topic.tweet_engagement('1749528513'),
|
|
4484
|
+
... Topic.tweet_engagement('1765829534')
|
|
4485
|
+
... }
|
|
4486
|
+
>>> unsubscribe_topics = {
|
|
4487
|
+
... Topic.tweet_engagement('1739617652'),
|
|
4488
|
+
... Topic.dm_update('17544932482-174455537996'),
|
|
4489
|
+
... Topic.dm_update('17544932482-174455537996')
|
|
4490
|
+
... }
|
|
4491
|
+
>>> await session.update_subscriptions(
|
|
4492
|
+
... subscribe_topics, unsubscribe_topics
|
|
4493
|
+
... )
|
|
4494
|
+
|
|
4495
|
+
See Also
|
|
4496
|
+
--------
|
|
4497
|
+
.StreamingSession
|
|
4498
|
+
.StreamingSession.update_subscriptions
|
|
4499
|
+
.Payload
|
|
4500
|
+
.Topic
|
|
4501
|
+
"""
|
|
4502
|
+
stream = self._stream(topics)
|
|
4503
|
+
session_id = (await anext(stream))[1].config.session_id
|
|
4504
|
+
return StreamingSession(self, session_id, stream, topics, auto_reconnect)
|
|
4505
|
+
|
|
4506
|
+
async def _update_subscriptions(
|
|
4507
|
+
self,
|
|
4508
|
+
session: StreamingSession,
|
|
4509
|
+
subscribe: set[str] | None = None,
|
|
4510
|
+
unsubscribe: set[str] | None = None
|
|
4511
|
+
) -> Payload:
|
|
4512
|
+
if subscribe is None:
|
|
4513
|
+
subscribe = set()
|
|
4514
|
+
if unsubscribe is None:
|
|
4515
|
+
unsubscribe = set()
|
|
4516
|
+
|
|
4517
|
+
response, _ = await self.v11.live_pipeline_update_subscriptions(
|
|
4518
|
+
session.id, ','.join(subscribe), ','.join(unsubscribe)
|
|
4519
|
+
)
|
|
4520
|
+
session.topics |= subscribe
|
|
4521
|
+
session.topics -= unsubscribe
|
|
4522
|
+
|
|
4523
|
+
return _payload_from_data(response)
|
|
4524
|
+
|
|
4525
|
+
async def _get_user_state(self) -> Literal['normal', 'bounced', 'suspended']:
|
|
4526
|
+
response, _ = await self.v11.user_state()
|
|
4527
|
+
return response['userState']
|