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

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