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.
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/PKG-INFO +1 -1
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/pyproject.toml +1 -1
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/__init__.py +9 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/core/base.py +75 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/core/exceptions.py +30 -1
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/core/models.py +135 -1
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/factory.py +37 -5
- marqetive_lib-0.1.22/src/marqetive/platforms/twitter/__init__.py +15 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/twitter/client.py +346 -2
- marqetive_lib-0.1.22/src/marqetive/platforms/twitter/models.py +130 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/oauth.py +22 -0
- marqetive_lib-0.1.20/src/marqetive/platforms/twitter/__init__.py +0 -6
- marqetive_lib-0.1.20/src/marqetive/platforms/twitter/models.py +0 -58
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/README.md +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/core/__init__.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/core/client.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/__init__.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/instagram/__init__.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/instagram/client.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/instagram/exceptions.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/instagram/media.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/instagram/models.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/linkedin/__init__.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/linkedin/client.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/linkedin/exceptions.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/linkedin/media.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/linkedin/models.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/tiktok/__init__.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/tiktok/client.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/tiktok/exceptions.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/tiktok/media.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/tiktok/models.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/twitter/exceptions.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/twitter/media.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/py.typed +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/__init__.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/file_handlers.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/helpers.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/media.py +0 -0
- {marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/utils/retry.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/instagram/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{marqetive_lib-0.1.20 → marqetive_lib-0.1.22}/src/marqetive/platforms/linkedin/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|