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.
Files changed (43) hide show
  1. marqetive/__init__.py +113 -0
  2. marqetive/core/__init__.py +5 -0
  3. marqetive/core/account_factory.py +212 -0
  4. marqetive/core/base_manager.py +303 -0
  5. marqetive/core/client.py +108 -0
  6. marqetive/core/progress.py +291 -0
  7. marqetive/core/registry.py +257 -0
  8. marqetive/platforms/__init__.py +55 -0
  9. marqetive/platforms/base.py +390 -0
  10. marqetive/platforms/exceptions.py +238 -0
  11. marqetive/platforms/instagram/__init__.py +7 -0
  12. marqetive/platforms/instagram/client.py +786 -0
  13. marqetive/platforms/instagram/exceptions.py +311 -0
  14. marqetive/platforms/instagram/factory.py +106 -0
  15. marqetive/platforms/instagram/manager.py +112 -0
  16. marqetive/platforms/instagram/media.py +669 -0
  17. marqetive/platforms/linkedin/__init__.py +7 -0
  18. marqetive/platforms/linkedin/client.py +733 -0
  19. marqetive/platforms/linkedin/exceptions.py +335 -0
  20. marqetive/platforms/linkedin/factory.py +130 -0
  21. marqetive/platforms/linkedin/manager.py +119 -0
  22. marqetive/platforms/linkedin/media.py +549 -0
  23. marqetive/platforms/models.py +345 -0
  24. marqetive/platforms/tiktok/__init__.py +0 -0
  25. marqetive/platforms/twitter/__init__.py +7 -0
  26. marqetive/platforms/twitter/client.py +647 -0
  27. marqetive/platforms/twitter/exceptions.py +311 -0
  28. marqetive/platforms/twitter/factory.py +151 -0
  29. marqetive/platforms/twitter/manager.py +121 -0
  30. marqetive/platforms/twitter/media.py +779 -0
  31. marqetive/platforms/twitter/threads.py +442 -0
  32. marqetive/py.typed +0 -0
  33. marqetive/registry_init.py +66 -0
  34. marqetive/utils/__init__.py +45 -0
  35. marqetive/utils/file_handlers.py +438 -0
  36. marqetive/utils/helpers.py +99 -0
  37. marqetive/utils/media.py +399 -0
  38. marqetive/utils/oauth.py +265 -0
  39. marqetive/utils/retry.py +239 -0
  40. marqetive/utils/token_validator.py +240 -0
  41. marqetive_lib-0.1.0.dist-info/METADATA +261 -0
  42. marqetive_lib-0.1.0.dist-info/RECORD +43 -0
  43. 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
+ )