marqetive-lib 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- marqetive/__init__.py +113 -0
- marqetive/core/__init__.py +5 -0
- marqetive/core/account_factory.py +212 -0
- marqetive/core/base_manager.py +303 -0
- marqetive/core/client.py +108 -0
- marqetive/core/progress.py +291 -0
- marqetive/core/registry.py +257 -0
- marqetive/platforms/__init__.py +55 -0
- marqetive/platforms/base.py +390 -0
- marqetive/platforms/exceptions.py +238 -0
- marqetive/platforms/instagram/__init__.py +7 -0
- marqetive/platforms/instagram/client.py +786 -0
- marqetive/platforms/instagram/exceptions.py +311 -0
- marqetive/platforms/instagram/factory.py +106 -0
- marqetive/platforms/instagram/manager.py +112 -0
- marqetive/platforms/instagram/media.py +669 -0
- marqetive/platforms/linkedin/__init__.py +7 -0
- marqetive/platforms/linkedin/client.py +733 -0
- marqetive/platforms/linkedin/exceptions.py +335 -0
- marqetive/platforms/linkedin/factory.py +130 -0
- marqetive/platforms/linkedin/manager.py +119 -0
- marqetive/platforms/linkedin/media.py +549 -0
- marqetive/platforms/models.py +345 -0
- marqetive/platforms/tiktok/__init__.py +0 -0
- marqetive/platforms/twitter/__init__.py +7 -0
- marqetive/platforms/twitter/client.py +647 -0
- marqetive/platforms/twitter/exceptions.py +311 -0
- marqetive/platforms/twitter/factory.py +151 -0
- marqetive/platforms/twitter/manager.py +121 -0
- marqetive/platforms/twitter/media.py +779 -0
- marqetive/platforms/twitter/threads.py +442 -0
- marqetive/py.typed +0 -0
- marqetive/registry_init.py +66 -0
- marqetive/utils/__init__.py +45 -0
- marqetive/utils/file_handlers.py +438 -0
- marqetive/utils/helpers.py +99 -0
- marqetive/utils/media.py +399 -0
- marqetive/utils/oauth.py +265 -0
- marqetive/utils/retry.py +239 -0
- marqetive/utils/token_validator.py +240 -0
- marqetive_lib-0.1.0.dist-info/METADATA +261 -0
- marqetive_lib-0.1.0.dist-info/RECORD +43 -0
- marqetive_lib-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"""Twitter/X API v2 client implementation.
|
|
2
|
+
|
|
3
|
+
This module provides a concrete implementation of the SocialMediaPlatform
|
|
4
|
+
ABC for Twitter (X), using the Twitter API v2 via tweepy.
|
|
5
|
+
|
|
6
|
+
API Documentation: https://developer.x.com/en/docs/twitter-api
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import tweepy
|
|
14
|
+
from pydantic import HttpUrl
|
|
15
|
+
|
|
16
|
+
from marqetive.platforms.base import SocialMediaPlatform
|
|
17
|
+
from marqetive.platforms.exceptions import (
|
|
18
|
+
MediaUploadError,
|
|
19
|
+
PlatformAuthError,
|
|
20
|
+
PlatformError,
|
|
21
|
+
PostNotFoundError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
ValidationError,
|
|
24
|
+
)
|
|
25
|
+
from marqetive.platforms.models import (
|
|
26
|
+
AuthCredentials,
|
|
27
|
+
Comment,
|
|
28
|
+
CommentStatus,
|
|
29
|
+
MediaAttachment,
|
|
30
|
+
MediaType,
|
|
31
|
+
Post,
|
|
32
|
+
PostCreateRequest,
|
|
33
|
+
PostStatus,
|
|
34
|
+
PostUpdateRequest,
|
|
35
|
+
)
|
|
36
|
+
from marqetive.platforms.twitter.media import TwitterMediaManager
|
|
37
|
+
from marqetive.platforms.twitter.threads import (
|
|
38
|
+
ThreadResult,
|
|
39
|
+
TweetData,
|
|
40
|
+
TwitterThreadManager,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TwitterClient(SocialMediaPlatform):
|
|
45
|
+
"""Twitter/X API v2 client.
|
|
46
|
+
|
|
47
|
+
This client implements the SocialMediaPlatform interface for Twitter (X),
|
|
48
|
+
using the Twitter API v2. It supports tweets, replies, and media uploads.
|
|
49
|
+
|
|
50
|
+
Note:
|
|
51
|
+
- Requires Twitter Developer account and app credentials
|
|
52
|
+
- Requires OAuth 2.0 or OAuth 1.0a authentication
|
|
53
|
+
- Rate limits vary by endpoint and authentication method
|
|
54
|
+
- Leverages tweepy library for API interactions
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
>>> credentials = AuthCredentials(
|
|
58
|
+
... platform="twitter",
|
|
59
|
+
... access_token="your_access_token",
|
|
60
|
+
... additional_data={
|
|
61
|
+
... "api_key": "your_api_key",
|
|
62
|
+
... "api_secret": "your_api_secret",
|
|
63
|
+
... "access_secret": "your_access_secret"
|
|
64
|
+
... }
|
|
65
|
+
... )
|
|
66
|
+
>>> async with TwitterClient(credentials) as client:
|
|
67
|
+
... request = PostCreateRequest(content="Hello Twitter!")
|
|
68
|
+
... tweet = await client.create_post(request)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
credentials: AuthCredentials,
|
|
74
|
+
timeout: float = 30.0,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Initialize Twitter client.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
credentials: Twitter authentication credentials
|
|
80
|
+
timeout: Request timeout in seconds
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
PlatformAuthError: If credentials are invalid
|
|
84
|
+
"""
|
|
85
|
+
base_url = "https://api.x.com/2"
|
|
86
|
+
super().__init__(
|
|
87
|
+
platform_name="twitter",
|
|
88
|
+
credentials=credentials,
|
|
89
|
+
base_url=base_url,
|
|
90
|
+
timeout=timeout,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Initialize tweepy client
|
|
94
|
+
self._tweepy_client: tweepy.Client | None = None
|
|
95
|
+
self._setup_tweepy_client()
|
|
96
|
+
|
|
97
|
+
# Initialize media and thread managers
|
|
98
|
+
self._media_manager: TwitterMediaManager | None = None
|
|
99
|
+
self._thread_manager: TwitterThreadManager | None = None
|
|
100
|
+
|
|
101
|
+
def _setup_tweepy_client(self) -> None:
|
|
102
|
+
"""Setup tweepy Client with credentials."""
|
|
103
|
+
# Extract credentials from additional_data
|
|
104
|
+
|
|
105
|
+
self._tweepy_client = tweepy.Client(
|
|
106
|
+
bearer_token=self.credentials.access_token,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def _setup_managers(self) -> None:
|
|
110
|
+
"""Setup media and thread managers."""
|
|
111
|
+
if not self._tweepy_client:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
# Initialize media manager
|
|
115
|
+
self._media_manager = TwitterMediaManager(
|
|
116
|
+
bearer_token=self.credentials.access_token,
|
|
117
|
+
timeout=self.timeout,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Initialize thread manager
|
|
121
|
+
self._thread_manager = TwitterThreadManager(self._tweepy_client)
|
|
122
|
+
|
|
123
|
+
async def _cleanup_managers(self) -> None:
|
|
124
|
+
"""Cleanup media and thread managers."""
|
|
125
|
+
if self._media_manager:
|
|
126
|
+
await self._media_manager.__aexit__(None, None, None)
|
|
127
|
+
self._media_manager = None
|
|
128
|
+
self._thread_manager = None
|
|
129
|
+
|
|
130
|
+
async def __aenter__(self) -> "TwitterClient":
|
|
131
|
+
"""Async context manager entry."""
|
|
132
|
+
await super().__aenter__()
|
|
133
|
+
await self._setup_managers()
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
137
|
+
"""Async context manager exit."""
|
|
138
|
+
await self._cleanup_managers()
|
|
139
|
+
await super().__aexit__(exc_type, exc_val, exc_tb)
|
|
140
|
+
|
|
141
|
+
# ==================== Thread Methods ====================
|
|
142
|
+
|
|
143
|
+
async def create_thread(
|
|
144
|
+
self,
|
|
145
|
+
tweets: list[TweetData],
|
|
146
|
+
*,
|
|
147
|
+
auto_number: bool = False,
|
|
148
|
+
number_format: str = "{index}/{total}",
|
|
149
|
+
) -> ThreadResult:
|
|
150
|
+
"""Create a Twitter thread from multiple tweets."""
|
|
151
|
+
if not self._thread_manager:
|
|
152
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
153
|
+
|
|
154
|
+
return await self._thread_manager.create_thread(
|
|
155
|
+
tweets,
|
|
156
|
+
auto_number=auto_number,
|
|
157
|
+
number_format=number_format,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
async def delete_thread(
|
|
161
|
+
self,
|
|
162
|
+
thread_id: str,
|
|
163
|
+
tweet_ids: list[str],
|
|
164
|
+
*,
|
|
165
|
+
continue_on_error: bool = True,
|
|
166
|
+
) -> dict[str, bool]:
|
|
167
|
+
"""Delete all tweets in a thread."""
|
|
168
|
+
if not self._thread_manager:
|
|
169
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
170
|
+
|
|
171
|
+
return await self._thread_manager.delete_thread(
|
|
172
|
+
thread_id,
|
|
173
|
+
tweet_ids,
|
|
174
|
+
continue_on_error=continue_on_error,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# ==================== Authentication Methods ====================
|
|
178
|
+
|
|
179
|
+
async def authenticate(self) -> AuthCredentials:
|
|
180
|
+
"""Perform Twitter authentication flow.
|
|
181
|
+
|
|
182
|
+
Note: This method assumes you already have valid OAuth credentials.
|
|
183
|
+
For the full OAuth flow, use Twitter's OAuth implementation with tweepy.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Current credentials if valid.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
PlatformAuthError: If authentication fails.
|
|
190
|
+
"""
|
|
191
|
+
if await self.is_authenticated():
|
|
192
|
+
return self.credentials
|
|
193
|
+
|
|
194
|
+
raise PlatformAuthError(
|
|
195
|
+
"Invalid or expired credentials. Please re-authenticate via Twitter OAuth.",
|
|
196
|
+
platform=self.platform_name,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
async def refresh_token(self) -> AuthCredentials:
|
|
200
|
+
"""Refresh Twitter access token.
|
|
201
|
+
|
|
202
|
+
Note: Twitter OAuth 1.0a tokens don't expire, so this method
|
|
203
|
+
returns the current credentials. For OAuth 2.0, implement
|
|
204
|
+
token refresh logic.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Current credentials.
|
|
208
|
+
"""
|
|
209
|
+
# OAuth 1.0a tokens don't expire
|
|
210
|
+
return self.credentials
|
|
211
|
+
|
|
212
|
+
async def is_authenticated(self) -> bool:
|
|
213
|
+
"""Check if Twitter credentials are valid.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
True if authenticated and token is valid.
|
|
217
|
+
"""
|
|
218
|
+
if not self._tweepy_client:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
# Verify credentials by fetching authenticated user
|
|
223
|
+
self._tweepy_client.get_me(user_auth=False)
|
|
224
|
+
return True
|
|
225
|
+
except tweepy.TweepyException:
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
# ==================== Post CRUD Methods ====================
|
|
229
|
+
|
|
230
|
+
async def create_post(self, request: PostCreateRequest) -> Post:
|
|
231
|
+
"""Create and publish a tweet.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
request: Post creation request.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Created Post object.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
ValidationError: If request is invalid.
|
|
241
|
+
MediaUploadError: If media upload fails.
|
|
242
|
+
"""
|
|
243
|
+
if not self._tweepy_client:
|
|
244
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
245
|
+
|
|
246
|
+
if not request.content and not request.media_urls:
|
|
247
|
+
raise ValidationError(
|
|
248
|
+
"Tweet must contain content or media",
|
|
249
|
+
platform=self.platform_name,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Validate tweet length (280 characters)
|
|
253
|
+
if request.content and len(request.content) > 280:
|
|
254
|
+
raise ValidationError(
|
|
255
|
+
f"Tweet exceeds 280 characters ({len(request.content)} characters)",
|
|
256
|
+
platform=self.platform_name,
|
|
257
|
+
field="content",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
media_ids = []
|
|
262
|
+
|
|
263
|
+
# Upload media if provided
|
|
264
|
+
if request.media_urls:
|
|
265
|
+
for media_url in request.media_urls:
|
|
266
|
+
# Download media from URL and upload to Twitter
|
|
267
|
+
# Note: This is a simplified implementation
|
|
268
|
+
# In production, handle media download properly
|
|
269
|
+
media_attachment = await self.upload_media(media_url, "image")
|
|
270
|
+
media_ids.append(media_attachment.media_id)
|
|
271
|
+
|
|
272
|
+
# Create tweet
|
|
273
|
+
tweet_params: dict[str, Any] = {}
|
|
274
|
+
if request.content:
|
|
275
|
+
tweet_params["text"] = request.content
|
|
276
|
+
if media_ids:
|
|
277
|
+
tweet_params["media_ids"] = media_ids
|
|
278
|
+
|
|
279
|
+
response = self._tweepy_client.create_tweet(**tweet_params)
|
|
280
|
+
tweet_id = response.data["id"] # type: ignore[index]
|
|
281
|
+
|
|
282
|
+
# Fetch full tweet details
|
|
283
|
+
return await self.get_post(tweet_id)
|
|
284
|
+
|
|
285
|
+
except tweepy.TweepyException as e:
|
|
286
|
+
if "429" in str(e):
|
|
287
|
+
raise RateLimitError(
|
|
288
|
+
"Twitter rate limit exceeded",
|
|
289
|
+
platform=self.platform_name,
|
|
290
|
+
status_code=429,
|
|
291
|
+
) from e
|
|
292
|
+
raise PlatformError(
|
|
293
|
+
f"Failed to create tweet: {e}",
|
|
294
|
+
platform=self.platform_name,
|
|
295
|
+
) from e
|
|
296
|
+
|
|
297
|
+
async def get_post(self, post_id: str) -> Post:
|
|
298
|
+
"""Retrieve a tweet by ID.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
post_id: Twitter tweet ID.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Post object with current data.
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
PostNotFoundError: If tweet doesn't exist.
|
|
308
|
+
"""
|
|
309
|
+
if not self._tweepy_client:
|
|
310
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
response = self._tweepy_client.get_tweet(
|
|
314
|
+
post_id,
|
|
315
|
+
tweet_fields=[
|
|
316
|
+
"created_at",
|
|
317
|
+
"public_metrics",
|
|
318
|
+
"attachments",
|
|
319
|
+
"author_id",
|
|
320
|
+
],
|
|
321
|
+
expansions=["attachments.media_keys"],
|
|
322
|
+
media_fields=["url", "type", "width", "height"],
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if not response.data: # type: ignore[attr-defined]
|
|
326
|
+
raise PostNotFoundError(
|
|
327
|
+
post_id=post_id,
|
|
328
|
+
platform=self.platform_name,
|
|
329
|
+
status_code=404,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return self._parse_tweet(response.data, response.includes) # type: ignore[attr-defined]
|
|
333
|
+
|
|
334
|
+
except tweepy.errors.NotFound as e: # type: ignore[attr-defined]
|
|
335
|
+
raise PostNotFoundError(
|
|
336
|
+
post_id=post_id,
|
|
337
|
+
platform=self.platform_name,
|
|
338
|
+
status_code=404,
|
|
339
|
+
) from e
|
|
340
|
+
except tweepy.TweepyException as e:
|
|
341
|
+
raise PlatformError(
|
|
342
|
+
f"Failed to fetch tweet: {e}",
|
|
343
|
+
platform=self.platform_name,
|
|
344
|
+
) from e
|
|
345
|
+
|
|
346
|
+
async def update_post(
|
|
347
|
+
self,
|
|
348
|
+
post_id: str,
|
|
349
|
+
request: PostUpdateRequest, # noqa: ARG002
|
|
350
|
+
) -> Post:
|
|
351
|
+
"""Update a tweet.
|
|
352
|
+
|
|
353
|
+
Note: Twitter does not support editing tweets (except for Twitter Blue
|
|
354
|
+
subscribers with limited edit window). This method will raise an error.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
post_id: Twitter tweet ID.
|
|
358
|
+
request: Post update request.
|
|
359
|
+
|
|
360
|
+
Raises:
|
|
361
|
+
PlatformError: Twitter doesn't support tweet editing for most users.
|
|
362
|
+
"""
|
|
363
|
+
raise PlatformError(
|
|
364
|
+
"Twitter does not support editing tweets for most accounts",
|
|
365
|
+
platform=self.platform_name,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
async def delete_post(self, post_id: str) -> bool:
|
|
369
|
+
"""Delete a tweet.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
post_id: Twitter tweet ID.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
True if deletion was successful.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
PostNotFoundError: If tweet doesn't exist.
|
|
379
|
+
"""
|
|
380
|
+
if not self._tweepy_client:
|
|
381
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
self._tweepy_client.delete_tweet(post_id)
|
|
385
|
+
return True
|
|
386
|
+
|
|
387
|
+
except tweepy.errors.NotFound as e: # type: ignore[attr-defined]
|
|
388
|
+
raise PostNotFoundError(
|
|
389
|
+
post_id=post_id,
|
|
390
|
+
platform=self.platform_name,
|
|
391
|
+
status_code=404,
|
|
392
|
+
) from e
|
|
393
|
+
except tweepy.TweepyException as e:
|
|
394
|
+
raise PlatformError(
|
|
395
|
+
f"Failed to delete tweet: {e}",
|
|
396
|
+
platform=self.platform_name,
|
|
397
|
+
) from e
|
|
398
|
+
|
|
399
|
+
# ==================== Comment Methods ====================
|
|
400
|
+
|
|
401
|
+
async def get_comments(
|
|
402
|
+
self,
|
|
403
|
+
post_id: str,
|
|
404
|
+
limit: int = 50,
|
|
405
|
+
offset: int = 0, # noqa: ARG002
|
|
406
|
+
) -> list[Comment]:
|
|
407
|
+
"""Retrieve replies to a tweet.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
post_id: Twitter tweet ID.
|
|
411
|
+
limit: Maximum number of replies to retrieve.
|
|
412
|
+
offset: Number of replies to skip.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
List of Comment objects (replies).
|
|
416
|
+
"""
|
|
417
|
+
if not self.api_client:
|
|
418
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
# Use Twitter API v2 search to find replies
|
|
422
|
+
response = await self.api_client.get(
|
|
423
|
+
"/tweets/search/recent",
|
|
424
|
+
params={
|
|
425
|
+
"query": f"conversation_id:{post_id}",
|
|
426
|
+
"max_results": min(limit, 100),
|
|
427
|
+
"tweet.fields": "created_at,author_id,public_metrics",
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
comments = []
|
|
432
|
+
for tweet_data in response.data.get("data", []):
|
|
433
|
+
comments.append(self._parse_reply(tweet_data, post_id))
|
|
434
|
+
|
|
435
|
+
return comments
|
|
436
|
+
|
|
437
|
+
except httpx.HTTPError as e:
|
|
438
|
+
raise PlatformError(
|
|
439
|
+
f"Failed to fetch replies: {e}",
|
|
440
|
+
platform=self.platform_name,
|
|
441
|
+
) from e
|
|
442
|
+
|
|
443
|
+
async def create_comment(self, post_id: str, content: str) -> Comment:
|
|
444
|
+
"""Reply to a tweet.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
post_id: Twitter tweet ID.
|
|
448
|
+
content: Text content of the reply.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Created Comment object (reply).
|
|
452
|
+
|
|
453
|
+
Raises:
|
|
454
|
+
ValidationError: If reply content is invalid.
|
|
455
|
+
"""
|
|
456
|
+
if not self._tweepy_client:
|
|
457
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
458
|
+
|
|
459
|
+
if not content or len(content) == 0:
|
|
460
|
+
raise ValidationError(
|
|
461
|
+
"Reply content cannot be empty",
|
|
462
|
+
platform=self.platform_name,
|
|
463
|
+
field="content",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
if len(content) > 280:
|
|
467
|
+
raise ValidationError(
|
|
468
|
+
f"Reply exceeds 280 characters ({len(content)} characters)",
|
|
469
|
+
platform=self.platform_name,
|
|
470
|
+
field="content",
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
response = self._tweepy_client.create_tweet(
|
|
475
|
+
text=content,
|
|
476
|
+
in_reply_to_tweet_id=post_id,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
reply_id = response.data["id"] # type: ignore[index]
|
|
480
|
+
|
|
481
|
+
# Fetch full reply details
|
|
482
|
+
reply_response = self._tweepy_client.get_tweet(
|
|
483
|
+
reply_id,
|
|
484
|
+
tweet_fields=["created_at", "author_id", "public_metrics"],
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
return self._parse_reply(reply_response.data, post_id) # type: ignore[attr-defined]
|
|
488
|
+
|
|
489
|
+
except tweepy.TweepyException as e:
|
|
490
|
+
raise PlatformError(
|
|
491
|
+
f"Failed to create reply: {e}",
|
|
492
|
+
platform=self.platform_name,
|
|
493
|
+
) from e
|
|
494
|
+
|
|
495
|
+
async def delete_comment(self, comment_id: str) -> bool:
|
|
496
|
+
"""Delete a reply (tweet).
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
comment_id: Twitter tweet ID of the reply.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
True if deletion was successful.
|
|
503
|
+
"""
|
|
504
|
+
# Replies are just tweets, so we can use delete_post
|
|
505
|
+
return await self.delete_post(comment_id)
|
|
506
|
+
|
|
507
|
+
# ==================== Media Methods ====================
|
|
508
|
+
|
|
509
|
+
async def upload_media(
|
|
510
|
+
self,
|
|
511
|
+
media_url: str,
|
|
512
|
+
media_type: str,
|
|
513
|
+
alt_text: str | None = None,
|
|
514
|
+
) -> MediaAttachment:
|
|
515
|
+
"""Upload media to Twitter.
|
|
516
|
+
|
|
517
|
+
Supports both local files and URLs. Automatically handles:
|
|
518
|
+
- Chunked upload for large files (videos, GIFs)
|
|
519
|
+
- Simple upload for images
|
|
520
|
+
- Progress tracking
|
|
521
|
+
- Alt text for accessibility
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
media_url: URL or file path of the media.
|
|
525
|
+
media_type: Type of media (image or video).
|
|
526
|
+
alt_text: Alternative text for accessibility.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
MediaAttachment object with media ID.
|
|
530
|
+
|
|
531
|
+
Raises:
|
|
532
|
+
MediaUploadError: If upload fails.
|
|
533
|
+
RuntimeError: If client not used as context manager.
|
|
534
|
+
|
|
535
|
+
Example:
|
|
536
|
+
>>> async with TwitterClient(credentials) as client:
|
|
537
|
+
... media = await client.upload_media(
|
|
538
|
+
... "/path/to/image.jpg",
|
|
539
|
+
... "image",
|
|
540
|
+
... alt_text="A beautiful sunset"
|
|
541
|
+
... )
|
|
542
|
+
... print(f"Uploaded: {media.media_id}")
|
|
543
|
+
"""
|
|
544
|
+
if not self._media_manager:
|
|
545
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
# Upload media using media manager
|
|
549
|
+
result = await self._media_manager.upload_media(
|
|
550
|
+
media_url,
|
|
551
|
+
alt_text=alt_text,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# Convert to MediaAttachment
|
|
555
|
+
return MediaAttachment(
|
|
556
|
+
media_id=result.media_id,
|
|
557
|
+
media_type=(
|
|
558
|
+
MediaType.IMAGE
|
|
559
|
+
if media_type.lower() in ("image", "photo")
|
|
560
|
+
else MediaType.VIDEO
|
|
561
|
+
),
|
|
562
|
+
url=HttpUrl(media_url),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
except Exception as e:
|
|
566
|
+
raise MediaUploadError(
|
|
567
|
+
f"Failed to upload media: {e}",
|
|
568
|
+
platform=self.platform_name,
|
|
569
|
+
media_type=media_type,
|
|
570
|
+
) from e
|
|
571
|
+
|
|
572
|
+
# ==================== Helper Methods ====================
|
|
573
|
+
|
|
574
|
+
def _parse_tweet(
|
|
575
|
+
self,
|
|
576
|
+
tweet: tweepy.Tweet,
|
|
577
|
+
includes: dict[str, Any] | None = None,
|
|
578
|
+
) -> Post:
|
|
579
|
+
"""Parse Twitter API response into Post model.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
tweet: Tweepy Tweet object.
|
|
583
|
+
includes: Additional data from API response.
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
Post object.
|
|
587
|
+
"""
|
|
588
|
+
media = []
|
|
589
|
+
if includes and "media" in includes:
|
|
590
|
+
for media_item in includes["media"]:
|
|
591
|
+
media.append(
|
|
592
|
+
MediaAttachment(
|
|
593
|
+
media_id=media_item.get("media_key", ""),
|
|
594
|
+
media_type=(
|
|
595
|
+
MediaType.IMAGE
|
|
596
|
+
if media_item.get("type") == "photo"
|
|
597
|
+
else MediaType.VIDEO
|
|
598
|
+
),
|
|
599
|
+
url=media_item.get("url", ""),
|
|
600
|
+
width=media_item.get("width"),
|
|
601
|
+
height=media_item.get("height"),
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
metrics = tweet.public_metrics or {}
|
|
606
|
+
|
|
607
|
+
return Post(
|
|
608
|
+
post_id=str(tweet.id),
|
|
609
|
+
platform=self.platform_name,
|
|
610
|
+
content=tweet.text,
|
|
611
|
+
media=media,
|
|
612
|
+
status=PostStatus.PUBLISHED,
|
|
613
|
+
created_at=tweet.created_at or datetime.now(),
|
|
614
|
+
author_id=str(tweet.author_id) if tweet.author_id else None,
|
|
615
|
+
likes_count=metrics.get("like_count", 0),
|
|
616
|
+
comments_count=metrics.get("reply_count", 0),
|
|
617
|
+
shares_count=metrics.get("retweet_count", 0),
|
|
618
|
+
views_count=metrics.get("impression_count", 0),
|
|
619
|
+
raw_data=tweet.data,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def _parse_reply(self, tweet_data: dict[str, Any], post_id: str) -> Comment:
|
|
623
|
+
"""Parse Twitter API response into Comment model.
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
tweet_data: Raw tweet data.
|
|
627
|
+
post_id: ID of the original tweet.
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
Comment object.
|
|
631
|
+
"""
|
|
632
|
+
metrics = tweet_data.get("public_metrics", {})
|
|
633
|
+
|
|
634
|
+
return Comment(
|
|
635
|
+
comment_id=tweet_data["id"],
|
|
636
|
+
post_id=post_id,
|
|
637
|
+
platform=self.platform_name,
|
|
638
|
+
content=tweet_data["text"],
|
|
639
|
+
author_id=tweet_data.get("author_id", ""),
|
|
640
|
+
created_at=datetime.fromisoformat(
|
|
641
|
+
tweet_data["created_at"].replace("Z", "+00:00")
|
|
642
|
+
),
|
|
643
|
+
likes_count=metrics.get("like_count", 0),
|
|
644
|
+
replies_count=metrics.get("reply_count", 0),
|
|
645
|
+
status=CommentStatus.VISIBLE,
|
|
646
|
+
raw_data=tweet_data,
|
|
647
|
+
)
|