marqetive-lib 0.1.20__tar.gz → 0.1.22__tar.gz

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 (40) hide show
  1. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/PKG-INFO +1 -1
  2. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/pyproject.toml +1 -1
  3. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/__init__.py +9 -0
  4. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/core/base.py +75 -0
  5. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/core/exceptions.py +30 -1
  6. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/core/models.py +135 -1
  7. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/factory.py +37 -5
  8. marqetive_lib-0.1.22/src/marqetive/platforms/twitter/__init__.py +15 -0
  9. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/twitter/client.py +346 -2
  10. marqetive_lib-0.1.22/src/marqetive/platforms/twitter/models.py +130 -0
  11. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/oauth.py +22 -0
  12. marqetive_lib-0.1.20/src/marqetive/platforms/twitter/__init__.py +0 -6
  13. marqetive_lib-0.1.20/src/marqetive/platforms/twitter/models.py +0 -58
  14. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/README.md +0 -0
  15. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/core/__init__.py +0 -0
  16. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/core/client.py +0 -0
  17. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/__init__.py +0 -0
  18. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/instagram/__init__.py +0 -0
  19. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/instagram/client.py +0 -0
  20. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/instagram/exceptions.py +0 -0
  21. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/instagram/media.py +0 -0
  22. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/instagram/models.py +0 -0
  23. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/linkedin/__init__.py +0 -0
  24. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/linkedin/client.py +0 -0
  25. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/linkedin/exceptions.py +0 -0
  26. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/linkedin/media.py +0 -0
  27. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/linkedin/models.py +0 -0
  28. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/tiktok/__init__.py +0 -0
  29. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/tiktok/client.py +0 -0
  30. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/tiktok/exceptions.py +0 -0
  31. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/tiktok/media.py +0 -0
  32. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/tiktok/models.py +0 -0
  33. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/twitter/exceptions.py +0 -0
  34. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/twitter/media.py +0 -0
  35. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/py.typed +0 -0
  36. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/__init__.py +0 -0
  37. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/file_handlers.py +0 -0
  38. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/helpers.py +0 -0
  39. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/media.py +0 -0
  40. {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: marqetive-lib
3
- Version: 0.1.20
3
+ Version: 0.1.22
4
4
  Summary: Modern Python utilities for web APIs
5
5
  Keywords: api,utilities,web,http,marqetive
6
6
  Requires-Python: >=3.12
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "marqetive-lib"
7
- version = "0.1.20"
7
+ version = "0.1.22"
8
8
  description = "Modern Python utilities for web APIs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -56,6 +56,10 @@ from marqetive.core.models import (
56
56
  AuthCredentials,
57
57
  Comment,
58
58
  CommentStatus,
59
+ Conversation,
60
+ DirectMessage,
61
+ DMCreateRequest,
62
+ GroupDMCreateRequest,
59
63
  MediaAttachment,
60
64
  MediaType,
61
65
  Post,
@@ -102,6 +106,11 @@ __all__ = [
102
106
  # Base Request Models
103
107
  "PostCreateRequest",
104
108
  "PostUpdateRequest",
109
+ # Direct Message Models
110
+ "DMCreateRequest",
111
+ "GroupDMCreateRequest",
112
+ "DirectMessage",
113
+ "Conversation",
105
114
  # Exceptions
106
115
  "PlatformError",
107
116
  "PlatformAuthError",
@@ -20,6 +20,10 @@ from marqetive.core.exceptions import (
20
20
  from marqetive.core.models import (
21
21
  AuthCredentials,
22
22
  Comment,
23
+ Conversation,
24
+ DirectMessage,
25
+ DMCreateRequest,
26
+ GroupDMCreateRequest,
23
27
  MediaAttachment,
24
28
  PlatformResponse,
25
29
  Post,
@@ -418,6 +422,77 @@ class SocialMediaPlatform(ABC):
418
422
  f"{self.platform_name} does not support thread creation"
419
423
  )
420
424
 
425
+ # ==================== Direct Message Methods ====================
426
+
427
+ async def send_direct_message(
428
+ self,
429
+ request: DMCreateRequest,
430
+ ) -> DirectMessage:
431
+ """Send a direct message to a user or conversation.
432
+
433
+ Not all platforms support direct messaging. The default implementation
434
+ raises NotImplementedError. Platforms that support DMs (like Twitter)
435
+ should override this method.
436
+
437
+ Args:
438
+ request: DM creation request with text and recipient info.
439
+ Must provide either participant_id (for new 1-to-1 DM)
440
+ or conversation_id (for existing conversation).
441
+
442
+ Returns:
443
+ DirectMessage object with message ID and metadata.
444
+
445
+ Raises:
446
+ NotImplementedError: If platform doesn't support DMs.
447
+ ValidationError: If request is invalid (missing recipient, text too long).
448
+ PlatformAuthError: If not authenticated or unauthorized.
449
+ MediaUploadError: If media attachment fails.
450
+
451
+ Example:
452
+ >>> request = DMCreateRequest(
453
+ ... text="Hello!",
454
+ ... participant_id="user123"
455
+ ... )
456
+ >>> dm = await client.send_direct_message(request)
457
+ >>> print(f"Sent: {dm.message_id}")
458
+ """
459
+ raise NotImplementedError(
460
+ f"{self.platform_name} does not support direct messages"
461
+ )
462
+
463
+ async def create_group_conversation(
464
+ self,
465
+ request: GroupDMCreateRequest,
466
+ ) -> Conversation:
467
+ """Create a group DM conversation with an initial message.
468
+
469
+ Not all platforms support group DMs. The default implementation
470
+ raises NotImplementedError. Platforms that support group DMs
471
+ should override this method.
472
+
473
+ Args:
474
+ request: Group DM request with participant IDs and initial message.
475
+
476
+ Returns:
477
+ Conversation object with conversation ID and participant info.
478
+
479
+ Raises:
480
+ NotImplementedError: If platform doesn't support group DMs.
481
+ ValidationError: If request is invalid (too few/many participants).
482
+ PlatformAuthError: If not authenticated or unauthorized.
483
+
484
+ Example:
485
+ >>> request = GroupDMCreateRequest(
486
+ ... participant_ids=["user1", "user2", "user3"],
487
+ ... text="Welcome to the group!"
488
+ ... )
489
+ >>> conversation = await client.create_group_conversation(request)
490
+ >>> print(f"Created: {conversation.conversation_id}")
491
+ """
492
+ raise NotImplementedError(
493
+ f"{self.platform_name} does not support group conversations"
494
+ )
495
+
421
496
  # ==================== Abstract Comment Methods ====================
422
497
 
423
498
  @abstractmethod
@@ -47,15 +47,44 @@ class PlatformAuthError(PlatformError):
47
47
  - OAuth flow encounters errors
48
48
  - Insufficient permissions for requested operation
49
49
 
50
+ Args:
51
+ message: Human-readable error message
52
+ platform: Name of the platform where error occurred
53
+ status_code: HTTP status code if applicable
54
+ requires_reconnection: If True, user must re-authenticate (token permanently invalid)
55
+
50
56
  Example:
51
57
  >>> raise PlatformAuthError(
52
58
  ... "Access token expired",
53
59
  ... platform="twitter",
54
60
  ... status_code=401
55
61
  ... )
62
+ >>> # For invalid refresh token requiring user re-auth:
63
+ >>> raise PlatformAuthError(
64
+ ... "Refresh token invalid",
65
+ ... platform="twitter",
66
+ ... status_code=400,
67
+ ... requires_reconnection=True
68
+ ... )
56
69
  """
57
70
 
58
- pass
71
+ def __init__(
72
+ self,
73
+ message: str,
74
+ platform: str | None = None,
75
+ status_code: int | None = None,
76
+ *,
77
+ requires_reconnection: bool = False,
78
+ ) -> None:
79
+ self.requires_reconnection = requires_reconnection
80
+ super().__init__(message, platform, status_code)
81
+
82
+ def _format_message(self) -> str:
83
+ """Format the error message with reconnection info."""
84
+ base_message = super()._format_message()
85
+ if self.requires_reconnection:
86
+ return f"{base_message} | Reconnection required"
87
+ return base_message
59
88
 
60
89
 
61
90
  class RateLimitError(PlatformError):
@@ -7,7 +7,7 @@ and type safety.
7
7
 
8
8
  from datetime import UTC, datetime, timedelta
9
9
  from enum import Enum, StrEnum
10
- from typing import Any
10
+ from typing import Any, Literal
11
11
 
12
12
  from pydantic import BaseModel, ConfigDict, Field, HttpUrl
13
13
 
@@ -451,3 +451,137 @@ class PostUpdateRequest(BaseModel):
451
451
  content: str | None = None
452
452
  tags: list[str] | None = None
453
453
  location: str | None = None
454
+
455
+
456
+ # ==================== Direct Message Models ====================
457
+
458
+
459
+ class DMCreateRequest(BaseModel):
460
+ """Base request model for sending a direct message.
461
+
462
+ Supports sending DMs to individuals or existing conversations.
463
+ For platform-specific features, use dedicated request models like TwitterDMRequest.
464
+
465
+ Attributes:
466
+ text: Message content (required, max length varies by platform)
467
+ participant_id: User ID for new 1-to-1 conversation
468
+ conversation_id: ID of existing conversation (1-1 or group)
469
+ media_url: URL to media file to attach (single attachment)
470
+ media_id: Pre-uploaded media ID
471
+ additional_data: Platform-specific data
472
+
473
+ Example:
474
+ >>> # Send 1-to-1 DM
475
+ >>> request = DMCreateRequest(
476
+ ... text="Hello!",
477
+ ... participant_id="1234567890"
478
+ ... )
479
+
480
+ >>> # Send to existing conversation
481
+ >>> request = DMCreateRequest(
482
+ ... text="Following up...",
483
+ ... conversation_id="dm_conv_123"
484
+ ... )
485
+ """
486
+
487
+ text: str
488
+ participant_id: str | None = None
489
+ conversation_id: str | None = None
490
+ media_url: str | None = None
491
+ media_id: str | None = None
492
+ additional_data: dict[str, Any] = Field(default_factory=dict)
493
+
494
+
495
+ class GroupDMCreateRequest(BaseModel):
496
+ """Request model for creating a group conversation with initial message.
497
+
498
+ Creates a new group DM conversation with multiple participants and sends
499
+ an initial message to the group.
500
+
501
+ Attributes:
502
+ participant_ids: List of user IDs to include in group (platform limits vary)
503
+ text: Initial message content (required)
504
+ media_url: Optional media URL to attach
505
+ media_id: Optional pre-uploaded media ID
506
+ additional_data: Platform-specific data
507
+
508
+ Example:
509
+ >>> request = GroupDMCreateRequest(
510
+ ... participant_ids=["user1_id", "user2_id", "user3_id"],
511
+ ... text="Welcome to the group!"
512
+ ... )
513
+ """
514
+
515
+ participant_ids: list[str] = Field(min_length=2)
516
+ text: str
517
+ media_url: str | None = None
518
+ media_id: str | None = None
519
+ additional_data: dict[str, Any] = Field(default_factory=dict)
520
+
521
+
522
+ class DirectMessage(BaseModel):
523
+ """Universal representation of a direct message.
524
+
525
+ Provides a consistent interface for DMs across platforms that support messaging.
526
+
527
+ Attributes:
528
+ message_id: Platform-specific message identifier
529
+ conversation_id: ID of the conversation this message belongs to
530
+ platform: Name of the platform (twitter, etc.)
531
+ text: Message content
532
+ sender_id: ID of the user who sent the message
533
+ created_at: Timestamp when message was sent
534
+ media: Optional media attachment
535
+ raw_data: Original platform-specific response data
536
+
537
+ Example:
538
+ >>> dm = DirectMessage(
539
+ ... message_id="dm_event_123",
540
+ ... conversation_id="dm_conv_456",
541
+ ... platform="twitter",
542
+ ... text="Hello!",
543
+ ... sender_id="user789",
544
+ ... created_at=datetime.now()
545
+ ... )
546
+ """
547
+
548
+ message_id: str
549
+ conversation_id: str
550
+ platform: str
551
+ text: str
552
+ sender_id: str | None = None
553
+ created_at: datetime
554
+ media: MediaAttachment | None = None
555
+ raw_data: dict[str, Any] = Field(default_factory=dict)
556
+
557
+
558
+ class Conversation(BaseModel):
559
+ """Universal representation of a DM conversation.
560
+
561
+ Represents a direct message conversation (1-to-1 or group) on platforms
562
+ that support messaging.
563
+
564
+ Attributes:
565
+ conversation_id: Platform-specific conversation identifier
566
+ platform: Name of the platform
567
+ conversation_type: Type of conversation ("group" or "one_to_one")
568
+ participant_ids: List of participant user IDs
569
+ created_at: Timestamp when conversation was created
570
+ raw_data: Original platform-specific response data
571
+
572
+ Example:
573
+ >>> conversation = Conversation(
574
+ ... conversation_id="dm_conv_123",
575
+ ... platform="twitter",
576
+ ... conversation_type="group",
577
+ ... participant_ids=["user1", "user2", "user3"],
578
+ ... created_at=datetime.now()
579
+ ... )
580
+ """
581
+
582
+ conversation_id: str
583
+ platform: str
584
+ conversation_type: Literal["group", "one_to_one"]
585
+ participant_ids: list[str] = Field(default_factory=list)
586
+ created_at: datetime
587
+ raw_data: dict[str, Any] = Field(default_factory=dict)
@@ -25,6 +25,24 @@ logger = logging.getLogger(__name__)
25
25
  # Supported platforms
26
26
  SUPPORTED_PLATFORMS = frozenset({"twitter", "linkedin", "instagram", "tiktok"})
27
27
 
28
+ # Platform aliases (alternative names that map to canonical names)
29
+ PLATFORM_ALIASES: dict[str, str] = {
30
+ "x": "twitter",
31
+ }
32
+
33
+
34
+ def normalize_platform(platform: str) -> str:
35
+ """Normalize platform name to canonical form.
36
+
37
+ Args:
38
+ platform: Platform name (may be an alias like 'x').
39
+
40
+ Returns:
41
+ Canonical platform name (e.g., 'twitter').
42
+ """
43
+ platform = platform.lower()
44
+ return PLATFORM_ALIASES.get(platform, platform)
45
+
28
46
 
29
47
  def _create_client(
30
48
  platform: str, credentials: "AuthCredentials"
@@ -172,7 +190,12 @@ class PlatformFactory:
172
190
  >>> async with client:
173
191
  ... post = await client.create_post(request)
174
192
  """
175
- platform = credentials.platform.lower()
193
+ # Normalize platform name (handle aliases like 'x' -> 'twitter')
194
+ platform = normalize_platform(credentials.platform)
195
+
196
+ # Update credentials with normalized platform name
197
+ if platform != credentials.platform.lower():
198
+ credentials.platform = platform
176
199
 
177
200
  # Validate platform-specific requirements
178
201
  self._validate_credentials(credentials)
@@ -180,8 +203,17 @@ class PlatformFactory:
180
203
  # Refresh token if needed
181
204
  if auto_refresh and credentials.needs_refresh():
182
205
  logger.info(f"Refreshing expired token for {platform}")
183
- credentials = await self._refresh_token(credentials)
184
- credentials.mark_valid()
206
+ try:
207
+ credentials = await self._refresh_token(credentials)
208
+ credentials.mark_valid()
209
+ except PlatformAuthError as e:
210
+ # If refresh failed and requires reconnection, update credentials status
211
+ if e.requires_reconnection:
212
+ credentials.mark_reconnection_required()
213
+ logger.warning(
214
+ f"Token refresh for {platform} requires user reconnection"
215
+ )
216
+ raise
185
217
 
186
218
  # Enrich credentials with API keys for Twitter (needed for media operations)
187
219
  if platform == "twitter":
@@ -337,9 +369,9 @@ class PlatformFactory:
337
369
  """Get the set of supported platform names.
338
370
 
339
371
  Returns:
340
- Frozenset of supported platform names.
372
+ Frozenset of supported platform names (includes aliases).
341
373
  """
342
- return SUPPORTED_PLATFORMS
374
+ return SUPPORTED_PLATFORMS | frozenset(PLATFORM_ALIASES.keys())
343
375
 
344
376
 
345
377
  async def get_client(
@@ -0,0 +1,15 @@
1
+ """Twitter/X platform integration."""
2
+
3
+ from marqetive.platforms.twitter.client import TwitterClient
4
+ from marqetive.platforms.twitter.models import (
5
+ TwitterDMRequest,
6
+ TwitterGroupDMRequest,
7
+ TwitterPostRequest,
8
+ )
9
+
10
+ __all__ = [
11
+ "TwitterClient",
12
+ "TwitterPostRequest",
13
+ "TwitterDMRequest",
14
+ "TwitterGroupDMRequest",
15
+ ]
@@ -26,6 +26,10 @@ from marqetive.core.models import (
26
26
  AuthCredentials,
27
27
  Comment,
28
28
  CommentStatus,
29
+ Conversation,
30
+ DirectMessage,
31
+ DMCreateRequest,
32
+ GroupDMCreateRequest,
29
33
  MediaAttachment,
30
34
  MediaType,
31
35
  Post,
@@ -34,8 +38,8 @@ from marqetive.core.models import (
34
38
  PostUpdateRequest,
35
39
  ProgressStatus,
36
40
  )
37
- from marqetive.platforms.twitter.media import TwitterMediaManager
38
- from marqetive.platforms.twitter.models import TwitterPostRequest
41
+ from marqetive.platforms.twitter.media import MediaCategory, TwitterMediaManager
42
+ from marqetive.platforms.twitter.models import TwitterDMRequest, TwitterPostRequest
39
43
 
40
44
 
41
45
  class TwitterClient(SocialMediaPlatform):
@@ -755,6 +759,346 @@ class TwitterClient(SocialMediaPlatform):
755
759
 
756
760
  return created_posts
757
761
 
762
+ # ==================== Direct Message Methods ====================
763
+
764
+ def _validate_dm_request(
765
+ self,
766
+ request: DMCreateRequest | TwitterDMRequest,
767
+ ) -> None:
768
+ """Validate DM request.
769
+
770
+ Twitter DM Requirements:
771
+ - Must have text content (required)
772
+ - Text max 10,000 characters
773
+ - Must have either participant_id OR conversation_id (not both)
774
+ - Max 1 media attachment
775
+
776
+ Args:
777
+ request: DM request to validate.
778
+
779
+ Raises:
780
+ ValidationError: If validation fails.
781
+ """
782
+ if not request.text:
783
+ raise ValidationError(
784
+ "DM text content is required",
785
+ platform=self.platform_name,
786
+ field="text",
787
+ )
788
+
789
+ if len(request.text) > 10000:
790
+ raise ValidationError(
791
+ f"DM text exceeds 10,000 characters ({len(request.text)} characters)",
792
+ platform=self.platform_name,
793
+ field="text",
794
+ )
795
+
796
+ # Must have exactly one of participant_id or conversation_id
797
+ has_participant = request.participant_id is not None
798
+ has_conversation = request.conversation_id is not None
799
+
800
+ if not has_participant and not has_conversation:
801
+ raise ValidationError(
802
+ "Either participant_id or conversation_id is required",
803
+ platform=self.platform_name,
804
+ field="participant_id",
805
+ )
806
+
807
+ if has_participant and has_conversation:
808
+ raise ValidationError(
809
+ "Cannot specify both participant_id and conversation_id",
810
+ platform=self.platform_name,
811
+ field="conversation_id",
812
+ )
813
+
814
+ async def _upload_dm_media(
815
+ self,
816
+ media_url: str,
817
+ alt_text: str | None = None,
818
+ ) -> MediaAttachment:
819
+ """Upload media for DM attachment.
820
+
821
+ Uses DM-specific media category for proper Twitter processing.
822
+
823
+ Args:
824
+ media_url: URL or file path of media.
825
+ alt_text: Optional alt text for accessibility.
826
+
827
+ Returns:
828
+ MediaAttachment with media ID.
829
+
830
+ Raises:
831
+ MediaUploadError: If upload fails.
832
+ """
833
+ if not self._media_manager:
834
+ raise RuntimeError("Client must be used as async context manager")
835
+
836
+ try:
837
+ # Detect media type and use appropriate DM category
838
+ from marqetive.utils.media import detect_mime_type
839
+
840
+ # Determine DM-specific media category based on file type
841
+ if media_url.startswith(("http://", "https://")):
842
+ # Default to DM_IMAGE for URLs (will be validated by Twitter)
843
+ category = MediaCategory.DM_IMAGE
844
+ else:
845
+ mime_type = detect_mime_type(media_url)
846
+ if "video" in mime_type:
847
+ category = MediaCategory.DM_VIDEO
848
+ elif "gif" in mime_type:
849
+ category = MediaCategory.DM_GIF
850
+ else:
851
+ category = MediaCategory.DM_IMAGE
852
+
853
+ result = await self._media_manager.upload_media(
854
+ media_url,
855
+ media_category=category,
856
+ alt_text=alt_text,
857
+ )
858
+
859
+ return MediaAttachment(
860
+ media_id=result.media_id,
861
+ media_type=MediaType.IMAGE, # Simplified type
862
+ url=HttpUrl(media_url)
863
+ if media_url.startswith("http")
864
+ else HttpUrl(f"file://{media_url}"),
865
+ )
866
+
867
+ except Exception as e:
868
+ raise MediaUploadError(
869
+ f"Failed to upload DM media: {e}",
870
+ platform=self.platform_name,
871
+ ) from e
872
+
873
+ async def send_direct_message(
874
+ self,
875
+ request: DMCreateRequest,
876
+ ) -> DirectMessage:
877
+ """Send a direct message on Twitter.
878
+
879
+ Supports both 1-to-1 DMs (new conversations) and messages to existing
880
+ conversations (including groups). Optionally attach a single media file.
881
+
882
+ Twitter Requirements:
883
+ - Text required (max 10,000 characters)
884
+ - Either participant_id (new 1-to-1) or conversation_id (existing)
885
+ - Max 1 media attachment per DM
886
+
887
+ Args:
888
+ request: DM creation request. Supports:
889
+ - text: Message content (required, max 10,000 chars)
890
+ - participant_id: User ID for new 1-to-1 DM
891
+ - conversation_id: Existing conversation ID
892
+ - media_url: URL of media to attach
893
+ - media_id: Pre-uploaded media ID
894
+
895
+ Returns:
896
+ DirectMessage object with:
897
+ - message_id: Twitter DM event ID
898
+ - conversation_id: Conversation ID
899
+ - platform: "twitter"
900
+ - text: Message content
901
+ - sender_id: Authenticated user ID (if available)
902
+ - created_at: Timestamp
903
+ - media: Attached media (if any)
904
+ - raw_data: Full API response
905
+
906
+ Raises:
907
+ ValidationError: If request is invalid.
908
+ MediaUploadError: If media upload fails.
909
+ RateLimitError: If Twitter rate limit exceeded.
910
+ PlatformError: For other Twitter API errors.
911
+ RuntimeError: If client not used as context manager.
912
+
913
+ Example:
914
+ >>> async with TwitterClient(credentials) as client:
915
+ ... request = DMCreateRequest(
916
+ ... text="Hello!",
917
+ ... participant_id="1234567890"
918
+ ... )
919
+ ... dm = await client.send_direct_message(request)
920
+ ... print(f"Sent DM: {dm.message_id}")
921
+ """
922
+ if not self._tweepy_client:
923
+ raise RuntimeError("Client must be used as async context manager")
924
+
925
+ # Validate request
926
+ self._validate_dm_request(request)
927
+
928
+ try:
929
+ # Handle media upload if provided
930
+ media_id = request.media_id
931
+ if request.media_url and not media_id:
932
+ media_attachment = await self._upload_dm_media(request.media_url)
933
+ media_id = media_attachment.media_id
934
+
935
+ # Build DM parameters
936
+ dm_params: dict[str, Any] = {"text": request.text}
937
+
938
+ if request.participant_id:
939
+ dm_params["participant_id"] = request.participant_id
940
+ elif request.conversation_id:
941
+ dm_params["dm_conversation_id"] = request.conversation_id
942
+
943
+ if media_id:
944
+ dm_params["media_id"] = media_id
945
+
946
+ # Send DM using tweepy
947
+ response = self._tweepy_client.create_direct_message(
948
+ **dm_params,
949
+ user_auth=False,
950
+ )
951
+
952
+ # Parse response
953
+ dm_data = response.data # type: ignore[attr-defined]
954
+
955
+ return DirectMessage(
956
+ message_id=str(dm_data["dm_event_id"]),
957
+ conversation_id=str(dm_data["dm_conversation_id"]),
958
+ platform=self.platform_name,
959
+ text=request.text,
960
+ sender_id=self.credentials.user_id,
961
+ created_at=datetime.now(),
962
+ media=None, # Media info not returned in create response
963
+ raw_data=dm_data,
964
+ )
965
+
966
+ except tweepy.TweepyException as e:
967
+ if "429" in str(e):
968
+ raise RateLimitError(
969
+ "Twitter rate limit exceeded",
970
+ platform=self.platform_name,
971
+ status_code=429,
972
+ ) from e
973
+ if "403" in str(e) or "401" in str(e):
974
+ raise PlatformAuthError(
975
+ f"Not authorized to send DM: {e}",
976
+ platform=self.platform_name,
977
+ ) from e
978
+ raise PlatformError(
979
+ f"Failed to send direct message: {e}",
980
+ platform=self.platform_name,
981
+ ) from e
982
+
983
+ async def create_group_conversation(
984
+ self,
985
+ request: GroupDMCreateRequest,
986
+ ) -> Conversation:
987
+ """Create a Twitter group DM conversation with initial message.
988
+
989
+ Creates a new group conversation with 2-49 other participants
990
+ and sends an initial message to the group.
991
+
992
+ Args:
993
+ request: Group DM request with:
994
+ - participant_ids: List of user IDs (2-49 participants)
995
+ - text: Initial message text (required, max 10,000 chars)
996
+ - media_url: Optional media URL
997
+ - media_id: Optional pre-uploaded media ID
998
+
999
+ Returns:
1000
+ Conversation object with:
1001
+ - conversation_id: Twitter conversation ID
1002
+ - platform: "twitter"
1003
+ - conversation_type: "group"
1004
+ - participant_ids: List of participant IDs
1005
+ - created_at: Timestamp
1006
+ - raw_data: Full API response
1007
+
1008
+ Raises:
1009
+ ValidationError: If request is invalid.
1010
+ MediaUploadError: If media upload fails.
1011
+ RateLimitError: If rate limit exceeded.
1012
+ PlatformError: For other API errors.
1013
+ RuntimeError: If client not used as context manager.
1014
+
1015
+ Example:
1016
+ >>> async with TwitterClient(credentials) as client:
1017
+ ... request = GroupDMCreateRequest(
1018
+ ... participant_ids=["user1", "user2"],
1019
+ ... text="Welcome to the group!"
1020
+ ... )
1021
+ ... conv = await client.create_group_conversation(request)
1022
+ ... print(f"Created group: {conv.conversation_id}")
1023
+ """
1024
+ if not self._tweepy_client:
1025
+ raise RuntimeError("Client must be used as async context manager")
1026
+
1027
+ # Validate participant count
1028
+ if len(request.participant_ids) < 2:
1029
+ raise ValidationError(
1030
+ "Group conversation requires at least 2 participants",
1031
+ platform=self.platform_name,
1032
+ field="participant_ids",
1033
+ )
1034
+
1035
+ if len(request.participant_ids) > 49:
1036
+ raise ValidationError(
1037
+ "Group conversation cannot exceed 49 participants",
1038
+ platform=self.platform_name,
1039
+ field="participant_ids",
1040
+ )
1041
+
1042
+ # Validate text
1043
+ if not request.text:
1044
+ raise ValidationError(
1045
+ "Initial message text is required",
1046
+ platform=self.platform_name,
1047
+ field="text",
1048
+ )
1049
+
1050
+ if len(request.text) > 10000:
1051
+ raise ValidationError(
1052
+ f"Message text exceeds 10,000 characters ({len(request.text)} chars)",
1053
+ platform=self.platform_name,
1054
+ field="text",
1055
+ )
1056
+
1057
+ try:
1058
+ # Handle media upload if provided
1059
+ media_id = request.media_id
1060
+ if request.media_url and not media_id:
1061
+ media_attachment = await self._upload_dm_media(request.media_url)
1062
+ media_id = media_attachment.media_id
1063
+
1064
+ # Create group conversation using tweepy
1065
+ conv_params: dict[str, Any] = {
1066
+ "conversation_type": "Group",
1067
+ "participant_ids": request.participant_ids,
1068
+ "text": request.text,
1069
+ }
1070
+
1071
+ if media_id:
1072
+ conv_params["media_id"] = media_id
1073
+
1074
+ response = self._tweepy_client.create_direct_message_conversation(
1075
+ **conv_params,
1076
+ user_auth=False,
1077
+ )
1078
+
1079
+ conv_data = response.data # type: ignore[attr-defined]
1080
+
1081
+ return Conversation(
1082
+ conversation_id=str(conv_data["dm_conversation_id"]),
1083
+ platform=self.platform_name,
1084
+ conversation_type="group",
1085
+ participant_ids=request.participant_ids,
1086
+ created_at=datetime.now(),
1087
+ raw_data=conv_data,
1088
+ )
1089
+
1090
+ except tweepy.TweepyException as e:
1091
+ if "429" in str(e):
1092
+ raise RateLimitError(
1093
+ "Twitter rate limit exceeded",
1094
+ platform=self.platform_name,
1095
+ status_code=429,
1096
+ ) from e
1097
+ raise PlatformError(
1098
+ f"Failed to create group conversation: {e}",
1099
+ platform=self.platform_name,
1100
+ ) from e
1101
+
758
1102
  # ==================== Helper Methods ====================
759
1103
 
760
1104
  def _parse_tweet(
@@ -0,0 +1,130 @@
1
+ """Twitter/X-specific models for post creation and direct messages.
2
+
3
+ This module defines Twitter-specific data models for creating tweets,
4
+ replies, quote tweets, polls, and direct messages.
5
+ """
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ # ==================== Post Models ====================
10
+
11
+
12
+ class TwitterPostRequest(BaseModel):
13
+ """Twitter/X-specific post creation request.
14
+
15
+ Supports tweets, replies, quote tweets, and media attachments.
16
+ Twitter has a 280 character limit for text content.
17
+
18
+ Attributes:
19
+ content: Tweet text (max 280 characters)
20
+ media_urls: List of media URLs to attach (max 4 images or 1 video)
21
+ media_ids: List of pre-uploaded media IDs
22
+ reply_to_post_id: Tweet ID to reply to
23
+ quote_post_id: Tweet ID to quote
24
+ poll_options: List of poll options (2-4 options, each max 25 chars)
25
+ poll_duration_minutes: Poll duration in minutes (5-10080)
26
+ alt_texts: Alt text for each media item (for accessibility)
27
+
28
+ Example:
29
+ >>> # Simple tweet
30
+ >>> request = TwitterPostRequest(content="Hello Twitter!")
31
+
32
+ >>> # Reply to a tweet
33
+ >>> request = TwitterPostRequest(
34
+ ... content="Great point!",
35
+ ... reply_to_post_id="1234567890"
36
+ ... )
37
+
38
+ >>> # Quote tweet with media
39
+ >>> request = TwitterPostRequest(
40
+ ... content="Check this out!",
41
+ ... quote_post_id="1234567890",
42
+ ... media_urls=["https://example.com/image.jpg"]
43
+ ... )
44
+
45
+ >>> # Tweet with poll
46
+ >>> request = TwitterPostRequest(
47
+ ... content="What's your favorite?",
48
+ ... poll_options=["Option A", "Option B", "Option C"],
49
+ ... poll_duration_minutes=1440
50
+ ... )
51
+ """
52
+
53
+ content: str | None = None
54
+ media_urls: list[str] = Field(default_factory=list, max_length=4)
55
+ media_ids: list[str] = Field(default_factory=list, max_length=4)
56
+ reply_to_post_id: str | None = None
57
+ quote_post_id: str | None = None
58
+ poll_options: list[str] = Field(default_factory=list, max_length=4)
59
+ poll_duration_minutes: int | None = Field(default=None, ge=5, le=10080)
60
+ alt_texts: list[str] = Field(default_factory=list)
61
+
62
+
63
+ # ==================== Direct Message Models ====================
64
+
65
+
66
+ class TwitterDMRequest(BaseModel):
67
+ """Twitter/X-specific direct message request.
68
+
69
+ Supports sending DMs to individuals or existing conversations.
70
+ Twitter has a 10,000 character limit for DM text content.
71
+ Only one media attachment is allowed per DM.
72
+
73
+ Attributes:
74
+ text: Message text (max 10,000 characters)
75
+ participant_id: User ID for new 1-to-1 DM (mutually exclusive with conversation_id)
76
+ conversation_id: Existing conversation ID (mutually exclusive with participant_id)
77
+ media_url: URL of media to attach (max 1 attachment)
78
+ media_id: Pre-uploaded media ID (from TwitterMediaManager)
79
+
80
+ Example:
81
+ >>> # Send 1-to-1 DM
82
+ >>> request = TwitterDMRequest(
83
+ ... text="Hello!",
84
+ ... participant_id="1234567890"
85
+ ... )
86
+
87
+ >>> # Send to existing conversation
88
+ >>> request = TwitterDMRequest(
89
+ ... text="Hello group!",
90
+ ... conversation_id="dm_conv_123456"
91
+ ... )
92
+
93
+ >>> # Send DM with media
94
+ >>> request = TwitterDMRequest(
95
+ ... text="Check this out!",
96
+ ... participant_id="1234567890",
97
+ ... media_url="https://example.com/image.jpg"
98
+ ... )
99
+ """
100
+
101
+ text: str = Field(max_length=10000)
102
+ participant_id: str | None = None
103
+ conversation_id: str | None = None
104
+ media_url: str | None = None
105
+ media_id: str | None = None
106
+
107
+
108
+ class TwitterGroupDMRequest(BaseModel):
109
+ """Twitter/X-specific group DM creation request.
110
+
111
+ Creates a new group conversation with multiple participants and an initial message.
112
+ Twitter allows 2-49 participants in a group DM (excluding the sender).
113
+
114
+ Attributes:
115
+ participant_ids: List of user IDs (2-49 participants, excluding sender)
116
+ text: Initial message text (max 10,000 characters)
117
+ media_url: URL of media to attach
118
+ media_id: Pre-uploaded media ID
119
+
120
+ Example:
121
+ >>> request = TwitterGroupDMRequest(
122
+ ... participant_ids=["user1_id", "user2_id", "user3_id"],
123
+ ... text="Welcome to the group!"
124
+ ... )
125
+ """
126
+
127
+ participant_ids: list[str] = Field(min_length=2, max_length=49)
128
+ text: str = Field(max_length=10000)
129
+ media_url: str | None = None
130
+ media_id: str | None = None
@@ -180,10 +180,32 @@ async def refresh_twitter_token(
180
180
 
181
181
  except httpx.HTTPStatusError as e:
182
182
  logger.error(f"HTTP error refreshing Twitter token: {e.response.status_code}")
183
+
184
+ # Determine if this is a permanent failure requiring user re-authentication
185
+ # Twitter returns 400 with "invalid_request" or "invalid_grant" when:
186
+ # - Refresh token was already used (single-use tokens)
187
+ # - Refresh token expired
188
+ # - User revoked access
189
+ requires_reconnection = False
190
+ if e.response.status_code == 400:
191
+ try:
192
+ error_data = e.response.json()
193
+ error_code = error_data.get("error", "")
194
+ if error_code in ("invalid_request", "invalid_grant"):
195
+ requires_reconnection = True
196
+ logger.warning(
197
+ f"Twitter refresh token is invalid ({error_code}), "
198
+ "user needs to reconnect their account"
199
+ )
200
+ except Exception:
201
+ # If we can't parse the response, assume reconnection is needed for 400
202
+ requires_reconnection = True
203
+
183
204
  raise PlatformAuthError(
184
205
  f"Failed to refresh token: {_sanitize_response_text(e.response.text)}",
185
206
  platform="twitter",
186
207
  status_code=e.response.status_code,
208
+ requires_reconnection=requires_reconnection,
187
209
  ) from e
188
210
 
189
211
  except httpx.HTTPError as e:
@@ -1,6 +0,0 @@
1
- """Twitter/X platform integration."""
2
-
3
- from marqetive.platforms.twitter.client import TwitterClient
4
- from marqetive.platforms.twitter.models import TwitterPostRequest
5
-
6
- __all__ = ["TwitterClient", "TwitterPostRequest"]
@@ -1,58 +0,0 @@
1
- """Twitter/X-specific models for post creation.
2
-
3
- This module defines Twitter-specific data models for creating tweets,
4
- replies, quote tweets, and polls.
5
- """
6
-
7
- from pydantic import BaseModel, Field
8
-
9
-
10
- class TwitterPostRequest(BaseModel):
11
- """Twitter/X-specific post creation request.
12
-
13
- Supports tweets, replies, quote tweets, and media attachments.
14
- Twitter has a 280 character limit for text content.
15
-
16
- Attributes:
17
- content: Tweet text (max 280 characters)
18
- media_urls: List of media URLs to attach (max 4 images or 1 video)
19
- media_ids: List of pre-uploaded media IDs
20
- reply_to_post_id: Tweet ID to reply to
21
- quote_post_id: Tweet ID to quote
22
- poll_options: List of poll options (2-4 options, each max 25 chars)
23
- poll_duration_minutes: Poll duration in minutes (5-10080)
24
- alt_texts: Alt text for each media item (for accessibility)
25
-
26
- Example:
27
- >>> # Simple tweet
28
- >>> request = TwitterPostRequest(content="Hello Twitter!")
29
-
30
- >>> # Reply to a tweet
31
- >>> request = TwitterPostRequest(
32
- ... content="Great point!",
33
- ... reply_to_post_id="1234567890"
34
- ... )
35
-
36
- >>> # Quote tweet with media
37
- >>> request = TwitterPostRequest(
38
- ... content="Check this out!",
39
- ... quote_post_id="1234567890",
40
- ... media_urls=["https://example.com/image.jpg"]
41
- ... )
42
-
43
- >>> # Tweet with poll
44
- >>> request = TwitterPostRequest(
45
- ... content="What's your favorite?",
46
- ... poll_options=["Option A", "Option B", "Option C"],
47
- ... poll_duration_minutes=1440
48
- ... )
49
- """
50
-
51
- content: str | None = None
52
- media_urls: list[str] = Field(default_factory=list, max_length=4)
53
- media_ids: list[str] = Field(default_factory=list, max_length=4)
54
- reply_to_post_id: str | None = None
55
- quote_post_id: str | None = None
56
- poll_options: list[str] = Field(default_factory=list, max_length=4)
57
- poll_duration_minutes: int | None = Field(default=None, ge=5, le=10080)
58
- alt_texts: list[str] = Field(default_factory=list)
File without changes