tweepy-self 1.9.0__py3-none-any.whl → 1.10.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.
twitter/client.py CHANGED
@@ -2,6 +2,7 @@ from typing import Any, Literal, Iterable
2
2
  from time import time
3
3
  import asyncio
4
4
  import base64
5
+ import json
5
6
  import re
6
7
 
7
8
  from loguru import logger
@@ -29,9 +30,8 @@ from .errors import (
29
30
  from .utils import to_json
30
31
  from .base import BaseHTTPClient
31
32
  from .account import Account, AccountStatus
32
- from .models import User, Tweet, Media
33
+ from .models import User, Tweet, Media, Subtask
33
34
  from .utils import (
34
- remove_at_sign,
35
35
  parse_oauth_html,
36
36
  parse_unlock_html,
37
37
  tweets_data_from_instructions,
@@ -44,7 +44,6 @@ class Client(BaseHTTPClient):
44
44
  "authority": "twitter.com",
45
45
  "origin": "https://twitter.com",
46
46
  "x-twitter-active-user": "yes",
47
- # 'x-twitter-auth-type': 'OAuth2Session',
48
47
  "x-twitter-client-language": "en",
49
48
  }
50
49
  _GRAPHQL_URL = "https://twitter.com/i/api/graphql"
@@ -52,7 +51,7 @@ class Client(BaseHTTPClient):
52
51
  "CreateRetweet": "ojPdsZsimiJrUGLR1sjUtA",
53
52
  "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
54
53
  "UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA",
55
- "CreateTweet": "SoVnbfCycZ7fERGCwpZkYA",
54
+ "CreateTweet": "v0en1yVV-Ybeek8ClmXwYw",
56
55
  "TweetResultByRestId": "V3vfsYzNEyD9tsf4xoFRgw",
57
56
  "ModerateTweet": "p'jF:GVqCjTcZol0xcBJjw",
58
57
  "DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg",
@@ -84,6 +83,8 @@ class Client(BaseHTTPClient):
84
83
  wait_on_rate_limit: bool = True,
85
84
  capsolver_api_key: str = None,
86
85
  max_unlock_attempts: int = 5,
86
+ auto_relogin: bool = True,
87
+ update_account_info_on_startup: bool = True,
87
88
  **session_kwargs,
88
89
  ):
89
90
  super().__init__(**session_kwargs)
@@ -91,13 +92,21 @@ class Client(BaseHTTPClient):
91
92
  self.wait_on_rate_limit = wait_on_rate_limit
92
93
  self.capsolver_api_key = capsolver_api_key
93
94
  self.max_unlock_attempts = max_unlock_attempts
95
+ self.auto_relogin = auto_relogin
96
+ self._update_account_info_on_startup = update_account_info_on_startup
94
97
 
