tweepy-self 1.9.0__py3-none-any.whl → 1.10.0b3__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tweepy-self
3
- Version: 1.9.0
3
+ Version: 1.10.0b3
4
4
  Summary: Twitter (selfbot) for Python!
5
5
  Home-page: https://github.com/alenkimov/tweepy-self
6
6
  Author: Alen
@@ -12,10 +12,10 @@ Classifier: Programming Language :: Python :: 3.12
12
12
  Requires-Dist: aiohttp (>=3.9,<4.0)
13
13
  Requires-Dist: beautifulsoup4 (>=4,<5)
14
14
  Requires-Dist: better-proxy (>=1.1,<2.0)
15
- Requires-Dist: curl_cffi (==0.6.0b9)
15
+ Requires-Dist: curl_cffi (==0.6.2)
16
16
  Requires-Dist: loguru (>=0.7,<0.8)
17
17
  Requires-Dist: lxml (>=5,<6)
18
- Requires-Dist: pydantic (>=1)
18
+ Requires-Dist: pydantic (>=2)
19
19
  Requires-Dist: pyotp (>=2,<3)
20
20
  Requires-Dist: requests (>=2,<3)
21
21
  Requires-Dist: tenacity (>=8,<9)
@@ -4,20 +4,20 @@ twitter/_capsolver/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG
4
4
  twitter/_capsolver/core/base.py,sha256=In3qDLgRh1z1UZLaLFgYcDEdnqW3d62PVzgEjU2S4BU,8883
5
5
  twitter/_capsolver/core/config.py,sha256=8_eXT6N2hBheN2uCMNhqk8tLZRJjLDTYLK208fqIkhM,1054
6
6
  twitter/_capsolver/core/enum.py,sha256=ivfAEN6jrg3iaq5C3H7CuRqsvOloX1b8lF8cLa3zaiY,1741
7
- twitter/_capsolver/core/serializer.py,sha256=2KuZ84VRUQp86iE386qUxReJryFIHnqueRGQUJDQadk,2616
7
+ twitter/_capsolver/core/serializer.py,sha256=xPEUIPgytuw2wM1ubTY3RMhJGVyp_d3bokPTx0BjF0c,2602
8
8
  twitter/_capsolver/fun_captcha.py,sha256=VVbTmn08cGnvPMGdJmPxaLfAIPxyA68oTSAyEL8RWnU,10974
9
- twitter/account.py,sha256=kczlNN3YQcfec7eB1DKDbjn4zmZfJPl3e5o-70IFPFA,3142
9
+ twitter/account.py,sha256=joAB5Zw-Le5E3kOZ-1nb4DPGlTqWYv2Vs6gJ3cwu7is,3175
10
10
  twitter/base/__init__.py,sha256=Q2ko0HeOS5tiBnDVKxxaZYetwRR3YXJ67ujL3oThGd4,141
11
- twitter/base/client.py,sha256=4z_keQnr8YxqRCsWfilQViGDKHulCZ_YBU1fEOr0AnE,482
11
+ twitter/base/client.py,sha256=J_iL4ZGfwTbZ2gpjtFCbBxNgt7weJ55EeMGzYsLtjf4,500
12
12
  twitter/base/session.py,sha256=JFPS-9Qae1iY3NfNcywxvWWmRDijaU_Rjs3WaQ00iFA,2071
13
- twitter/client.py,sha256=hLMW20v3hSPwOPwoXNUbNFFOWmlqG77jt02VGUKoQDU,64199
13
+ twitter/client.py,sha256=gboNB1N2ftrwJFlk43HV41d_2YXkxf1W5K_ZubPp6Xw,68665
14
14
  twitter/enums.py,sha256=-OH6Ibxarq5qt4E2AhkProVawcEyIf5YG_h_G5xiV9Y,270
15
- twitter/errors.py,sha256=NuoHmYz97VwXU1RWAaNELXR69snpwnCtFH0bII1hHyo,5088
16
- twitter/models.py,sha256=3KtyNHm6GGMiapk8I4jUmbnfrRTMBCFvN4g4dEW99tI,4985
15
+ twitter/errors.py,sha256=oNa0Neos80ZK4-0FBzqgxXonH564qFnoN-kavHalfR4,5274
16
+ twitter/models.py,sha256=7yObMPUUEwJEbraHzFwmUKd91UhR2-zyfJTm4xIqrSQ,4834
17
17
  twitter/utils/__init__.py,sha256=usxpfcRQ7zxTTgZ-i425tT7hIz73Pwh9FDj4t6O3dYg,663
