tweepy-self 1.6.3__py3-none-any.whl → 1.10.0b9__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
@@ -1,13 +1,15 @@
1
- from typing import Any, Literal
1
+ 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
 
8
+ from loguru import logger
7
9
  from curl_cffi import requests
8
10
  from yarl import URL
9
11
 
10
- from python3_capsolver.fun_captcha import FunCaptcha, FunCaptchaTypeEnm
12
+ from ._capsolver.fun_captcha import FunCaptcha, FunCaptchaTypeEnm
11
13
 
12
14
  from .errors import (
13
15
  TwitterException,
@@ -25,20 +27,23 @@ from .errors import (
25
27
  ConsentLocked,
26
28
  Suspended,
27
29
  )
28
- from .utils import to_json, tweet_url as create_tweet_url
29
- from .base import BaseClient
30
+ from .utils import to_json
31
+ from .base import BaseHTTPClient
30
32
  from .account import Account, AccountStatus
31
- from .models import UserData, Tweet
32
- from .utils import remove_at_sign, parse_oauth_html, parse_unlock_html
33
+ from .models import User, Tweet, Media, Subtask
34
+ from .utils import (
35
+ parse_oauth_html,
36
+ parse_unlock_html,
37
+ tweets_data_from_instructions,
38
+ )
33
39
 
34
40
 
35
- class Client(BaseClient):
36
- _BEARER_TOKEN = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
41
+ class Client(BaseHTTPClient):
42
+ _BEARER_TOKEN = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
37
43
  _DEFAULT_HEADERS = {
38
44
  "authority": "twitter.com",
39
45
  "origin": "https://twitter.com",
40
46
  "x-twitter-active-user": "yes",
41
- # 'x-twitter-auth-type': 'OAuth2Session',
42
47
  "x-twitter-client-language": "en",
43
48
  }
44
49
  _GRAPHQL_URL = "https://twitter.com/i/api/graphql"
@@ -46,7 +51,7 @@ class Client(BaseClient):
46
51
  "CreateRetweet": "ojPdsZsimiJrUGLR1sjUtA",
47
52
  "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
48
53
  "UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA",
49
- "CreateTweet": "SoVnbfCycZ7fERGCwpZkYA",
54
+ "CreateTweet": "v0en1yVV-Ybeek8ClmXwYw",
50
55
  "TweetResultByRestId": "V3vfsYzNEyD9tsf4xoFRgw",
51
56
  "ModerateTweet": "p'jF:GVqCjTcZol0xcBJjw",
52
57
  "DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg",
@@ -56,6 +61,7 @@ class Client(BaseClient):
56
61
  "Following": "t-BPOrMIduGUJWO_LxcvNQ",
57
62
  "Followers": "3yX7xr2hKjcZYnXt6cU6lQ",
58
63
  "UserByScreenName": "G3KGOASz96M-Qu0nwmGXNg",
64
+ "UsersByRestIds": "itEhGywpgX9b3GJCzOtSrA",
59
65
  "Viewer": "W62NnYgkgziw9bwyoVht0g",
60
66
  }
61
67
  _CAPTCHA_URL = "https://twitter.com/account/access"
@@ -76,7 +82,9 @@ class Client(BaseClient):
76
82
  *,
77
83
  wait_on_rate_limit: bool = True,
78
84
  capsolver_api_key: str = None,
79
- max_unlock_attempts: int = 4,
85
+ max_unlock_attempts: int = 5,
86
+ auto_relogin: bool = True,
87
+ update_account_info_on_startup: bool = True,
80
88
  **session_kwargs,
81
89
  ):
82
90
  super().__init__(**session_kwargs)
@@ -84,20 +92,29 @@ class Client(BaseClient):
84
92
  self.wait_on_rate_limit = wait_on_rate_limit
85
93
  self.capsolver_api_key = capsolver_api_key
86
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
87
97
 
88
- async def request(
98
+ async def __aenter__(self):
99
+ await self.on_startup()
100
+ return await super().__aenter__()
101
+
102
+ async def _request(
89
103
  self,
90
104
  method,
91
105
  url,
106
+ *,
92
107
  auth: bool = True,
93
108
  bearer: bool = True,
109
+ wait_on_rate_limit: bool = None,
94
110
  **kwargs,
95
111
  ) -> tuple[requests.Response, Any]:
96
112
  cookies = kwargs["cookies"] = kwargs.get("cookies") or {}
97
113
  headers = kwargs["headers"] = kwargs.get("headers") or {}
98
114
 
99
115
  if bearer:
100
- headers["authorization"] = self._BEARER_TOKEN
116
+ headers["authorization"] = f"Bearer {self._BEARER_TOKEN}"
117
+ # headers["x-twitter-auth-type"] = "OAuth2Session"
101
118
 
102
119
  if auth:
103
120
  if not self.account.auth_token:
@@ -108,6 +125,14 @@ class Client(BaseClient):
108
125
  cookies["ct0"] = self.account.ct0
109
126
  headers["x-csrf-token"] = self.account.ct0
110
127
 
128
+ # fmt: off
129
+ log_message = (f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
130
+ f" ==> Request {method} {url}")
131
+ if kwargs.get('data'): log_message += f"\nRequest data: {kwargs.get('data')}"
132
+ if kwargs.get('json'): log_message += f"\nRequest data: {kwargs.get('json')}"
133
+ logger.debug(log_message)
134
+ # fmt: on
135
+
111
136
  try:
112
137
  response = await self._session.request(method, url, **kwargs)
113
138
  except requests.errors.RequestsError as exc:
@@ -120,17 +145,52 @@ class Client(BaseClient):
120
145
  raise
121
146
 
122
147
  data = response.text
123
- if response.headers["content-type"].startswith("application/json"):
148
+ # fmt: off
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}")
153
+ # fmt: on
154
+
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:
124
167
  data = response.json()
168
+ except json.decoder.JSONDecodeError:
169
+ pass
125
170
 
126
- if response.status_code == 429:
127
- if self.wait_on_rate_limit:
128
- reset_time = int(response.headers["x-rate-limit-reset"])
129
- sleep_time = reset_time - int(time()) + 1
130
- if sleep_time > 0:
131
- await asyncio.sleep(sleep_time)
132
- return await self.request(method, url, auth, bearer, **kwargs)
133
- 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
134
194
 
135
195
  if response.status_code == 400:
136
196
  raise BadRequest(response, data)
@@ -147,10 +207,6 @@ class Client(BaseClient):
147
207
  if response.status_code == 403:
148
208
  exc = Forbidden(response, data)
149
209
 
150
- if 353 in exc.api_codes and "ct0" in response.cookies:
151
- self.account.ct0 = response.cookies["ct0"]
152
- return await self.request(method, url, auth, bearer, **kwargs)
153
-
154
210
  if 64 in exc.api_codes:
155
211
  self.account.status = AccountStatus.SUSPENDED
156
212
  raise Suspended(exc, self.account)
@@ -165,52 +221,83 @@ class Client(BaseClient):
165
221
  raise ConsentLocked(exc, self.account)
166
222
 
167
223
  self.account.status = AccountStatus.LOCKED
