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.
@@ -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']