18
18
  twitter/utils/file.py,sha256=Sz2KEF9DnL04aOP1XabuMYMMF4VR8dJ_KWMEVvQ666Y,1120
19
19
  twitter/utils/html.py,sha256=hVtIRFI2yRAdWEaShFNBG-_ZWxd16og8i8OVDnFy5Hc,1971
20
20
  twitter/utils/other.py,sha256=9RIYF2AMdmNKIwClG3jBP7zlvxZPEgYfuHaIiOhURzM,1061
21
- tweepy_self-1.9.0.dist-info/METADATA,sha256=LgnHHwoM-zyQF9l-XTR58GuHMXiVR_QHEOWGnUm-jDg,9437
22
- tweepy_self-1.9.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
23
- tweepy_self-1.9.0.dist-info/RECORD,,
21
+ tweepy_self-1.10.0b3.dist-info/METADATA,sha256=E9sSg4Y165wtSBFd39-ol2ir5nt4sCBKgqFVrMBW6PA,9438
22
+ tweepy_self-1.10.0b3.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
23
+ tweepy_self-1.10.0b3.dist-info/RECORD,,
@@ -45,7 +45,7 @@ class CaptchaResponseSer(ResponseSer):
45
45
  solution: Dict[str, Any] = Field(None, description="Task result data. Different for each type of task.")
46
46
 
47
47
  class Config:
48
- allow_population_by_field_name = True
48
+ populate_by_name = True
49
49
 
50
50
 
51
51
  class ControlResponseSer(ResponseSer):
twitter/account.py CHANGED
@@ -12,11 +12,11 @@ from .models import User
12
12
  class Account(User):
13
13
  # fmt: off
14
14
  auth_token: str | None = Field(default=None, pattern=r"^[a-f0-9]{40}$")
15
- ct0: str | None = None
16
- password: str | None = None
17
- email: str | None = None
18
- totp_secret: str | None = None
19
- backup_code: str | None = None
15
+ ct0: str | None = None # 160
16
+ password: str | None = None # 128
17
+ email: str | None = None # 254
18
+ totp_secret: str | None = None # 16
19
+ backup_code: str | None = None # 12
20
20
  status: AccountStatus = AccountStatus.UNKNOWN
21
21
  # fmt: on
22
22
 
twitter/base/client.py CHANGED
@@ -14,7 +14,7 @@ class BaseHTTPClient:
14
14
  return self
15
15
 
16
16
  async def __aexit__(self, *args):
17
- self.close()
17
+ await self.close()
18
18
 
19
- def close(self):
20
- self._session.close()
19
+ async def close(self):
20
+ await self._session.close()
twitter/client.py CHANGED
@@ -2,6 +2,7 @@ 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
 
7
8
  from loguru import logger
@@ -44,7 +45,6 @@ class Client(BaseHTTPClient):
44
45
  "authority": "twitter.com",
45
46
  "origin": "https://twitter.com",
46
47
  "x-twitter-active-user": "yes",
47
- # 'x-twitter-auth-type': 'OAuth2Session',
48
48
  "x-twitter-client-language": "en",
49
49
  }
50
50
  _GRAPHQL_URL = "https://twitter.com/i/api/graphql"
@@ -52,7 +52,7 @@ class Client(BaseHTTPClient):
52
52
  "CreateRetweet": "ojPdsZsimiJrUGLR1sjUtA",
53
53
  "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
54
54
  "UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA",
55
- "CreateTweet": "SoVnbfCycZ7fERGCwpZkYA",
55
+ "CreateTweet": "v0en1yVV-Ybeek8ClmXwYw",
56
56
  "TweetResultByRestId": "V3vfsYzNEyD9tsf4xoFRgw",
57
57
  "ModerateTweet": "p'jF:GVqCjTcZol0xcBJjw",
58
58
  "DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg",
@@ -84,6 +84,8 @@ class Client(BaseHTTPClient):
84
84
  wait_on_rate_limit: bool = True,
85
85
  capsolver_api_key: str = None,
86
86
  max_unlock_attempts: int = 5,
87
+ auto_relogin: bool = True,
88
+ request_self_on_startup: bool = True,
87
89
  **session_kwargs,
88
90
  ):
89
91
  super().__init__(**session_kwargs)
@@ -91,13 +93,21 @@ class Client(BaseHTTPClient):
91
93
  self.wait_on_rate_limit = wait_on_rate_limit
92
94
  self.capsolver_api_key = capsolver_api_key
93
95
  self.max_unlock_attempts = max_unlock_attempts
96
+ self.auto_relogin = auto_relogin
97
+ self.request_self_on_startup = request_self_on_startup
94
98
 
