tweepy-self 1.6.3__py3-none-any.whl → 1.10.0b1__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
twitter/client.py CHANGED
@@ -1,13 +1,15 @@
1
- from typing import Any, Literal
1
+ from typing import Any, Literal, Iterable
2
2
  from time import time
3
3
  import asyncio
4
4
  import base64
5
+ import json
5
6
  import re
6
7
 
8
+ from loguru import logger
7
9
  from curl_cffi import requests
8
10
  from yarl import URL
9
11
 
10
- from python3_capsolver.fun_captcha import FunCaptcha, FunCaptchaTypeEnm
12
+ from ._capsolver.fun_captcha import FunCaptcha, FunCaptchaTypeEnm
11
13
 
12
14
  from .errors import (
13
15
  TwitterException,
@@ -25,20 +27,24 @@ from .errors import (
25
27
  ConsentLocked,
26
28
  Suspended,
27
29
  )
28
- from .utils import to_json, tweet_url as create_tweet_url
29
- from .base import BaseClient
30
+ from .utils import to_json
31
+ from .base import BaseHTTPClient
30
32
  from .account import Account, AccountStatus
31
- from .models import UserData, Tweet
32
- from .utils import remove_at_sign, parse_oauth_html, parse_unlock_html
33
+ from .models import User, Tweet, Media
34
+ from .utils import (
35
+ remove_at_sign,
36
+ parse_oauth_html,
37
+ parse_unlock_html,
38
+ tweets_data_from_instructions,
39
+ )
33
40
 
34
41
 
35
- class Client(BaseClient):
36
- _BEARER_TOKEN = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
42
+ class Client(BaseHTTPClient):
43
+ _BEARER_TOKEN = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
37
44
  _DEFAULT_HEADERS = {
38
45
  "authority": "twitter.com",
39
46
  "origin": "https://twitter.com",
40
47
  "x-twitter-active-user": "yes",
41
- # 'x-twitter-auth-type': 'OAuth2Session',
42
48
  "x-twitter-client-language": "en",
43
49
  }
44
50
  _GRAPHQL_URL = "https://twitter.com/i/api/graphql"
@@ -56,6 +62,7 @@ class Client(BaseClient):
56
62
  "Following": "t-BPOrMIduGUJWO_LxcvNQ",
57
63
  "Followers": "3yX7xr2hKjcZYnXt6cU6lQ",
58
64
  "UserByScreenName": "G3KGOASz96M-Qu0nwmGXNg",
65
+ "UsersByRestIds": "itEhGywpgX9b3GJCzOtSrA",
59
66
  "Viewer": "W62NnYgkgziw9bwyoVht0g",
60
67
  }
61
68
  _CAPTCHA_URL = "https://twitter.com/account/access"
@@ -76,7 +83,9 @@ class Client(BaseClient):
76
83
  *,
77
84
  wait_on_rate_limit: bool = True,
78
85
  capsolver_api_key: str = None,
79
- max_unlock_attempts: int = 4,
86
+ max_unlock_attempts: int = 5,
87
+ auto_relogin: bool = True,
88
+ request_self_on_startup: bool = True,
80
89
  **session_kwargs,
81
90
  ):
82
91
  super().__init__(**session_kwargs)
@@ -84,20 +93,29 @@ class Client(BaseClient):
84
93
  self.wait_on_rate_limit = wait_on_rate_limit
85
94
  self.capsolver_api_key = capsolver_api_key
86
95
  self.max_unlock_attempts = max_unlock_attempts
96
+ self.auto_relogin = auto_relogin
97
+ self.request_self_on_startup = request_self_on_startup
87
98
 
