tweepy-self 1.9.0__py3-none-any.whl → 1.10.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
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()