95
- async def request(
99
+ async def __aenter__(self):
100
+ await self.on_startup()
101
+ return await super().__aenter__()
102
+
103
+ async def _request(
96
104
  self,
97
105
  method,
98
106
  url,
107
+ *,
99
108
  auth: bool = True,
100
109
  bearer: bool = True,
110
+ wait_on_rate_limit: bool = None,
101
111
  **kwargs,
102
112
  ) -> tuple[requests.Response, Any]:
103
113
  cookies = kwargs["cookies"] = kwargs.get("cookies") or {}
@@ -105,6 +115,7 @@ class Client(BaseHTTPClient):
105
115
 
106
116
  if bearer:
107
117
  headers["authorization"] = f"Bearer {self._BEARER_TOKEN}"
118
+ # headers["x-twitter-auth-type"] = "OAuth2Session"
108
119
 
109
120
  if auth:
110
121
  if not self.account.auth_token:
@@ -116,7 +127,8 @@ class Client(BaseHTTPClient):
116
127
  headers["x-csrf-token"] = self.account.ct0
117
128
 
118
129
  # fmt: off
119
- log_message = f"{self.account} Request {method} {url}"
130
+ log_message = (f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
131
+ f" ==> Request {method} {url}")
120
132
  if kwargs.get('data'): log_message += f"\nRequest data: {kwargs.get('data')}"
121
133
  if kwargs.get('json'): log_message += f"\nRequest data: {kwargs.get('json')}"
122
134
  logger.debug(log_message)
@@ -135,23 +147,52 @@ class Client(BaseHTTPClient):
135
147
 
136
148
  data = response.text
137
149
  # fmt: off
138
- log_message = (f"{self.account} Response {method} {url}"
139
- f"\nStatus code: {response.status_code}"
140
- f"\nResponse data: {data}")
141
- logger.debug(log_message)
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}")
142
154
  # fmt: on
143
155
 
