tweepy-self 0.1.0__py3-none-any.whl → 1.0.0b2__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
twitter/client.py ADDED
@@ -0,0 +1,1218 @@
1
+ from typing import Any, Literal
2
+ from time import time
3
+ import asyncio
4
+ import base64
5
+ import re
6
+
7
+ from curl_cffi import requests
8
+ from yarl import URL
9
+
10
+ from python3_capsolver.fun_captcha import FunCaptcha, FunCaptchaTypeEnm
11
+
12
+ from .errors import (
13
+ TwitterException,
14
+ HTTPException,
15
+ BadRequest,
16
+ Unauthorized,
17
+ Forbidden,
18
+ NotFound,
19
+ RateLimited,
20
+ ServerError,
21
+ BadAccount,
22
+ BadToken,
23
+ Locked,
24
+ Suspended,
25
+ )
26
+ from .utils import to_json
27
+ from .base import BaseClient
28
+ from .account import Account, AccountStatus
29
+ from .models import UserData, Tweet
30
+ from .utils import remove_at_sign, parse_oauth_html, parse_unlock_html
31
+
32
+
33
+ class Client(BaseClient):
34
+ _BEARER_TOKEN = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'
35
+ _DEFAULT_HEADERS = {
36
+ 'authority': 'twitter.com',
37
+ 'origin': 'https://twitter.com',
38
+ 'x-twitter-active-user': 'yes',
39
+ # 'x-twitter-auth-type': 'OAuth2Session',
40
+ 'x-twitter-client-language': 'en',
41
+ }
42
+ _GRAPHQL_URL = 'https://twitter.com/i/api/graphql'
43
+ _ACTION_TO_QUERY_ID = {
44
+ 'CreateRetweet': "ojPdsZsimiJrUGLR1sjUtA",
45
+ 'FavoriteTweet': "lI07N6Otwv1PhnEgXILM7A",
46
+ 'UnfavoriteTweet': "ZYKSe-w7KEslx3JhSIk5LA",
47
+ 'CreateTweet': "SoVnbfCycZ7fERGCwpZkYA",
48
+ 'TweetResultByRestId': "V3vfsYzNEyD9tsf4xoFRgw",
49
+ 'ModerateTweet': "p'jF:GVqCjTcZol0xcBJjw",
50
+ 'DeleteTweet': "VaenaVgh5q5ih7kvyVjgtg",
51
+ 'UserTweets': "V1ze5q3ijDS1VeLwLY0m7g",
52
+ 'TweetDetail': 'VWFGPVAGkZMGRKGe3GFFnA',
53
+ 'ProfileSpotlightsQuery': '9zwVLJ48lmVUk8u_Gh9DmA',
54
+ 'Following': 't-BPOrMIduGUJWO_LxcvNQ',
55
+ 'Followers': '3yX7xr2hKjcZYnXt6cU6lQ',
56
+ 'UserByScreenName': 'G3KGOASz96M-Qu0nwmGXNg',
57
+ 'Viewer': 'W62NnYgkgziw9bwyoVht0g',
58
+ }
59
+ _CAPTCHA_URL = 'https://twitter.com/account/access'
60
+ _CAPTCHA_SITE_KEY = '0152B4EB-D2DC-460A-89A1-629838B529C9'
61
+
62
+ @classmethod
63
+ def _action_to_url(cls, action: str) -> tuple[str, str]:
64
+ """
65
+ :return: URL and Query ID
66
+ """
67
+ query_id = cls._ACTION_TO_QUERY_ID[action]
68
+ url = f"{cls._GRAPHQL_URL}/{query_id}/{action}"
69
+ return url, query_id
70
+
71
+ def __init__(
72
+ self,
73
+ account: Account,
74
+ *,
75
+ wait_on_rate_limit: bool = True,
76
+ **session_kwargs,
77
+ ):
78
+ super().__init__(**session_kwargs)
79
+ self.account = account
80
+ self.wait_on_rate_limit = wait_on_rate_limit
81
+
82
+ async def request(
83
+ self,
84
+ method,
85
+ url,
86
+ auth: bool = True,
87
+ bearer: bool = True,
88
+ **kwargs,
89
+ ) -> tuple[requests.Response, Any]:
90
+ cookies = kwargs["cookies"] = kwargs.get("cookies") or {}
91
+ headers = kwargs["headers"] = kwargs.get("headers") or {}
92
+
93
+ if bearer:
94
+ headers["authorization"] = self._BEARER_TOKEN
95
+
96
+ if auth:
97
+ if not self.account.auth_token:
98
+ raise ValueError("No auth_token. Login before")
99
+
100
+ cookies["auth_token"] = self.account.auth_token
101
+ if self.account.ct0:
102
+ cookies["ct0"] = self.account.ct0
103
+ headers["x-csrf-token"] = self.account.ct0
104
+
105
+ try:
106
+ response = await self._session.request(method, url, **kwargs)
107
+ except requests.errors.RequestsError as exc:
108
+ if exc.code == 35:
109
+ msg = "The IP address may have been blocked by Twitter. Blocked countries: Russia. " + str(exc)
110
+ raise requests.errors.RequestsError(msg, 35, exc.response)
111
+ raise
112
+
113
+ data = response.text
114
+ if response.headers['content-type'].startswith('application/json'):
115
+ data = response.json()
116
+
117
+ if response.status_code == 429:
118
+ if self.wait_on_rate_limit:
119
+ reset_time = int(response.headers["x-rate-limit-reset"])
120
+ sleep_time = reset_time - int(time()) + 1
121
+ if sleep_time > 0:
122
+ await asyncio.sleep(sleep_time)
123
+ return await self.request(method, url, auth, bearer, **kwargs)
124
+ raise RateLimited(response, data)
125
+
126
+ if response.status_code == 400:
127
+ raise BadRequest(response, data)
128
+
129
+ if response.status_code == 401:
130
+ exc = Unauthorized(response, data)
131
+
132
+ if 32 in exc.api_codes:
133
+ self.account.status = AccountStatus.BAD_TOKEN
134
+ raise BadToken(self.account)
135
+
136
+ raise exc
137
+
138
+ if response.status_code == 403:
139
+ exc = Forbidden(response, data)
140
+
141
+ if 353 in exc.api_codes and "ct0" in response.cookies:
142
+ self.account.ct0 = response.cookies["ct0"]
143
+ return await self.request(method, url, auth, bearer, **kwargs)
144
+
145
+ if 64 in exc.api_codes:
146
+ self.account.status = AccountStatus.SUSPENDED
147
+ raise Suspended(self.account)
148
+
149
+ if 326 in exc.api_codes:
150
+ self.account.status = AccountStatus.LOCKED
151
+ raise Locked(self.account)
152
+
153
+ raise exc
154
+
155
+ if response.status_code == 404:
156
+ raise NotFound(response, data)
157
+
158
+ if response.status_code >= 500:
159
+ raise ServerError(response, data)
160
+
161
+ if not 200 <= response.status_code < 300:
162
+ raise HTTPException(response, data)
163
+
164
+ if isinstance(data, dict) and "errors" in data:
165
+ exc = HTTPException(response, data)
166
+
167
+ if 141 in exc.api_codes:
168
+ self.account.status = AccountStatus.SUSPENDED
169
+ raise Suspended(self.account)
170
+
171
+ if 326 in exc.api_codes:
172
+ self.account.status = AccountStatus.LOCKED
173
+ raise Locked(self.account)
174
+
175
+ raise exc
176
+
177
+ self.account.status = AccountStatus.GOOD
178
+ return response, data
179
+
180
+ async def _request_oauth_2_auth_code(
181
+ self,
182
+ client_id: str,
183
+ code_challenge: str,
184
+ state: str,
185
+ redirect_uri: str,
186
+ code_challenge_method: str,
187
+ scope: str,
188
+ response_type: str,
189
+ ) -> str:
190
+ url = "https://twitter.com/i/api/2/oauth2/authorize"
191
+ querystring = {
192
+ "client_id": client_id,
193
+ "code_challenge": code_challenge,
194
+ "code_challenge_method": code_challenge_method,
195
+ "state": state,
196
+ "scope": scope,
197
+ "response_type": response_type,
198
+ "redirect_uri": redirect_uri,
199
+ }
200
+ response, response_json = await self.request("GET", url, params=querystring)
201
+ auth_code = response_json["auth_code"]
202
+ return auth_code
203
+
204
+ async def _confirm_oauth_2(self, auth_code: str):
205
+ data = {
206
+ 'approval': 'true',
207
+ 'code': auth_code,
208
+ }
209
+ headers = {'content-type': 'application/x-www-form-urlencoded'}
210
+ await self.request("POST", 'https://twitter.com/i/api/2/oauth2/authorize', headers=headers, data=data)
211
+
212
+ async def oauth_2(
213
+ self,
214
+ client_id: str,
215
+ code_challenge: str,
216
+ state: str,
217
+ redirect_uri: str,
218
+ code_challenge_method: str,
219
+ scope: str,
220
+ response_type: str,
221
+ ):
222
+ """
223
+ Запрашивает код авторизации для OAuth 2.0 авторизации.
224
+
225
+ Привязка (бинд, линк) приложения.
226
+
227
+ :param client_id: Идентификатор клиента, используемый для OAuth.
228
+ :param code_challenge: Код-вызов, используемый для PKCE (Proof Key for Code Exchange).
229
+ :param state: Уникальная строка состояния для предотвращения CSRF-атак.
230
+ :param redirect_uri: URI перенаправления, на который будет отправлен ответ.
231
+ :param code_challenge_method: Метод, используемый для преобразования code_verifier в code_challenge.
232
+ :param scope: Строка областей доступа, запрашиваемых у пользователя.
233
+ :param response_type: Тип ответа, который ожидается от сервера авторизации.
234
+ :return: Код авторизации (привязки).
235
+ """
236
+ auth_code = await self._request_oauth_2_auth_code(
237
+ client_id, code_challenge, state, redirect_uri, code_challenge_method, scope, response_type,
238
+ )
239
+ await self._confirm_oauth_2(auth_code)
240
+ return auth_code
241
+
242
+ async def _oauth(self, oauth_token: str, **oauth_params) -> requests.Response:
243
+ """
244
+
245
+ :return: Response: html страница привязки приложения (аутентификации) старого типа.
246
+ """
247
+ url = "https://api.twitter.com/oauth/authenticate"
248
+ oauth_params["oauth_token"] = oauth_token
249
+ response, _ = await self.request("GET", url, params=oauth_params)
250
+
251
+ if response.status_code == 403:
252
+ raise ValueError("The request token (oauth_token) for this page is invalid."
253
+ " It may have already been used, or expired because it is too old.")
254
+
255
+ return response
256
+
257
+ async def _confirm_oauth(
258
+ self,
259
+ oauth_token: str,
260
+ authenticity_token: str,
261
+ redirect_after_login_url: str,
262
+ ) -> requests.Response:
263
+ url = "https://api.twitter.com/oauth/authorize"
264
+ params = {
265
+ "redirect_after_login": redirect_after_login_url,
266
+ "authenticity_token": authenticity_token,
267
+ "oauth_token": oauth_token,
268
+ }
269
+ response, _ = await self.request("POST", url, data=params)
270
+ return response
271
+
272
+ async def oauth(self, oauth_token: str, **oauth_params) -> tuple[str, str]:
273
+ """
274
+ :return: authenticity_token, redirect_url
275
+ """
276
+ response = await self._oauth(oauth_token, **oauth_params)
277
+ authenticity_token, redirect_url, redirect_after_login_url = parse_oauth_html(response.text)
278
+
279
+ # Первая привязка требует подтверждения
280
+ if redirect_after_login_url:
281
+ response = await self._confirm_oauth(oauth_token, authenticity_token, redirect_after_login_url)
282
+ authenticity_token, redirect_url, redirect_after_login_url = parse_oauth_html(response.text)
283
+
284
+ return authenticity_token, redirect_url
285
+
286
+ async def request_username(self):
287
+ url = "https://twitter.com/i/api/1.1/account/settings.json"
288
+ response, response_json = await self.request("POST", url)
289
+ self.account.username = response_json["screen_name"]
290
+
291
+ async def _request_user_data(self, username: str) -> UserData:
292
+ url, query_id = self._action_to_url("UserByScreenName")
293
+ username = remove_at_sign(username)
294
+ variables = {
295
+ "screen_name": username,
296
+ "withSafetyModeUserFields": True,
297
+ }
298
+ features = {
299
+ "hidden_profile_likes_enabled": True,
300
+ "hidden_profile_subscriptions_enabled": True,
301
+ "responsive_web_graphql_exclude_directive_enabled": True,
302
+ "verified_phone_label_enabled": False,
303
+ "subscriptions_verification_info_is_identity_verified_enabled": True,
304
+ "subscriptions_verification_info_verified_since_enabled": True,
305
+ "highlights_tweets_tab_ui_enabled": True,
306
+ "creator_subscriptions_tweet_preview_api_enabled": True,
307
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
308
+ "responsive_web_graphql_timeline_navigation_enabled": True,
309
+ }
310
+ field_toggles = {
311
+ "withAuxiliaryUserLabels": False,
312
+ }
313
+ params = {
314
+ "variables": to_json(variables),
315
+ "features": to_json(features),
316
+ "fieldToggles": to_json(field_toggles),
317
+ }
318
+ response, response_json = await self.request("GET", url, params=params)
319
+ user_data = UserData.from_raw_user_data(response_json["data"]["user"]["result"])
320
+
321
+ if self.account.username == user_data.username:
322
+ self.account.id = user_data.id
323
+ self.account.name = user_data.name
324
+
325
+ return user_data
326
+
327
+ async def request_user_data(self, username: str = None) -> UserData:
328
+ if username:
329
+ return await self._request_user_data(username)
330
+ else:
331
+ if not self.account.username:
332
+ await self.request_username()
333
+ return await self._request_user_data(self.account.username)
334
+
335
+ async def upload_image(self, image: bytes) -> int:
336
+ """
337
+ Upload image as bytes.
338
+
339
+ :return: Media ID
340
+ """
341
+ url = "https://upload.twitter.com/1.1/media/upload.json"
342
+
343
+ data = {"media_data": base64.b64encode(image)}
344
+ response, response_json = await self.request("POST", url, data=data)
345
+ media_id = response_json["media_id"]
346
+ return media_id
347
+
348
+ async def _follow_action(self, action: str, user_id: int | str) -> bool:
349
+ url = f"https://twitter.com/i/api/1.1/friendships/{action}.json"
350
+ params = {
351
+ 'include_profile_interstitial_type': '1',
352
+ 'include_blocking': '1',
353
+ 'include_blocked_by': '1',
354
+ 'include_followed_by': '1',
355
+ 'include_want_retweets': '1',
356
+ 'include_mute_edge': '1',
357
+ 'include_can_dm': '1',
358
+ 'include_can_media_tag': '1',
359
+ 'include_ext_has_nft_avatar': '1',
360
+ 'include_ext_is_blue_verified': '1',
361
+ 'include_ext_verified_type': '1',
362
+ 'include_ext_profile_image_shape': '1',
363
+ 'skip_status': '1',
364
+ 'user_id': user_id,
365
+ }
366
+ headers = {
367
+ 'content-type': 'application/x-www-form-urlencoded',
368
+ }
369
+ response, response_json = await self.request("POST", url, params=params, headers=headers)
370
+ return bool(response_json)
371
+
372
+ async def follow(self, user_id: str | int) -> bool:
373
+ return await self._follow_action("create", user_id)
374
+
375
+ async def unfollow(self, user_id: str | int) -> bool:
376
+ return await self._follow_action("destroy", user_id)
377
+
378
+ async def _interact_with_tweet(self, action: str, tweet_id: int) -> dict:
379
+ url, query_id = self._action_to_url(action)
380
+ json_payload = {
381
+ 'variables': {
382
+ 'tweet_id': tweet_id,
383
+ 'dark_request': False
384
+ },
385
+ 'queryId': query_id
386
+ }
387
+ response, response_json = await self.request("POST", url, json=json_payload)
388
+ return response_json
389
+
390
+ async def repost(self, tweet_id: int) -> int:
391
+ """
392
+ Repost (retweet)
393
+
394
+ :return: Tweet ID
395
+ """
396
+ response_json = await self._interact_with_tweet('CreateRetweet', tweet_id)
397
+ retweet_id = int(response_json['data']['create_retweet']['retweet_results']['result']['rest_id'])
398
+ return retweet_id
399
+
400
+ async def like(self, tweet_id: int) -> bool:
401
+ response_json = await self._interact_with_tweet('FavoriteTweet', tweet_id)
402
+ is_liked = response_json['data']['favorite_tweet'] == 'Done'
403
+ return is_liked
404
+
405
+ async def unlike(self, tweet_id: int) -> dict:
406
+ response_json = await self._interact_with_tweet('UnfavoriteTweet', tweet_id)
407
+ is_unliked = 'data' in response_json and response_json['data']['unfavorite_tweet'] == 'Done'
408
+ return is_unliked
409
+
410
+ async def delete_tweet(self, tweet_id: int | str) -> bool:
411
+ url, query_id = self._action_to_url('DeleteTweet')
412
+ json_payload = {
413
+ 'variables': {
414
+ 'tweet_id': tweet_id,
415
+ 'dark_request': False,
416
+ },
417
+ 'queryId': query_id,
418
+ }
419
+ response, response_json = await self.request("POST", url, json=json_payload)
420
+ is_deleted = "data" in response_json and "delete_tweet" in response_json["data"]
421
+ return is_deleted
422
+
423
+ async def pin_tweet(self, tweet_id: str | int) -> bool:
424
+ url = 'https://api.twitter.com/1.1/account/pin_tweet.json'
425
+ data = {
426
+ 'tweet_mode': 'extended',
427
+ 'id': str(tweet_id),
428
+ }
429
+ headers = {
430
+ 'content-type': 'application/x-www-form-urlencoded',
431
+ }
432
+ response, response_json = await self.request("POST", url, headers=headers, data=data)
433
+ is_pinned = bool(response_json["pinned_tweets"])
434
+ return is_pinned
435
+
436
+ async def _tweet(
437
+ self,
438
+ text: str = None,
439
+ *,
440
+ media_id: int | str = None,
441
+ tweet_id_to_reply: str | int = None,
442
+ attachment_url: str = None,
443
+ ) -> int:
444
+ url, query_id = self._action_to_url('CreateTweet')
445
+ payload = {
446
+ 'variables': {
447
+ 'tweet_text': text if text is not None else "",
448
+ 'dark_request': False,
449
+ 'media': {
450
+ 'media_entities': [],
451
+ 'possibly_sensitive': False},
452
+ 'semantic_annotation_ids': [],
453
+ },
454
+ 'features': {
455
+ 'tweetypie_unmention_optimization_enabled': True,
456
+ 'responsive_web_edit_tweet_api_enabled': True,
457
+ 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True,
458
+ 'view_counts_everywhere_api_enabled': True,
459
+ 'longform_notetweets_consumption_enabled': True,
460
+ 'tweet_awards_web_tipping_enabled': False,
461
+ 'longform_notetweets_rich_text_read_enabled': True,
462
+ 'longform_notetweets_inline_media_enabled': True,
463
+ 'responsive_web_graphql_exclude_directive_enabled': True,
464
+ 'verified_phone_label_enabled': False,
465
+ 'freedom_of_speech_not_reach_fetch_enabled': True,
466
+ 'standardized_nudges_misinfo': True,
467
+ 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': False,
468
+ 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False,
469
+ 'responsive_web_graphql_timeline_navigation_enabled': True,
470
+ 'responsive_web_enhance_cards_enabled': False,
471
+ 'responsive_web_twitter_article_tweet_consumption_enabled': False,
472
+ 'responsive_web_media_download_video_enabled': False
473
+ },
474
+ 'queryId': query_id,
475
+ }
476
+ if attachment_url:
477
+ payload['variables']['attachment_url'] = attachment_url
478
+ if tweet_id_to_reply:
479
+ payload['variables']['reply'] = {
480
+ 'in_reply_to_tweet_id': str(tweet_id_to_reply),
481
+ 'exclude_reply_user_ids': [],
482
+ }
483
+ if media_id:
484
+ payload['variables']['media']['media_entities'].append({'media_id': str(media_id), 'tagged_users': []})
485
+
486
+ response, response_json = await self.request("POST", url, json=payload)
487
+ tweet_id = response_json['data']['create_tweet']['tweet_results']['result']['rest_id']
488
+ return tweet_id
489
+
490
+ async def tweet(self, text: str, *, media_id: int | str = None) -> int:
491
+ """
492
+ :return: Tweet ID
493
+ """
494
+ return await self._tweet(text, media_id=media_id)
495
+
496
+ async def reply(self, tweet_id: str | int, text: str, *, media_id: int | str = None) -> int:
497
+ """
498
+ :return: Tweet ID
499
+ """
500
+ return await self._tweet(text, media_id=media_id, tweet_id_to_reply=tweet_id)
501
+
502
+ async def quote(self, tweet_url: str, text: str, *, media_id: int | str = None) -> int:
503
+ """
504
+ :return: Tweet ID
505
+ """
506
+ return await self._tweet(text, media_id=media_id, attachment_url=tweet_url)
507
+
508
+ async def vote(self, tweet_id: int | str, card_id: int | str, choice_number: int) -> dict:
509
+ """
510
+ :return: Raw vote information
511
+ """
512
+ url = "https://caps.twitter.com/v2/capi/passthrough/1"
513
+ params = {
514
+ "twitter:string:card_uri": f"card://{card_id}",
515
+ "twitter:long:original_tweet_id": str(tweet_id),
516
+ "twitter:string:response_card_name": "poll2choice_text_only",
517
+ "twitter:string:cards_platform": "Web-12",
518
+ "twitter:string:selected_choice": str(choice_number),
519
+ }
520
+ response, response_json = await self.request("POST", url, params=params)
521
+ return response_json
522
+
523
+ async def _request_users(self, action: str, user_id: int | str, count: int) -> list[UserData]:
524
+ url, query_id = self._action_to_url(action)
525
+ variables = {
526
+ 'userId': str(user_id),
527
+ 'count': count,
528
+ 'includePromotedContent': False,
529
+ }
530
+ features = {
531
+ "rweb_lists_timeline_redesign_enabled": True,
532
+ "responsive_web_graphql_exclude_directive_enabled": True,
533
+ "verified_phone_label_enabled": False,
534
+ "creator_subscriptions_tweet_preview_api_enabled": True,
535
+ "responsive_web_graphql_timeline_navigation_enabled": True,
536
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
537
+ "tweetypie_unmention_optimization_enabled": True,
538
+ "responsive_web_edit_tweet_api_enabled": True,
539
+ "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
540
+ "view_counts_everywhere_api_enabled": True,
541
+ "longform_notetweets_consumption_enabled": True,
542
+ "responsive_web_twitter_article_tweet_consumption_enabled": False,
543
+ "tweet_awards_web_tipping_enabled": False,
544
+ "freedom_of_speech_not_reach_fetch_enabled": True,
545
+ "standardized_nudges_misinfo": True,
546
+ "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
547
+ "longform_notetweets_rich_text_read_enabled": True,
548
+ "longform_notetweets_inline_media_enabled": True,
549
+ "responsive_web_media_download_video_enabled": False,
550
+ "responsive_web_enhance_cards_enabled": False
551
+ }
552
+ params = {
553
+ 'variables': to_json(variables),
554
+ 'features': to_json(features),
555
+ }
556
+ response, response_json = await self.request("GET", url, params=params)
557
+
558
+ users = []
559
+ if 'result' in response_json['data']['user']:
560
+ entries = response_json['data']['user']['result']['timeline']['timeline']['instructions'][-1]['entries']
561
+ for entry in entries:
562
+ if entry['entryId'].startswith('user'):
563
+ user_data_dict = entry["content"]["itemContent"]["user_results"]["result"]
564
+ users.append(UserData.from_raw_user_data(user_data_dict))
565
+ return users
566
+
567
+ async def request_followers(self, user_id: int | str = None, count: int = 10) -> list[UserData]:
568
+ """
569
+ :param user_id: Текущий пользователь, если не передан ID иного пользователя.
570
+ :param count: Количество подписчиков.
571
+ """
572
+ if user_id:
573
+ return await self._request_users('Followers', user_id, count)
574
+ else:
575
+ if not self.account.id:
576
+ await self.request_user_data()
577
+ return await self._request_users('Followers', self.account.id, count)
578
+
579
+ async def request_followings(self, user_id: int | str = None, count: int = 10) -> list[UserData]:
580
+ """
581
+ :param user_id: Текущий пользователь, если не передан ID иного пользователя.
582
+ :param count: Количество подписчиков.
583
+ """
584
+ if user_id:
585
+ return await self._request_users('Following', user_id, count)
586
+ else:
587
+ if not self.account.id:
588
+ await self.request_user_data()
589
+ return await self._request_users('Following', self.account.id, count)
590
+
591
+ async def _request_tweet_data(self, tweet_id: int) -> dict:
592
+ action = 'TweetDetail'
593
+ url, query_id = self._action_to_url(action)
594
+ variables = {
595
+ "focalTweetId": str(tweet_id),
596
+ "with_rux_injections": False,
597
+ "includePromotedContent": True,
598
+ "withCommunity": True,
599
+ "withQuickPromoteEligibilityTweetFields": True,
600
+ "withBirdwatchNotes": True,
601
+ "withVoice": True,
602
+ "withV2Timeline": True,
603
+ }
604
+ features = {
605
+ "rweb_lists_timeline_redesign_enabled": True,
606
+ "responsive_web_graphql_exclude_directive_enabled": True,
607
+ "verified_phone_label_enabled": False,
608
+ "creator_subscriptions_tweet_preview_api_enabled": True,
609
+ "responsive_web_graphql_timeline_navigation_enabled": True,
610
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
611
+ "tweetypie_unmention_optimization_enabled": True,
612
+ "responsive_web_edit_tweet_api_enabled": True,
613
+ "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
614
+ "view_counts_everywhere_api_enabled": True,
615
+ "longform_notetweets_consumption_enabled": True,
616
+ "tweet_awards_web_tipping_enabled": False,
617
+ "freedom_of_speech_not_reach_fetch_enabled": True,
618
+ "standardized_nudges_misinfo": True,
619
+ "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
620
+ "longform_notetweets_rich_text_read_enabled": True,
621
+ "longform_notetweets_inline_media_enabled": True,
622
+ "responsive_web_enhance_cards_enabled": False,
623
+ }
624
+ params = {
625
+ 'variables': to_json(variables),
626
+ 'features': to_json(features),
627
+ }
628
+ response, response_json = await self.request("GET", url, params=params)
629
+ return response_json
630
+
631
+ async def _update_profile_image(self, type: Literal["banner", "image"], media_id: str | int) -> str:
632
+ """
633
+ :return: Image URL
634
+ """
635
+ url = f"https://api.twitter.com/1.1/account/update_profile_{type}.json"
636
+ params = {
637
+ 'media_id': str(media_id),
638
+ 'include_profile_interstitial_type': '1',
639
+ 'include_blocking': '1',
640
+ 'include_blocked_by': '1',
641
+ 'include_followed_by': '1',
642
+ 'include_want_retweets': '1',
643
+ 'include_mute_edge': '1',
644
+ 'include_can_dm': '1',
645
+ 'include_can_media_tag': '1',
646
+ 'include_ext_has_nft_avatar': '1',
647
+ 'include_ext_is_blue_verified': '1',
648
+ 'include_ext_verified_type': '1',
649
+ 'include_ext_profile_image_shape': '1',
650
+ 'skip_status': '1',
651
+ 'return_user': 'true',
652
+ }
653
+ response, response_json = await self.request("POST", url, params=params)
654
+ image_url = response_json[f"profile_{type}_url"]
655
+ return image_url
656
+
657
+ async def update_profile_avatar(self, media_id: int | str) -> str:
658
+ """
659
+ :return: Image URL
660
+ """
661
+ return await self._update_profile_image("image", media_id)
662
+
663
+ async def update_profile_banner(self, media_id: int | str) -> str:
664
+ """
665
+ :return: Image URL
666
+ """
667
+ return await self._update_profile_image("banner", media_id)
668
+
669
+ async def change_username(self, username: str) -> bool:
670
+ url = "https://twitter.com/i/api/1.1/account/settings.json"
671
+ data = {"screen_name": username}
672
+ response, response_json = await self.request("POST", url, data=data)
673
+ new_username = response_json["screen_name"]
674
+ is_changed = new_username == username
675
+ self.account.username = new_username
676
+ return is_changed
677
+
678
+ async def change_password(self, password: str) -> bool:
679
+ """
680
+ После изменения пароля обновляется auth_token!
681
+ """
682
+ if not self.account.password:
683
+ raise ValueError(f"Specify the current password before changing it")
684
+
685
+ url = "https://twitter.com/i/api/i/account/change_password.json"
686
+ data = {
687
+ "current_password": self.account.password,
688
+ "password": password,
689
+ "password_confirmation": password
690
+ }
691
+ response, response_json = await self.request("POST", url, data=data)
692
+ is_changed = response_json["status"] == "ok"
693
+ auth_token = response.cookies.get("auth_token", domain=".twitter.com")
694
+ self.account.auth_token = auth_token
695
+ self.account.password = password
696
+ return is_changed
697
+
698
+ async def update_profile(
699
+ self,
700
+ name: str = None,
701
+ description: str = None,
702
+ location: str = None,
703
+ website: str = None,
704
+ ) -> bool:
705
+ """
706
+ Locks an account!
707
+ """
708
+ if name is None and description is None:
709
+ raise ValueError("Specify at least one param")
710
+
711
+ url = "https://twitter.com/i/api/1.1/account/update_profile.json"
712
+ headers = {"content-type": "application/x-www-form-urlencoded"}
713
+ # Создаем словарь data, включая в него только те ключи, для которых значения не равны None
714
+ data = {k: v for k, v in [
715
+ ("name", name),
716
+ ("description", description),
717
+ ("location", location),
718
+ ("url", website),
719
+ ] if v is not None}
720
+ response, response_json = await self.request("POST", url, headers=headers, data=data)
721
+ # Проверяем, что все переданные параметры соответствуют полученным
722
+ is_updated = all(response_json.get(key) == value for key, value in data.items() if key != "url")
723
+ if website: is_updated &= URL(website) == URL(response_json["entities"]["url"]["urls"][0]["expanded_url"])
724
+ await self.establish_status() # Изменение данных профиля часто замораживает аккаунт
725
+ return is_updated
726
+
727
+ async def establish_status(self):
728
+ url = "https://twitter.com/i/api/1.1/account/update_profile.json"
729
+ try:
730
+ await self.request("POST", url)
731
+ except BadAccount:
732
+ pass
733
+
734
+ async def update_birthdate(
735
+ self,
736
+ day: int,
737
+ month: int,
738
+ year: int,
739
+ visibility: Literal["self", "mutualfollow"] = "self",
740
+ year_visibility: Literal["self"] = "self",
741
+ ) -> bool:
742
+ url = "https://twitter.com/i/api/1.1/account/update_profile.json"
743
+ headers = {"content-type": "application/x-www-form-urlencoded"}
744
+ data = {
745
+ "birthdate_day": day,
746
+ "birthdate_month": month,
747
+ "birthdate_year": year,
748
+ "birthdate_visibility": visibility,
749
+ "birthdate_year_visibility": year_visibility,
750
+ }
751
+ response, response_json = await self.request("POST", url, headers=headers, data=data)
752
+ birthdate_data = response_json["extended_profile"]["birthdate"]
753
+ is_updated = all((
754
+ birthdate_data["day"] == day,
755
+ birthdate_data["month"] == month,
756
+ birthdate_data["year"] == year,
757
+ birthdate_data["visibility"] == visibility,
758
+ birthdate_data["year_visibility"] == year_visibility,
759
+ ))
760
+ return is_updated
761
+
762
+ async def send_message(self, user_id: int | str, text: str) -> dict:
763
+ """
764
+ :return: Event data
765
+ """
766
+ url = "https://api.twitter.com/1.1/direct_messages/events/new.json"
767
+ payload = {"event": {
768
+ "type": "message_create",
769
+ "message_create": {
770
+ "target": {
771
+ "recipient_id": user_id
772
+ }, "message_data": {
773
+ "text": text}
774
+ }
775
+ }}
776
+ response, response_json = await self.request("POST", url, json=payload)
777
+ event_data = response_json["event"]
778
+ return event_data
779
+
780
+ async def request_messages(self) -> list[dict]:
781
+ """
782
+ :return: Messages data
783
+ """
784
+ url = 'https://twitter.com/i/api/1.1/dm/inbox_initial_state.json'
785
+ params = {
786
+ 'nsfw_filtering_enabled': 'false',
787
+ 'filter_low_quality': 'false',
788
+ 'include_quality': 'all',
789
+ 'include_profile_interstitial_type': '1',
790
+ 'include_blocking': '1',
791
+ 'include_blocked_by': '1',
792
+ 'include_followed_by': '1',
793
+ 'include_want_retweets': '1',
794
+ 'include_mute_edge': '1',
795
+ 'include_can_dm': '1',
796
+ 'include_can_media_tag': '1',
797
+ 'include_ext_has_nft_avatar': '1',
798
+ 'include_ext_is_blue_verified': '1',
799
+ 'include_ext_verified_type': '1',
800
+ 'include_ext_profile_image_shape': '1',
801
+ 'skip_status': '1',
802
+ 'dm_secret_conversations_enabled': 'false',
803
+ 'krs_registration_enabled': 'true',
804
+ 'cards_platform': 'Web-12',
805
+ 'include_cards': '1',
806
+ 'include_ext_alt_text': 'true',
807
+ 'include_ext_limited_action_results': 'true',
808
+ 'include_quote_count': 'true',
809
+ 'include_reply_count': '1',
810
+ 'tweet_mode': 'extended',
811
+ 'include_ext_views': 'true',
812
+ 'dm_users': 'true',
813
+ 'include_groups': 'true',
814
+ 'include_inbox_timelines': 'true',
815
+ 'include_ext_media_color': 'true',
816
+ 'supports_reactions': 'true',
817
+ 'include_ext_edit_control': 'true',
818
+ 'include_ext_business_affiliations_label': 'true',
819
+ 'ext': 'mediaColor,altText,mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,birdwatchPivot,superFollowMetadata,unmentionInfo,editControl',
820
+ }
821
+ response, response_json = await self.request("GET", url, params=params)
822
+ messages = [entry["message"] for entry in response_json["inbox_initial_state"]["entries"] if "message" in entry]
823
+ return messages
824
+
825
+ async def request_tweets(self, user_id: str | int, count: int = 20) -> list[Tweet]:
826
+ url, query_id = self._action_to_url("UserTweets")
827
+ variables = {
828
+ "userId": str(user_id),
829
+ "count": count,
830
+ "includePromotedContent": True,
831
+ "withQuickPromoteEligibilityTweetFields": True,
832
+ "withVoice": True,
833
+ "withV2Timeline": True
834
+ }
835
+ features = {
836
+ "responsive_web_graphql_exclude_directive_enabled": True,
837
+ "verified_phone_label_enabled": False,
838
+ "creator_subscriptions_tweet_preview_api_enabled": True,
839
+ "responsive_web_graphql_timeline_navigation_enabled": True,
840
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
841
+ "c9s_tweet_anatomy_moderator_badge_enabled": True,
842
+ "tweetypie_unmention_optimization_enabled": True,
843
+ "responsive_web_edit_tweet_api_enabled": True,
844
+ "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
845
+ "view_counts_everywhere_api_enabled": True,
846
+ "longform_notetweets_consumption_enabled": True,
847
+ "responsive_web_twitter_article_tweet_consumption_enabled": False,
848
+ "tweet_awards_web_tipping_enabled": False,
849
+ "freedom_of_speech_not_reach_fetch_enabled": True,
850
+ "standardized_nudges_misinfo": True,
851
+ "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
852
+ "rweb_video_timestamps_enabled": True,
853
+ "longform_notetweets_rich_text_read_enabled": True,
854
+ "longform_notetweets_inline_media_enabled": True,
855
+ "responsive_web_media_download_video_enabled": False,
856
+ "responsive_web_enhance_cards_enabled": False
857
+ }
858
+ params = {
859
+ 'variables': to_json(variables),
860
+ 'features': to_json(features)
861
+ }
862
+ response, response_json = await self.request("GET", url, params=params)
863
+
864
+ tweets = []
865
+ for instruction in response_json['data']['user']['result']['timeline_v2']['timeline']['instructions']:
866
+ if instruction['type'] == 'TimelineAddEntries':
867
+ for entry in instruction['entries']:
868
+ if entry['entryId'].startswith('tweet'):
869
+ tweet_data = entry["content"]['itemContent']['tweet_results']['result']
870
+ tweets.append(Tweet.from_raw_data(tweet_data))
871
+ return tweets
872
+
873
+ async def _confirm_unlock(
874
+ self,
875
+ authenticity_token: str,
876
+ assignment_token: str,
877
+ verification_string: str = None,
878
+ ) -> tuple[requests.Response, str]:
879
+ payload = {
880
+ "authenticity_token": authenticity_token,
881
+ "assignment_token": assignment_token,
882
+ "lang": "en",
883
+ "flow": "",
884
+ }
885
+ if verification_string:
886
+ payload["verification_string"] = verification_string
887
+ payload["language_code"] = "en"
888
+
889
+ return await self.request("POST", self._CAPTCHA_URL, data=payload, bearer=False)
890
+
891
+ async def unlock(
892
+ self,
893
+ capsolver_api_key: str,
894
+ attempts: int = 4):
895
+ await self.establish_status()
896
+ if not self.account.status == "LOCKED":
897
+ return
898
+
899
+ response, html = await self.request("GET", self._CAPTCHA_URL, bearer=False)
900
+ authenticity_token, assignment_token, needs_unlock = parse_unlock_html(html)
901
+ attempt = 1
902
+
903
+ funcaptcha = {
904
+ "api_key": capsolver_api_key,
905
+ "websiteURL": self._CAPTCHA_URL,
906
+ "websitePublicKey": self._CAPTCHA_SITE_KEY,
907
+ }
908
+ if self._session.proxy is not None:
909
+ funcaptcha["captcha_type"] = FunCaptchaTypeEnm.FunCaptchaTask
910
+ funcaptcha["proxyType"] = self._session.proxy.protocol
911
+ funcaptcha["proxyAddress"] = self._session.proxy.host
912
+ funcaptcha["proxyPort"] = self._session.proxy.port
913
+ funcaptcha["proxyLogin"] = self._session.proxy.login
914
+ funcaptcha["proxyPassword"] = self._session.proxy.password
915
+ else:
916
+ funcaptcha["captcha_type"] = FunCaptchaTypeEnm.FunCaptchaTaskProxyLess
917
+
918
+ while needs_unlock:
919
+ solution = await FunCaptcha(**funcaptcha).aio_captcha_handler()
920
+ token = solution.solution["token"]
921
+ response, html = await self._confirm_unlock(authenticity_token, assignment_token,
922
+ verification_string=token)
923
+
924
+ if attempt > attempts or response.url == "https://twitter.com/?lang=en":
925
+ await self.establish_status()
926
+ return
927
+
928
+ authenticity_token, assignment_token, needs_unlock = parse_unlock_html(html)
929
+ attempt += 1
930
+
931
+ async def _task(self, **kwargs):
932
+ """
933
+ :return: flow_token, subtasks
934
+ """
935
+ url = 'https://api.twitter.com/1.1/onboarding/task.json'
936
+ response, response_json = await self.request("POST", url, **kwargs)
937
+ return response_json["flow_token"], response_json["subtasks"]
938
+
939
+ async def _request_login_tasks(self):
940
+ """
941
+ :return: flow_token, subtask_ids
942
+ """
943
+ params = {
944
+ "flow_name": "login",
945
+ }
946
+ payload = {
947
+ "input_flow_data": {
948
+ "flow_context": {
949
+ "debug_overrides": {},
950
+ "start_location": {"location": "splash_screen"}
951
+ }
952
+ },
953
+ "subtask_versions": {
954
+ "action_list": 2, "alert_dialog": 1, "app_download_cta": 1, "check_logged_in_account": 1,
955
+ "choice_selection": 3, "contacts_live_sync_permission_prompt": 0, "cta": 7,
956
+ "email_verification": 2, "end_flow": 1, "enter_date": 1, "enter_email": 2,
957
+ "enter_password": 5, "enter_phone": 2, "enter_recaptcha": 1, "enter_text": 5,
958
+ "enter_username": 2, "generic_urt": 3, "in_app_notification": 1, "interest_picker": 3,
959
+ "js_instrumentation": 1, "menu_dialog": 1, "notifications_permission_prompt": 2,
960
+ "open_account": 2, "open_home_timeline": 1, "open_link": 1, "phone_verification": 4,
961
+ "privacy_options": 1, "security_key": 3, "select_avatar": 4, "select_banner": 2,
962
+ "settings_list": 7, "show_code": 1, "sign_up": 2, "sign_up_review": 4, "tweet_selection_urt": 1,
963
+ "update_users": 1, "upload_media": 1, "user_recommendations_list": 4,
964
+ "user_recommendations_urt": 1, "wait_spinner": 3, "web_modal": 1
965
+ }
966
+ }
967
+ return await self._task(params=params, json=payload, auth=False)
968
+
969
+ async def _send_task(self, flow_token: str, subtask_inputs: list[dict], **kwargs):
970
+ payload = kwargs["json"] = kwargs.get("json") or {}
971
+ payload.update({
972
+ "flow_token": flow_token,
973
+ "subtask_inputs": subtask_inputs,
974
+ })
975
+ return await self._task(**kwargs)
976
+
977
+ async def _finish_task(self, flow_token):
978
+ payload = {
979
+ "flow_token": flow_token,
980
+ "subtask_inputs": [],
981
+ }
982
+ return await self._task(json=payload)
983
+
984
+ async def _login_enter_user_identifier(self, flow_token):
985
+ subtask_inputs = [
986
+ {
987
+ "subtask_id": "LoginEnterUserIdentifierSSO",
988
+ "settings_list": {
989
+ "setting_responses": [
990
+ {
991
+ "key": "user_identifier",
992
+ "response_data": {"text_data": {"result": self.account.email or self.account.username}}
993
+ }
994
+ ],
995
+ "link": "next_link"
996
+ }
997
+ }
998
+ ]
999
+ return await self._send_task(flow_token, subtask_inputs, auth=False)
1000
+
1001
+ async def _login_enter_password(self, flow_token):
1002
+ subtask_inputs = [
1003
+ {
1004
+ "subtask_id": "LoginEnterPassword",
1005
+ "enter_password": {
1006
+ "password": self.account.password,
1007
+ "link": "next_link"
1008
+ }
1009
+ }
1010
+ ]
1011
+ return await self._send_task(flow_token, subtask_inputs, auth=False)
1012
+
1013
+ async def _account_duplication_check(self, flow_token):
1014
+ subtask_inputs = [
1015
+ {
1016
+ "subtask_id": "AccountDuplicationCheck",
1017
+ "check_logged_in_account": {
1018
+ "link": "AccountDuplicationCheck_false"
1019
+ }
1020
+ }
1021
+ ]
1022
+ return await self._send_task(flow_token, subtask_inputs, auth=False)
1023
+
1024
+ async def _login_two_factor_auth_challenge(self, flow_token):
1025
+ if not self.account.key2fa:
1026
+ raise TwitterException(f"Failed to login. Task id: LoginTwoFactorAuthChallenge")
1027
+
1028
+ subtask_inputs = [
1029
+ {
1030
+ "subtask_id": "LoginTwoFactorAuthChallenge",
1031
+ "enter_text": {"text": self.account.get_2fa_code(), "link": "next_link"}
1032
+ }
1033
+ ]
1034
+ return await self._send_task(flow_token, subtask_inputs, auth=False)
1035
+
1036
+ async def _viewer(self):
1037
+ """
1038
+ Здесь нужно забрать ct0
1039
+ :return:
1040
+ """
1041
+ url, query_id = self._action_to_url("Viewer")
1042
+ features = {
1043
+ 'responsive_web_graphql_exclude_directive_enabled': True,
1044
+ 'verified_phone_label_enabled': False,
1045
+ 'creator_subscriptions_tweet_preview_api_enabled': True,
1046
+ 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False,
1047
+ 'responsive_web_graphql_timeline_navigation_enabled': True,
1048
+ }
1049
+ field_toggles = {
1050
+ 'isDelegate': False,
1051
+ 'withAuxiliaryUserLabels': False,
1052
+ }
1053
+ variables = {"withCommunitiesMemberships": True}
1054
+ params = {
1055
+ "features": to_json(features),
1056
+ "fieldToggles": to_json(field_toggles),
1057
+ "variables": to_json(variables),
1058
+ }
1059
+ return self.request("GET", url, params=params)
1060
+
1061
+ async def _request_guest_token(self) -> str:
1062
+ """
1063
+ Помимо запроса guest_token также устанавливает в сессию guest_id cookie
1064
+
1065
+ :return: guest_token
1066
+ """
1067
+ url = 'https://twitter.com'
1068
+ response = await self._session.request("GET", url)
1069
+ guest_token = re.search(r'gt\s?=\s?\d+', response.text)[0].split('=')[1]
1070
+ return guest_token
1071
+
1072
+ async def _login(self):
1073
+ guest_token = await self._request_guest_token()
1074
+ self._session.cookies["gt"] = guest_token
1075
+ self._session.headers["X-Guest-Token"] = guest_token
1076
+
1077
+ flow_token, subtasks = await self._request_login_tasks()
1078
+ for _ in range(2):
1079
+ flow_token, subtasks = await self._login_enter_user_identifier(flow_token)
1080
+ flow_token, subtasks = await self._login_enter_password(flow_token)
1081
+ flow_token, subtasks = await self._account_duplication_check(flow_token)
1082
+
1083
+ subtask_ids = [subtask["subtask_id"] for subtask in subtasks]
1084
+
1085
+ # TODO Обработчик
1086
+ if "LoginAcid" in subtask_ids:
1087
+ raise TwitterException(f"Failed to login: email verification!")
1088
+
1089
+ if "LoginTwoFactorAuthChallenge" in subtask_ids:
1090
+ flow_token, subtasks = await self._login_two_factor_auth_challenge(flow_token)
1091
+
1092
+ # TODO Возможно, стоит добавить отслеживание этих параметров прямо в request
1093
+ self.account.auth_token = self._session.cookies["auth_token"]
1094
+ self.account.ct0 = self._session.cookies["ct0"]
1095
+
1096
+ await self._finish_task(flow_token)
1097
+
1098
+ async def login(self):
1099
+ if self.account.auth_token:
1100
+ await self.establish_status()
1101
+ if self.account.status != "BAD_TOKEN":
1102
+ return
1103
+
1104
+ if not self.account.email and not self.account.username:
1105
+ raise ValueError("No email or username")
1106
+
1107
+ if not self.account.password:
1108
+ raise ValueError("No password")
1109
+
1110
+ await self._login()
1111
+ await self.establish_status()
1112
+
1113
+ async def is_enabled_2fa(self):
1114
+ if not self.account.id:
1115
+ await self.request_user_data()
1116
+
1117
+ url = f'https://twitter.com/i/api/1.1/strato/column/User/{self.account.id}/account-security/twoFactorAuthSettings2'
1118
+ response, response_json = await self.request("GET", url)
1119
+ return 'Totp' in [i['twoFactorType'] for i in response_json['methods']]
1120
+
1121
+ async def _request_2fa_tasks(self):
1122
+ """
1123
+ :return: flow_token, subtask_ids
1124
+ """
1125
+ params = {
1126
+ "flow_name": "two-factor-auth-app-enrollment",
1127
+ }
1128
+ payload = {
1129
+ "input_flow_data": {"flow_context": {"debug_overrides": {}, "start_location": {"location": "settings"}}},
1130
+ "subtask_versions": {"action_list": 2, "alert_dialog": 1, "app_download_cta": 1,
1131
+ "check_logged_in_account": 1, "choice_selection": 3,
1132
+ "contacts_live_sync_permission_prompt": 0, "cta": 7, "email_verification": 2,
1133
+ "end_flow": 1, "enter_date": 1, "enter_email": 2,
1134
+ "enter_password": 5, "enter_phone": 2, "enter_recaptcha": 1, "enter_text": 5,
1135
+ "enter_username": 2, "generic_urt": 3,
1136
+ "in_app_notification": 1, "interest_picker": 3, "js_instrumentation": 1,
1137
+ "menu_dialog": 1, "notifications_permission_prompt": 2,
1138
+ "open_account": 2, "open_home_timeline": 1, "open_link": 1, "phone_verification": 4,
1139
+ "privacy_options": 1, "security_key": 3,
1140
+ "select_avatar": 4, "select_banner": 2, "settings_list": 7, "show_code": 1,
1141
+ "sign_up": 2, "sign_up_review": 4, "tweet_selection_urt": 1,
1142
+ "update_users": 1, "upload_media": 1, "user_recommendations_list": 4,
1143
+ "user_recommendations_urt": 1, "wait_spinner": 3, "web_modal": 1
1144
+ }
1145
+ }
1146
+ return await self._task(params=params, json=payload)
1147
+
1148
+ async def _two_factor_enrollment_verify_password_subtask(self, flow_token: str):
1149
+ subtask_inputs = [
1150
+ {
1151
+ "subtask_id": "TwoFactorEnrollmentVerifyPasswordSubtask",
1152
+ "enter_password": {"password": self.account.password, "link": "next_link"}
1153
+ }
1154
+ ]
1155
+ return await self._send_task(flow_token, subtask_inputs)
1156
+
1157
+ async def _two_factor_enrollment_authentication_app_begin_subtask(self, flow_token: str):
1158
+ subtask_inputs = [
1159
+ {
1160
+ "subtask_id": "TwoFactorEnrollmentAuthenticationAppBeginSubtask",
1161
+ "action_list": {"link": "next_link"}
1162
+ }
1163
+ ]
1164
+ return await self._send_task(flow_token, subtask_inputs)
1165
+
1166
+ async def _two_factor_enrollment_authentication_app_plain_code_subtask(self, flow_token: str):
1167
+ subtask_inputs = [
1168
+ {
1169
+ "subtask_id": "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask",
1170
+ "show_code": {"link": "next_link"}
1171
+ },
1172
+ {
1173
+ "subtask_id": "TwoFactorEnrollmentAuthenticationAppEnterCodeSubtask",
1174
+ "enter_text": {"text": self.account.get_2fa_code(), "link": "next_link"}
1175
+ }
1176
+ ]
1177
+ return await self._send_task(flow_token, subtask_inputs)
1178
+
1179
+ async def _finish_2fa_task(self, flow_token: str):
1180
+ subtask_inputs = [
1181
+ {
1182
+ "subtask_id": "TwoFactorEnrollmentAuthenticationAppCompleteSubtask",
1183
+ "cta": {"link": "finish_link"}
1184
+ }
1185
+ ]
1186
+ return await self._send_task(flow_token, subtask_inputs)
1187
+
1188
+ async def _enable_2fa(self):
1189
+ flow_token, subtasks = await self._request_2fa_tasks()
1190
+ flow_token, subtasks = await self._two_factor_enrollment_verify_password_subtask(flow_token)
1191
+ flow_token, subtasks = await self._two_factor_enrollment_authentication_app_begin_subtask(flow_token)
1192
+
1193
+ for subtask in subtasks:
1194
+ if subtask["subtask_id"] == 'TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask':
1195
+ self.account.key2fa = subtask['show_code']['code']
1196
+ break
1197
+
1198
+ flow_token, subtasks = await self._two_factor_enrollment_authentication_app_plain_code_subtask(flow_token)
1199
+
1200
+ for subtask in subtasks:
1201
+ if subtask["subtask_id"] == 'TwoFactorEnrollmentAuthenticationAppCompleteSubtask':
1202
+ result = re.search(r'\n[a-z0-9]{12}\n', subtask['cta']['secondary_text']['text'])
1203
+ backup_code = result[0].strip() if result else None
1204
+ self.account.backup_code = backup_code
1205
+ break
1206
+
1207
+ await self._finish_2fa_task(flow_token)
1208
+
1209
+ async def enable_2fa(self):
1210
+ if not self.account.password:
1211
+ raise ValueError("Password is required for this action")
1212
+
1213
+ if await self.is_enabled_2fa():
1214
+ return
1215
+
1216
+ # TODO Перед началом работы вызываем request_user_data, чтоб убедиться что нет других ошибок
1217
+ await self.request_user_data()
1218
+ await self._enable_2fa()