95
- async def request(
98
+ async def __aenter__(self):
99
+ await self.on_startup()
100
+ return await super().__aenter__()
101
+
102
+ async def _request(
96
103
  self,
97
104
  method,
98
105
  url,
106
+ *,
99
107
  auth: bool = True,
100
108
  bearer: bool = True,
109
+ wait_on_rate_limit: bool = None,
101
110
  **kwargs,
102
111
  ) -> tuple[requests.Response, Any]:
103
112
  cookies = kwargs["cookies"] = kwargs.get("cookies") or {}
@@ -105,6 +114,7 @@ class Client(BaseHTTPClient):
105
114
 
106
115
  if bearer:
107
116
  headers["authorization"] = f"Bearer {self._BEARER_TOKEN}"
117
+ # headers["x-twitter-auth-type"] = "OAuth2Session"
108
118
 
109
119
  if auth:
110
120
  if not self.account.auth_token:
@@ -116,7 +126,8 @@ class Client(BaseHTTPClient):
116
126
  headers["x-csrf-token"] = self.account.ct0
117
127
 
118
128
  # fmt: off
119
- log_message = f"{self.account} Request {method} {url}"
129
+ log_message = (f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
130
+ f" ==> Request {method} {url}")
120
131
  if kwargs.get('data'): log_message += f"\nRequest data: {kwargs.get('data')}"
121
132
  if kwargs.get('json'): log_message += f"\nRequest data: {kwargs.get('json')}"
122
133
  logger.debug(log_message)
@@ -135,23 +146,51 @@ class Client(BaseHTTPClient):
135
146
 
136
147
  data = response.text
137
148
  # fmt: off
138
- log_message = (f"{self.account} Response {method} {url}"
139
- f"\nStatus code: {response.status_code}"
140
- f"\nResponse data: {data}")
141
- logger.debug(log_message)
149
+ logger.debug(f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
150
+ f" <== Response {method} {url}"
151
+ f"\nStatus code: {response.status_code}"
152
+ f"\nResponse data: {data}")
142
153
  # fmt: on
143
154
 
144
- if response.headers["content-type"].startswith("application/json"):
155
+ if ct0 := self._session.cookies.get("ct0", domain=".twitter.com"):
156
+ self.account.ct0 = ct0
157
+
158
+ auth_token = self._session.cookies.get("auth_token")
159
+ if auth_token and auth_token != self.account.auth_token:
160
+ self.account.auth_token = auth_token
161
+ logger.warning(
162
+ f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
163
+ f" Requested new auth_token!"
164
+ )
165
+
166
+ try:
145
167
  data = response.json()
168
+ except json.decoder.JSONDecodeError:
169
+ pass
146
170
 
147
- if response.status_code == 429:
148
- if self.wait_on_rate_limit:
149
- reset_time = int(response.headers["x-rate-limit-reset"])
150
- sleep_time = reset_time - int(time()) + 1
151
- if sleep_time > 0:
152
- await asyncio.sleep(sleep_time)
153
- return await self.request(method, url, auth, bearer, **kwargs)
154
- raise RateLimited(response, data)
171
+ if 300 > response.status_code >= 200:
172
+ if isinstance(data, dict) and "errors" in data:
173
+ exc = HTTPException(response, data)
174
+
175
+ if 141 in exc.api_codes:
176
+ self.account.status = AccountStatus.SUSPENDED
177
+ raise Suspended(exc, self.account)
178
+
179
+ if 326 in exc.api_codes:
180
+ for error_data in exc.api_errors:
181
+ if (
182
+ error_data.get("code") == 326
183
+ and error_data.get("bounce_location")
184
+ == "/i/flow/consent_flow"
185
+ ):
186
+ self.account.status = AccountStatus.CONSENT_LOCKED
187
+ raise ConsentLocked(exc, self.account)
188
+
189
+ self.account.status = AccountStatus.LOCKED
190
+ raise Locked(exc, self.account)
191
+ raise exc
192
+
193
+ return response, data
155
194
 
156
195
  if response.status_code == 400:
157
196
  raise BadRequest(response, data)
@@ -168,10 +207,6 @@ class Client(BaseHTTPClient):
168
207
  if response.status_code == 403:
169
208
  exc = Forbidden(response, data)
170
209
 
171
- if 353 in exc.api_codes and "ct0" in response.cookies:
172
- self.account.ct0 = response.cookies["ct0"]
173
- return await self.request(method, url, auth, bearer, **kwargs)
174
-
175
210
  if 64 in exc.api_codes:
176
211
  self.account.status = AccountStatus.SUSPENDED
177
212
  raise Suspended(exc, self.account)
@@ -186,52 +221,83 @@ class Client(BaseHTTPClient):
186
221
  raise ConsentLocked(exc, self.account)
187
222
 
188
223
  self.account.status = AccountStatus.LOCKED
189
- if not self.capsolver_api_key:
190
- raise Locked(exc, self.account)
191
-
192
- await self.unlock()
193
- return await self.request(method, url, auth, bearer, **kwargs)
224
+ raise Locked(exc, self.account)
194
225
 
195
226
  raise exc
196
227
 
197
228
  if response.status_code == 404:
198
229
  raise NotFound(response, data)
199
230
 
231
+ if response.status_code == 429:
232
+ if wait_on_rate_limit is None:
233
+ wait_on_rate_limit = self.wait_on_rate_limit
234
+ if not wait_on_rate_limit:
235
+ raise RateLimited(response, data)
236
+
237
+ reset_time = int(response.headers["x-rate-limit-reset"])
238
+ sleep_time = reset_time - int(time()) + 1
239
+ if sleep_time > 0:
240
+ logger.warning(
241
+ f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
242
+ f"Rate limited! Sleep time: {sleep_time} sec."
243
+ )
244
+ await asyncio.sleep(sleep_time)
245
+ return await self._request(
246
+ method,
247
+ url,
248
+ auth=auth,
249
+ bearer=bearer,
250
+ wait_on_rate_limit=wait_on_rate_limit,
251
+ **kwargs,
252
+ )
253
+
200
254
  if response.status_code >= 500:
201
255
  raise ServerError(response, data)
202
256
 
203
- if not 200 <= response.status_code < 300:
204
- raise HTTPException(response, data)
205
-
206
- if isinstance(data, dict) and "errors" in data:
207
- exc = HTTPException(response, data)
257
+ async def request(
258
+ self,
259
+ method,
260
+ url,
261
+ *,
262
+ auto_unlock: bool = True,
263
+ auto_relogin: bool = None,
264
+ **kwargs,
265
+ ) -> tuple[requests.Response, Any]:
266
+ try:
267
+ return await self._request(method, url, **kwargs)
208
268
 
209
- if 141 in exc.api_codes:
210
- self.account.status = AccountStatus.SUSPENDED
211
- raise Suspended(exc, self.account)
269
+ except Locked:
270
+ if not self.capsolver_api_key or not auto_unlock:
271
+ raise
212
272
 
213
- if 326 in exc.api_codes:
214
- for error_data in exc.api_errors:
215
- if (
216
- error_data.get("code") == 326
217
- and error_data.get("bounce_location") == "/i/flow/consent_flow"
218
- ):
219
- self.account.status = AccountStatus.CONSENT_LOCKED
220
- raise ConsentLocked(exc, self.account)
273
+ await self.unlock()
274
+ return await self._request(method, url, **kwargs)
221
275
 
222
- self.account.status = AccountStatus.LOCKED
223
- if not self.capsolver_api_key:
224
- raise Locked(exc, self.account)
276
+ except BadToken:
277
+ if auto_relogin is None:
278
+ auto_relogin = self.auto_relogin
279
+ if (
280
+ not auto_relogin
281
+ or not self.account.password
282
+ or not (self.account.email or self.account.username)
283
+ ):
284
+ raise
225
285
 
226
- await self.unlock()
227
- return await self.request(method, url, auth, bearer, **kwargs)
286
+ await self.relogin()
287
+ return await self._request(method, url, **kwargs)
228
288
 
229
- raise exc
289
+ except Forbidden as exc:
290
+ if 353 in exc.api_codes and "ct0" in exc.response.cookies:
291
+ return await self.request(method, url, **kwargs)
292
+ else:
293
+ raise
230
294
 
231
- self.account.status = AccountStatus.GOOD
232
- return response, data
295
+ async def on_startup(self):
296
+ if self._update_account_info_on_startup:
297
+ await self.update_account_info()
298
+ await self.establish_status()
233
299
 
234
- async def _request_oauth_2_auth_code(
300
+ async def _request_oauth2_auth_code(
235
301
  self,
236
302
  client_id: str,
237
303
  code_challenge: str,
@@ -255,7 +321,7 @@ class Client(BaseHTTPClient):
255
321
  auth_code = response_json["auth_code"]
256
322
  return auth_code
257
323
 
258
- async def _confirm_oauth_2(self, auth_code: str):
324
+ async def _confirm_oauth2(self, auth_code: str):
259
325
  data = {
260
326
  "approval": "true",
261
327
  "code": auth_code,
@@ -268,7 +334,7 @@ class Client(BaseHTTPClient):
268
334
  data=data,
269
335
  )
270
336
 
271
- async def oauth_2(
337
+ async def oauth2(
272
338
  self,
273
339
  client_id: str,
274
340
  code_challenge: str,
@@ -284,15 +350,13 @@ class Client(BaseHTTPClient):
284
350
  Привязка (бинд, линк) приложения.
285
351
 
286
352
  :param client_id: Идентификатор клиента, используемый для OAuth.
287
- :param code_challenge: Код-вызов, используемый для PKCE (Proof Key for Code Exchange).
288
353
  :param state: Уникальная строка состояния для предотвращения CSRF-атак.
289
354
  :param redirect_uri: URI перенаправления, на который будет отправлен ответ.
290
- :param code_challenge_method: Метод, используемый для преобразования code_verifier в code_challenge.
291
355
  :param scope: Строка областей доступа, запрашиваемых у пользователя.
292
356
  :param response_type: Тип ответа, который ожидается от сервера авторизации.
293
357
  :return: Код авторизации (привязки).
294
358
  """
295
- auth_code = await self._request_oauth_2_auth_code(
359
+ auth_code = await self._request_oauth2_auth_code(
296
360
  client_id,
297
361
  code_challenge,
298
362
  state,
@@ -301,7 +365,7 @@ class Client(BaseHTTPClient):
301
365
  scope,
302
366
  response_type,
303
367
  )
304
- await self._confirm_oauth_2(auth_code)
368
+ await self._confirm_oauth2(auth_code)
305
369
  return auth_code
306
370
 
307
371
  async def _oauth(self, oauth_token: str, **oauth_params) -> requests.Response:
@@ -356,14 +420,13 @@ class Client(BaseHTTPClient):
356
420
 
357
421
  return authenticity_token, redirect_url
358
422
 
359
- async def request_and_set_username(self):
423
+ async def _update_account_username(self):
360
424
  url = "https://twitter.com/i/api/1.1/account/settings.json"
361
425
  response, response_json = await self.request("POST", url)
362
426
  self.account.username = response_json["screen_name"]
363
427
 
364
- async def _request_user(self, username: str) -> User:
428
+ async def _request_user_by_username(self, username: str) -> User | None:
365
429
  url, query_id = self._action_to_url("UserByScreenName")
366
- username = remove_at_sign(username)
367
430
  variables = {
368
431
  "screen_name": username,
369
432
  "withSafetyModeUserFields": True,
@@ -389,31 +452,76 @@ class Client(BaseHTTPClient):
389
452
  "fieldToggles": to_json(field_toggles),
390
453
  }
391
454
  response, data = await self.request("GET", url, params=params)
455
+ if not data["data"]:
456
+ return None
392
457
  return User.from_raw_data(data["data"]["user"]["result"])
393
458
 
394
- async def request_user(
395
- self, *, username: str = None, user_id: int | str = None
396
- ) -> User | Account:
397
- if username and user_id:
398
- raise ValueError("Specify username or user_id, not both.")
399
-
400
- if user_id:
401
- users = await self.request_users((user_id,))
402
- user = users[user_id]
403
- else:
404
- if not username:
405
- if not self.account.username:
406
- await self.request_and_set_username()
407
- username = self.account.username
459
+ async def request_user_by_username(self, username: str) -> User | Account | None:
460
+ """
461
+ :param username: Имя пользователя без знака `@`
462
+ :return: Пользователь, если существует, иначе None. Или собственный аккаунт, если совпадает имя пользователя.
463
+ """
464
+ if not self.account.username:
465
+ await self.update_account_info()
408
466
 
409
- user = await self._request_user(username)
467
+ user = await self._request_user_by_username(username)
410
468
 
411
- if self.account.username == user.username:
469
+ if user and user.username == self.account.username:
412
470
  self.account.update(**user.model_dump())
413
- user = self.account
471
+ return self.account
472
+
473
+ return user
474
+
475
+ async def _request_users_by_ids(
476
+ self, user_ids: Iterable[str | int]
477
+ ) -> dict[int : User | Account]:
478
+ url, query_id = self._action_to_url("UsersByRestIds")
479
+ variables = {"userIds": list({str(user_id) for user_id in user_ids})}
480
+ features = {
481
+ "responsive_web_graphql_exclude_directive_enabled": True,
482
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
483
+ "responsive_web_graphql_timeline_navigation_enabled": True,
484
+ "verified_phone_label_enabled": False,
485
+ }
486
+ query = {"variables": variables, "features": features}
487
+ response, data = await self.request("GET", url, params=query)
488
+
489
+ users = {}
490
+ for user_data in data["data"]["users"]:
491
+ user_data = user_data["result"]
492
+ user = User.from_raw_data(user_data)
493
+ users[user.id] = user
494
+ if user.id == self.account.id:
495
+ users[self.account.id] = self.account
496
+ return users
414
497
 
498
+ async def request_user_by_id(self, user_id: int | str) -> User | Account | None:
499
+ """
500
+ :param user_id: ID пользователя
501
+ :return: Пользователь, если существует, иначе None. Или собственный аккаунт, если совпадает ID.
502
+ """
503
+ if not self.account.id:
504
+ await self.update_account_info()
505
+
506
+ users = await self._request_users_by_ids((user_id,))
507
+ user = users[user_id]
415
508
  return user
416
509
 
510
+ async def request_users_by_ids(
511
+ self, user_ids: Iterable[str | int]
512
+ ) -> dict[int : User | Account]:
513
+ """
514
+ :param user_ids: ID пользователей
515
+ :return: Пользователи, если существует, иначе None. Или собственный аккаунт, если совпадает ID.
516
+ """
517
+ return await self._request_users_by_ids(user_ids)
518
+
519
+ async def update_account_info(self):
520
+ if not self.account.username:
521
+ await self._update_account_username()
522
+
523
+ await self.request_user_by_username(self.account.username)
524
+
417
525
  async def upload_image(
418
526
  self,
419
527
  image: bytes,
@@ -429,15 +537,13 @@ class Client(BaseHTTPClient):
429
537
  :return: Media
430
538
  """
431
539
  url = "https://upload.twitter.com/1.1/media/upload.json"
432
-
433
540
  payload = {"media_data": base64.b64encode(image)}
434
-
435
541
  for attempt in range(attempts):
436
542
  try:
437
543
  response, data = await self.request(
438
544
  "POST", url, data=payload, timeout=timeout
439
545
  )
440
- return Media.from_raw_data(data)
546
+ return Media(**data)
441
547
  except (HTTPException, requests.errors.RequestsError) as exc:
442
548
  if (
443
549
  attempt < attempts - 1
@@ -541,6 +647,8 @@ class Client(BaseHTTPClient):
541
647
  """
542
648
  Repost (retweet)
543
649
 
650
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
651
+
544
652
  :return: Tweet
545
653
  """
546
654
  return await self._repost_or_search_duplicate(
@@ -548,9 +656,18 @@ class Client(BaseHTTPClient):
548
656
  )
549
657
 
550
658
  async def like(self, tweet_id: int) -> bool:
551
- response_json = await self._interact_with_tweet("FavoriteTweet", tweet_id)
552
- is_liked = response_json["data"]["favorite_tweet"] == "Done"
553
- return is_liked
659
+ """
660
+ :return: Liked or not
661
+ """
662
+ try:
663
+ response_json = await self._interact_with_tweet("FavoriteTweet", tweet_id)
664
+ except HTTPException as exc:
665
+ if 139 in exc.api_codes:
666
+ # Already liked
667
+ return True
668
+ else:
669
+ raise
670
+ return response_json["data"]["favorite_tweet"] == "Done"
554
671
 
555
672
  async def unlike(self, tweet_id: int) -> dict:
556
673
  response_json = await self._interact_with_tweet("UnfavoriteTweet", tweet_id)
@@ -597,47 +714,50 @@ class Client(BaseHTTPClient):
597
714
  attachment_url: str = None,
598
715
  ) -> Tweet:
599
716
  url, query_id = self._action_to_url("CreateTweet")
600
- payload = {
601
- "variables": {
602
- "tweet_text": text if text is not None else "",
603
- "dark_request": False,
604
- "media": {"media_entities": [], "possibly_sensitive": False},
605
- "semantic_annotation_ids": [],
606
- },
607
- "features": {
608
- "tweetypie_unmention_optimization_enabled": True,
609
- "responsive_web_edit_tweet_api_enabled": True,
610
- "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
611
- "view_counts_everywhere_api_enabled": True,
612
- "longform_notetweets_consumption_enabled": True,
613
- "tweet_awards_web_tipping_enabled": False,
614
- "longform_notetweets_rich_text_read_enabled": True,
615
- "longform_notetweets_inline_media_enabled": True,
616
- "responsive_web_graphql_exclude_directive_enabled": True,
617
- "verified_phone_label_enabled": False,
618
- "freedom_of_speech_not_reach_fetch_enabled": True,
619
- "standardized_nudges_misinfo": True,
620
- "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": False,
621
- "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
622
- "responsive_web_graphql_timeline_navigation_enabled": True,
623
- "responsive_web_enhance_cards_enabled": False,
624
- "responsive_web_twitter_article_tweet_consumption_enabled": False,
625
- "responsive_web_media_download_video_enabled": False,
626
- },
627
- "queryId": query_id,
717
+ variables = {
718
+ "tweet_text": text if text is not None else "",
719
+ "dark_request": False,
720
+ "media": {"media_entities": [], "possibly_sensitive": False},
721
+ "semantic_annotation_ids": [],
628
722
  }
629
723
  if attachment_url:
630
- payload["variables"]["attachment_url"] = attachment_url
724
+ variables["attachment_url"] = attachment_url
631
725
  if tweet_id_to_reply:
632
- payload["variables"]["reply"] = {
726
+ variables["reply"] = {
633
727
  "in_reply_to_tweet_id": str(tweet_id_to_reply),
634
728
  "exclude_reply_user_ids": [],
635
729
  }
636
730
  if media_id:
637
- payload["variables"]["media"]["media_entities"].append(
731
+ variables["media"]["media_entities"].append(
638
732
  {"media_id": str(media_id), "tagged_users": []}
639
733
  )
640
-
734
+ features = {
735
+ "communities_web_enable_tweet_community_results_fetch": True,
736
+ "c9s_tweet_anatomy_moderator_badge_enabled": True,
737
+ "tweetypie_unmention_optimization_enabled": True,
738
+ "responsive_web_edit_tweet_api_enabled": True,
739
+ "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
740
+ "view_counts_everywhere_api_enabled": True,
741
+ "longform_notetweets_consumption_enabled": True,
742
+ "responsive_web_twitter_article_tweet_consumption_enabled": True,
743
+ "tweet_awards_web_tipping_enabled": False,
744
+ "longform_notetweets_rich_text_read_enabled": True,
745
+ "longform_notetweets_inline_media_enabled": True,
746
+ "rweb_video_timestamps_enabled": True,
747
+ "responsive_web_graphql_exclude_directive_enabled": True,
748
+ "verified_phone_label_enabled": False,
749
+ "freedom_of_speech_not_reach_fetch_enabled": True,
750
+ "standardized_nudges_misinfo": True,
751
+ "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
752
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
753
+ "responsive_web_graphql_timeline_navigation_enabled": True,
754
+ "responsive_web_enhance_cards_enabled": False,
755
+ }
756
+ payload = {
757
+ "variables": variables,
758
+ "features": features,
759
+ "queryId": query_id,
760
+ }
641
761
  response, response_json = await self.request("POST", url, json=payload)
642
762
  tweet = Tweet.from_raw_data(
643
763
  response_json["data"]["create_tweet"]["tweet_results"]["result"]
@@ -689,6 +809,11 @@ class Client(BaseHTTPClient):
689
809
  media_id: int | str = None,
690
810
  search_duplicate: bool = True,
691
811
  ) -> Tweet:
812
+ """
813
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
814
+
815
+ :return: Tweet
816
+ """
692
817
  return await self._tweet_or_search_duplicate(
693
818
  text,
694
819
  media_id=media_id,
@@ -703,6 +828,11 @@ class Client(BaseHTTPClient):
703
828
  media_id: int | str = None,
704
829
  search_duplicate: bool = True,
705
830
  ) -> Tweet:
831
+ """
832
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
833
+
834
+ :return: Tweet
835
+ """
706
836
  return await self._tweet_or_search_duplicate(
707
837
  text,
708
838
  media_id=media_id,
@@ -718,6 +848,11 @@ class Client(BaseHTTPClient):
718
848
  media_id: int | str = None,
719
849
  search_duplicate: bool = True,
720
850
  ) -> Tweet:
851
+ """
852
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
853
+
854
+ :return: Tweet
855
+ """
721
856
  return await self._tweet_or_search_duplicate(
722
857
  text,
723
858
  media_id=media_id,
@@ -742,8 +877,12 @@ class Client(BaseHTTPClient):
742
877
  response, response_json = await self.request("POST", url, params=params)
743
878
  return response_json
744
879
 
745
- async def _request_users(
746
- self, action: str, user_id: int | str, count: int
880
+ async def _request_users_by_action(
881
+ self,
882
+ action: str,
883
+ user_id: int | str,
884
+ count: int,
885
+ cursor: str = None,
747
886
  ) -> list[User]:
748
887
  url, query_id = self._action_to_url(action)
749
888
  variables = {
@@ -751,6 +890,8 @@ class Client(BaseHTTPClient):
751
890
  "count": count,
752
891
  "includePromotedContent": False,
753
892
  }
893
+ if cursor:
894
+ variables["cursor"] = cursor
754
895
  features = {
755
896
  "rweb_lists_timeline_redesign_enabled": True,
756
897
  "responsive_web_graphql_exclude_directive_enabled": True,
@@ -793,53 +934,46 @@ class Client(BaseHTTPClient):
793
934
  return users
794
935
 
795
936
  async def request_followers(
796
- self, user_id: int | str = None, count: int = 10
937
+ self,
938
+ user_id: int | str = None,
939
+ count: int = 20,
940
+ cursor: str = None,
797
941
  ) -> list[User]:
798
942
  """
799
943
  :param user_id: Текущий пользователь, если не передан ID иного пользователя.
800
944
  :param count: Количество подписчиков.
801
945
  """
802
946
  if user_id:
803
- return await self._request_users("Followers", user_id, count)
947
+ return await self._request_users_by_action(
948
+ "Followers", user_id, count, cursor
949
+ )
804
950
  else:
805
951
  if not self.account.id:
806
- await self.request_user()
807
- return await self._request_users("Followers", self.account.id, count)
952
+ await self.update_account_info()
953
+ return await self._request_users_by_action(
954
+ "Followers", self.account.id, count, cursor
955
+ )
808
956
 
809
957
  async def request_followings(
810
- self, user_id: int | str = None, count: int = 10
958
+ self,
959
+ user_id: int | str = None,
960
+ count: int = 20,
961
+ cursor: str = None,
811
962
  ) -> list[User]:
812
963
  """
813
964
  :param user_id: Текущий пользователь, если не передан ID иного пользователя.
814
965
  :param count: Количество подписчиков.
815
966
  """
816
967
  if user_id:
817
- return await self._request_users("Following", user_id, count)
968
+ return await self._request_users_by_action(
969
+ "Following", user_id, count, cursor
970
+ )
818
971
  else:
819
972
  if not self.account.id:
820
- await self.request_user()
821
- return await self._request_users("Following", self.account.id, count)
822
-
823
- async def request_users(
824
- self, user_ids: Iterable[str | int]
825
- ) -> dict[int : User | Account]:
826
- url, query_id = self._action_to_url("UsersByRestIds")
827
- variables = {"userIds": list({str(user_id) for user_id in user_ids})}
828
- features = {
829
- "responsive_web_graphql_exclude_directive_enabled": True,
830
- "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
831
- "responsive_web_graphql_timeline_navigation_enabled": True,
832
- "verified_phone_label_enabled": False,
833
- }
834
- query = {"variables": variables, "features": features}
835
- response, data = await self.request("GET", url, params=query)
836
-
837
- users = {}
838
- for user_data in data["data"]["users"]:
839
- user_data = user_data["result"]
840
- user = User.from_raw_data(user_data)
841
- users[user.id] = user
842
- return users
973
+ await self.update_account_info()
974
+ return await self._request_users_by_action(
975
+ "Following", self.account.id, count, cursor
976
+ )
843
977
 
844
978
  async def _request_tweet(self, tweet_id: int | str) -> Tweet:
845
979
  url, query_id = self._action_to_url("TweetDetail")
@@ -882,7 +1016,9 @@ class Client(BaseHTTPClient):
882
1016
  tweet_data = tweets_data_from_instructions(instructions)[0]
883
1017
  return Tweet.from_raw_data(tweet_data)
884
1018
 
885
- async def _request_tweets(self, user_id: int | str, count: int = 20) -> list[Tweet]:
1019
+ async def _request_tweets(
1020
+ self, user_id: int | str, count: int = 20, cursor: str = None
1021
+ ) -> list[Tweet]:
886
1022
  url, query_id = self._action_to_url("UserTweets")
887
1023
  variables = {
888
1024
  "userId": str(user_id),
@@ -892,6 +1028,8 @@ class Client(BaseHTTPClient):
892
1028
  "withVoice": True,
893
1029
  "withV2Timeline": True,
894
1030
  }
1031
+ if cursor:
1032
+ variables["cursor"] = cursor
895
1033
  features = {
896
1034
  "responsive_web_graphql_exclude_directive_enabled": True,
897
1035
  "verified_phone_label_enabled": False,
@@ -928,16 +1066,14 @@ class Client(BaseHTTPClient):
928
1066
  return await self._request_tweet(tweet_id)
929
1067
 
930
1068
  async def request_tweets(
931
- self,
932
- user_id: int | str = None,
933
- count: int = 20,
1069
+ self, user_id: int | str = None, count: int = 20, cursor: str = None
934
1070
  ) -> list[Tweet]:
935
1071
  if not user_id:
936
1072
  if not self.account.id:
937
- await self.request_user()
1073
+ await self.update_account_info()
938
1074
  user_id = self.account.id
939
1075
 
940
- return await self._request_tweets(user_id, count)
1076
+ return await self._request_tweets(user_id, count, cursor)
941
1077
 
942
1078
  async def _update_profile_image(
943
1079
  self, type: Literal["banner", "image"], media_id: str | int
@@ -1003,9 +1139,6 @@ class Client(BaseHTTPClient):
1003
1139
  }
1004
1140
  response, data = await self.request("POST", url, data=payload)
1005
1141
  changed = data["status"] == "ok"
1006
- # TODO Делать это автоматически в методе request
1007
- auth_token = response.cookies.get("auth_token", domain=".twitter.com")
1008
- self.account.auth_token = auth_token
1009
1142
  self.account.password = password
1010
1143
  return changed
1011
1144
 
@@ -1023,7 +1156,6 @@ class Client(BaseHTTPClient):
1023
1156
  raise ValueError("Specify at least one param")
1024
1157
 
1025
1158
  url = "https://twitter.com/i/api/1.1/account/update_profile.json"
1026
- # headers = {"content-type": "application/x-www-form-urlencoded"}
1027
1159
  # Создаем словарь data, включая в него только те ключи, для которых значения не равны None
1028
1160
  payload = {
1029
1161
  k: v
@@ -1035,7 +1167,6 @@ class Client(BaseHTTPClient):
1035
1167
  ]
1036
1168
  if v is not None
1037
1169
  }
1038
- # response, response_json = await self.request("POST", url, headers=headers, data=payload)
1039
1170
  response, data = await self.request("POST", url, data=payload)
1040
1171
  # Проверяем, что все переданные параметры соответствуют полученным
1041
1172
  updated = all(
@@ -1045,13 +1176,14 @@ class Client(BaseHTTPClient):
1045
1176
  updated &= URL(website) == URL(
1046
1177
  data["entities"]["url"]["urls"][0]["expanded_url"]
1047
1178
  )
1048
- await self.request_user()
1179
+ await self.update_account_info()
1049
1180
  return updated
1050
1181
 
1051
1182
  async def establish_status(self):
1052
1183
  url = "https://twitter.com/i/api/1.1/account/update_profile.json"
1053
1184
  try:
1054
- await self.request("POST", url)
1185
+ await self.request("POST", url, auto_unlock=False, auto_relogin=False)
1186
+ self.account.status = AccountStatus.GOOD
1055
1187
  except BadAccount:
1056
1188
  pass
1057
1189
 
@@ -1064,17 +1196,14 @@ class Client(BaseHTTPClient):
1064
1196
  year_visibility: Literal["self"] = "self",
1065
1197
  ) -> bool:
1066
1198
  url = "https://twitter.com/i/api/1.1/account/update_profile.json"
1067
- headers = {"content-type": "application/x-www-form-urlencoded"}
1068
- data = {
1199
+ payload = {
1069
1200
  "birthdate_day": day,
1070
1201
  "birthdate_month": month,
1071
1202
  "birthdate_year": year,
1072
1203
  "birthdate_visibility": visibility,
1073
1204
  "birthdate_year_visibility": year_visibility,
1074
1205
  }
1075
- response, response_json = await self.request(
1076
- "POST", url, headers=headers, data=data
1077
- )
1206
+ response, response_json = await self.request("POST", url, json=payload)
1078
1207
  birthdate_data = response_json["extended_profile"]["birthdate"]
1079
1208
  updated = all(
1080
1209
  (
@@ -1105,6 +1234,20 @@ class Client(BaseHTTPClient):
1105
1234
  event_data = data["event"]
1106
1235
  return event_data # TODO Возвращать модель, а не словарь
1107
1236
 
1237
+ async def send_message_to_conversation(
1238
+ self, conversation_id: int | str, text: str
1239
+ ) -> dict:
1240
+ """
1241
+ requires OAuth1 or OAuth2
1242
+
1243
+ :return: Event data
1244
+ """
1245
+ url = f"https://api.twitter.com/2/dm_conversations/{conversation_id}/messages"
1246
+ payload = {"text": text}
1247
+ response, response_json = await self.request("POST", url, json=payload)
1248
+ event_data = response_json["event"]
1249
+ return event_data
1250
+
1108
1251
  async def request_messages(self) -> list[dict]:
1109
1252
  """
1110
1253
  :return: Messages data
@@ -1170,6 +1313,8 @@ class Client(BaseHTTPClient):
1170
1313
  payload["verification_string"] = verification_string
1171
1314
  payload["language_code"] = "en"
1172
1315
 
1316
+ # TODO ui_metrics
1317
+
1173
1318
  return await self.request("POST", self._CAPTCHA_URL, data=payload, bearer=False)
1174
1319
 
1175
1320
  async def unlock(self):
@@ -1183,9 +1328,23 @@ class Client(BaseHTTPClient):
1183
1328
  needs_unlock,
1184
1329
  start_button,
1185
1330
  finish_button,
1331
+ delete_button,
1186
1332
  ) = parse_unlock_html(html)
1187
1333
  attempt = 1
1188
1334
 
1335
+ if delete_button:
1336
+ response, html = await self._confirm_unlock(
1337
+ authenticity_token, assignment_token
1338
+ )
1339
+ (
1340
+ authenticity_token,
1341
+ assignment_token,
1342
+ needs_unlock,
1343
+ start_button,
1344
+ finish_button,
1345
+ delete_button,
1346
+ ) = parse_unlock_html(html)
1347
+
1189
1348
  if start_button or finish_button:
1190
1349
  response, html = await self._confirm_unlock(
1191
1350
  authenticity_token, assignment_token
@@ -1196,6 +1355,7 @@ class Client(BaseHTTPClient):
1196
1355
  needs_unlock,
1197
1356
  start_button,
1198
1357
  finish_button,
1358
+ delete_button,
1199
1359
  ) = parse_unlock_html(html)
1200
1360
 
1201
1361
  funcaptcha = {
@@ -1213,8 +1373,20 @@ class Client(BaseHTTPClient):
1213
1373
  else:
1214
1374
  funcaptcha["captcha_type"] = FunCaptchaTypeEnm.FunCaptchaTaskProxyLess
1215
1375
 
1216
- while needs_unlock:
1376
+ while needs_unlock and attempt <= self.max_unlock_attempts:
1217
1377
  solution = await FunCaptcha(**funcaptcha).aio_captcha_handler()
1378
+ if solution.errorId:
1379
+ logger.warning(
1380
+ f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
1381
+ f"Failed to solve funcaptcha:"
1382
+ f"\n\tUnlock attempt: {attempt}/{self.max_unlock_attempts}"
1383
+ f"\n\tError ID: {solution.errorId}"
1384
+ f"\n\tError code: {solution.errorCode}"
1385
+ f"\n\tError description: {solution.errorDescription}"
1386
+ )
1387
+ attempt += 1
1388
+ continue
1389
+
1218
1390
  token = solution.solution["token"]
1219
1391
  response, html = await self._confirm_unlock(
1220
1392
  authenticity_token,
@@ -1222,12 +1394,8 @@ class Client(BaseHTTPClient):
1222
1394
  verification_string=token,
1223
1395
  )
1224
1396
 
1225
- if (
1226
- attempt > self.max_unlock_attempts
1227
- or response.url == "https://twitter.com/?lang=en"
1228
- ):
1229
- await self.establish_status()
1230
- return
1397
+ if response.url == "https://twitter.com/?lang=en":
1398
+ break
1231
1399
 
1232
1400
  (
1233
1401
  authenticity_token,
@@ -1235,6 +1403,7 @@ class Client(BaseHTTPClient):
1235
1403
  needs_unlock,
1236
1404
  start_button,
1237
1405
  finish_button,
1406
+ delete_button,
1238
1407
  ) = parse_unlock_html(html)
1239
1408
 
1240
1409
  if finish_button:
@@ -1247,22 +1416,58 @@ class Client(BaseHTTPClient):
1247
1416
  needs_unlock,
1248
1417
  start_button,
1249
1418
  finish_button,
1419
+ delete_button,
1250
1420
  ) = parse_unlock_html(html)
1251
1421
 
1252
1422
  attempt += 1
1253
1423
 
1254
- async def _task(self, **kwargs):
1424
+ await self.establish_status()
1425
+
1426
+ async def update_backup_code(self):
1427
+ url = "https://api.twitter.com/1.1/account/backup_code.json"
1428
+ response, response_json = await self.request("GET", url)
1429
+ self.account.backup_code = response_json["codes"][0]
1430
+
1431
+ async def _send_raw_subtask(self, **request_kwargs) -> tuple[str, list[Subtask]]:
1255
1432
  """
1256
- :return: flow_token, subtasks
1433
+ :return: flow_token and subtasks
1257
1434
  """
1258
1435
  url = "https://api.twitter.com/1.1/onboarding/task.json"
1259
- response, response_json = await self.request("POST", url, **kwargs)
1260
- return response_json["flow_token"], response_json["subtasks"]
1436
+ response, data = await self.request("POST", url, **request_kwargs)
1437
+ subtasks = [
1438
+ Subtask.from_raw_data(subtask_data) for subtask_data in data["subtasks"]
1439
+ ]
1440
+ log_message = (
1441
+ f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
1442
+ f" Requested subtasks:"
1443
+ )
1444
+ for subtask in subtasks:
1445
+ log_message += f"\n\t{subtask.id}"
1446
+ if subtask.primary_text:
1447
+ log_message += f"\n\tPrimary text: {subtask.primary_text}"
1448
+ if subtask.secondary_text:
1449
+ log_message += f"\n\tSecondary text: {subtask.secondary_text}"
1450
+ if subtask.detail_text:
1451
+ log_message += f"\n\tDetail text: {subtask.detail_text}"
1452
+ logger.debug(log_message)
1453
+ return data["flow_token"], subtasks
1261
1454
 
1262
- async def _request_login_tasks(self):
1263
- """
1264
- :return: flow_token, subtask_ids
1265
- """
1455
+ async def _complete_subtask(
1456
+ self,
1457
+ flow_token: str,
1458
+ inputs: list[dict],
1459
+ **request_kwargs,
1460
+ ) -> tuple[str, list[Subtask]]:
1461
+ payload = request_kwargs["json"] = request_kwargs.get("json") or {}
1462
+ payload.update(
1463
+ {
1464
+ "flow_token": flow_token,
1465
+ "subtask_inputs": inputs,
1466
+ }
1467
+ )
1468
+ return await self._send_raw_subtask(**request_kwargs)
1469
+
1470
+ async def _request_login_tasks(self) -> tuple[str, list[Subtask]]:
1266
1471
  params = {
1267
1472
  "flow_name": "login",
1268
1473
  }
@@ -1317,84 +1522,89 @@ class Client(BaseHTTPClient):
1317
1522
  "web_modal": 1,
1318
1523
  },
1319
1524
  }
1320
- return await self._task(params=params, json=payload, auth=False)
1321
-
1322
- async def _send_task(self, flow_token: str, subtask_inputs: list[dict], **kwargs):
1323
- payload = kwargs["json"] = kwargs.get("json") or {}
1324
- payload.update(
1325
- {
1326
- "flow_token": flow_token,
1327
- "subtask_inputs": subtask_inputs,
1328
- }
1329
- )
1330
- return await self._task(**kwargs)
1331
-
1332
- async def _finish_task(self, flow_token):
1333
- payload = {
1334
- "flow_token": flow_token,
1335
- "subtask_inputs": [],
1336
- }
1337
- return await self._task(json=payload)
1525
+ return await self._send_raw_subtask(params=params, json=payload, auth=False)
1338
1526
 
1339
- async def _login_enter_user_identifier(self, flow_token):
1340
- subtask_inputs = [
1527
+ async def _login_enter_user_identifier(self, flow_token: str):
1528
+ inputs = [
1341
1529
  {
1342
1530
  "subtask_id": "LoginEnterUserIdentifierSSO",
1343
1531
  "settings_list": {
1532
+ "link": "next_link",
1344
1533
  "setting_responses": [
1345
1534
  {
1346
1535
  "key": "user_identifier",
1347
1536
  "response_data": {
1348
1537
  "text_data": {
1349
- "result": self.account.email
1350
- or self.account.username
1538
+ "result": self.account.username
1539
+ or self.account.email
1351
1540
  }
1352
1541
  },
1353
1542
  }
1354
1543
  ],
1355
- "link": "next_link",
1356
1544
  },
1357
1545
  }
1358
1546
  ]
1359
- return await self._send_task(flow_token, subtask_inputs, auth=False)
1547
+ return await self._complete_subtask(flow_token, inputs, auth=False)
1360
1548
 
1361
- async def _login_enter_password(self, flow_token):
1362
- subtask_inputs = [
1549
+ async def _login_enter_password(self, flow_token: str):
1550
+ inputs = [
1363
1551
  {
1364
1552
  "subtask_id": "LoginEnterPassword",
1365
1553
  "enter_password": {
1366
- "password": self.account.password,
1367
1554
  "link": "next_link",
1555
+ "password": self.account.password,
1368
1556
  },
1369
1557
  }
1370
1558
  ]
1371
- return await self._send_task(flow_token, subtask_inputs, auth=False)
1559
+ return await self._complete_subtask(flow_token, inputs, auth=False)
1372
1560
 
1373
1561
  async def _account_duplication_check(self, flow_token):
1374
- subtask_inputs = [
1562
+ inputs = [
1375
1563
  {
1376
1564
  "subtask_id": "AccountDuplicationCheck",
1377
1565
  "check_logged_in_account": {"link": "AccountDuplicationCheck_false"},
1378
1566
  }
1379
1567
  ]
1380
- return await self._send_task(flow_token, subtask_inputs, auth=False)
1381
-
1382
- async def _login_two_factor_auth_challenge(self, flow_token):
1383
- if not self.account.totp_secret:
1384
- raise TwitterException(
1385
- f"Failed to login. Task id: LoginTwoFactorAuthChallenge"
1386
- )
1568
+ return await self._complete_subtask(flow_token, inputs, auth=False)
1387
1569
 
1388
- subtask_inputs = [
1570
+ async def _login_two_factor_auth_challenge(self, flow_token, value: str):
1571
+ inputs = [
1389
1572
  {
1390
1573
  "subtask_id": "LoginTwoFactorAuthChallenge",
1391
1574
  "enter_text": {
1392
- "text": self.account.get_totp_code(),
1393
1575
  "link": "next_link",
1576
+ "text": value,
1577
+ },
1578
+ }
1579
+ ]
1580
+ return await self._complete_subtask(flow_token, inputs, auth=False)
1581
+
1582
+ async def _login_two_factor_auth_choose_method(
1583
+ self, flow_token: str, choices: Iterable[int] = (0,)
1584
+ ):
1585
+ inputs = [
1586
+ {
1587
+ "subtask_id": "LoginTwoFactorAuthChooseMethod",
1588
+ "choice_selection": {
1589
+ "link": "next_link",
1590
+ "selected_choices": [str(choice) for choice in choices],
1394
1591
  },
1395
1592
  }
1396
1593
  ]
1397
- return await self._send_task(flow_token, subtask_inputs, auth=False)
1594
+ return await self._complete_subtask(flow_token, inputs, auth=False)
1595
+
1596
+ async def _login_acid(
1597
+ self,
1598
+ flow_token: str,
1599
+ value: str,
1600
+ ):
1601
+ inputs = [
1602
+ {
1603
+ "subtask_id": "LoginAcid",
1604
+ "enter_text": {"text": value, "link": "next_link"},
1605
+ }
1606
+ ]
1607
+ return await self._complete_subtask(flow_token, inputs, auth=False)
1398
1608
 
1399
1609
  async def _viewer(self):
1400
1610
  url, query_id = self._action_to_url("Viewer")
@@ -1411,11 +1621,11 @@ class Client(BaseHTTPClient):
1411
1621
  }
1412
1622
  variables = {"withCommunitiesMemberships": True}
1413
1623
  params = {
1414
- "features": to_json(features),
1415
- "fieldToggles": to_json(field_toggles),
1416
- "variables": to_json(variables),
1624
+ "features": features,
1625
+ "fieldToggles": field_toggles,
1626
+ "variables": variables,
1417
1627
  }
1418
- return self.request("GET", url, params=params)
1628
+ return await self.request("GET", url, params=params)
1419
1629
 
1420
1630
  async def _request_guest_token(self) -> str:
1421
1631
  """
@@ -1430,66 +1640,151 @@ class Client(BaseHTTPClient):
1430
1640
  guest_token = re.search(r"gt\s?=\s?\d+", response.text)[0].split("=")[1]
1431
1641
  return guest_token
1432
1642
 
1433
- async def _login(self):
1643
+ async def _login(self) -> bool:
1644
+ update_backup_code = False
1645
+
1434
1646
  guest_token = await self._request_guest_token()
1435
1647
  self._session.headers["X-Guest-Token"] = guest_token
1436
1648
 
1437
- # Можно не устанавливать, так как твиттер сам вернет этот токен
1438
- # self._session.cookies["gt"] = guest_token
1439
-
1440
1649
  flow_token, subtasks = await self._request_login_tasks()
1441
1650
  for _ in range(2):
1442
1651
  flow_token, subtasks = await self._login_enter_user_identifier(flow_token)
1652
+
1653
+ subtask_ids = {subtask.id for subtask in subtasks}
1654
+ if "LoginEnterAlternateIdentifierSubtask" in subtask_ids:
1655
+ if not self.account.username:
1656
+ raise TwitterException("Failed to login: no username to relogin")
1657
+
1443
1658
  flow_token, subtasks = await self._login_enter_password(flow_token)
1444
1659
  flow_token, subtasks = await self._account_duplication_check(flow_token)
1445
1660
 
1446
- subtask_ids = [subtask["subtask_id"] for subtask in subtasks]
1661
+ for subtask in subtasks:
1662
+ if subtask.id == "LoginAcid":
1663
+ if not self.account.email:
1664
+ raise TwitterException(
1665
+ f"Failed to login. Task id: LoginAcid." f" No email!"
1666
+ )
1667
+
1668
+ if subtask.primary_text == "Check your email":
1669
+ raise TwitterException(
1670
+ f"Failed to login. Task id: LoginAcid."
1671
+ f" Email verification required!"
1672
+ f" No IMAP handler for this version of library :<"
1673
+ )
1447
1674
 
1448
- # TODO IMAP Обработчик
1449
- if "LoginAcid" in subtask_ids:
1450
- raise TwitterException(f"Failed to login: email verification!")
1675
+ try:
1676
+ # fmt: off
1677
+ flow_token, subtasks = await self._login_acid(flow_token, self.account.email)
1678
+ # fmt: on
1679
+ except HTTPException as exc:
1680
+ if 399 in exc.api_codes:
1681
+ logger.warning(
1682
+ f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
1683
+ f" Bad email!"
1684
+ )
1685
+ raise TwitterException(
1686
+ f"Failed to login. Task id: LoginAcid. Bad email!"
1687
+ )
1688
+ else:
1689
+ raise
1690
+
1691
+ subtask_ids = {subtask.id for subtask in subtasks}
1451
1692
 
1452
1693
  if "LoginTwoFactorAuthChallenge" in subtask_ids:
1453
- flow_token, subtasks = await self._login_two_factor_auth_challenge(
1454
- flow_token
1455
- )
1456
-
1457
- # TODO Делать это автоматически в методе request
1458
- self.account.auth_token = self._session.cookies["auth_token"]
1459
- self.account.ct0 = self._session.cookies["ct0"]
1694
+ if not self.account.totp_secret:
1695
+ raise TwitterException(
1696
+ f"Failed to login. Task id: LoginTwoFactorAuthChallenge. No totp_secret!"
1697
+ )
1460
1698
 
1461
- await self._finish_task(flow_token)
1699
+ try:
1700
+ # fmt: off
1701
+ flow_token, subtasks = await self._login_two_factor_auth_challenge(flow_token, self.account.get_totp_code())
1702
+ # fmt: on
1703
+ except HTTPException as exc:
1704
+ if 399 in exc.api_codes:
1705
+ logger.warning(
1706
+ f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
1707
+ f" Bad TOTP secret!"
1708
+ )
1709
+ if not self.account.backup_code:
1710
+ raise TwitterException(
1711
+ f"Failed to login. Task id: LoginTwoFactorAuthChallenge. No backup code!"
1712
+ )
1713
+
1714
+ # Enter backup code
1715
+ # fmt: off
1716
+ flow_token, subtasks = await self._login_two_factor_auth_choose_method(flow_token)
1717
+ try:
1718
+ flow_token, subtasks = await self._login_two_factor_auth_challenge(flow_token, self.account.backup_code)
1719
+ except HTTPException as exc:
1720
+ if 399 in exc.api_codes:
1721
+ logger.warning(
1722
+ f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
1723
+ f" Bad backup code!"
1724
+ )
1725
+ raise TwitterException(
1726
+ f"Failed to login. Task id: LoginTwoFactorAuthChallenge. Bad backup_code!"
1727
+ )
1728
+ else:
1729
+ raise
1730
+
1731
+ update_backup_code = True
1732
+ # fmt: on
1733
+ else:
1734
+ raise
1462
1735
 
1463
- async def login(self):
1464
- if self.account.auth_token:
1465
- await self.establish_status()
1466
- if self.account.status != "BAD_TOKEN":
1467
- return
1736
+ await self._complete_subtask(flow_token, [])
1737
+ return update_backup_code
1468
1738
 
1739
+ async def relogin(self):
1740
+ """
1741
+ Может вызвать следующую ошибку:
1742
+ twitter.errors.BadRequest: (response status: 400)
1743
+ (code 398) Can't complete your signup right now.
1744
+ Причина возникновения ошибки неизвестна. Не забудьте обработать ее.
1745
+ """
1469
1746
  if not self.account.email and not self.account.username:
1470
1747
  raise ValueError("No email or username")
1471
1748
 
1472
1749
  if not self.account.password:
1473
1750
  raise ValueError("No password")
1474
1751
 
1475
- await self._login()
1752
+ update_backup_code = await self._login()
1753
+ await self._viewer()
1754
+
1755
+ if update_backup_code:
1756
+ await self.update_backup_code()
1757
+ logger.warning(
1758
+ f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
1759
+ f" Requested new backup code!"
1760
+ )
1761
+ # TODO Также обновлять totp_secret
1762
+
1476
1763
  await self.establish_status()
1477
1764
 
1765
+ async def login(self):
1766
+ if self.account.auth_token:
1767
+ await self.establish_status()
1768
+ if self.account.status not in ("BAD_TOKEN", "CONSENT_LOCKED"):
1769
+ return
1770
+
1771
+ await self.relogin()
1772
+
1478
1773
  async def totp_is_enabled(self):
1479
1774
  if not self.account.id:
1480
- await self.request_user()
1775
+ await self.update_account_info()
1481
1776
 
1482
1777
  url = f"https://twitter.com/i/api/1.1/strato/column/User/{self.account.id}/account-security/twoFactorAuthSettings2"
1483
1778
  response, data = await self.request("GET", url)
1484
- return "Totp" in [
1485
- method_data["twoFactorType"] for method_data in data["methods"]
1486
- ]
1779
+ # fmt: off
1780
+ return "Totp" in [method_data["twoFactorType"] for method_data in data["methods"]]
1781
+ # fmt: on
1487
1782
 
1488
- async def _request_2fa_tasks(self):
1783
+ async def _request_2fa_tasks(self) -> tuple[str, list[Subtask]]:
1489
1784
  """
1490
- :return: flow_token, subtask_ids
1785
+ :return: flow_token, tasks
1491
1786
  """
1492
- params = {
1787
+ query = {
1493
1788
  "flow_name": "two-factor-auth-app-enrollment",
1494
1789
  }
1495
1790
  payload = {
@@ -1543,34 +1838,37 @@ class Client(BaseHTTPClient):
1543
1838
  "web_modal": 1,
1544
1839
  },
1545
1840
  }
1546
- return await self._task(params=params, json=payload)
1841
+ return await self._send_raw_subtask(params=query, json=payload)
1547
1842
 
1548
- async def _two_factor_enrollment_verify_password_subtask(self, flow_token: str):
1549
- subtask_inputs = [
1843
+ async def _two_factor_enrollment_verify_password_subtask(
1844
+ self, flow_token: str
1845
+ ) -> tuple[str, list[Subtask]]:
1846
+ inputs = [
1550
1847
  {
1551
1848
  "subtask_id": "TwoFactorEnrollmentVerifyPasswordSubtask",
1552
1849
  "enter_password": {
1553
- "password": self.account.password,
1554
1850
  "link": "next_link",
1851
+ "password": self.account.password,
1555
1852
  },
1556
1853
  }
1557
1854
  ]
1558
- return await self._send_task(flow_token, subtask_inputs)
1855
+ return await self._complete_subtask(flow_token, inputs)
1559
1856
 
1560
1857
  async def _two_factor_enrollment_authentication_app_begin_subtask(
1561
1858
  self, flow_token: str
1562
- ):
1563
- subtask_inputs = [
1859
+ ) -> tuple[str, list[Subtask]]:
1860
+ inputs = [
1564
1861
  {
1565
1862
  "subtask_id": "TwoFactorEnrollmentAuthenticationAppBeginSubtask",
1566
1863
  "action_list": {"link": "next_link"},
1567
1864
  }
1568
1865
  ]
1569
- return await self._send_task(flow_token, subtask_inputs)
1866
+ return await self._complete_subtask(flow_token, inputs)
1570
1867
 
1571
1868
  async def _two_factor_enrollment_authentication_app_plain_code_subtask(
1572
- self, flow_token: str
1573
- ):
1869
+ self,
1870
+ flow_token: str,
1871
+ ) -> tuple[str, list[Subtask]]:
1574
1872
  subtask_inputs = [
1575
1873
  {
1576
1874
  "subtask_id": "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask",
@@ -1579,12 +1877,12 @@ class Client(BaseHTTPClient):
1579
1877
  {
1580
1878
  "subtask_id": "TwoFactorEnrollmentAuthenticationAppEnterCodeSubtask",
1581
1879
  "enter_text": {
1582
- "text": self.account.get_totp_code(),
1583
1880
  "link": "next_link",
1881
+ "text": self.account.get_totp_code(),
1584
1882
  },
1585
1883
  },
1586
1884
  ]
1587
- return await self._send_task(flow_token, subtask_inputs)
1885
+ return await self._complete_subtask(flow_token, subtask_inputs)
1588
1886
 
1589
1887
  async def _finish_2fa_task(self, flow_token: str):
1590
1888
  subtask_inputs = [
@@ -1593,54 +1891,38 @@ class Client(BaseHTTPClient):
1593
1891
  "cta": {"link": "finish_link"},
1594
1892
  }
1595
1893
  ]
1596
- return await self._send_task(flow_token, subtask_inputs)
1894
+ await self._complete_subtask(flow_token, subtask_inputs)
1597
1895
 
1598
1896
  async def _enable_totp(self):
1897
+ # fmt: off
1599
1898
  flow_token, subtasks = await self._request_2fa_tasks()
1600
- flow_token, subtasks = (
1601
- await self._two_factor_enrollment_verify_password_subtask(flow_token)
1602
- )
1603
- flow_token, subtasks = (
1604
- await self._two_factor_enrollment_authentication_app_begin_subtask(
1605
- flow_token
1606
- )
1899
+ flow_token, subtasks = await self._two_factor_enrollment_verify_password_subtask(
1900
+ flow_token
1607
1901
  )
1902
+ flow_token, subtasks = (await self._two_factor_enrollment_authentication_app_begin_subtask(flow_token))
1608
1903
 
1609
1904
  for subtask in subtasks:
1610
- if (
1611
- subtask["subtask_id"]
1612
- == "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask"
1613
- ):
1614
- self.account.totp_secret = subtask["show_code"]["code"]
1905
+ if subtask.id == "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask":
1906
+ self.account.totp_secret = subtask.raw_data["show_code"]["code"]
1615
1907
  break
1616
1908
 
1617
- flow_token, subtasks = (
1618
- await self._two_factor_enrollment_authentication_app_plain_code_subtask(
1619
- flow_token
1620
- )
1621
- )
1909
+ flow_token, subtasks = await self._two_factor_enrollment_authentication_app_plain_code_subtask(flow_token)
1622
1910
 
1623
1911
  for subtask in subtasks:
1624
- if (
1625
- subtask["subtask_id"]
1626
- == "TwoFactorEnrollmentAuthenticationAppCompleteSubtask"
1627
- ):
1628
- result = re.search(
1629
- r"\n[a-z0-9]{12}\n", subtask["cta"]["secondary_text"]["text"]
1630
- )
1912
+ if subtask.id == "TwoFactorEnrollmentAuthenticationAppCompleteSubtask":
1913
+ result = re.search(r"\n[a-z0-9]{12}\n", subtask.raw_data["cta"]["secondary_text"]["text"])
1631
1914
  backup_code = result[0].strip() if result else None
1632
1915
  self.account.backup_code = backup_code
1633
1916
  break
1634
1917
 
1918
+ # fmt: on
1635
1919
  await self._finish_2fa_task(flow_token)
1636
1920
 
1637
1921
  async def enable_totp(self):
1638
- if not self.account.password:
1639
- raise ValueError("Password is required for this action")
1640
-
1641
1922
  if await self.totp_is_enabled():
1642
1923
  return
1643
1924
 
1644
- # TODO Осторожно, костыль! Перед началом работы вызываем request_user_data, чтоб убедиться что нет других ошибок
1645
- await self.request_user()
1925
+ if not self.account.password:
1926
+ raise ValueError("Password required to enable TOTP")
1927
+
1646
1928
  await self._enable_totp()