168
- if not self.capsolver_api_key:
169
- raise Locked(exc, self.account)
170
-
171
- await self.unlock()
172
- return await self.request(method, url, auth, bearer, **kwargs)
224
+ raise Locked(exc, self.account)
173
225
 
174
226
  raise exc
175
227
 
176
228
  if response.status_code == 404:
177
229
  raise NotFound(response, data)
178
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
+
179
254
  if response.status_code >= 500:
180
255
  raise ServerError(response, data)
181
256
 
182
- if not 200 <= response.status_code < 300:
183
- raise HTTPException(response, data)
184
-
185
- if isinstance(data, dict) and "errors" in data:
186
- 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)
187
268
 
188
- if 141 in exc.api_codes:
189
- self.account.status = AccountStatus.SUSPENDED
190
- raise Suspended(exc, self.account)
269
+ except Locked:
270
+ if not self.capsolver_api_key or not auto_unlock:
271
+ raise
191
272
 
192
- if 326 in exc.api_codes:
193
- for error_data in exc.api_errors:
194
- if (
195
- error_data.get("code") == 326
196
- and error_data.get("bounce_location") == "/i/flow/consent_flow"
197
- ):
198
- self.account.status = AccountStatus.CONSENT_LOCKED
199
- raise ConsentLocked(exc, self.account)
273
+ await self.unlock()
274
+ return await self._request(method, url, **kwargs)
200
275
 
201
- self.account.status = AccountStatus.LOCKED
202
- if not self.capsolver_api_key:
203
- 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
204
285
 
205
- await self.unlock()
206
- return await self.request(method, url, auth, bearer, **kwargs)
286
+ await self.relogin()
287
+ return await self._request(method, url, **kwargs)
207
288
 
208
- 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
209
294
 
210
- self.account.status = AccountStatus.GOOD
211
- 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()
212
299
 