88
- async def request(
99
+ async def __aenter__(self):
100
+ await self.on_startup()
101
+ return await super().__aenter__()
102
+
103
+ async def _request(
89
104
  self,
90
105
  method,
91
106
  url,
107
+ *,
92
108
  auth: bool = True,
93
109
  bearer: bool = True,
110
+ wait_on_rate_limit: bool = None,
94
111
  **kwargs,
95
112
  ) -> tuple[requests.Response, Any]:
96
113
  cookies = kwargs["cookies"] = kwargs.get("cookies") or {}
97
114
  headers = kwargs["headers"] = kwargs.get("headers") or {}
98
115
 
99
116
  if bearer:
100
- headers["authorization"] = self._BEARER_TOKEN
117
+ headers["authorization"] = f"Bearer {self._BEARER_TOKEN}"
118
+ # headers["x-twitter-auth-type"] = "OAuth2Session"
101
119
 
102
120
  if auth:
103
121
  if not self.account.auth_token:
@@ -108,6 +126,14 @@ class Client(BaseClient):
108
126
  cookies["ct0"] = self.account.ct0
109
127
  headers["x-csrf-token"] = self.account.ct0
110
128
 
129
+ # fmt: off
130
+ log_message = (f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
131
+ f" ==> Request {method} {url}")
132
+ if kwargs.get('data'): log_message += f"\nRequest data: {kwargs.get('data')}"
133
+ if kwargs.get('json'): log_message += f"\nRequest data: {kwargs.get('json')}"
134
+ logger.debug(log_message)
135
+ # fmt: on
136
+
111
137
  try:
112
138
  response = await self._session.request(method, url, **kwargs)
113
139
  except requests.errors.RequestsError as exc:
@@ -120,17 +146,53 @@ class Client(BaseClient):
120
146
  raise
121
147
 
122
148
  data = response.text
123
- if response.headers["content-type"].startswith("application/json"):
149
+ # fmt: off
150
+ logger.debug(f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
151
+ f" <== Response {method} {url}"
152
+ f"\nStatus code: {response.status_code}"
153
+ f"\nResponse data: {data}")
154
+ # fmt: on
155
+
156
+ if ct0 := self._session.cookies.get("ct0", domain=".twitter.com"):
157
+ self.account.ct0 = ct0
158
+
159
+ auth_token = self._session.cookies.get("auth_token")
160
+ if auth_token and auth_token != self.account.auth_token:
161
+ self.account.auth_token = auth_token
162
+ logger.info(
163
+ f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
164
+ f" Requested new auth_token!"
165
+ )
166
+
167
+ try:
124
168
  data = response.json()
169
+ except json.decoder.JSONDecodeError:
170
+ pass
125
171
 
126
- if response.status_code == 429:
127
- if self.wait_on_rate_limit:
128
- reset_time = int(response.headers["x-rate-limit-reset"])
129
- sleep_time = reset_time - int(time()) + 1
130
- if sleep_time > 0:
131
- await asyncio.sleep(sleep_time)
132
- return await self.request(method, url, auth, bearer, **kwargs)
133
- raise RateLimited(response, data)
172
+ if 300 > response.status_code >= 200:
173
+ if isinstance(data, dict) and "errors" in data:
174
+ exc = HTTPException(response, data)
175
+
176
+ if 141 in exc.api_codes:
177
+ self.account.status = AccountStatus.SUSPENDED
178
+ raise Suspended(exc, self.account)
179
+
180
+ if 326 in exc.api_codes:
181
+ for error_data in exc.api_errors:
182
+ if (
183
+ error_data.get("code") == 326
184
+ and error_data.get("bounce_location")
185
+ == "/i/flow/consent_flow"
186
+ ):
187
+ self.account.status = AccountStatus.CONSENT_LOCKED
188
+ raise ConsentLocked(exc, self.account)
189
+
190
+ self.account.status = AccountStatus.LOCKED
191
+ raise Locked(exc, self.account)
192
+ raise exc
193
+
194
+ self.account.status = AccountStatus.GOOD
195
+ return response, data
134
196
 
135
197
  if response.status_code == 400:
136
198
  raise BadRequest(response, data)
@@ -147,10 +209,6 @@ class Client(BaseClient):
147
209
  if response.status_code == 403:
148
210
  exc = Forbidden(response, data)
149
211
 
150
- if 353 in exc.api_codes and "ct0" in response.cookies:
151
- self.account.ct0 = response.cookies["ct0"]
152
- return await self.request(method, url, auth, bearer, **kwargs)
153
-
154
212
  if 64 in exc.api_codes:
155
213
  self.account.status = AccountStatus.SUSPENDED
156
214
  raise Suspended(exc, self.account)
@@ -165,52 +223,82 @@ class Client(BaseClient):
165
223
  raise ConsentLocked(exc, self.account)
166
224
 
167
225
  self.account.status = AccountStatus.LOCKED
168
- if not self.capsolver_api_key:
169
- raise Locked(exc, self.account)
170
-
171
- await self.unlock()
172
- return await self.request(method, url, auth, bearer, **kwargs)
226
+ raise Locked(exc, self.account)
173
227
 
174
228
  raise exc
175
229
 
176
230
  if response.status_code == 404:
177
231
  raise NotFound(response, data)
178
232
 
233
+ if response.status_code == 429:
234
+ if wait_on_rate_limit is None:
235
+ wait_on_rate_limit = self.wait_on_rate_limit
236
+ if not wait_on_rate_limit:
237
+ raise RateLimited(response, data)
238
+
239
+ reset_time = int(response.headers["x-rate-limit-reset"])
240
+ sleep_time = reset_time - int(time()) + 1
241
+ if sleep_time > 0:
242
+ logger.info(
243
+ f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
244
+ f"Rate limited! Sleep time: {sleep_time} sec."
245
+ )
246
+ await asyncio.sleep(sleep_time)
247
+ return await self._request(
248
+ method,
249
+ url,
250
+ auth=auth,
251
+ bearer=bearer,
252
+ wait_on_rate_limit=wait_on_rate_limit,
253
+ **kwargs,
254
+ )
255
+
179
256
  if response.status_code >= 500:
180
257
  raise ServerError(response, data)
181
258
 
182
- if not 200 <= response.status_code < 300:
183
- raise HTTPException(response, data)
184
-
185
- if isinstance(data, dict) and "errors" in data:
186
- exc = HTTPException(response, data)
259
+ async def request(
260
+ self,
261
+ method,
262
+ url,
263
+ *,
264
+ auto_unlock: bool = True,
265
+ auto_relogin: bool = None,
266
+ **kwargs,
267
+ ) -> tuple[requests.Response, Any]:
268
+ try:
269
+ return await self._request(method, url, **kwargs)
187
270
 
188
- if 141 in exc.api_codes:
189
- self.account.status = AccountStatus.SUSPENDED
190
- raise Suspended(exc, self.account)
271
+ except Locked:
272
+ if not self.capsolver_api_key or not auto_unlock:
273
+ raise
191
274
 
192
- if 326 in exc.api_codes:
193
- for error_data in exc.api_errors:
194
- if (
195
- error_data.get("code") == 326
196
- and error_data.get("bounce_location") == "/i/flow/consent_flow"
197
- ):
198
- self.account.status = AccountStatus.CONSENT_LOCKED
199
- raise ConsentLocked(exc, self.account)
275
+ await self.unlock()
276
+ return await self._request(method, url, **kwargs)
200
277
 
201
- self.account.status = AccountStatus.LOCKED
202
- if not self.capsolver_api_key:
203
- raise Locked(exc, self.account)
278
+ except BadToken:
279
+ if auto_relogin is None:
280
+ auto_relogin = self.auto_relogin
281
+ if (
282
+ not auto_relogin
283
+ or not self.account.password
284
+ or not (self.account.email or self.account.username)
285
+ ):
286
+ raise
204
287
 
205
- await self.unlock()
206
- return await self.request(method, url, auth, bearer, **kwargs)
288
+ await self.relogin()
289
+ return await self._request(method, url, **kwargs)
207
290
 
208
- raise exc
291
+ except Forbidden as exc:
292
+ if 353 in exc.api_codes and "ct0" in exc.response.cookies:
293
+ return await self._request(method, url, **kwargs)
294
+ else:
295
+ raise
209
296
 
210
- self.account.status = AccountStatus.GOOD
211
- return response, data
297
+ async def on_startup(self):
298
+ if self.request_self_on_startup:
299
+ await self.request_user()
212
300
 
213
- async def _request_oauth_2_auth_code(
301
+ async def _request_oauth2_auth_code(
214
302
  self,
215
303
  client_id: str,
216
304
  code_challenge: str,
@@ -234,7 +322,7 @@ class Client(BaseClient):
234
322
  auth_code = response_json["auth_code"]
235
323
  return auth_code
236
324
 
237
- async def _confirm_oauth_2(self, auth_code: str):
325
+ async def _confirm_oauth2(self, auth_code: str):
238
326
  data = {
239
327
  "approval": "true",
240
328
  "code": auth_code,
@@ -247,7 +335,7 @@ class Client(BaseClient):
247
335
  data=data,
248
336
  )
249
337
 
250
- async def oauth_2(
338
+ async def oauth2(
251
339
  self,
252
340
  client_id: str,
253
341
  code_challenge: str,
@@ -263,15 +351,13 @@ class Client(BaseClient):
263
351
  Привязка (бинд, линк) приложения.
264
352
 
265
353
  :param client_id: Идентификатор клиента, используемый для OAuth.
266
- :param code_challenge: Код-вызов, используемый для PKCE (Proof Key for Code Exchange).
267
354
  :param state: Уникальная строка состояния для предотвращения CSRF-атак.
268
355
  :param redirect_uri: URI перенаправления, на который будет отправлен ответ.
269
- :param code_challenge_method: Метод, используемый для преобразования code_verifier в code_challenge.
270
356
  :param scope: Строка областей доступа, запрашиваемых у пользователя.
271
357
  :param response_type: Тип ответа, который ожидается от сервера авторизации.
272
358
  :return: Код авторизации (привязки).
273
359
  """
274
- auth_code = await self._request_oauth_2_auth_code(
360
+ auth_code = await self._request_oauth2_auth_code(
275
361
  client_id,
276
362
  code_challenge,
277
363
  state,
@@ -280,7 +366,7 @@ class Client(BaseClient):
280
366
  scope,
281
367
  response_type,
282
368
  )
283
- await self._confirm_oauth_2(auth_code)
369
+ await self._confirm_oauth2(auth_code)
284
370
  return auth_code
285
371
 
286
372
  async def _oauth(self, oauth_token: str, **oauth_params) -> requests.Response:
@@ -335,12 +421,12 @@ class Client(BaseClient):
335
421
 
336
422
  return authenticity_token, redirect_url
337
423
 
338
- async def request_username(self):
424
+ async def request_and_set_username(self):
339
425
  url = "https://twitter.com/i/api/1.1/account/settings.json"
340
426
  response, response_json = await self.request("POST", url)
341
427
  self.account.username = response_json["screen_name"]
342
428
 
343
- async def _request_user_data(self, username: str) -> UserData:
429
+ async def _request_user(self, username: str) -> User:
344
430
  url, query_id = self._action_to_url("UserByScreenName")
345
431
  username = remove_at_sign(username)
346
432
  variables = {
@@ -367,48 +453,56 @@ class Client(BaseClient):
367
453
  "features": to_json(features),
368
454
  "fieldToggles": to_json(field_toggles),
369
455
  }
370
- response, response_json = await self.request("GET", url, params=params)
371
- user_data = UserData.from_raw_user_data(response_json["data"]["user"]["result"])
456
+ response, data = await self.request("GET", url, params=params)
457
+ return User.from_raw_data(data["data"]["user"]["result"])
372
458
 
373
- if self.account.username == user_data.username:
374
- self.account.id = user_data.id
375
- self.account.name = user_data.name
459
+ async def request_user(
460
+ self, *, username: str = None, user_id: int | str = None
461
+ ) -> User | Account:
462
+ if username and user_id:
463
+ raise ValueError("Specify username or user_id, not both.")
376
464
 
377
- return user_data
378
-
379
- async def request_user_data(self, username: str = None) -> UserData:
380
- if username:
381
- return await self._request_user_data(username)
465
+ if user_id:
466
+ users = await self.request_users((user_id,))
467
+ user = users[user_id]
382
468
  else:
383
- if not self.account.username:
384
- await self.request_username()
385
- return await self._request_user_data(self.account.username)
469
+ if not username:
470
+ if not self.account.username:
471
+ await self.request_and_set_username()
472
+ username = self.account.username
473
+
474
+ user = await self._request_user(username)
475
+
476
+ if self.account.username == user.username:
477
+ self.account.update(**user.model_dump())
478
+ user = self.account
479
+
480
+ return user
386
481
 
387
482
  async def upload_image(
388
483
  self,
389
484
  image: bytes,
390
485
  attempts: int = 3,
391
486
  timeout: float | tuple[float, float] = 10,
392
- ) -> int:
487
+ ) -> Media:
393
488
  """
394
489
  Upload image as bytes.
395
490
 
396
491
  Иногда при первой попытке загрузки изображения возвращает 408,
397
492
  после чего повторная попытка загрузки изображения проходит успешно
398
493
 
399
- :return: Media ID
494
+ :return: Media
400
495
  """
401
496
  url = "https://upload.twitter.com/1.1/media/upload.json"
402
497
 
403
- data = {"media_data": base64.b64encode(image)}
498
+ payload = {"media_data": base64.b64encode(image)}
404
499
 
405
500
  for attempt in range(attempts):
406
501
  try:
407
- response, response_json = await self.request(
408
- "POST", url, data=data, timeout=timeout
502
+ response, data = await self.request(
503
+ "POST", url, data=payload, timeout=timeout
409
504
  )
410
- media_id = response_json["media_id"]
411
- return media_id
505
+ return Media(**data)
412
506
  except (HTTPException, requests.errors.RequestsError) as exc:
413
507
  if (
414
508
  attempt < attempts - 1
@@ -425,9 +519,6 @@ class Client(BaseClient):
425
519
  else:
426
520
  raise
427
521
 
428
- media_id = response_json["media_id"]
429
- return media_id
430
-
431
522
  async def _follow_action(self, action: str, user_id: int | str) -> bool:
432
523
  url = f"https://twitter.com/i/api/1.1/friendships/{action}.json"
433
524
  params = {
@@ -466,22 +557,60 @@ class Client(BaseClient):
466
557
  "variables": {"tweet_id": tweet_id, "dark_request": False},
467
558
  "queryId": query_id,
468
559
  }
469
- response, response_json = await self.request("POST", url, json=json_payload)
470
- return response_json
560
+ response, data = await self.request("POST", url, json=json_payload)
561
+ return data
471
562
 
472
- async def repost(self, tweet_id: int) -> int:
563
+ async def _repost(self, tweet_id: int | str) -> Tweet:
564
+ data = await self._interact_with_tweet("CreateRetweet", tweet_id)
565
+ tweet_id = data["data"]["create_retweet"]["retweet_results"]["result"]["rest_id"] # type: ignore
566
+ return await self.request_tweet(tweet_id)
567
+
568
+ async def _repost_or_search_duplicate(
569
+ self,
570
+ tweet_id: int,
571
+ *,
572
+ search_duplicate: bool = True,
573
+ ) -> Tweet:
574
+ try:
575
+ tweet = await self._repost(tweet_id)
576
+ except HTTPException as exc:
577
+ if (
578
+ search_duplicate
579
+ and 327
580
+ in exc.api_codes # duplicate retweet (You have already retweeted this Tweet)
581
+ ):
582
+ tweets = await self.request_tweets(self.account.id)
583
+ duplicate_tweet = None
584
+ for tweet_ in tweets: # type: Tweet
585
+ if tweet_.retweeted_tweet and tweet_.retweeted_tweet.id == tweet_id:
586
+ duplicate_tweet = tweet_
587
+
588
+ if not duplicate_tweet:
589
+ raise FailedToFindDuplicatePost(
590
+ f"Couldn't find a post duplicate in the next 20 posts"
591
+ )
592
+
593
+ tweet = duplicate_tweet
594
+
595
+ else:
596
+ raise
597
+
598
+ return tweet
599
+
600
+ async def repost(
601
+ self,
602
+ tweet_id: int,
603
+ *,
604
+ search_duplicate: bool = True,
605
+ ) -> Tweet:
473
606
  """
474
607
  Repost (retweet)
475
608
 
476
- :return: Tweet ID
609
+ :return: Tweet
477
610
  """
478
- response_json = await self._interact_with_tweet("CreateRetweet", tweet_id)
479
- retweet_id = int(
480
- response_json["data"]["create_retweet"]["retweet_results"]["result"][
481
- "rest_id"
482
- ]
611
+ return await self._repost_or_search_duplicate(
612
+ tweet_id, search_duplicate=search_duplicate
483
613
  )
484
- return retweet_id
485
614
 
486
615
  async def like(self, tweet_id: int) -> bool:
487
616
  response_json = await self._interact_with_tweet("FavoriteTweet", tweet_id)
@@ -588,7 +717,6 @@ class Client(BaseClient):
588
717
  tweet_id_to_reply: str | int = None,
589
718
  attachment_url: str = None,
590
719
  search_duplicate: bool = True,
591
- with_tweet_url: bool = True,
592
720
  ) -> Tweet:
593
721
  try:
594
722
  tweet = await self._tweet(
@@ -602,10 +730,10 @@ class Client(BaseClient):
602
730
  search_duplicate
603
731
  and 187 in exc.api_codes # duplicate tweet (Status is a duplicate)
604
732
  ):
605
- tweets = await self.request_tweets(self.account.id)
733
+ tweets = await self.request_tweets()
606
734
  duplicate_tweet = None
607
735
  for tweet_ in tweets:
608
- if tweet_.full_text.startswith(text.strip()):
736
+ if tweet_.text.startswith(text.strip()):
609
737
  duplicate_tweet = tweet_
610
738
 
611
739
  if not duplicate_tweet:
@@ -617,11 +745,6 @@ class Client(BaseClient):
617
745
  else:
618
746
  raise
619
747
 
620
- if with_tweet_url:
621
- if not self.account.username:
622
- await self.request_user_data()
623
- tweet.url = create_tweet_url(self.account.username, tweet.id)
624
-
625
748
  return tweet
626
749
 
627
750
  async def tweet(
@@ -630,13 +753,11 @@ class Client(BaseClient):
630
753
  *,
631
754
  media_id: int | str = None,
632
755
  search_duplicate: bool = True,
633
- with_tweet_url: bool = True,
634
756
  ) -> Tweet:
635
757
  return await self._tweet_or_search_duplicate(
636
758
  text,
637
759
  media_id=media_id,
638
760
  search_duplicate=search_duplicate,
639
- with_tweet_url=with_tweet_url,
640
761
  )
641
762
 
642
763
  async def reply(
@@ -646,14 +767,12 @@ class Client(BaseClient):
646
767
  *,
647
768
  media_id: int | str = None,
648
769
  search_duplicate: bool = True,
649
- with_tweet_url: bool = True,
650
770
  ) -> Tweet:
651
771
  return await self._tweet_or_search_duplicate(
652
772
  text,
653
773
  media_id=media_id,
654
774
  tweet_id_to_reply=tweet_id,
655
775
  search_duplicate=search_duplicate,
656
- with_tweet_url=with_tweet_url,
657
776
  )
658
777
 
659
778
  async def quote(
@@ -663,14 +782,12 @@ class Client(BaseClient):
663
782
  *,
664
783
  media_id: int | str = None,
665
784
  search_duplicate: bool = True,
666
- with_tweet_url: bool = True,
667
785
  ) -> Tweet:
668
786
  return await self._tweet_or_search_duplicate(
669
787
  text,
670
788
  media_id=media_id,
671
789
  attachment_url=tweet_url,
672
790
  search_duplicate=search_duplicate,
673
- with_tweet_url=with_tweet_url,
674
791
  )
675
792
 
676
793
  async def vote(
@@ -692,7 +809,7 @@ class Client(BaseClient):
692
809
 
693
810
  async def _request_users(
694
811
  self, action: str, user_id: int | str, count: int
695
- ) -> list[UserData]:
812
+ ) -> list[User]:
696
813
  url, query_id = self._action_to_url(action)
697
814
  variables = {
698
815
  "userId": str(user_id),
@@ -737,12 +854,12 @@ class Client(BaseClient):
737
854
  user_data_dict = entry["content"]["itemContent"]["user_results"][
738
855
  "result"
739
856
  ]
740
- users.append(UserData.from_raw_user_data(user_data_dict))
857
+ users.append(User.from_raw_data(user_data_dict))
741
858
  return users
742
859
 
743
860
  async def request_followers(
744
861
  self, user_id: int | str = None, count: int = 10
745
- ) -> list[UserData]:
862
+ ) -> list[User]:
746
863
  """
747
864
  :param user_id: Текущий пользователь, если не передан ID иного пользователя.
748
865
  :param count: Количество подписчиков.
@@ -751,12 +868,12 @@ class Client(BaseClient):
751
868
  return await self._request_users("Followers", user_id, count)
752
869
  else:
753
870
  if not self.account.id:
754
- await self.request_user_data()
871
+ await self.request_user()
755
872
  return await self._request_users("Followers", self.account.id, count)
756
873
 
757
874
  async def request_followings(
758
875
  self, user_id: int | str = None, count: int = 10
759
- ) -> list[UserData]:
876
+ ) -> list[User]:
760
877
  """
761
878
  :param user_id: Текущий пользователь, если не передан ID иного пользователя.
762
879
  :param count: Количество подписчиков.
@@ -765,12 +882,32 @@ class Client(BaseClient):
765
882
  return await self._request_users("Following", user_id, count)
766
883
  else:
767
884
  if not self.account.id:
768
- await self.request_user_data()
885
+ await self.request_user()
769
886
  return await self._request_users("Following", self.account.id, count)
770
887
 
771
- async def _request_tweet_data(self, tweet_id: int) -> dict:
772
- action = "TweetDetail"
773
- url, query_id = self._action_to_url(action)
888
+ async def request_users(
889
+ self, user_ids: Iterable[str | int]
890
+ ) -> dict[int : User | Account]:
891
+ url, query_id = self._action_to_url("UsersByRestIds")
892
+ variables = {"userIds": list({str(user_id) for user_id in user_ids})}
893
+ features = {
894
+ "responsive_web_graphql_exclude_directive_enabled": True,
895
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
896
+ "responsive_web_graphql_timeline_navigation_enabled": True,
897
+ "verified_phone_label_enabled": False,
898
+ }
899
+ query = {"variables": variables, "features": features}
900
+ response, data = await self.request("GET", url, params=query)
901
+
902
+ users = {}
903
+ for user_data in data["data"]["users"]:
904
+ user_data = user_data["result"]
905
+ user = User.from_raw_data(user_data)
906
+ users[user.id] = user
907
+ return users
908
+
909
+ async def _request_tweet(self, tweet_id: int | str) -> Tweet:
910
+ url, query_id = self._action_to_url("TweetDetail")
774
911
  variables = {
775
912
  "focalTweetId": str(tweet_id),
776
913
  "with_rux_injections": False,
@@ -801,12 +938,71 @@ class Client(BaseClient):
801
938
  "longform_notetweets_inline_media_enabled": True,
802
939
  "responsive_web_enhance_cards_enabled": False,
803
940
  }
804
- params = {
941
+ query = {
805
942
  "variables": to_json(variables),
806
943
  "features": to_json(features),
807
944
  }
808
- response, response_json = await self.request("GET", url, params=params)
809
- return response_json
945
+ response, data = await self.request("GET", url, params=query)
946
+ instructions = data["data"]["threaded_conversation_with_injections_v2"]["instructions"] # type: ignore
947
+ tweet_data = tweets_data_from_instructions(instructions)[0]
948
+ return Tweet.from_raw_data(tweet_data)
949
+
950
+ async def _request_tweets(self, user_id: int | str, count: int = 20) -> list[Tweet]:
951
+ url, query_id = self._action_to_url("UserTweets")
952
+ variables = {
953
+ "userId": str(user_id),
954
+ "count": count,
955
+ "includePromotedContent": True,
956
+ "withQuickPromoteEligibilityTweetFields": True,
957
+ "withVoice": True,
958
+ "withV2Timeline": True,
959
+ }
960
+ features = {
961
+ "responsive_web_graphql_exclude_directive_enabled": True,
962
+ "verified_phone_label_enabled": False,
963
+ "creator_subscriptions_tweet_preview_api_enabled": True,
964
+ "responsive_web_graphql_timeline_navigation_enabled": True,
965
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
966
+ "c9s_tweet_anatomy_moderator_badge_enabled": True,
967
+ "tweetypie_unmention_optimization_enabled": True,
968
+ "responsive_web_edit_tweet_api_enabled": True,
969
+ "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
970
+ "view_counts_everywhere_api_enabled": True,
971
+ "longform_notetweets_consumption_enabled": True,
972
+ "responsive_web_twitter_article_tweet_consumption_enabled": False,
973
+ "tweet_awards_web_tipping_enabled": False,
974
+ "freedom_of_speech_not_reach_fetch_enabled": True,
975
+ "standardized_nudges_misinfo": True,
976
+ "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
977
+ "rweb_video_timestamps_enabled": True,
978
+ "longform_notetweets_rich_text_read_enabled": True,
979
+ "longform_notetweets_inline_media_enabled": True,
980
+ "responsive_web_media_download_video_enabled": False,
981
+ "responsive_web_enhance_cards_enabled": False,
982
+ }
983
+ params = {"variables": to_json(variables), "features": to_json(features)}
984
+ response, data = await self.request("GET", url, params=params)
985
+
986
+ instructions = data["data"]["user"]["result"]["timeline_v2"]["timeline"][
987
+ "instructions"
988
+ ]
989
+ tweets_data = tweets_data_from_instructions(instructions)
990
+ return [Tweet.from_raw_data(tweet_data) for tweet_data in tweets_data]
991
+
992
+ async def request_tweet(self, tweet_id: int | str) -> Tweet:
993
+ return await self._request_tweet(tweet_id)
994
+
995
+ async def request_tweets(
996
+ self,
997
+ user_id: int | str = None,
998
+ count: int = 20,
999
+ ) -> list[Tweet]:
1000
+ if not user_id:
1001
+ if not self.account.id:
1002
+ await self.request_user()
1003
+ user_id = self.account.id
1004
+
1005
+ return await self._request_tweets(user_id, count)
810
1006
 
811
1007
  async def _update_profile_image(
812
1008
  self, type: Literal["banner", "image"], media_id: str | int
@@ -832,8 +1028,8 @@ class Client(BaseClient):
832
1028
  "skip_status": "1",
833
1029
  "return_user": "true",
834
1030
  }
835
- response, response_json = await self.request("POST", url, params=params)
836
- image_url = response_json[f"profile_{type}_url"]
1031
+ response, data = await self.request("POST", url, params=params)
1032
+ image_url = data[f"profile_{type}_url"]
837
1033
  return image_url
838
1034
 
839
1035
  async def update_profile_avatar(self, media_id: int | str) -> str:
@@ -850,12 +1046,12 @@ class Client(BaseClient):
850
1046
 
851
1047
  async def change_username(self, username: str) -> bool:
852
1048
  url = "https://twitter.com/i/api/1.1/account/settings.json"
853
- data = {"screen_name": username}
854
- response, response_json = await self.request("POST", url, data=data)
855
- new_username = response_json["screen_name"]
856
- is_changed = new_username == username
1049
+ payload = {"screen_name": username}
1050
+ response, data = await self.request("POST", url, data=payload)
1051
+ new_username = data["screen_name"]
1052
+ changed = new_username == username
857
1053
  self.account.username = new_username
858
- return is_changed
1054
+ return changed
859
1055
 
860
1056
  async def change_password(self, password: str) -> bool:
861
1057
  """
@@ -865,17 +1061,18 @@ class Client(BaseClient):
865
1061
  raise ValueError(f"Specify the current password before changing it")
866
1062
 
867
1063
  url = "https://twitter.com/i/api/i/account/change_password.json"
868
- data = {
1064
+ payload = {
869
1065
  "current_password": self.account.password,
870
1066
  "password": password,
871
1067
  "password_confirmation": password,
872
1068
  }
873
- response, response_json = await self.request("POST", url, data=data)
874
- is_changed = response_json["status"] == "ok"
1069
+ response, data = await self.request("POST", url, data=payload)
1070
+ changed = data["status"] == "ok"
1071
+ # TODO Делать это автоматически в методе request
875
1072
  auth_token = response.cookies.get("auth_token", domain=".twitter.com")
876
1073
  self.account.auth_token = auth_token
877
1074
  self.account.password = password
878
- return is_changed
1075
+ return changed
879
1076
 
880
1077
  async def update_profile(
881
1078
  self,
@@ -891,9 +1088,9 @@ class Client(BaseClient):
891
1088
  raise ValueError("Specify at least one param")
892
1089
 
893
1090
  url = "https://twitter.com/i/api/1.1/account/update_profile.json"
894
- headers = {"content-type": "application/x-www-form-urlencoded"}
1091
+ # headers = {"content-type": "application/x-www-form-urlencoded"}
895
1092
  # Создаем словарь data, включая в него только те ключи, для которых значения не равны None
896
- data = {
1093
+ payload = {
897
1094
  k: v
898
1095
  for k, v in [
899
1096
  ("name", name),
@@ -903,26 +1100,23 @@ class Client(BaseClient):
903
1100
  ]
904
1101
  if v is not None
905
1102
  }
906
- response, response_json = await self.request(
907
- "POST", url, headers=headers, data=data
908
- )
1103
+ # response, response_json = await self.request("POST", url, headers=headers, data=payload)
1104
+ response, data = await self.request("POST", url, data=payload)
909
1105
  # Проверяем, что все переданные параметры соответствуют полученным
910
- is_updated = all(
911
- response_json.get(key) == value
912
- for key, value in data.items()
913
- if key != "url"
1106
+ updated = all(
1107
+ data.get(key) == value for key, value in payload.items() if key != "url"
914
1108
  )
915
1109
  if website:
916
- is_updated &= URL(website) == URL(
917
- response_json["entities"]["url"]["urls"][0]["expanded_url"]
1110
+ updated &= URL(website) == URL(
1111
+ data["entities"]["url"]["urls"][0]["expanded_url"]
918
1112
  )
919
- await self.request_user_data()
920
- return is_updated
1113
+ await self.request_user()
1114
+ return updated
921
1115
 
922
1116
  async def establish_status(self):
923
1117
  url = "https://twitter.com/i/api/1.1/account/update_profile.json"
924
1118
  try:
925
- await self.request("POST", url)
1119
+ await self.request("POST", url, auto_unlock=False, auto_relogin=False)
926
1120
  except BadAccount:
927
1121
  pass
928
1122
 
@@ -947,7 +1141,7 @@ class Client(BaseClient):
947
1141
  "POST", url, headers=headers, data=data
948
1142
  )
949
1143
  birthdate_data = response_json["extended_profile"]["birthdate"]
950
- is_updated = all(
1144
+ updated = all(
951
1145
  (
952
1146
  birthdate_data["day"] == day,
953
1147
  birthdate_data["month"] == month,
@@ -956,7 +1150,7 @@ class Client(BaseClient):
956
1150
  birthdate_data["year_visibility"] == year_visibility,
957
1151
  )
958
1152
  )
959
- return is_updated
1153
+ return updated
960
1154
 
961
1155
  async def send_message(self, user_id: int | str, text: str) -> dict:
962
1156
  """
@@ -972,6 +1166,20 @@ class Client(BaseClient):
972
1166
  },
973
1167
  }
974
1168
  }
1169
+ response, data = await self.request("POST", url, json=payload)
1170
+ event_data = data["event"]
1171
+ return event_data # TODO Возвращать модель, а не словарь
1172
+
1173
+ async def send_message_to_conversation(
1174
+ self, conversation_id: int | str, text: str
1175
+ ) -> dict:
1176
+ """
1177
+ requires OAuth1 or OAuth2
1178
+
1179
+ :return: Event data
1180
+ """
1181
+ url = f"https://api.twitter.com/2/dm_conversations/{conversation_id}/messages"
1182
+ payload = {"text": text}
975
1183
  response, response_json = await self.request("POST", url, json=payload)
976
1184
  event_data = response_json["event"]
977
1185
  return event_data
@@ -1023,56 +1231,7 @@ class Client(BaseClient):
1023
1231
  for entry in response_json["inbox_initial_state"]["entries"]
1024
1232
  if "message" in entry
1025
1233
  ]
1026
- return messages
1027
-
1028
- async def request_tweets(self, user_id: str | int, count: int = 20) -> list[Tweet]:
1029
- url, query_id = self._action_to_url("UserTweets")
1030
- variables = {
1031
- "userId": str(user_id),
1032
- "count": count,
1033
- "includePromotedContent": True,
1034
- "withQuickPromoteEligibilityTweetFields": True,
1035
- "withVoice": True,
1036
- "withV2Timeline": True,
1037
- }
1038
- features = {
1039
- "responsive_web_graphql_exclude_directive_enabled": True,
1040
- "verified_phone_label_enabled": False,
1041
- "creator_subscriptions_tweet_preview_api_enabled": True,
1042
- "responsive_web_graphql_timeline_navigation_enabled": True,
1043
- "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
1044
- "c9s_tweet_anatomy_moderator_badge_enabled": True,
1045
- "tweetypie_unmention_optimization_enabled": True,
1046
- "responsive_web_edit_tweet_api_enabled": True,
1047
- "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
1048
- "view_counts_everywhere_api_enabled": True,
1049
- "longform_notetweets_consumption_enabled": True,
1050
- "responsive_web_twitter_article_tweet_consumption_enabled": False,
1051
- "tweet_awards_web_tipping_enabled": False,
1052
- "freedom_of_speech_not_reach_fetch_enabled": True,
1053
- "standardized_nudges_misinfo": True,
1054
- "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
1055
- "rweb_video_timestamps_enabled": True,
1056
- "longform_notetweets_rich_text_read_enabled": True,
1057
- "longform_notetweets_inline_media_enabled": True,
1058
- "responsive_web_media_download_video_enabled": False,
1059
- "responsive_web_enhance_cards_enabled": False,
1060
- }
1061
- params = {"variables": to_json(variables), "features": to_json(features)}
1062
- response, response_json = await self.request("GET", url, params=params)
1063
-
1064
- tweets = []
1065
- for instruction in response_json["data"]["user"]["result"]["timeline_v2"][
1066
- "timeline"
1067
- ]["instructions"]:
1068
- if instruction["type"] == "TimelineAddEntries":
1069
- for entry in instruction["entries"]:
1070
- if entry["entryId"].startswith("tweet"):
1071
- tweet_data = entry["content"]["itemContent"]["tweet_results"][
1072
- "result"
1073
- ]
1074
- tweets.append(Tweet.from_raw_data(tweet_data))
1075
- return tweets
1234
+ return messages # TODO Возвращать модели, а не словари
1076
1235
 
1077
1236
  async def _confirm_unlock(
1078
1237
  self,
@@ -1133,8 +1292,19 @@ class Client(BaseClient):
1133
1292
  else:
1134
1293
  funcaptcha["captcha_type"] = FunCaptchaTypeEnm.FunCaptchaTaskProxyLess
1135
1294
 
1136
- while needs_unlock:
1295
+ while needs_unlock and attempt <= self.max_unlock_attempts:
1137
1296
  solution = await FunCaptcha(**funcaptcha).aio_captcha_handler()
1297
+ if solution.errorId:
1298
+ logger.warning(
1299
+ f"{self.account} Failed to solve funcaptcha:"
1300
+ f"\n\tUnlock attempt: {attempt}/{self.max_unlock_attempts}"
1301
+ f"\n\tError ID: {solution.errorId}"
1302
+ f"\n\tError code: {solution.errorCode}"
1303
+ f"\n\tError description: {solution.errorDescription}"
1304
+ )
1305
+ attempt += 1
1306
+ continue
1307
+
1138
1308
  token = solution.solution["token"]
1139
1309
  response, html = await self._confirm_unlock(
1140
1310
  authenticity_token,
@@ -1142,12 +1312,8 @@ class Client(BaseClient):
1142
1312
  verification_string=token,
1143
1313
  )
1144
1314
 
1145
- if (
1146
- attempt > self.max_unlock_attempts
1147
- or response.url == "https://twitter.com/?lang=en"
1148
- ):
1149
- await self.establish_status()
1150
- return
1315
+ if response.url == "https://twitter.com/?lang=en":
1316
+ break
1151
1317
 
1152
1318
  (
1153
1319
  authenticity_token,
@@ -1171,6 +1337,8 @@ class Client(BaseClient):
1171
1337
 
1172
1338
  attempt += 1
1173
1339
 
1340
+ await self.establish_status()
1341
+
1174
1342
  async def _task(self, **kwargs):
1175
1343
  """
1176
1344
  :return: flow_token, subtasks
@@ -1335,7 +1503,7 @@ class Client(BaseClient):
1335
1503
  "fieldToggles": to_json(field_toggles),
1336
1504
  "variables": to_json(variables),
1337
1505
  }
1338
- return self.request("GET", url, params=params)
1506
+ return await self.request("GET", url, params=params)
1339
1507
 
1340
1508
  async def _request_guest_token(self) -> str:
1341
1509
  """
@@ -1374,18 +1542,9 @@ class Client(BaseClient):
1374
1542
  flow_token
1375
1543
  )
1376
1544
 
1377
- # TODO Возможно, стоит добавить отслеживание этих параметров прямо в request
1378
- self.account.auth_token = self._session.cookies["auth_token"]
1379
- self.account.ct0 = self._session.cookies["ct0"]
1380
-
1381
1545
  await self._finish_task(flow_token)
1382
1546
 
1383
- async def login(self):
1384
- if self.account.auth_token:
1385
- await self.establish_status()
1386
- if self.account.status != "BAD_TOKEN":
1387
- return
1388
-
1547
+ async def relogin(self):
1389
1548
  if not self.account.email and not self.account.username:
1390
1549
  raise ValueError("No email or username")
1391
1550
 
@@ -1393,11 +1552,20 @@ class Client(BaseClient):
1393
1552
  raise ValueError("No password")
1394
1553
 
1395
1554
  await self._login()
1555
+ await self._viewer()
1396
1556
  await self.establish_status()
1397
1557
 
1558
+ async def login(self):
1559
+ if self.account.auth_token:
1560
+ await self.establish_status()
1561
+ if self.account.status not in ("BAD_TOKEN", "CONSENT_LOCKED"):
1562
+ return
1563
+
1564
+ await self.relogin()
1565
+
1398
1566
  async def totp_is_enabled(self):
1399
1567
  if not self.account.id:
1400
- await self.request_user_data()
1568
+ await self.request_user()
1401
1569
 
1402
1570
  url = f"https://twitter.com/i/api/1.1/strato/column/User/{self.account.id}/account-security/twoFactorAuthSettings2"
1403
1571
  response, data = await self.request("GET", url)
@@ -1409,7 +1577,7 @@ class Client(BaseClient):
1409
1577
  """
1410
1578
  :return: flow_token, subtask_ids
1411
1579
  """
1412
- params = {
1580
+ query = {
1413
1581
  "flow_name": "two-factor-auth-app-enrollment",
1414
1582
  }
1415
1583
  payload = {
@@ -1463,7 +1631,7 @@ class Client(BaseClient):
1463
1631
  "web_modal": 1,
1464
1632
  },
1465
1633
  }
1466
- return await self._task(params=params, json=payload)
1634
+ return await self._task(params=query, json=payload)
1467
1635
 
1468
1636
  async def _two_factor_enrollment_verify_password_subtask(self, flow_token: str):
1469
1637
  subtask_inputs = [
@@ -1561,6 +1729,4 @@ class Client(BaseClient):
1561
1729
  if await self.totp_is_enabled():
1562
1730
  return
1563
1731
 
1564
- # TODO Осторожно, костыль! Перед началом работы вызываем request_user_data, чтоб убедиться что нет других ошибок
1565
- await self.request_user_data()
1566
1732
  await self._enable_totp()