marqetive-lib 0.1.21__py3-none-any.whl → 0.2.1__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 CHANGED
@@ -47,6 +47,7 @@ from marqetive.core.exceptions import (
47
47
  PlatformError,
48
48
  PostNotFoundError,
49
49
  RateLimitError,
50
+ ThreadCancelledException,
50
51
  ValidationError,
51
52
  )
52
53
 
@@ -56,6 +57,10 @@ from marqetive.core.models import (
56
57
  AuthCredentials,
57
58
  Comment,
58
59
  CommentStatus,
60
+ Conversation,
61
+ DirectMessage,
62
+ DMCreateRequest,
63
+ GroupDMCreateRequest,
59
64
  MediaAttachment,
60
65
  MediaType,
61
66
  Post,
@@ -75,7 +80,7 @@ from marqetive.utils.helpers import format_response
75
80
  # Retry utilities
76
81
  from marqetive.utils.retry import STANDARD_BACKOFF, BackoffConfig, retry_async
77
82
 
78
- __version__ = "0.2.0"
83
+ __version__ = "0.2.1"
79
84
 
80
85
  __all__ = [
81
86
  # Core
@@ -102,6 +107,11 @@ __all__ = [
102
107
  # Base Request Models
103
108
  "PostCreateRequest",
104
109
  "PostUpdateRequest",
110
+ # Direct Message Models
111
+ "DMCreateRequest",
112
+ "GroupDMCreateRequest",
113
+ "DirectMessage",
114
+ "Conversation",
105
115
  # Exceptions
106
116
  "PlatformError",
107
117
  "PlatformAuthError",
@@ -110,6 +120,7 @@ __all__ = [
110
120
  "MediaUploadError",
111
121
  "ValidationError",
112
122
  "InvalidFileTypeError",
123
+ "ThreadCancelledException",
113
124
  # Types
114
125
  "ProgressCallback",
115
126
  # Utilities
marqetive/core/base.py CHANGED
@@ -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
@@ -4,6 +4,13 @@ This module defines platform-specific exceptions for handling errors
4
4
  that may occur during API interactions with various social media platforms.
5
5
  """
6
6
 
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from marqetive.core.models import Post
13
+
7
14
 
8
15
  class PlatformError(Exception):
9
16
  """Base exception for all platform-related errors.
@@ -266,3 +273,42 @@ class InvalidFileTypeError(PlatformError):
266
273
  if self.file_type:
267
274
  return f"{base_message} | File type: {self.file_type}"
268
275
  return base_message
276
+
277
+
278
+ class ThreadCancelledException(PlatformError):
279
+ """Raised when a thread is cancelled mid-posting.
280
+
281
+ This exception is raised when:
282
+ - A cancellation callback returns True during thread creation
283
+ - Thread posting is interrupted before completion
284
+
285
+ The exception includes the list of posts that were successfully
286
+ created before cancellation, allowing the caller to handle rollback.
287
+
288
+ Args:
289
+ message: Human-readable error message
290
+ platform: Name of the platform where cancellation occurred
291
+ posted_tweets: List of Post objects that were created before cancellation
292
+
293
+ Example:
294
+ >>> try:
295
+ ... await client.create_thread(tweets, cancellation_check=check_cancel)
296
+ ... except ThreadCancelledException as e:
297
+ ... for post in e.posted_tweets:
298
+ ... await client.delete_post(post.post_id)
299
+ """
300
+
301
+ def __init__(
302
+ self,
303
+ message: str,
304
+ platform: str | None = None,
305
+ posted_tweets: list[Post] | None = None,
306
+ ) -> None:
307
+ self.posted_tweets = posted_tweets or []
308
+ super().__init__(message, platform)
309
+
310
+ def _format_message(self) -> str:
311
+ """Format the error message with posted tweet count."""
312
+ base_message = super()._format_message()
313
+ count = len(self.posted_tweets)
314
+ return f"{base_message} | Posted tweets: {count}"
marqetive/core/models.py CHANGED
@@ -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)
@@ -1,6 +1,15 @@
1
1
  """Twitter/X platform integration."""
2
2
 
3
3
  from marqetive.platforms.twitter.client import TwitterClient
4
- from marqetive.platforms.twitter.models import TwitterPostRequest
4
+ from marqetive.platforms.twitter.models import (
5
+ TwitterDMRequest,
6
+ TwitterGroupDMRequest,
7
+ TwitterPostRequest,
8
+ )
5
9
 
6
- __all__ = ["TwitterClient", "TwitterPostRequest"]
10
+ __all__ = [
11
+ "TwitterClient",
12
+ "TwitterPostRequest",
13
+ "TwitterDMRequest",
14
+ "TwitterGroupDMRequest",
15
+ ]
@@ -6,6 +6,7 @@ ABC for Twitter (X), using the Twitter API v2 via tweepy.
6
6
  API Documentation: https://developer.x.com/en/docs/twitter-api
7
7
  """
8
8
 
9
+ from collections.abc import Awaitable, Callable
9
10
  from datetime import datetime
10
11
  from typing import Any
11
12
 
@@ -20,12 +21,17 @@ from marqetive.core.exceptions import (
20
21
  PlatformError,
21
22
  PostNotFoundError,
22
23
  RateLimitError,
24
+ ThreadCancelledException,
23
25
  ValidationError,
24
26
  )
25
27
  from marqetive.core.models import (
26
28
  AuthCredentials,
27
29
  Comment,
28
30
  CommentStatus,
31
+ Conversation,
32
+ DirectMessage,
33
+ DMCreateRequest,
34
+ GroupDMCreateRequest,
29
35
  MediaAttachment,
30
36
  MediaType,
31
37
  Post,
@@ -34,8 +40,8 @@ from marqetive.core.models import (
34
40
  PostUpdateRequest,
35
41
  ProgressStatus,
36
42
  )
37
- from marqetive.platforms.twitter.media import TwitterMediaManager
38
- from marqetive.platforms.twitter.models import TwitterPostRequest
43
+ from marqetive.platforms.twitter.media import MediaCategory, TwitterMediaManager
44
+ from marqetive.platforms.twitter.models import TwitterDMRequest, TwitterPostRequest
39
45
 
40
46
 
41
47
  class TwitterClient(SocialMediaPlatform):
@@ -677,6 +683,7 @@ class TwitterClient(SocialMediaPlatform):
677
683
  async def create_thread(
678
684
  self,
679
685
  posts: list[PostCreateRequest],
686
+ cancellation_check: Callable[[], Awaitable[bool]] | None = None,
680
687
  ) -> list[Post]:
681
688
  """Create a Twitter thread (multiple linked tweets).
682
689
 
@@ -687,6 +694,8 @@ class TwitterClient(SocialMediaPlatform):
687
694
  posts: List of PostCreateRequest objects to create as a thread.
688
695
  First tweet is the head of the thread.
689
696
  Use TwitterPostRequest for Twitter-specific features.
697
+ cancellation_check: Optional async callback that returns True if the
698
+ thread creation should be cancelled. Called before each tweet.
690
699
 
691
700
  Returns:
692
701
  List of Post objects for each tweet in the thread.
@@ -695,6 +704,7 @@ class TwitterClient(SocialMediaPlatform):
695
704
  ValidationError: If posts list is empty.
696
705
  PlatformAuthError: If not authenticated.
697
706
  MediaUploadError: If media upload fails.
707
+ ThreadCancelledException: If cancelled mid-thread (includes posted tweets).
698
708
  RuntimeError: If client not used as context manager.
699
709
 
700
710
  Example:
@@ -719,6 +729,14 @@ class TwitterClient(SocialMediaPlatform):
719
729
  reply_to_id: str | None = None
720
730
 
721
731
  for idx, post_request in enumerate(posts):
732
+ # Check cancellation BEFORE posting each tweet
733
+ if cancellation_check is not None and await cancellation_check():
734
+ raise ThreadCancelledException(
735
+ f"Thread cancelled after {len(created_posts)} of {len(posts)} tweets",
736
+ platform=self.platform_name,
737
+ posted_tweets=created_posts,
738
+ )
739
+
722
740
  # Convert to TwitterPostRequest if needed and set reply chain
723
741
  if isinstance(post_request, TwitterPostRequest):
724
742
  if reply_to_id is not None:
@@ -755,6 +773,346 @@ class TwitterClient(SocialMediaPlatform):
755
773
 
756
774
  return created_posts
757
775
 
776
+ # ==================== Direct Message Methods ====================
777
+
778
+ def _validate_dm_request(
779
+ self,
780
+ request: DMCreateRequest | TwitterDMRequest,
781
+ ) -> None:
782
+ """Validate DM request.
783
+
784
+ Twitter DM Requirements:
785
+ - Must have text content (required)
786
+ - Text max 10,000 characters
787
+ - Must have either participant_id OR conversation_id (not both)
788
+ - Max 1 media attachment
789
+
790
+ Args:
791
+ request: DM request to validate.
792
+
793
+ Raises:
794
+ ValidationError: If validation fails.
795
+ """
796
+ if not request.text:
797
+ raise ValidationError(
798
+ "DM text content is required",
799
+ platform=self.platform_name,
800
+ field="text",
801
+ )
802
+
803
+ if len(request.text) > 10000:
804
+ raise ValidationError(
805
+ f"DM text exceeds 10,000 characters ({len(request.text)} characters)",
806
+ platform=self.platform_name,
807
+ field="text",
808
+ )
809
+
810
+ # Must have exactly one of participant_id or conversation_id
811
+ has_participant = request.participant_id is not None
812
+ has_conversation = request.conversation_id is not None
813
+
814
+ if not has_participant and not has_conversation:
815
+ raise ValidationError(
816
+ "Either participant_id or conversation_id is required",
817
+ platform=self.platform_name,
818
+ field="participant_id",
819
+ )
820
+
821
+ if has_participant and has_conversation:
822
+ raise ValidationError(
823
+ "Cannot specify both participant_id and conversation_id",
824
+ platform=self.platform_name,
825
+ field="conversation_id",
826
+ )
827
+
828
+ async def _upload_dm_media(
829
+ self,
830
+ media_url: str,
831
+ alt_text: str | None = None,
832
+ ) -> MediaAttachment:
833
+ """Upload media for DM attachment.
834
+
835
+ Uses DM-specific media category for proper Twitter processing.
836
+
837
+ Args:
838
+ media_url: URL or file path of media.
839
+ alt_text: Optional alt text for accessibility.
840
+
841
+ Returns:
842
+ MediaAttachment with media ID.
843
+
844
+ Raises:
845
+ MediaUploadError: If upload fails.
846
+ """
847
+ if not self._media_manager:
848
+ raise RuntimeError("Client must be used as async context manager")
849
+
850
+ try:
851
+ # Detect media type and use appropriate DM category
852
+ from marqetive.utils.media import detect_mime_type
853
+
854
+ # Determine DM-specific media category based on file type
855
+ if media_url.startswith(("http://", "https://")):
856
+ # Default to DM_IMAGE for URLs (will be validated by Twitter)
857
+ category = MediaCategory.DM_IMAGE
858
+ else:
859
+ mime_type = detect_mime_type(media_url)
860
+ if "video" in mime_type:
861
+ category = MediaCategory.DM_VIDEO
862
+ elif "gif" in mime_type:
863
+ category = MediaCategory.DM_GIF
864
+ else:
865
+ category = MediaCategory.DM_IMAGE
866
+
867
+ result = await self._media_manager.upload_media(
868
+ media_url,
869
+ media_category=category,
870
+ alt_text=alt_text,
871
+ )
872
+
873
+ return MediaAttachment(
874
+ media_id=result.media_id,
875
+ media_type=MediaType.IMAGE, # Simplified type
876
+ url=HttpUrl(media_url)
877
+ if media_url.startswith("http")
878
+ else HttpUrl(f"file://{media_url}"),
879
+ )
880
+
881
+ except Exception as e:
882
+ raise MediaUploadError(
883
+ f"Failed to upload DM media: {e}",
884
+ platform=self.platform_name,
885
+ ) from e
886
+
887
+ async def send_direct_message(
888
+ self,
889
+ request: DMCreateRequest,
890
+ ) -> DirectMessage:
891
+ """Send a direct message on Twitter.
892
+
893
+ Supports both 1-to-1 DMs (new conversations) and messages to existing
894
+ conversations (including groups). Optionally attach a single media file.
895
+
896
+ Twitter Requirements:
897
+ - Text required (max 10,000 characters)
898
+ - Either participant_id (new 1-to-1) or conversation_id (existing)
899
+ - Max 1 media attachment per DM
900
+
901
+ Args:
902
+ request: DM creation request. Supports:
903
+ - text: Message content (required, max 10,000 chars)
904
+ - participant_id: User ID for new 1-to-1 DM
905
+ - conversation_id: Existing conversation ID
906
+ - media_url: URL of media to attach
907
+ - media_id: Pre-uploaded media ID
908
+
909
+ Returns:
910
+ DirectMessage object with:
911
+ - message_id: Twitter DM event ID
912
+ - conversation_id: Conversation ID
913
+ - platform: "twitter"
914
+ - text: Message content
915
+ - sender_id: Authenticated user ID (if available)
916
+ - created_at: Timestamp
917
+ - media: Attached media (if any)
918
+ - raw_data: Full API response
919
+
920
+ Raises:
921
+ ValidationError: If request is invalid.
922
+ MediaUploadError: If media upload fails.
923
+ RateLimitError: If Twitter rate limit exceeded.
924
+ PlatformError: For other Twitter API errors.
925
+ RuntimeError: If client not used as context manager.
926
+
927
+ Example:
928
+ >>> async with TwitterClient(credentials) as client:
929
+ ... request = DMCreateRequest(
930
+ ... text="Hello!",
931
+ ... participant_id="1234567890"
932
+ ... )
933
+ ... dm = await client.send_direct_message(request)
934
+ ... print(f"Sent DM: {dm.message_id}")
935
+ """
936
+ if not self._tweepy_client:
937
+ raise RuntimeError("Client must be used as async context manager")
938
+
939
+ # Validate request
940
+ self._validate_dm_request(request)
941
+
942
+ try:
943
+ # Handle media upload if provided
944
+ media_id = request.media_id
945
+ if request.media_url and not media_id:
946
+ media_attachment = await self._upload_dm_media(request.media_url)
947
+ media_id = media_attachment.media_id
948
+
949
+ # Build DM parameters
950
+ dm_params: dict[str, Any] = {"text": request.text}
951
+
952
+ if request.participant_id:
953
+ dm_params["participant_id"] = request.participant_id
954
+ elif request.conversation_id:
955
+ dm_params["dm_conversation_id"] = request.conversation_id
956
+
957
+ if media_id:
958
+ dm_params["media_id"] = media_id
959
+
960
+ # Send DM using tweepy
961
+ response = self._tweepy_client.create_direct_message(
962
+ **dm_params,
963
+ user_auth=False,
964
+ )
965
+
966
+ # Parse response
967
+ dm_data = response.data # type: ignore[attr-defined]
968
+
969
+ return DirectMessage(
970
+ message_id=str(dm_data["dm_event_id"]),
971
+ conversation_id=str(dm_data["dm_conversation_id"]),
972
+ platform=self.platform_name,
973
+ text=request.text,
974
+ sender_id=self.credentials.user_id,
975
+ created_at=datetime.now(),
976
+ media=None, # Media info not returned in create response
977
+ raw_data=dm_data,
978
+ )
979
+
980
+ except tweepy.TweepyException as e:
981
+ if "429" in str(e):
982
+ raise RateLimitError(
983
+ "Twitter rate limit exceeded",
984
+ platform=self.platform_name,
985
+ status_code=429,
986
+ ) from e
987
+ if "403" in str(e) or "401" in str(e):
988
+ raise PlatformAuthError(
989
+ f"Not authorized to send DM: {e}",
990
+ platform=self.platform_name,
991
+ ) from e
992
+ raise PlatformError(
993
+ f"Failed to send direct message: {e}",
994
+ platform=self.platform_name,
995
+ ) from e
996
+
997
+ async def create_group_conversation(
998
+ self,
999
+ request: GroupDMCreateRequest,
1000
+ ) -> Conversation:
1001
+ """Create a Twitter group DM conversation with initial message.
1002
+
1003
+ Creates a new group conversation with 2-49 other participants
1004
+ and sends an initial message to the group.
1005
+
1006
+ Args:
1007
+ request: Group DM request with:
1008
+ - participant_ids: List of user IDs (2-49 participants)
1009
+ - text: Initial message text (required, max 10,000 chars)
1010
+ - media_url: Optional media URL
1011
+ - media_id: Optional pre-uploaded media ID
1012
+
1013
+ Returns:
1014
+ Conversation object with:
1015
+ - conversation_id: Twitter conversation ID
1016
+ - platform: "twitter"
1017
+ - conversation_type: "group"
1018
+ - participant_ids: List of participant IDs
1019
+ - created_at: Timestamp
1020
+ - raw_data: Full API response
1021
+
1022
+ Raises:
1023
+ ValidationError: If request is invalid.
1024
+ MediaUploadError: If media upload fails.
1025
+ RateLimitError: If rate limit exceeded.
1026
+ PlatformError: For other API errors.
1027
+ RuntimeError: If client not used as context manager.
1028
+
1029
+ Example:
1030
+ >>> async with TwitterClient(credentials) as client:
1031
+ ... request = GroupDMCreateRequest(
1032
+ ... participant_ids=["user1", "user2"],
1033
+ ... text="Welcome to the group!"
1034
+ ... )
1035
+ ... conv = await client.create_group_conversation(request)
1036
+ ... print(f"Created group: {conv.conversation_id}")
1037
+ """
1038
+ if not self._tweepy_client:
1039
+ raise RuntimeError("Client must be used as async context manager")
1040
+
1041
+ # Validate participant count
1042
+ if len(request.participant_ids) < 2:
1043
+ raise ValidationError(
1044
+ "Group conversation requires at least 2 participants",
1045
+ platform=self.platform_name,
1046
+ field="participant_ids",
1047
+ )
1048
+
1049
+ if len(request.participant_ids) > 49:
1050
+ raise ValidationError(
1051
+ "Group conversation cannot exceed 49 participants",
1052
+ platform=self.platform_name,
1053
+ field="participant_ids",
1054
+ )
1055
+
1056
+ # Validate text
1057
+ if not request.text:
1058
+ raise ValidationError(
1059
+ "Initial message text is required",
1060
+ platform=self.platform_name,
1061
+ field="text",
1062
+ )
1063
+
1064
+ if len(request.text) > 10000:
1065
+ raise ValidationError(
1066
+ f"Message text exceeds 10,000 characters ({len(request.text)} chars)",
1067
+ platform=self.platform_name,
1068
+ field="text",
1069
+ )
1070
+
1071
+ try:
1072
+ # Handle media upload if provided
1073
+ media_id = request.media_id
1074
+ if request.media_url and not media_id:
1075
+ media_attachment = await self._upload_dm_media(request.media_url)
1076
+ media_id = media_attachment.media_id
1077
+
1078
+ # Create group conversation using tweepy
1079
+ conv_params: dict[str, Any] = {
1080
+ "conversation_type": "Group",
1081
+ "participant_ids": request.participant_ids,
1082
+ "text": request.text,
1083
+ }
1084
+
1085
+ if media_id:
1086
+ conv_params["media_id"] = media_id
1087
+
1088
+ response = self._tweepy_client.create_direct_message_conversation(
1089
+ **conv_params,
1090
+ user_auth=False,
1091
+ )
1092
+
1093
+ conv_data = response.data # type: ignore[attr-defined]
1094
+
1095
+ return Conversation(
1096
+ conversation_id=str(conv_data["dm_conversation_id"]),
1097
+ platform=self.platform_name,
1098
+ conversation_type="group",
1099
+ participant_ids=request.participant_ids,
1100
+ created_at=datetime.now(),
1101
+ raw_data=conv_data,
1102
+ )
1103
+
1104
+ except tweepy.TweepyException as e:
1105
+ if "429" in str(e):
1106
+ raise RateLimitError(
1107
+ "Twitter rate limit exceeded",
1108
+ platform=self.platform_name,
1109
+ status_code=429,
1110
+ ) from e
1111
+ raise PlatformError(
1112
+ f"Failed to create group conversation: {e}",
1113
+ platform=self.platform_name,
1114
+ ) from e
1115
+
758
1116
  # ==================== Helper Methods ====================
759
1117
 
760
1118
  def _parse_tweet(
@@ -1,11 +1,13 @@
1
- """Twitter/X-specific models for post creation.
1
+ """Twitter/X-specific models for post creation and direct messages.
2
2
 
3
3
  This module defines Twitter-specific data models for creating tweets,
4
- replies, quote tweets, and polls.
4
+ replies, quote tweets, polls, and direct messages.
5
5
  """
6
6
 
7
7
  from pydantic import BaseModel, Field
8
8
 
9
+ # ==================== Post Models ====================
10
+
9
11
 
10
12
  class TwitterPostRequest(BaseModel):
11
13
  """Twitter/X-specific post creation request.
@@ -56,3 +58,73 @@ class TwitterPostRequest(BaseModel):
56
58
  poll_options: list[str] = Field(default_factory=list, max_length=4)
57
59
  poll_duration_minutes: int | None = Field(default=None, ge=5, le=10080)
58
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: marqetive-lib
3
- Version: 0.1.21
3
+ Version: 0.2.1
4
4
  Summary: Modern Python utilities for web APIs
5
5
  Keywords: api,utilities,web,http,marqetive
6
6
  Requires-Python: >=3.12
@@ -1,9 +1,9 @@
1
- marqetive/__init__.py,sha256=pW77CUnzOQ0X1pb-GTcRgrrvsSaJBdVhGZLnvCD_4q4,3032
1
+ marqetive/__init__.py,sha256=oAbgnTV2fTfnocxwampca87E3HTY_DclPv-rjOCQlhM,3298
2
2
  marqetive/core/__init__.py,sha256=0_0vzxJ619YIJkz1yzSvhnGDJRkrErs_QSg2q3Bloss,1172
3
- marqetive/core/base.py,sha256=A1RgZZLX5aMkFVmjNdJak9O0WFWOl1PPMIU8-ngEjxA,17225
3
+ marqetive/core/base.py,sha256=PXq1u0QT3oKC338-QJXu0gebXI1n6v5MCGgLi2Kt-Sg,20001
4
4
  marqetive/core/client.py,sha256=eCtvL100dkxYQHC_TJzHbs3dGjgsa_me9VTHo-CUN2M,3900
5
- marqetive/core/exceptions.py,sha256=CQXGPjzWJU2NZ0a7E007PxrG_orOOJNmvcsZ0L4HTiQ,8195
6
- marqetive/core/models.py,sha256=HmUSKZgJFw4o6Orjn4SGaCwLN6s8_FCsFkK1ptvnad8,14819
5
+ marqetive/core/exceptions.py,sha256=h4boyxGY2tUQXMfnDcpVzAKVW71VpqvOZtGkQ-oVkYM,9710
6
+ marqetive/core/models.py,sha256=0n5z1GcrNJWmPCgc_k5glxd3dLGUANwp4qvHCM5FIC4,19287
7
7
  marqetive/factory.py,sha256=SfHsp6mdIZpDBtryUvHSwljBRkCyAHhtwV7qDfgL9k4,15268
8
8
  marqetive/platforms/__init__.py,sha256=RBxlQSGyELsulSnwf5uaE1ohxFc7jC61OO9CrKaZp48,1312
9
9
  marqetive/platforms/instagram/__init__.py,sha256=c1Gs0ozG6D7Z-Uz_UQ7S3joL0qUTT9eUZPWcePyESk8,229
@@ -21,11 +21,11 @@ marqetive/platforms/tiktok/client.py,sha256=IhLCphzu_qxTSP6CDFqLBQqM3zJ7bLy7uHDS
21
21
  marqetive/platforms/tiktok/exceptions.py,sha256=vxwyAKujMGZJh0LetG1QsLF95QfUs_kR6ujsWSHGqL0,10124
22
22
  marqetive/platforms/tiktok/media.py,sha256=NmvzvzfaZMmzIx88wkXI5tWLd4vYN1VJXN-IkaEAO2c,28638
23
23
  marqetive/platforms/tiktok/models.py,sha256=WWdjuFqhTIR8SnHkz-8UaNc5Mm2PrGomwQ3W7pJcQFg,2962
24
- marqetive/platforms/twitter/__init__.py,sha256=dvcgVT-v-JOtjSz-OUvxGrn_43OI6w_ep42Wx_nHTSM,217
25
- marqetive/platforms/twitter/client.py,sha256=x4czyl1TnP-JmxRKl19fEE0gcYCppqK2Xdwpyf7G2E8,29035
24
+ marqetive/platforms/twitter/__init__.py,sha256=0-EETW3kIAw7kFo-JuOivS42BDVdNJfqHe2depIbx2U,339
25
+ marqetive/platforms/twitter/client.py,sha256=BCVPz-wIKJx1FThm-dI9QfTVG9VdYLYBPOWJEzX8QJc,42417
26
26
  marqetive/platforms/twitter/exceptions.py,sha256=eZ-dJKOXH_-bAMg29zWKbEqMFud29piEJ5IWfC9wFts,8926
27
27
  marqetive/platforms/twitter/media.py,sha256=KpPxnLCas8NhnsEvaXSJZ7To4wW4FY0YqQvUkJIIr7g,28010
28
- marqetive/platforms/twitter/models.py,sha256=yPQlx40SlNmz7YGasXUqdx7rEDEgrQ64aYovlPKo6oc,2126
28
+ marqetive/platforms/twitter/models.py,sha256=afFK1jJyrIC6BvY6ShkHlg-KnvawdeLGS-hB-GoloWA,4579
29
29
  marqetive/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
30
  marqetive/utils/__init__.py,sha256=bSrNajbxYBSKQayrPviLz8JeGjplnyK8y_NGDtgb7yQ,977
31
31
  marqetive/utils/file_handlers.py,sha256=mDBGLQpCBYD-AN3W_qysZn0JmXxQE017_lnURdkkucA,17224
@@ -33,6 +33,6 @@ marqetive/utils/helpers.py,sha256=Sh5HZD6AOJig_6T84n6JsKLosIkKIkpkiYTl69rnOOw,13
33
33
  marqetive/utils/media.py,sha256=reVousdueG-h5jeI6uLGqVCfjYxlsMiWhx6XZwg-iHY,14664
34
34
  marqetive/utils/oauth.py,sha256=3TtbUCVuGxtOBxIUvVJH_DUMIHrP76XDpabPYaLXhTU,15392
35
35
  marqetive/utils/retry.py,sha256=UcgrmVBVG5zd30_11mZnRnTaSFrbUYXBO1DrXPR0f8E,7627
36
- marqetive_lib-0.1.21.dist-info/METADATA,sha256=bYGdoaoexmFhLH4yVmG5CxkTKyP0fnztXVGa-msRi7M,7876
37
- marqetive_lib-0.1.21.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
38
- marqetive_lib-0.1.21.dist-info/RECORD,,
36
+ marqetive_lib-0.2.1.dist-info/METADATA,sha256=TGRiCpdcZ3x0eCi1OsaH37-BIuimbqVCrbaZvWvUNqE,7875
37
+ marqetive_lib-0.2.1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
38
+ marqetive_lib-0.2.1.dist-info/RECORD,,