213
- async def _request_oauth_2_auth_code(
300
+ async def _request_oauth2_auth_code(
214
301
  self,
215
302
  client_id: str,
216
303
  code_challenge: str,
@@ -234,7 +321,7 @@ class Client(BaseClient):
234
321
  auth_code = response_json["auth_code"]
235
322
  return auth_code
236
323
 
237
- async def _confirm_oauth_2(self, auth_code: str):
324
+ async def _confirm_oauth2(self, auth_code: str):
238
325
  data = {
239
326
  "approval": "true",
240
327
  "code": auth_code,
@@ -247,7 +334,7 @@ class Client(BaseClient):
247
334
  data=data,
248
335
  )
249
336
 
250
- async def oauth_2(
337
+ async def oauth2(
251
338
  self,
252
339
  client_id: str,
253
340
  code_challenge: str,
@@ -263,15 +350,13 @@ class Client(BaseClient):
263
350
  Привязка (бинд, линк) приложения.
264
351
 
265
352
  :param client_id: Идентификатор клиента, используемый для OAuth.
266
- :param code_challenge: Код-вызов, используемый для PKCE (Proof Key for Code Exchange).
267
353
  :param state: Уникальная строка состояния для предотвращения CSRF-атак.
268
354
  :param redirect_uri: URI перенаправления, на который будет отправлен ответ.
269
- :param code_challenge_method: Метод, используемый для преобразования code_verifier в code_challenge.
270
355
  :param scope: Строка областей доступа, запрашиваемых у пользователя.
271
356
  :param response_type: Тип ответа, который ожидается от сервера авторизации.
272
357
  :return: Код авторизации (привязки).
273
358
  """
274
- auth_code = await self._request_oauth_2_auth_code(
359
+ auth_code = await self._request_oauth2_auth_code(
275
360
  client_id,
276
361
  code_challenge,
277
362
  state,
@@ -280,7 +365,7 @@ class Client(BaseClient):
280
365
  scope,
281
366
  response_type,
282
367
  )
283
- await self._confirm_oauth_2(auth_code)
368
+ await self._confirm_oauth2(auth_code)
284
369
  return auth_code
285
370
 
286
371
  async def _oauth(self, oauth_token: str, **oauth_params) -> requests.Response:
@@ -335,14 +420,13 @@ class Client(BaseClient):
335
420
 
336
421
  return authenticity_token, redirect_url
337
422
 
338
- async def request_username(self):
423
+ async def _update_account_username(self):
339
424
  url = "https://twitter.com/i/api/1.1/account/settings.json"
340
425
  response, response_json = await self.request("POST", url)
341
426
  self.account.username = response_json["screen_name"]
342
427
 
343
- async def _request_user_data(self, username: str) -> UserData:
428
+ async def _request_user_by_username(self, username: str) -> User | None:
344
429
  url, query_id = self._action_to_url("UserByScreenName")
345
- username = remove_at_sign(username)
346
430
  variables = {
347
431
  "screen_name": username,
348
432
  "withSafetyModeUserFields": True,
@@ -367,48 +451,99 @@ class Client(BaseClient):
367
451
  "features": to_json(features),
368
452
  "fieldToggles": to_json(field_toggles),
369
453
  }
370
- response, response_json = await self.request("GET", url, params=params)
371
- user_data = UserData.from_raw_user_data(response_json["data"]["user"]["result"])
454
+ response, data = await self.request("GET", url, params=params)
455
+ if not data["data"]:
456
+ return None
457
+ return User.from_raw_data(data["data"]["user"]["result"])
372
458
 
373
- if self.account.username == user_data.username:
374
- self.account.id = user_data.id
375
- self.account.name = user_data.name
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()
376
466
 
377
- return user_data
467
+ user = await self._request_user_by_username(username)
378
468
 
379
- async def request_user_data(self, username: str = None) -> UserData:
380
- if username:
381
- return await self._request_user_data(username)
382
- else:
383
- if not self.account.username:
384
- await self.request_username()
385
- return await self._request_user_data(self.account.username)
469
+ if user and user.username == self.account.username:
470
+ self.account.update(**user.model_dump())
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
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]
508
+ return user
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)
386
524
 
387
525
  async def upload_image(
388
526
  self,
389
527
  image: bytes,
390
528
  attempts: int = 3,
391
529
  timeout: float | tuple[float, float] = 10,
392
- ) -> int:
530
+ ) -> Media:
393
531
  """
394
532
  Upload image as bytes.
395
533
 
396
534
  Иногда при первой попытке загрузки изображения возвращает 408,
397
535
  после чего повторная попытка загрузки изображения проходит успешно
398
536
 
399
- :return: Media ID
537
+ :return: Media
400
538
  """
401
539
  url = "https://upload.twitter.com/1.1/media/upload.json"
402
-
403
- data = {"media_data": base64.b64encode(image)}
404
-
540
+ payload = {"media_data": base64.b64encode(image)}
405
541
  for attempt in range(attempts):
406
542
  try:
407
- response, response_json = await self.request(
408
- "POST", url, data=data, timeout=timeout
543
+ response, data = await self.request(
544
+ "POST", url, data=payload, timeout=timeout
409
545
  )
410
- media_id = response_json["media_id"]
411
- return media_id
546
+ return Media(**data)
412
547
  except (HTTPException, requests.errors.RequestsError) as exc:
413
548
  if (
414
549
  attempt < attempts - 1
@@ -425,9 +560,6 @@ class Client(BaseClient):
425
560
  else:
426
561
  raise
427
562
 
428
- media_id = response_json["media_id"]
429
- return media_id
430
-
431
563
  async def _follow_action(self, action: str, user_id: int | str) -> bool:
432
564
  url = f"https://twitter.com/i/api/1.1/friendships/{action}.json"
433
565
  params = {
@@ -466,27 +598,76 @@ class Client(BaseClient):
466
598
  "variables": {"tweet_id": tweet_id, "dark_request": False},
467
599
  "queryId": query_id,
468
600
  }
469
- response, response_json = await self.request("POST", url, json=json_payload)
470
- return response_json
601
+ response, data = await self.request("POST", url, json=json_payload)
602
+ return data
603
+
604
+ async def _repost(self, tweet_id: int | str) -> Tweet:
605
+ data = await self._interact_with_tweet("CreateRetweet", tweet_id)
606
+ tweet_id = data["data"]["create_retweet"]["retweet_results"]["result"]["rest_id"] # type: ignore
607
+ return await self.request_tweet(tweet_id)
608
+
609
+ async def _repost_or_search_duplicate(
610
+ self,
611
+ tweet_id: int,
612
+ *,
613
+ search_duplicate: bool = True,
614
+ ) -> Tweet:
615
+ try:
616
+ tweet = await self._repost(tweet_id)
617
+ except HTTPException as exc:
618
+ if (
619
+ search_duplicate
620
+ and 327
621
+ in exc.api_codes # duplicate retweet (You have already retweeted this Tweet)
622
+ ):
623
+ tweets = await self.request_tweets(self.account.id)
624
+ duplicate_tweet = None
625
+ for tweet_ in tweets: # type: Tweet
626
+ if tweet_.retweeted_tweet and tweet_.retweeted_tweet.id == tweet_id:
627
+ duplicate_tweet = tweet_
628
+
629
+ if not duplicate_tweet:
630
+ raise FailedToFindDuplicatePost(
631
+ f"Couldn't find a post duplicate in the next 20 posts"
632
+ )
633
+
634
+ tweet = duplicate_tweet
471
635
 
472
- async def repost(self, tweet_id: int) -> int:
636
+ else:
637
+ raise
638
+
639
+ return tweet
640
+
641
+ async def repost(
642
+ self,
643
+ tweet_id: int,
644
+ *,
645
+ search_duplicate: bool = True,
646
+ ) -> Tweet:
473
647
  """
474
648
  Repost (retweet)
475
649
 
476
- :return: Tweet ID
650
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
651
+
652
+ :return: Tweet
477
653
  """
478
- response_json = await self._interact_with_tweet("CreateRetweet", tweet_id)
479
- retweet_id = int(
480
- response_json["data"]["create_retweet"]["retweet_results"]["result"][
481
- "rest_id"
482
- ]
654
+ return await self._repost_or_search_duplicate(
655
+ tweet_id, search_duplicate=search_duplicate
483
656
  )
484
- return retweet_id
485
657
 
486
658
  async def like(self, tweet_id: int) -> bool:
487
- response_json = await self._interact_with_tweet("FavoriteTweet", tweet_id)
488
- is_liked = response_json["data"]["favorite_tweet"] == "Done"
489
- 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"
490
671
 
491
672
  async def unlike(self, tweet_id: int) -> dict:
492
673
  response_json = await self._interact_with_tweet("UnfavoriteTweet", tweet_id)
@@ -533,47 +714,50 @@ class Client(BaseClient):
533
714
  attachment_url: str = None,
534
715
  ) -> Tweet:
535
716
  url, query_id = self._action_to_url("CreateTweet")
536
- payload = {
537
- "variables": {
538
- "tweet_text": text if text is not None else "",
539
- "dark_request": False,
540
- "media": {"media_entities": [], "possibly_sensitive": False},
541
- "semantic_annotation_ids": [],
542
- },
543
- "features": {
544
- "tweetypie_unmention_optimization_enabled": True,
545
- "responsive_web_edit_tweet_api_enabled": True,
546
- "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
547
- "view_counts_everywhere_api_enabled": True,
548
- "longform_notetweets_consumption_enabled": True,
549
- "tweet_awards_web_tipping_enabled": False,
550
- "longform_notetweets_rich_text_read_enabled": True,
551
- "longform_notetweets_inline_media_enabled": True,
552
- "responsive_web_graphql_exclude_directive_enabled": True,
553
- "verified_phone_label_enabled": False,
554
- "freedom_of_speech_not_reach_fetch_enabled": True,
555
- "standardized_nudges_misinfo": True,
556
- "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": False,
557
- "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
558
- "responsive_web_graphql_timeline_navigation_enabled": True,
559
- "responsive_web_enhance_cards_enabled": False,
560
- "responsive_web_twitter_article_tweet_consumption_enabled": False,
561
- "responsive_web_media_download_video_enabled": False,
562
- },
563
- "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": [],
564
722
  }
565
723
  if attachment_url:
566
- payload["variables"]["attachment_url"] = attachment_url
724
+ variables["attachment_url"] = attachment_url
567
725
  if tweet_id_to_reply:
568
- payload["variables"]["reply"] = {
726
+ variables["reply"] = {
569
727
  "in_reply_to_tweet_id": str(tweet_id_to_reply),
570
728
  "exclude_reply_user_ids": [],
571
729
  }
572
730
  if media_id:
573
- payload["variables"]["media"]["media_entities"].append(
731
+ variables["media"]["media_entities"].append(
574
732
  {"media_id": str(media_id), "tagged_users": []}
575
733
  )
576
-
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
+ }
577
761
  response, response_json = await self.request("POST", url, json=payload)
578
762
  tweet = Tweet.from_raw_data(
579
763
  response_json["data"]["create_tweet"]["tweet_results"]["result"]
@@ -588,7 +772,6 @@ class Client(BaseClient):
588
772
  tweet_id_to_reply: str | int = None,
589
773
  attachment_url: str = None,
590
774
  search_duplicate: bool = True,
591
- with_tweet_url: bool = True,
592
775
  ) -> Tweet:
593
776
  try:
594
777
  tweet = await self._tweet(
@@ -602,10 +785,10 @@ class Client(BaseClient):
602
785
  search_duplicate
603
786
  and 187 in exc.api_codes # duplicate tweet (Status is a duplicate)
604
787
  ):
605
- tweets = await self.request_tweets(self.account.id)
788
+ tweets = await self.request_tweets()
606
789
  duplicate_tweet = None
607
790
  for tweet_ in tweets:
608
- if tweet_.full_text.startswith(text.strip()):
791
+ if tweet_.text.startswith(text.strip()):
609
792
  duplicate_tweet = tweet_
610
793
 
611
794
  if not duplicate_tweet:
@@ -617,11 +800,6 @@ class Client(BaseClient):
617
800
  else:
618
801
  raise
619
802
 
620
- if with_tweet_url:
621
- if not self.account.username:
622
- await self.request_user_data()
623
- tweet.url = create_tweet_url(self.account.username, tweet.id)
624
-
625
803
  return tweet
626
804
 
627
805
  async def tweet(
@@ -630,13 +808,16 @@ class Client(BaseClient):
630
808
  *,
631
809
  media_id: int | str = None,
632
810
  search_duplicate: bool = True,
633
- with_tweet_url: bool = True,
634
811
  ) -> Tweet:
812
+ """
813
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
814
+
815
+ :return: Tweet
816
+ """
635
817
  return await self._tweet_or_search_duplicate(
636
818
  text,
637
819
  media_id=media_id,
638
820
  search_duplicate=search_duplicate,
639
- with_tweet_url=with_tweet_url,
640
821
  )
641
822
 
642
823
  async def reply(
@@ -646,14 +827,17 @@ class Client(BaseClient):
646
827
  *,
647
828
  media_id: int | str = None,
648
829
  search_duplicate: bool = True,
649
- with_tweet_url: bool = True,
650
830
  ) -> Tweet:
831
+ """
832
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
833
+
834
+ :return: Tweet
835
+ """
651
836
  return await self._tweet_or_search_duplicate(
652
837
  text,
653
838
  media_id=media_id,
654
839
  tweet_id_to_reply=tweet_id,
655
840
  search_duplicate=search_duplicate,
656
- with_tweet_url=with_tweet_url,
657
841
  )
658
842
 
659
843
  async def quote(
@@ -663,14 +847,17 @@ class Client(BaseClient):
663
847
  *,
664
848
  media_id: int | str = None,
665
849
  search_duplicate: bool = True,
666
- with_tweet_url: bool = True,
667
850
  ) -> Tweet:
851
+ """
852
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
853
+
854
+ :return: Tweet
855
+ """
668
856
  return await self._tweet_or_search_duplicate(
669
857
  text,
670
858
  media_id=media_id,
671
859
  attachment_url=tweet_url,
672
860
  search_duplicate=search_duplicate,
673
- with_tweet_url=with_tweet_url,
674
861
  )
675
862
 
676
863
  async def vote(
@@ -690,15 +877,21 @@ class Client(BaseClient):
690
877
  response, response_json = await self.request("POST", url, params=params)
691
878
  return response_json
692
879
 
693
- async def _request_users(
694
- self, action: str, user_id: int | str, count: int
695
- ) -> list[UserData]:
880
+ async def _request_users_by_action(
881
+ self,
882
+ action: str,
883
+ user_id: int | str,
884
+ count: int,
885
+ cursor: str = None,
886
+ ) -> list[User]:
696
887
  url, query_id = self._action_to_url(action)
697
888
  variables = {
698
889
  "userId": str(user_id),
699
890
  "count": count,
700
891
  "includePromotedContent": False,
701
892
  }
893
+ if cursor:
894
+ variables["cursor"] = cursor
702
895
  features = {
703
896
  "rweb_lists_timeline_redesign_enabled": True,
704
897
  "responsive_web_graphql_exclude_directive_enabled": True,
@@ -737,40 +930,53 @@ class Client(BaseClient):
737
930
  user_data_dict = entry["content"]["itemContent"]["user_results"][
738
931
  "result"
739
932
  ]
740
- users.append(UserData.from_raw_user_data(user_data_dict))
933
+ users.append(User.from_raw_data(user_data_dict))
741
934
  return users
742
935
 
743
936
  async def request_followers(
744
- self, user_id: int | str = None, count: int = 10
745
- ) -> list[UserData]:
937
+ self,
938
+ user_id: int | str = None,
939
+ count: int = 20,
940
+ cursor: str = None,
941
+ ) -> list[User]:
746
942
  """
747
943
  :param user_id: Текущий пользователь, если не передан ID иного пользователя.
748
944
  :param count: Количество подписчиков.
749
945
  """
750
946
  if user_id:
751
- return await self._request_users("Followers", user_id, count)
947
+ return await self._request_users_by_action(
948
+ "Followers", user_id, count, cursor
949
+ )
752
950
  else:
753
951
  if not self.account.id:
754
- await self.request_user_data()
755
- 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
+ )
756
956
 
757
957
  async def request_followings(
758
- self, user_id: int | str = None, count: int = 10
759
- ) -> list[UserData]:
958
+ self,
959
+ user_id: int | str = None,
960
+ count: int = 20,
961
+ cursor: str = None,
962
+ ) -> list[User]:
760
963
  """
761
964
  :param user_id: Текущий пользователь, если не передан ID иного пользователя.
762
965
  :param count: Количество подписчиков.
763
966
  """
764
967
  if user_id:
765
- return await self._request_users("Following", user_id, count)
968
+ return await self._request_users_by_action(
969
+ "Following", user_id, count, cursor
970
+ )
766
971
  else:
767
972
  if not self.account.id:
768
- await self.request_user_data()
769
- return await self._request_users("Following", self.account.id, count)
973
+ await self.update_account_info()
974
+ return await self._request_users_by_action(
975
+ "Following", self.account.id, count, cursor
976
+ )
770
977
 
771
- async def _request_tweet_data(self, tweet_id: int) -> dict:
772
- action = "TweetDetail"
773
- url, query_id = self._action_to_url(action)
978
+ async def _request_tweet(self, tweet_id: int | str) -> Tweet:
979
+ url, query_id = self._action_to_url("TweetDetail")
774
980
  variables = {
775
981
  "focalTweetId": str(tweet_id),
776
982
  "with_rux_injections": False,
@@ -801,12 +1007,73 @@ class Client(BaseClient):
801
1007
  "longform_notetweets_inline_media_enabled": True,
802
1008
  "responsive_web_enhance_cards_enabled": False,
803
1009
  }
804
- params = {
1010
+ query = {
805
1011
  "variables": to_json(variables),
806
1012
  "features": to_json(features),
807
1013
  }
808
- response, response_json = await self.request("GET", url, params=params)
809
- return response_json
1014
+ response, data = await self.request("GET", url, params=query)
1015
+ instructions = data["data"]["threaded_conversation_with_injections_v2"]["instructions"] # type: ignore
1016
+ tweet_data = tweets_data_from_instructions(instructions)[0]
1017
+ return Tweet.from_raw_data(tweet_data)
1018
+
1019
+ async def _request_tweets(
1020
+ self, user_id: int | str, count: int = 20, cursor: str = None
1021
+ ) -> list[Tweet]:
1022
+ url, query_id = self._action_to_url("UserTweets")
1023
+ variables = {
1024
+ "userId": str(user_id),
1025
+ "count": count,
1026
+ "includePromotedContent": True,
1027
+ "withQuickPromoteEligibilityTweetFields": True,
1028
+ "withVoice": True,
1029
+ "withV2Timeline": True,
1030
+ }
1031
+ if cursor:
1032
+ variables["cursor"] = cursor
1033
+ features = {
1034
+ "responsive_web_graphql_exclude_directive_enabled": True,
1035
+ "verified_phone_label_enabled": False,
1036
+ "creator_subscriptions_tweet_preview_api_enabled": True,
1037
+ "responsive_web_graphql_timeline_navigation_enabled": True,
1038
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
1039
+ "c9s_tweet_anatomy_moderator_badge_enabled": True,
1040
+ "tweetypie_unmention_optimization_enabled": True,
1041
+ "responsive_web_edit_tweet_api_enabled": True,
1042
+ "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
1043
+ "view_counts_everywhere_api_enabled": True,
1044
+ "longform_notetweets_consumption_enabled": True,
1045
+ "responsive_web_twitter_article_tweet_consumption_enabled": False,
1046
+ "tweet_awards_web_tipping_enabled": False,
1047
+ "freedom_of_speech_not_reach_fetch_enabled": True,
1048
+ "standardized_nudges_misinfo": True,
1049
+ "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
1050
+ "rweb_video_timestamps_enabled": True,
1051
+ "longform_notetweets_rich_text_read_enabled": True,
1052
+ "longform_notetweets_inline_media_enabled": True,
1053
+ "responsive_web_media_download_video_enabled": False,
1054
+ "responsive_web_enhance_cards_enabled": False,
1055
+ }
1056
+ params = {"variables": to_json(variables), "features": to_json(features)}
1057
+ response, data = await self.request("GET", url, params=params)
1058
+
1059
+ instructions = data["data"]["user"]["result"]["timeline_v2"]["timeline"][
1060
+ "instructions"
1061
+ ]
1062
+ tweets_data = tweets_data_from_instructions(instructions)
1063
+ return [Tweet.from_raw_data(tweet_data) for tweet_data in tweets_data]
1064
+
1065
+ async def request_tweet(self, tweet_id: int | str) -> Tweet:
1066
+ return await self._request_tweet(tweet_id)
1067
+
1068
+ async def request_tweets(
1069
+ self, user_id: int | str = None, count: int = 20, cursor: str = None
1070
+ ) -> list[Tweet]:
1071
+ if not user_id:
1072
+ if not self.account.id:
1073
+ await self.update_account_info()
1074
+ user_id = self.account.id
1075
+
1076
+ return await self._request_tweets(user_id, count, cursor)
810
1077
 
811
1078
  async def _update_profile_image(
812
1079
  self, type: Literal["banner", "image"], media_id: str | int
@@ -832,8 +1099,8 @@ class Client(BaseClient):
832
1099
  "skip_status": "1",
833
1100
  "return_user": "true",
834
1101
  }
835
- response, response_json = await self.request("POST", url, params=params)
836
- image_url = response_json[f"profile_{type}_url"]
1102
+ response, data = await self.request("POST", url, params=params)
1103
+ image_url = data[f"profile_{type}_url"]
837
1104
  return image_url
838
1105
 
839
1106
  async def update_profile_avatar(self, media_id: int | str) -> str:
@@ -850,12 +1117,12 @@ class Client(BaseClient):
850
1117
 
851
1118
  async def change_username(self, username: str) -> bool:
852
1119
  url = "https://twitter.com/i/api/1.1/account/settings.json"
853
- data = {"screen_name": username}
854
- response, response_json = await self.request("POST", url, data=data)
855
- new_username = response_json["screen_name"]
856
- is_changed = new_username == username
1120
+ payload = {"screen_name": username}
1121
+ response, data = await self.request("POST", url, data=payload)
1122
+ new_username = data["screen_name"]
1123
+ changed = new_username == username
857
1124
  self.account.username = new_username
858
- return is_changed
1125
+ return changed
859
1126
 
860
1127
  async def change_password(self, password: str) -> bool:
861
1128
  """
@@ -865,17 +1132,15 @@ class Client(BaseClient):
865
1132
  raise ValueError(f"Specify the current password before changing it")
866
1133
 
867
1134
  url = "https://twitter.com/i/api/i/account/change_password.json"
868
- data = {
1135
+ payload = {
869
1136
  "current_password": self.account.password,
870
1137
  "password": password,
871
1138
  "password_confirmation": password,
872
1139
  }
873
- response, response_json = await self.request("POST", url, data=data)
874
- is_changed = response_json["status"] == "ok"
875
- auth_token = response.cookies.get("auth_token", domain=".twitter.com")
876
- self.account.auth_token = auth_token
1140
+ response, data = await self.request("POST", url, data=payload)
1141
+ changed = data["status"] == "ok"
877
1142
  self.account.password = password
878
- return is_changed
1143
+ return changed
879
1144
 
880
1145
  async def update_profile(
881
1146
  self,
@@ -891,9 +1156,8 @@ class Client(BaseClient):
891
1156
  raise ValueError("Specify at least one param")
892
1157
 
893
1158
  url = "https://twitter.com/i/api/1.1/account/update_profile.json"
894
- headers = {"content-type": "application/x-www-form-urlencoded"}
895
1159
  # Создаем словарь data, включая в него только те ключи, для которых значения не равны None
896
- data = {
1160
+ payload = {
897
1161
  k: v
898
1162
  for k, v in [
899
1163
  ("name", name),
@@ -903,26 +1167,23 @@ class Client(BaseClient):
903
1167
  ]
904
1168
  if v is not None
905
1169
  }
906
- response, response_json = await self.request(
907
- "POST", url, headers=headers, data=data
908
- )
1170
+ response, data = await self.request("POST", url, data=payload)
909
1171
  # Проверяем, что все переданные параметры соответствуют полученным
910
- is_updated = all(
911
- response_json.get(key) == value
912
- for key, value in data.items()
913
- if key != "url"
1172
+ updated = all(
1173
+ data.get(key) == value for key, value in payload.items() if key != "url"
914
1174
  )
915
1175
  if website:
916
- is_updated &= URL(website) == URL(
917
- response_json["entities"]["url"]["urls"][0]["expanded_url"]
1176
+ updated &= URL(website) == URL(
1177
+ data["entities"]["url"]["urls"][0]["expanded_url"]
918
1178
  )
919
- await self.request_user_data()
920
- return is_updated
1179
+ await self.update_account_info()
1180
+ return updated
921
1181
 
922
1182
  async def establish_status(self):
923
1183
  url = "https://twitter.com/i/api/1.1/account/update_profile.json"
924
1184
  try:
925
- await self.request("POST", url)
1185
+ await self.request("POST", url, auto_unlock=False, auto_relogin=False)
1186
+ self.account.status = AccountStatus.GOOD
926
1187
  except BadAccount:
927
1188
  pass
928
1189
 
@@ -935,19 +1196,16 @@ class Client(BaseClient):
935
1196
  year_visibility: Literal["self"] = "self",
936
1197
  ) -> bool:
937
1198
  url = "https://twitter.com/i/api/1.1/account/update_profile.json"
938
- headers = {"content-type": "application/x-www-form-urlencoded"}
939
- data = {
1199
+ payload = {
940
1200
  "birthdate_day": day,
941
1201
  "birthdate_month": month,
942
1202
  "birthdate_year": year,
943
1203
  "birthdate_visibility": visibility,
944
1204
  "birthdate_year_visibility": year_visibility,
945
1205
  }
946
- response, response_json = await self.request(
947
- "POST", url, headers=headers, data=data
948
- )
1206
+ response, response_json = await self.request("POST", url, json=payload)
949
1207
  birthdate_data = response_json["extended_profile"]["birthdate"]
950
- is_updated = all(
1208
+ updated = all(
951
1209
  (
952
1210
  birthdate_data["day"] == day,
953
1211
  birthdate_data["month"] == month,
@@ -956,7 +1214,7 @@ class Client(BaseClient):
956
1214
  birthdate_data["year_visibility"] == year_visibility,
957
1215
  )
958
1216
  )
959
- return is_updated
1217
+ return updated
960
1218
 
961
1219
  async def send_message(self, user_id: int | str, text: str) -> dict:
962
1220
  """
@@ -972,6 +1230,20 @@ class Client(BaseClient):
972
1230
  },
973
1231
  }
974
1232
  }
1233
+ response, data = await self.request("POST", url, json=payload)
1234
+ event_data = data["event"]
1235
+ return event_data # TODO Возвращать модель, а не словарь
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}
975
1247
  response, response_json = await self.request("POST", url, json=payload)
976
1248
  event_data = response_json["event"]
977
1249
  return event_data
@@ -1023,56 +1295,7 @@ class Client(BaseClient):
1023
1295
  for entry in response_json["inbox_initial_state"]["entries"]
1024
1296
  if "message" in entry
1025
1297
  ]
1026
- return messages
1027
-
1028
- async def request_tweets(self, user_id: str | int, count: int = 20) -> list[Tweet]:
1029
- url, query_id = self._action_to_url("UserTweets")
1030
- variables = {
1031
- "userId": str(user_id),
1032
- "count": count,
1033
- "includePromotedContent": True,
1034
- "withQuickPromoteEligibilityTweetFields": True,
1035
- "withVoice": True,
1036
- "withV2Timeline": True,
1037
- }
1038
- features = {
1039
- "responsive_web_graphql_exclude_directive_enabled": True,
1040
- "verified_phone_label_enabled": False,
1041
- "creator_subscriptions_tweet_preview_api_enabled": True,
1042
- "responsive_web_graphql_timeline_navigation_enabled": True,
1043
- "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
1044
- "c9s_tweet_anatomy_moderator_badge_enabled": True,
1045
- "tweetypie_unmention_optimization_enabled": True,
1046
- "responsive_web_edit_tweet_api_enabled": True,
1047
- "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
1048
- "view_counts_everywhere_api_enabled": True,
1049
- "longform_notetweets_consumption_enabled": True,
1050
- "responsive_web_twitter_article_tweet_consumption_enabled": False,
1051
- "tweet_awards_web_tipping_enabled": False,
1052
- "freedom_of_speech_not_reach_fetch_enabled": True,
1053
- "standardized_nudges_misinfo": True,
1054
- "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
1055
- "rweb_video_timestamps_enabled": True,
1056
- "longform_notetweets_rich_text_read_enabled": True,
1057
- "longform_notetweets_inline_media_enabled": True,
1058
- "responsive_web_media_download_video_enabled": False,
1059
- "responsive_web_enhance_cards_enabled": False,
1060
- }
1061
- params = {"variables": to_json(variables), "features": to_json(features)}
1062
- response, response_json = await self.request("GET", url, params=params)
1063
-
1064
- tweets = []
1065
- for instruction in response_json["data"]["user"]["result"]["timeline_v2"][
1066
- "timeline"
1067
- ]["instructions"]:
1068
- if instruction["type"] == "TimelineAddEntries":
1069
- for entry in instruction["entries"]:
1070
- if entry["entryId"].startswith("tweet"):
1071
- tweet_data = entry["content"]["itemContent"]["tweet_results"][
1072
- "result"
1073
- ]
1074
- tweets.append(Tweet.from_raw_data(tweet_data))
1075
- return tweets
1298
+ return messages # TODO Возвращать модели, а не словари
1076
1299
 
1077
1300
  async def _confirm_unlock(
1078
1301
  self,
@@ -1090,6 +1313,8 @@ class Client(BaseClient):
1090
1313
  payload["verification_string"] = verification_string
1091
1314
  payload["language_code"] = "en"
1092
1315
 
1316
+ # TODO ui_metrics
1317
+
1093
1318
  return await self.request("POST", self._CAPTCHA_URL, data=payload, bearer=False)
1094
1319
 
1095
1320
  async def unlock(self):
@@ -1103,9 +1328,23 @@ class Client(BaseClient):
1103
1328
  needs_unlock,
1104
1329
  start_button,
1105
1330
  finish_button,
1331
+ delete_button,
1106
1332
  ) = parse_unlock_html(html)
1107
1333
  attempt = 1
1108
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
+
1109
1348
  if start_button or finish_button:
1110
1349
  response, html = await self._confirm_unlock(
1111
1350
  authenticity_token, assignment_token
@@ -1116,6 +1355,7 @@ class Client(BaseClient):
1116
1355
  needs_unlock,
1117
1356
  start_button,
1118
1357
  finish_button,
1358
+ delete_button,
1119
1359
  ) = parse_unlock_html(html)
1120
1360
 
1121
1361
  funcaptcha = {
@@ -1133,8 +1373,20 @@ class Client(BaseClient):
1133
1373
  else:
1134
1374
  funcaptcha["captcha_type"] = FunCaptchaTypeEnm.FunCaptchaTaskProxyLess
1135
1375
 
1136
- while needs_unlock:
1376
+ while needs_unlock and attempt <= self.max_unlock_attempts:
1137
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
+
1138
1390
  token = solution.solution["token"]
1139
1391
  response, html = await self._confirm_unlock(
1140
1392
  authenticity_token,
@@ -1142,12 +1394,8 @@ class Client(BaseClient):
1142
1394
  verification_string=token,
1143
1395
  )
1144
1396
 
1145
- if (
1146
- attempt > self.max_unlock_attempts
1147
- or response.url == "https://twitter.com/?lang=en"
1148
- ):
1149
- await self.establish_status()
1150
- return
1397
+ if response.url == "https://twitter.com/?lang=en":
1398
+ break
1151
1399
 
1152
1400
  (
1153
1401
  authenticity_token,
@@ -1155,6 +1403,7 @@ class Client(BaseClient):
1155
1403
  needs_unlock,
1156
1404
  start_button,
1157
1405
  finish_button,
1406
+ delete_button,
1158
1407
  ) = parse_unlock_html(html)
1159
1408
 
1160
1409
  if finish_button:
@@ -1167,22 +1416,58 @@ class Client(BaseClient):
1167
1416
  needs_unlock,
1168
1417
  start_button,
1169
1418
  finish_button,
1419
+ delete_button,
1170
1420
  ) = parse_unlock_html(html)
1171
1421
 
1172
1422
  attempt += 1
1173
1423
 
1174
- 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]]:
1175
1432
  """
1176
- :return: flow_token, subtasks
1433
+ :return: flow_token and subtasks
1177
1434
  """
1178
1435
  url = "https://api.twitter.com/1.1/onboarding/task.json"
1179
- response, response_json = await self.request("POST", url, **kwargs)
1180
- 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
1454
+
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)
1181
1469
 
1182
- async def _request_login_tasks(self):
1183
- """
1184
- :return: flow_token, subtask_ids
1185
- """
1470
+ async def _request_login_tasks(self) -> tuple[str, list[Subtask]]:
1186
1471
  params = {
1187
1472
  "flow_name": "login",
1188
1473
  }
@@ -1237,84 +1522,89 @@ class Client(BaseClient):
1237
1522
  "web_modal": 1,
1238
1523
  },
1239
1524
  }
1240
- return await self._task(params=params, json=payload, auth=False)
1525
+ return await self._send_raw_subtask(params=params, json=payload, auth=False)
1241
1526
 
1242
- async def _send_task(self, flow_token: str, subtask_inputs: list[dict], **kwargs):
1243
- payload = kwargs["json"] = kwargs.get("json") or {}
1244
- payload.update(
1245
- {
1246
- "flow_token": flow_token,
1247
- "subtask_inputs": subtask_inputs,
1248
- }
1249
- )
1250
- return await self._task(**kwargs)
1251
-
1252
- async def _finish_task(self, flow_token):
1253
- payload = {
1254
- "flow_token": flow_token,
1255
- "subtask_inputs": [],
1256
- }
1257
- return await self._task(json=payload)
1258
-
1259
- async def _login_enter_user_identifier(self, flow_token):
1260
- subtask_inputs = [
1527
+ async def _login_enter_user_identifier(self, flow_token: str):
1528
+ inputs = [
1261
1529
  {
1262
1530
  "subtask_id": "LoginEnterUserIdentifierSSO",
1263
1531
  "settings_list": {
1532
+ "link": "next_link",
1264
1533
  "setting_responses": [
1265
1534
  {
1266
1535
  "key": "user_identifier",
1267
1536
  "response_data": {
1268
1537
  "text_data": {
1269
- "result": self.account.email
1270
- or self.account.username
1538
+ "result": self.account.username
1539
+ or self.account.email
1271
1540
  }
1272
1541
  },
1273
1542
  }
1274
1543
  ],
1275
- "link": "next_link",
1276
1544
  },
1277
1545
  }
1278
1546
  ]
1279
- return await self._send_task(flow_token, subtask_inputs, auth=False)
1547
+ return await self._complete_subtask(flow_token, inputs, auth=False)
1280
1548
 
1281
- async def _login_enter_password(self, flow_token):
1282
- subtask_inputs = [
1549
+ async def _login_enter_password(self, flow_token: str):
1550
+ inputs = [
1283
1551
  {
1284
1552
  "subtask_id": "LoginEnterPassword",
1285
1553
  "enter_password": {
1286
- "password": self.account.password,
1287
1554
  "link": "next_link",
1555
+ "password": self.account.password,
1288
1556
  },
1289
1557
  }
1290
1558
  ]
1291
- return await self._send_task(flow_token, subtask_inputs, auth=False)
1559
+ return await self._complete_subtask(flow_token, inputs, auth=False)
1292
1560
 
1293
1561
  async def _account_duplication_check(self, flow_token):
1294
- subtask_inputs = [
1562
+ inputs = [
1295
1563
  {
1296
1564
  "subtask_id": "AccountDuplicationCheck",
1297
1565
  "check_logged_in_account": {"link": "AccountDuplicationCheck_false"},
1298
1566
  }
1299
1567
  ]
1300
- return await self._send_task(flow_token, subtask_inputs, auth=False)
1301
-
1302
- async def _login_two_factor_auth_challenge(self, flow_token):
1303
- if not self.account.totp_secret:
1304
- raise TwitterException(
1305
- f"Failed to login. Task id: LoginTwoFactorAuthChallenge"
1306
- )
1568
+ return await self._complete_subtask(flow_token, inputs, auth=False)
1307
1569
 
1308
- subtask_inputs = [
1570
+ async def _login_two_factor_auth_challenge(self, flow_token, value: str):
1571
+ inputs = [
1309
1572
  {
1310
1573
  "subtask_id": "LoginTwoFactorAuthChallenge",
1311
1574
  "enter_text": {
1312
- "text": self.account.get_totp_code(),
1313
1575
  "link": "next_link",
1576
+ "text": value,
1314
1577
  },
1315
1578
  }
1316
1579
  ]
1317
- return await self._send_task(flow_token, subtask_inputs, auth=False)
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],
1591
+ },
1592
+ }
1593
+ ]
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)
1318
1608
 
1319
1609
  async def _viewer(self):
1320
1610
  url, query_id = self._action_to_url("Viewer")
@@ -1331,11 +1621,11 @@ class Client(BaseClient):
1331
1621
  }
1332
1622
  variables = {"withCommunitiesMemberships": True}
1333
1623
  params = {
1334
- "features": to_json(features),
1335
- "fieldToggles": to_json(field_toggles),
1336
- "variables": to_json(variables),
1624
+ "features": features,
1625
+ "fieldToggles": field_toggles,
1626
+ "variables": variables,
1337
1627
  }
1338
- return self.request("GET", url, params=params)
1628
+ return await self.request("GET", url, params=params)
1339
1629
 
1340
1630
  async def _request_guest_token(self) -> str:
1341
1631
  """
@@ -1350,66 +1640,151 @@ class Client(BaseClient):
1350
1640
  guest_token = re.search(r"gt\s?=\s?\d+", response.text)[0].split("=")[1]
1351
1641
  return guest_token
1352
1642
 
1353
- async def _login(self):
1643
+ async def _login(self) -> bool:
1644
+ update_backup_code = False
1645
+
1354
1646
  guest_token = await self._request_guest_token()
1355
1647
  self._session.headers["X-Guest-Token"] = guest_token
1356
1648
 
1357
- # Можно не устанавливать, так как твиттер сам вернет этот токен
1358
- # self._session.cookies["gt"] = guest_token
1359
-
1360
1649
  flow_token, subtasks = await self._request_login_tasks()
1361
1650
  for _ in range(2):
1362
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
+
1363
1658
  flow_token, subtasks = await self._login_enter_password(flow_token)
1364
1659
  flow_token, subtasks = await self._account_duplication_check(flow_token)
1365
1660
 
1366
- 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
+ )
1367
1667
 
1368
- # TODO IMAP Обработчик
1369
- if "LoginAcid" in subtask_ids:
1370
- raise TwitterException(f"Failed to login: email verification!")
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
+ )
1371
1674
 
1372
- if "LoginTwoFactorAuthChallenge" in subtask_ids:
1373
- flow_token, subtasks = await self._login_two_factor_auth_challenge(
1374
- flow_token
1375
- )
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}
1376
1692
 
1377
- # TODO Возможно, стоит добавить отслеживание этих параметров прямо в request
1378
- self.account.auth_token = self._session.cookies["auth_token"]
1379
- self.account.ct0 = self._session.cookies["ct0"]
1693
+ if "LoginTwoFactorAuthChallenge" in subtask_ids:
1694
+ if not self.account.totp_secret:
1695
+ raise TwitterException(
1696
+ f"Failed to login. Task id: LoginTwoFactorAuthChallenge. No totp_secret!"
1697
+ )
1380
1698
 
1381
- 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
1382
1735
 
1383
- async def login(self):
1384
- if self.account.auth_token:
1385
- await self.establish_status()
1386
- if self.account.status != "BAD_TOKEN":
1387
- return
1736
+ await self._complete_subtask(flow_token, [])
1737
+ return update_backup_code
1388
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
+ """
1389
1746
  if not self.account.email and not self.account.username:
1390
1747
  raise ValueError("No email or username")
1391
1748
 
1392
1749
  if not self.account.password:
1393
1750
  raise ValueError("No password")
1394
1751
 
1395
- 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
+
1396
1763
  await self.establish_status()
1397
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
+
1398
1773
  async def totp_is_enabled(self):
1399
1774
  if not self.account.id:
1400
- await self.request_user_data()
1775
+ await self.update_account_info()
1401
1776
 
1402
1777
  url = f"https://twitter.com/i/api/1.1/strato/column/User/{self.account.id}/account-security/twoFactorAuthSettings2"
1403
1778
  response, data = await self.request("GET", url)
1404
- return "Totp" in [
1405
- method_data["twoFactorType"] for method_data in data["methods"]
1406
- ]
1779
+ # fmt: off
1780
+ return "Totp" in [method_data["twoFactorType"] for method_data in data["methods"]]
1781
+ # fmt: on
1407
1782
 
1408
- async def _request_2fa_tasks(self):
1783
+ async def _request_2fa_tasks(self) -> tuple[str, list[Subtask]]:
1409
1784
  """
1410
- :return: flow_token, subtask_ids
1785
+ :return: flow_token, tasks
1411
1786
  """
1412
- params = {
1787
+ query = {
1413
1788
  "flow_name": "two-factor-auth-app-enrollment",
1414
1789
  }
1415
1790
  payload = {
@@ -1463,34 +1838,37 @@ class Client(BaseClient):
1463
1838
  "web_modal": 1,
1464
1839
  },
1465
1840
  }
1466
- return await self._task(params=params, json=payload)
1841
+ return await self._send_raw_subtask(params=query, json=payload)
1467
1842
 
1468
- async def _two_factor_enrollment_verify_password_subtask(self, flow_token: str):
1469
- subtask_inputs = [
1843
+ async def _two_factor_enrollment_verify_password_subtask(
1844
+ self, flow_token: str
1845
+ ) -> tuple[str, list[Subtask]]:
1846
+ inputs = [
1470
1847
  {
1471
1848
  "subtask_id": "TwoFactorEnrollmentVerifyPasswordSubtask",
1472
1849
  "enter_password": {
1473
- "password": self.account.password,
1474
1850
  "link": "next_link",
1851
+ "password": self.account.password,
1475
1852
  },
1476
1853
  }
1477
1854
  ]
1478
- return await self._send_task(flow_token, subtask_inputs)
1855
+ return await self._complete_subtask(flow_token, inputs)
1479
1856
 
1480
1857
  async def _two_factor_enrollment_authentication_app_begin_subtask(
1481
1858
  self, flow_token: str
1482
- ):
1483
- subtask_inputs = [
1859
+ ) -> tuple[str, list[Subtask]]:
1860
+ inputs = [
1484
1861
  {
1485
1862
  "subtask_id": "TwoFactorEnrollmentAuthenticationAppBeginSubtask",
1486
1863
  "action_list": {"link": "next_link"},
1487
1864
  }
1488
1865
  ]
1489
- return await self._send_task(flow_token, subtask_inputs)
1866
+ return await self._complete_subtask(flow_token, inputs)
1490
1867
 
1491
1868
  async def _two_factor_enrollment_authentication_app_plain_code_subtask(
1492
- self, flow_token: str
1493
- ):
1869
+ self,
1870
+ flow_token: str,
1871
+ ) -> tuple[str, list[Subtask]]:
1494
1872
  subtask_inputs = [
1495
1873
  {
1496
1874
  "subtask_id": "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask",
@@ -1499,12 +1877,12 @@ class Client(BaseClient):
1499
1877
  {
1500
1878
  "subtask_id": "TwoFactorEnrollmentAuthenticationAppEnterCodeSubtask",
1501
1879
  "enter_text": {
1502
- "text": self.account.get_totp_code(),
1503
1880
  "link": "next_link",
1881
+ "text": self.account.get_totp_code(),
1504
1882
  },
1505
1883
  },
1506
1884
  ]
1507
- return await self._send_task(flow_token, subtask_inputs)
1885
+ return await self._complete_subtask(flow_token, subtask_inputs)
1508
1886
 
1509
1887
  async def _finish_2fa_task(self, flow_token: str):
1510
1888
  subtask_inputs = [
@@ -1513,54 +1891,38 @@ class Client(BaseClient):
1513
1891
  "cta": {"link": "finish_link"},
1514
1892
  }
1515
1893
  ]
1516
- return await self._send_task(flow_token, subtask_inputs)
1894
+ await self._complete_subtask(flow_token, subtask_inputs)
1517
1895
 
1518
1896
  async def _enable_totp(self):
1897
+ # fmt: off
1519
1898
  flow_token, subtasks = await self._request_2fa_tasks()
1520
- flow_token, subtasks = (
1521
- await self._two_factor_enrollment_verify_password_subtask(flow_token)
1522
- )
1523
- flow_token, subtasks = (
1524
- await self._two_factor_enrollment_authentication_app_begin_subtask(
1525
- flow_token
1526
- )
1899
+ flow_token, subtasks = await self._two_factor_enrollment_verify_password_subtask(
1900
+ flow_token
1527
1901
  )
1902
+ flow_token, subtasks = (await self._two_factor_enrollment_authentication_app_begin_subtask(flow_token))
1528
1903
 
1529
1904
  for subtask in subtasks:
1530
- if (
1531
- subtask["subtask_id"]
1532
- == "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask"
1533
- ):
1534
- self.account.totp_secret = subtask["show_code"]["code"]
1905
+ if subtask.id == "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask":
1906
+ self.account.totp_secret = subtask.raw_data["show_code"]["code"]
1535
1907
  break
1536
1908
 
1537
- flow_token, subtasks = (
1538
- await self._two_factor_enrollment_authentication_app_plain_code_subtask(
1539
- flow_token
1540
- )
1541
- )
1909
+ flow_token, subtasks = await self._two_factor_enrollment_authentication_app_plain_code_subtask(flow_token)
1542
1910
 
1543
1911
  for subtask in subtasks:
1544
- if (
1545
- subtask["subtask_id"]
1546
- == "TwoFactorEnrollmentAuthenticationAppCompleteSubtask"
1547
- ):
1548
- result = re.search(
1549
- r"\n[a-z0-9]{12}\n", subtask["cta"]["secondary_text"]["text"]
1550
- )
1912
+ if subtask.id == "TwoFactorEnrollmentAuthenticationAppCompleteSubtask":
1913
+ result = re.search(r"\n[a-z0-9]{12}\n", subtask.raw_data["cta"]["secondary_text"]["text"])
1551
1914
  backup_code = result[0].strip() if result else None
1552
1915
  self.account.backup_code = backup_code
1553
1916
  break
1554
1917
 
1918
+ # fmt: on
1555
1919
  await self._finish_2fa_task(flow_token)
1556
1920
 
1557
1921
  async def enable_totp(self):
1558
- if not self.account.password:
1559
- raise ValueError("Password is required for this action")
1560
-
1561
1922
  if await self.totp_is_enabled():
1562
1923
  return
1563
1924
 
1564
- # TODO Осторожно, костыль! Перед началом работы вызываем request_user_data, чтоб убедиться что нет других ошибок
1565
- await self.request_user_data()
1925
+ if not self.account.password:
1926
+ raise ValueError("Password required to enable TOTP")
1927
+
1566
1928
  await self._enable_totp()