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

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