144
- if response.headers["content-type"].startswith("application/json"):
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.warning(
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:
145
168
  data = response.json()
169
+ except json.decoder.JSONDecodeError:
170
+ pass
146
171
 
147
- if response.status_code == 429:
148
- if self.wait_on_rate_limit:
149
- reset_time = int(response.headers["x-rate-limit-reset"])
150
- sleep_time = reset_time - int(time()) + 1
151
- if sleep_time > 0:
152
- await asyncio.sleep(sleep_time)
153
- return await self.request(method, url, auth, bearer, **kwargs)
154
- 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
155
196
 
156
197
  if response.status_code == 400:
157
198
  raise BadRequest(response, data)
@@ -168,10 +209,6 @@ class Client(BaseHTTPClient):
168
209
  if response.status_code == 403:
169
210
  exc = Forbidden(response, data)
170
211
 
171
- if 353 in exc.api_codes and "ct0" in response.cookies:
172
- self.account.ct0 = response.cookies["ct0"]
173
- return await self.request(method, url, auth, bearer, **kwargs)
174
-
175
212
  if 64 in exc.api_codes:
176
213
  self.account.status = AccountStatus.SUSPENDED
177
214
  raise Suspended(exc, self.account)
@@ -186,52 +223,82 @@ class Client(BaseHTTPClient):
186
223
  raise ConsentLocked(exc, self.account)
187
224
 
188
225
  self.account.status = AccountStatus.LOCKED
189
- if not self.capsolver_api_key:
190
- raise Locked(exc, self.account)
191
-
192
- await self.unlock()
193
- return await self.request(method, url, auth, bearer, **kwargs)
226
+ raise Locked(exc, self.account)
194
227
 
195
228
  raise exc
196
229
 
197
230
  if response.status_code == 404:
198
231
  raise NotFound(response, data)
199
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.warning(
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
+
200
256
  if response.status_code >= 500:
201
257
  raise ServerError(response, data)
202
258
 
203
- if not 200 <= response.status_code < 300:
204
- raise HTTPException(response, data)
205
-
206
- if isinstance(data, dict) and "errors" in data:
207
- 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)
208
270
 
209
- if 141 in exc.api_codes:
210
- self.account.status = AccountStatus.SUSPENDED
211
- raise Suspended(exc, self.account)
271
+ except Locked:
272
+ if not self.capsolver_api_key or not auto_unlock:
273
+ raise
212
274
 
213
- if 326 in exc.api_codes:
214
- for error_data in exc.api_errors:
215
- if (
216
- error_data.get("code") == 326
217
- and error_data.get("bounce_location") == "/i/flow/consent_flow"
218
- ):
219
- self.account.status = AccountStatus.CONSENT_LOCKED
220
- raise ConsentLocked(exc, self.account)
275
+ await self.unlock()
276
+ return await self._request(method, url, **kwargs)
221
277
 
222
- self.account.status = AccountStatus.LOCKED
223
- if not self.capsolver_api_key:
224
- 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
225
287
 
226
- await self.unlock()
227
- return await self.request(method, url, auth, bearer, **kwargs)
288
+ await self.relogin()
289
+ return await self._request(method, url, **kwargs)
228
290
 
229
- 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
230
296
 
231
- self.account.status = AccountStatus.GOOD
232
- return response, data
297
+ async def on_startup(self):
298
+ if self.request_self_on_startup:
299
+ await self.request_user()
233
300
 
234
- async def _request_oauth_2_auth_code(
301
+ async def _request_oauth2_auth_code(
235
302
  self,
236
303
  client_id: str,
237
304
  code_challenge: str,
@@ -255,7 +322,7 @@ class Client(BaseHTTPClient):
255
322
  auth_code = response_json["auth_code"]
256
323
  return auth_code
257
324
 
258
- async def _confirm_oauth_2(self, auth_code: str):
325
+ async def _confirm_oauth2(self, auth_code: str):
259
326
  data = {
260
327
  "approval": "true",
261
328
  "code": auth_code,
@@ -268,7 +335,7 @@ class Client(BaseHTTPClient):
268
335
  data=data,
269
336
  )
270
337
 
271
- async def oauth_2(
338
+ async def oauth2(
272
339
  self,
273
340
  client_id: str,
274
341
  code_challenge: str,
@@ -284,15 +351,13 @@ class Client(BaseHTTPClient):
284
351
  Привязка (бинд, линк) приложения.
285
352
 
286
353
  :param client_id: Идентификатор клиента, используемый для OAuth.
287
- :param code_challenge: Код-вызов, используемый для PKCE (Proof Key for Code Exchange).
288
354
  :param state: Уникальная строка состояния для предотвращения CSRF-атак.
289
355
  :param redirect_uri: URI перенаправления, на который будет отправлен ответ.
290
- :param code_challenge_method: Метод, используемый для преобразования code_verifier в code_challenge.
291
356
  :param scope: Строка областей доступа, запрашиваемых у пользователя.
292
357
  :param response_type: Тип ответа, который ожидается от сервера авторизации.
293
358
  :return: Код авторизации (привязки).
294
359
  """
295
- auth_code = await self._request_oauth_2_auth_code(
360
+ auth_code = await self._request_oauth2_auth_code(
296
361
  client_id,
297
362
  code_challenge,
298
363
  state,
@@ -301,7 +366,7 @@ class Client(BaseHTTPClient):
301
366
  scope,
302
367
  response_type,
303
368
  )
304
- await self._confirm_oauth_2(auth_code)
369
+ await self._confirm_oauth2(auth_code)
305
370
  return auth_code
306
371
 
307
372
  async def _oauth(self, oauth_token: str, **oauth_params) -> requests.Response:
@@ -361,7 +426,7 @@ class Client(BaseHTTPClient):
361
426
  response, response_json = await self.request("POST", url)
362
427
  self.account.username = response_json["screen_name"]
363
428
 
364
- async def _request_user(self, username: str) -> User:
429
+ async def _request_user(self, username: str) -> User | None:
365
430
  url, query_id = self._action_to_url("UserByScreenName")
366
431
  username = remove_at_sign(username)
367
432
  variables = {
@@ -389,26 +454,39 @@ class Client(BaseHTTPClient):
389
454
  "fieldToggles": to_json(field_toggles),
390
455
  }
391
456
  response, data = await self.request("GET", url, params=params)
457
+ if not data["data"]:
458
+ return None
392
459
  return User.from_raw_data(data["data"]["user"]["result"])
393
460
 
394
461
  async def request_user(
395
462
  self, *, username: str = None, user_id: int | str = None
396
- ) -> User | Account:
463
+ ) -> User | Account | None:
464
+ """
465
+ Возвращает None, если задано несуществующее имя пользователя
466
+ """
397
467
  if username and user_id:
398
468
  raise ValueError("Specify username or user_id, not both.")
399
469
 
400
470
  if user_id:
401
471
  users = await self.request_users((user_id,))
402
472
  user = users[user_id]
403
- else:
404
- if not username:
405
- if not self.account.username:
406
- await self.request_and_set_username()
407
- username = self.account.username
408
-
473
+ elif username:
409
474
  user = await self._request_user(username)
475
+ else:
476
+ if not self.account.username:
477
+ await self.request_and_set_username()
478
+
479
+ user = await self._request_user(self.account.username)
480
+
481
+ if not user:
482
+ bad_username = self.account.username
483
+ await self.request_and_set_username()
484
+ user = await self._request_user(self.account.username)
485
+ logger.warning(
486
+ f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
487
+ f" Bad username: {bad_username}. Requested a real username."
488
+ )
410
489
 
411
- if self.account.username == user.username:
412
490
  self.account.update(**user.model_dump())
413
491
  user = self.account
414
492
 
@@ -437,7 +515,7 @@ class Client(BaseHTTPClient):
437
515
  response, data = await self.request(
438
516
  "POST", url, data=payload, timeout=timeout
439
517
  )
440
- return Media.from_raw_data(data)
518
+ return Media(**data)
441
519
  except (HTTPException, requests.errors.RequestsError) as exc:
442
520
  if (
443
521
  attempt < attempts - 1
@@ -541,6 +619,8 @@ class Client(BaseHTTPClient):
541
619
  """
542
620
  Repost (retweet)
543
621
 
622
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
623
+
544
624
  :return: Tweet
545
625
  """
546
626
  return await self._repost_or_search_duplicate(
@@ -548,9 +628,18 @@ class Client(BaseHTTPClient):
548
628
  )
549
629
 
550
630
  async def like(self, tweet_id: int) -> bool:
551
- response_json = await self._interact_with_tweet("FavoriteTweet", tweet_id)
552
- is_liked = response_json["data"]["favorite_tweet"] == "Done"
553
- return is_liked
631
+ """
632
+ :return: Liked or not
633
+ """
634
+ try:
635
+ response_json = await self._interact_with_tweet("FavoriteTweet", tweet_id)
636
+ except HTTPException as exc:
637
+ if 139 in exc.api_codes:
638
+ # Already liked
639
+ return True
640
+ else:
641
+ raise
642
+ return response_json["data"]["favorite_tweet"] == "Done"
554
643
 
555
644
  async def unlike(self, tweet_id: int) -> dict:
556
645
  response_json = await self._interact_with_tweet("UnfavoriteTweet", tweet_id)
@@ -597,47 +686,50 @@ class Client(BaseHTTPClient):
597
686
  attachment_url: str = None,
598
687
  ) -> Tweet:
599
688
  url, query_id = self._action_to_url("CreateTweet")
600
- payload = {
601
- "variables": {
602
- "tweet_text": text if text is not None else "",
603
- "dark_request": False,
604
- "media": {"media_entities": [], "possibly_sensitive": False},
605
- "semantic_annotation_ids": [],
606
- },
607
- "features": {
608
- "tweetypie_unmention_optimization_enabled": True,
609
- "responsive_web_edit_tweet_api_enabled": True,
610
- "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
611
- "view_counts_everywhere_api_enabled": True,
612
- "longform_notetweets_consumption_enabled": True,
613
- "tweet_awards_web_tipping_enabled": False,
614
- "longform_notetweets_rich_text_read_enabled": True,
615
- "longform_notetweets_inline_media_enabled": True,
616
- "responsive_web_graphql_exclude_directive_enabled": True,
617
- "verified_phone_label_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": False,
621
- "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
622
- "responsive_web_graphql_timeline_navigation_enabled": True,
623
- "responsive_web_enhance_cards_enabled": False,
624
- "responsive_web_twitter_article_tweet_consumption_enabled": False,
625
- "responsive_web_media_download_video_enabled": False,
626
- },
627
- "queryId": query_id,
689
+ variables = {
690
+ "tweet_text": text if text is not None else "",
691
+ "dark_request": False,
692
+ "media": {"media_entities": [], "possibly_sensitive": False},
693
+ "semantic_annotation_ids": [],
628
694
  }
629
695
  if attachment_url:
630
- payload["variables"]["attachment_url"] = attachment_url
696
+ variables["attachment_url"] = attachment_url
631
697
  if tweet_id_to_reply:
632
- payload["variables"]["reply"] = {
698
+ variables["reply"] = {
633
699
  "in_reply_to_tweet_id": str(tweet_id_to_reply),
634
700
  "exclude_reply_user_ids": [],
635
701
  }
636
702
  if media_id:
637
- payload["variables"]["media"]["media_entities"].append(
703
+ variables["media"]["media_entities"].append(
638
704
  {"media_id": str(media_id), "tagged_users": []}
639
705
  )
640
-
706
+ features = {
707
+ "communities_web_enable_tweet_community_results_fetch": True,
708
+ "c9s_tweet_anatomy_moderator_badge_enabled": True,
709
+ "tweetypie_unmention_optimization_enabled": True,
710
+ "responsive_web_edit_tweet_api_enabled": True,
711
+ "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
712
+ "view_counts_everywhere_api_enabled": True,
713
+ "longform_notetweets_consumption_enabled": True,
714
+ "responsive_web_twitter_article_tweet_consumption_enabled": True,
715
+ "tweet_awards_web_tipping_enabled": False,
716
+ "longform_notetweets_rich_text_read_enabled": True,
717
+ "longform_notetweets_inline_media_enabled": True,
718
+ "rweb_video_timestamps_enabled": True,
719
+ "responsive_web_graphql_exclude_directive_enabled": True,
720
+ "verified_phone_label_enabled": False,
721
+ "freedom_of_speech_not_reach_fetch_enabled": True,
722
+ "standardized_nudges_misinfo": True,
723
+ "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
724
+ "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
725
+ "responsive_web_graphql_timeline_navigation_enabled": True,
726
+ "responsive_web_enhance_cards_enabled": False,
727
+ }
728
+ payload = {
729
+ "variables": variables,
730
+ "features": features,
731
+ "queryId": query_id,
732
+ }
641
733
  response, response_json = await self.request("POST", url, json=payload)
642
734
  tweet = Tweet.from_raw_data(
643
735
  response_json["data"]["create_tweet"]["tweet_results"]["result"]
@@ -689,6 +781,11 @@ class Client(BaseHTTPClient):
689
781
  media_id: int | str = None,
690
782
  search_duplicate: bool = True,
691
783
  ) -> Tweet:
784
+ """
785
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
786
+
787
+ :return: Tweet
788
+ """
692
789
  return await self._tweet_or_search_duplicate(
693
790
  text,
694
791
  media_id=media_id,
@@ -703,6 +800,11 @@ class Client(BaseHTTPClient):
703
800
  media_id: int | str = None,
704
801
  search_duplicate: bool = True,
705
802
  ) -> Tweet:
803
+ """
804
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
805
+
806
+ :return: Tweet
807
+ """
706
808
  return await self._tweet_or_search_duplicate(
707
809
  text,
708
810
  media_id=media_id,
@@ -718,6 +820,11 @@ class Client(BaseHTTPClient):
718
820
  media_id: int | str = None,
719
821
  search_duplicate: bool = True,
720
822
  ) -> Tweet:
823
+ """
824
+ Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
825
+
826
+ :return: Tweet
827
+ """
721
828
  return await self._tweet_or_search_duplicate(
722
829
  text,
723
830
  media_id=media_id,
@@ -1051,7 +1158,7 @@ class Client(BaseHTTPClient):
1051
1158
  async def establish_status(self):
1052
1159
  url = "https://twitter.com/i/api/1.1/account/update_profile.json"
1053
1160
  try:
1054
- await self.request("POST", url)
1161
+ await self.request("POST", url, auto_unlock=False, auto_relogin=False)
1055
1162
  except BadAccount:
1056
1163
  pass
1057
1164
 
@@ -1105,6 +1212,20 @@ class Client(BaseHTTPClient):
1105
1212
  event_data = data["event"]
1106
1213
  return event_data # TODO Возвращать модель, а не словарь
1107
1214
 
1215
+ async def send_message_to_conversation(
1216
+ self, conversation_id: int | str, text: str
1217
+ ) -> dict:
1218
+ """
1219
+ requires OAuth1 or OAuth2
1220
+
1221
+ :return: Event data
1222
+ """
1223
+ url = f"https://api.twitter.com/2/dm_conversations/{conversation_id}/messages"
1224
+ payload = {"text": text}
1225
+ response, response_json = await self.request("POST", url, json=payload)
1226
+ event_data = response_json["event"]
1227
+ return event_data
1228
+
1108
1229
  async def request_messages(self) -> list[dict]:
1109
1230
  """
1110
1231
  :return: Messages data
@@ -1213,8 +1334,19 @@ class Client(BaseHTTPClient):
1213
1334
  else:
1214
1335
  funcaptcha["captcha_type"] = FunCaptchaTypeEnm.FunCaptchaTaskProxyLess
1215
1336
 
1216
- while needs_unlock:
1337
+ while needs_unlock and attempt <= self.max_unlock_attempts:
1217
1338
  solution = await FunCaptcha(**funcaptcha).aio_captcha_handler()
1339
+ if solution.errorId:
1340
+ logger.warning(
1341
+ f"{self.account} Failed to solve funcaptcha:"
1342
+ f"\n\tUnlock attempt: {attempt}/{self.max_unlock_attempts}"
1343
+ f"\n\tError ID: {solution.errorId}"
1344
+ f"\n\tError code: {solution.errorCode}"
1345
+ f"\n\tError description: {solution.errorDescription}"
1346
+ )
1347
+ attempt += 1
1348
+ continue
1349
+
1218
1350
  token = solution.solution["token"]
1219
1351
  response, html = await self._confirm_unlock(
1220
1352
  authenticity_token,
@@ -1222,12 +1354,8 @@ class Client(BaseHTTPClient):
1222
1354
  verification_string=token,
1223
1355
  )
1224
1356
 
1225
- if (
1226
- attempt > self.max_unlock_attempts
1227
- or response.url == "https://twitter.com/?lang=en"
1228
- ):
1229
- await self.establish_status()
1230
- return
1357
+ if response.url == "https://twitter.com/?lang=en":
1358
+ break
1231
1359
 
1232
1360
  (
1233
1361
  authenticity_token,
@@ -1251,6 +1379,8 @@ class Client(BaseHTTPClient):
1251
1379
 
1252
1380
  attempt += 1
1253
1381
 
1382
+ await self.establish_status()
1383
+
1254
1384
  async def _task(self, **kwargs):
1255
1385
  """
1256
1386
  :return: flow_token, subtasks
@@ -1415,7 +1545,7 @@ class Client(BaseHTTPClient):
1415
1545
  "fieldToggles": to_json(field_toggles),
1416
1546
  "variables": to_json(variables),
1417
1547
  }
1418
- return self.request("GET", url, params=params)
1548
+ return await self.request("GET", url, params=params)
1419
1549
 
1420
1550
  async def _request_guest_token(self) -> str:
1421
1551
  """
@@ -1454,18 +1584,9 @@ class Client(BaseHTTPClient):
1454
1584
  flow_token
1455
1585
  )
1456
1586
 
1457
- # TODO Делать это автоматически в методе request
1458
- self.account.auth_token = self._session.cookies["auth_token"]
1459
- self.account.ct0 = self._session.cookies["ct0"]
1460
-
1461
1587
  await self._finish_task(flow_token)
1462
1588
 
1463
- async def login(self):
1464
- if self.account.auth_token:
1465
- await self.establish_status()
1466
- if self.account.status != "BAD_TOKEN":
1467
- return
1468
-
1589
+ async def relogin(self):
1469
1590
  if not self.account.email and not self.account.username:
1470
1591
  raise ValueError("No email or username")
1471
1592
 
@@ -1473,8 +1594,17 @@ class Client(BaseHTTPClient):
1473
1594
  raise ValueError("No password")
1474
1595
 
1475
1596
  await self._login()
1597
+ await self._viewer()
1476
1598
  await self.establish_status()
1477
1599
 
1600
+ async def login(self):
1601
+ if self.account.auth_token:
1602
+ await self.establish_status()
1603
+ if self.account.status not in ("BAD_TOKEN", "CONSENT_LOCKED"):
1604
+ return
1605
+
1606
+ await self.relogin()
1607
+
1478
1608
  async def totp_is_enabled(self):
1479
1609
  if not self.account.id:
1480
1610
  await self.request_user()
@@ -1489,7 +1619,7 @@ class Client(BaseHTTPClient):
1489
1619
  """
1490
1620
  :return: flow_token, subtask_ids
1491
1621
  """
1492
- params = {
1622
+ query = {
1493
1623
  "flow_name": "two-factor-auth-app-enrollment",
1494
1624
  }
1495
1625
  payload = {
@@ -1543,7 +1673,7 @@ class Client(BaseHTTPClient):
1543
1673
  "web_modal": 1,
1544
1674
  },
1545
1675
  }
1546
- return await self._task(params=params, json=payload)
1676
+ return await self._task(params=query, json=payload)
1547
1677
 
1548
1678
  async def _two_factor_enrollment_verify_password_subtask(self, flow_token: str):
1549
1679
  subtask_inputs = [
@@ -1641,6 +1771,4 @@ class Client(BaseHTTPClient):
1641
1771
  if await self.totp_is_enabled():
1642
1772
  return
1643
1773
 
1644
- # TODO Осторожно, костыль! Перед началом работы вызываем request_user_data, чтоб убедиться что нет других ошибок
1645
- await self.request_user()
1646
1774
  await self._enable_totp()
twitter/errors.py CHANGED
@@ -20,7 +20,6 @@ __all__ = [
20
20
  ]
21
21
 
22
22
 
23
- # TODO Возвращать аккаунт в теле исключения
24
23
  class TwitterException(Exception):
25
24
  pass
26
25
 
@@ -62,12 +61,17 @@ class HTTPException(TwitterException):
62
61
  self.api_codes: list[int] = []
63
62
  self.api_messages: list[str] = []
64
63
  self.detail: str | None = None
64
+ self.html: str | None = None
65
65
 
66
66
  # Если ответ — строка, то это html
67
67
  if isinstance(data, str):
68
- exception_message = (
69
- f"(response status: {response.status_code}) HTML Response:\n{data}"
70
- )
68
+ if not data:
69
+ exception_message = (
70
+ f"(response status: {response.status_code}) Empty response body."
71
+ )
72
+ else:
73
+ self.html = data
74
+ exception_message = f"(response status: {response.status_code}) HTML Response:\n{self.html}"
71
75
  if response.status_code == 429:
72
76
  exception_message = (
73
77
  f"(response status: {response.status_code}) Rate limit exceeded."
@@ -147,7 +151,9 @@ class BadAccount(TwitterException):
147
151
 
148
152
  class BadToken(BadAccount):
149
153
  def __init__(self, http_exception: "HTTPException", account: Account):
150
- exception_message = "Bad Twitter account's auth_token."
154
+ exception_message = (
155
+ "Bad Twitter account's auth_token. Relogin to get new token."
156
+ )
151
157
  super().__init__(http_exception, account, exception_message)
152
158
 
153
159
 
@@ -155,14 +161,14 @@ class Locked(BadAccount):
155
161
  def __init__(self, http_exception: "HTTPException", account: Account):
156
162
  exception_message = (
157
163
  f"Twitter account is locked."
158
- f" Set CapSolver API key (capsolver_api_key) to autounlock."
164
+ f" Set CapSolver API key (capsolver_api_key) to auto-unlock."
159
165
  )
160
166
  super().__init__(http_exception, account, exception_message)
161
167
 
162
168
 
163
169
  class ConsentLocked(BadAccount):
164
170
  def __init__(self, http_exception: "HTTPException", account: Account):
165
- exception_message = f"Twitter account is consent locked. Relogin to unlock."
171
+ exception_message = f"Twitter account is locked."
166
172
  super().__init__(http_exception, account, exception_message)
167
173
 
168
174
 
twitter/models.py CHANGED
@@ -1,50 +1,40 @@
1
1
  from typing import Optional
2
2
  from datetime import datetime, timedelta
3
3
 
4
- from pydantic import BaseModel
4
+ from pydantic import BaseModel, Field, field_validator
5
5
 
6
6
  from .utils import to_datetime, tweet_url
7
7
 
8
8
 
9
9
  class Image(BaseModel):
10
- type: str
11
- width: int
12
- height: int
10
+ type: str = Field(..., alias="image_type")
11
+ width: int = Field(..., alias="w")
12
+ height: int = Field(..., alias="h")
13
13
 
14
14
 
15
15
  class Media(BaseModel):
16
- id: int
16
+ id: int = Field(..., alias="media_id")
17
17
  image: Image
18
18
  size: int
19
- expires_at: datetime
19
+ expires_at: datetime = Field(..., alias="expires_after_secs")
20
+
21
+ @field_validator("expires_at", mode="before")
22
+ @classmethod
23
+ def set_expires_at(cls, v):
24
+ return datetime.now() + timedelta(seconds=v)
20
25
 
21
26
  def __str__(self):
22
27
  return str(self.id)
23
28
 
24
- @classmethod
25
- def from_raw_data(cls, data: dict):
26
- expires_at = datetime.now() + timedelta(seconds=data["expires_after_secs"])
27
- values = {
28
- "image": {
29
- "type": data["image"]["image_type"],
30
- "width": data["image"]["w"],
31
- "height": data["image"]["h"],
32
- },
33
- "size": data["size"],
34
- "id": data["media_id"],
35
- "expires_at": expires_at,
36
- }
37
- return cls(**values)
38
-
39
29
 
40
30
  class User(BaseModel):
41
31
  # fmt: off
42
32
  id: int | None = None
43
33
  username: str | None = None
44
- name: str | None = None
34
+ name: str | None = None # 50
45
35
  created_at: datetime | None = None
46
- description: str | None = None
47
- location: str | None = None
36
+ description: str | None = None # 160
37
+ location: str | None = None # 30
48
38
  followers_count: int | None = None
49
39
  friends_count: int | None = None
50
40
  raw_data: dict | None = None