tweepy-self 0.1.0__py3-none-any.whl → 1.0.0b2__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,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()