marqetive-lib 0.1.18__tar.gz → 0.1.21__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 (38) hide show
  1. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/PKG-INFO +1 -1
  2. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/pyproject.toml +1 -1
  3. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/core/base.py +33 -1
  4. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/core/exceptions.py +30 -1
  5. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/core/models.py +2 -0
  6. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/factory.py +37 -5
  7. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/instagram/client.py +43 -6
  8. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/linkedin/client.py +45 -14
  9. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/tiktok/client.py +51 -10
  10. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/twitter/client.py +66 -8
  11. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/utils/oauth.py +22 -0
  12. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/README.md +0 -0
  13. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/__init__.py +0 -0
  14. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/core/__init__.py +0 -0
  15. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/core/client.py +0 -0
  16. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/__init__.py +0 -0
  17. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/instagram/__init__.py +0 -0
  18. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/instagram/exceptions.py +0 -0
  19. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/instagram/media.py +0 -0
  20. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/instagram/models.py +0 -0
  21. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/linkedin/__init__.py +0 -0
  22. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/linkedin/exceptions.py +0 -0
  23. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/linkedin/media.py +0 -0
  24. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/linkedin/models.py +0 -0
  25. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/tiktok/__init__.py +0 -0
  26. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/tiktok/exceptions.py +0 -0
  27. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/tiktok/media.py +0 -0
  28. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/tiktok/models.py +0 -0
  29. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/twitter/__init__.py +0 -0
  30. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/twitter/exceptions.py +0 -0
  31. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/twitter/media.py +0 -0
  32. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/platforms/twitter/models.py +0 -0
  33. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/py.typed +0 -0
  34. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/utils/__init__.py +0 -0
  35. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/utils/file_handlers.py +0 -0
  36. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/utils/helpers.py +0 -0
  37. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/src/marqetive/utils/media.py +0 -0
  38. {marqetive_lib-0.1.18 → marqetive_lib-0.1.21}/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.18
3
+ Version: 0.1.21
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.18"
7
+ version = "0.1.21"
8
8
  description = "Modern Python utilities for web APIs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -119,7 +119,10 @@ class SocialMediaPlatform(ABC):
119
119
  return self
120
120
 
121
121
  async def __aexit__(
122
- self, exc_type: type[Exception] | None, exc_val: Exception | None, exc_tb: TracebackType | None
122
+ self,
123
+ exc_type: type[Exception] | None,
124
+ exc_val: Exception | None,
125
+ exc_tb: TracebackType | None,
123
126
  ) -> None:
124
127
  """Async context manager exit."""
125
128
  if self.api_client:
@@ -228,6 +231,35 @@ class SocialMediaPlatform(ABC):
228
231
  if inspect.iscoroutine(result):
229
232
  await result
230
233
 
234
+ # ==================== Validation Helpers ====================
235
+
236
+ @abstractmethod
237
+ def _validate_create_post_request(self, request: PostCreateRequest) -> None:
238
+ """Validate a post creation request.
239
+
240
+ This base implementation performs no validation. Subclasses should
241
+ override this method to implement platform-specific validation rules.
242
+
243
+ The validation method should raise ValidationError with descriptive
244
+ messages including platform name and field name for consistency.
245
+
246
+ Args:
247
+ request: The post creation request to validate.
248
+
249
+ Raises:
250
+ ValidationError: If validation fails.
251
+
252
+ Example:
253
+ >>> def _validate_create_post_request(self, request):
254
+ ... if not request.content:
255
+ ... raise ValidationError(
256
+ ... "Content is required",
257
+ ... platform=self.platform_name,
258
+ ... field="content",
259
+ ... )
260
+ """
261
+ pass
262
+
231
263
  # ==================== Abstract Authentication Methods ====================
232
264
 
233
265
  @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):
@@ -217,6 +217,7 @@ class AuthCredentials(BaseModel):
217
217
  expires_at: Timestamp when access token expires
218
218
  scope: List of permission scopes granted
219
219
  user_id: ID of the authenticated user
220
+ username: Username/handle of the authenticated user
220
221
  status: Current status of the account credentials
221
222
  additional_data: Platform-specific auth data
222
223
 
@@ -237,6 +238,7 @@ class AuthCredentials(BaseModel):
237
238
  expires_at: datetime | None = None
238
239
  scope: list[str] = Field(default_factory=list)
239
240
  user_id: str | None = None
241
+ username: str | None = None
240
242
  status: AccountStatus = AccountStatus.VALID
241
243
  additional_data: dict[str, Any] = Field(default_factory=dict)
242
244
 
@@ -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(
@@ -211,6 +211,39 @@ class InstagramClient(SocialMediaPlatform):
211
211
  except httpx.HTTPError:
212
212
  return False
213
213
 
214
+ # ==================== Validation ====================
215
+
216
+ def _validate_create_post_request(self, request: PostCreateRequest) -> None:
217
+ """Validate Instagram post creation request.
218
+
219
+ Instagram Requirements:
220
+ - Media is ALWAYS required (Instagram is a visual platform)
221
+ - For carousels: 2-10 images
222
+ - For reels: 1 video (3 sec - 15 min)
223
+ - Caption max 2200 characters
224
+ - Media must be publicly accessible URLs
225
+
226
+ Args:
227
+ request: Post creation request to validate.
228
+
229
+ Raises:
230
+ ValidationError: If validation fails.
231
+ """
232
+ if not request.media_urls and not request.media_ids:
233
+ raise ValidationError(
234
+ "Instagram posts require at least one media attachment. "
235
+ "Instagram is a visual platform - text-only posts are not supported.",
236
+ platform=self.platform_name,
237
+ field="media",
238
+ )
239
+
240
+ if request.content and len(request.content) > 2200:
241
+ raise ValidationError(
242
+ f"Caption exceeds 2200 characters ({len(request.content)} characters)",
243
+ platform=self.platform_name,
244
+ field="content",
245
+ )
246
+
214
247
  # ==================== Post CRUD Methods ====================
215
248
 
216
249
  async def create_post(self, request: PostCreateRequest) -> Post:
@@ -256,12 +289,8 @@ class InstagramClient(SocialMediaPlatform):
256
289
  if not self.api_client:
257
290
  raise RuntimeError("Client must be used as async context manager")
258
291
 
259
- if not request.media_urls and not request.media_ids:
260
- raise ValidationError(
261
- "Instagram posts require at least one media attachment",
262
- platform=self.platform_name,
263
- field="media",
264
- )
292
+ # Validate request
293
+ self._validate_create_post_request(request)
265
294
 
266
295
  # Determine content type from request
267
296
  media_type = self._get_media_type(request)
@@ -375,7 +404,9 @@ class InstagramClient(SocialMediaPlatform):
375
404
  content=request.content,
376
405
  status=PostStatus.PUBLISHED,
377
406
  created_at=datetime.now(),
407
+ author_id=self.instagram_account_id,
378
408
  url=cast(HttpUrl, result.permalink) if result.permalink else None,
409
+ raw_data={"container_id": container_ids[0]},
379
410
  )
380
411
 
381
412
  async def _create_carousel_post(self, request: PostCreateRequest) -> Post:
@@ -826,7 +857,9 @@ class InstagramClient(SocialMediaPlatform):
826
857
  content=caption,
827
858
  status=PostStatus.PUBLISHED,
828
859
  created_at=datetime.now(),
860
+ author_id=self.instagram_account_id,
829
861
  url=cast(HttpUrl, result.permalink) if result.permalink else None,
862
+ raw_data={"container_id": container_ids[0]},
830
863
  )
831
864
 
832
865
  async def create_reel(
@@ -890,7 +923,9 @@ class InstagramClient(SocialMediaPlatform):
890
923
  content=caption,
891
924
  status=PostStatus.PUBLISHED,
892
925
  created_at=datetime.now(),
926
+ author_id=self.instagram_account_id,
893
927
  url=cast(HttpUrl, result.permalink) if result.permalink else None,
928
+ raw_data={"container_id": container_id},
894
929
  )
895
930
 
896
931
  async def create_story(
@@ -941,7 +976,9 @@ class InstagramClient(SocialMediaPlatform):
941
976
  content=None, # Stories don't have captions
942
977
  status=PostStatus.PUBLISHED,
943
978
  created_at=datetime.now(),
979
+ author_id=self.instagram_account_id,
944
980
  url=cast(HttpUrl, result.permalink) if result.permalink else None,
981
+ raw_data={"container_id": container_id},
945
982
  )
946
983
 
947
984
  # ==================== Helper Methods ====================
@@ -319,6 +319,48 @@ class LinkedInClient(SocialMediaPlatform):
319
319
  except httpx.HTTPError:
320
320
  return False
321
321
 
322
+ # ==================== Validation ====================
323
+
324
+ def _validate_create_post_request(self, request: PostCreateRequest) -> None:
325
+ """Validate LinkedIn post creation request.
326
+
327
+ LinkedIn Requirements:
328
+ - Content is required (text post content)
329
+ - Content max 3000 characters
330
+ - CTA labels must be from approved list if provided
331
+ - Media: max 20 images, or 1 video, or 1 document
332
+
333
+ Args:
334
+ request: Post creation request to validate.
335
+
336
+ Raises:
337
+ ValidationError: If validation fails.
338
+ """
339
+ if not request.content:
340
+ raise ValidationError(
341
+ "LinkedIn posts require content",
342
+ platform=self.platform_name,
343
+ field="content",
344
+ )
345
+
346
+ if len(request.content) > 3000:
347
+ raise ValidationError(
348
+ f"Post content exceeds 3000 characters ({len(request.content)} characters)",
349
+ platform=self.platform_name,
350
+ field="content",
351
+ )
352
+
353
+ # Validate CTA label if provided
354
+ if cta := request.additional_data.get("call_to_action"):
355
+ cta_upper = cta.upper()
356
+ if cta_upper not in VALID_CTA_LABELS:
357
+ raise ValidationError(
358
+ f"Invalid call_to_action: '{cta}'. "
359
+ f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
360
+ platform=self.platform_name,
361
+ field="call_to_action",
362
+ )
363
+
322
364
  # ==================== Post CRUD Methods ====================
323
365
 
324
366
  async def create_post(self, request: PostCreateRequest) -> Post:
@@ -434,20 +476,8 @@ class LinkedInClient(SocialMediaPlatform):
434
476
  if not self.api_client:
435
477
  raise RuntimeError("Client must be used as async context manager")
436
478
 
437
- if not request.content:
438
- raise ValidationError(
439
- "LinkedIn posts require content",
440
- platform=self.platform_name,
441
- field="content",
442
- )
443
-
444
- # Validate content length (3000 characters for posts)
445
- if len(request.content) > 3000:
446
- raise ValidationError(
447
- f"Post content exceeds 3000 characters ({len(request.content)} characters)",
448
- platform=self.platform_name,
449
- field="content",
450
- )
479
+ # Validate request
480
+ self._validate_create_post_request(request)
451
481
 
452
482
  try:
453
483
  # Build REST API payload structure
@@ -578,6 +608,7 @@ class LinkedInClient(SocialMediaPlatform):
578
608
  status=PostStatus.PUBLISHED,
579
609
  created_at=datetime.now(),
580
610
  author_id=self.author_urn,
611
+ url=None, # Not available without separate fetch
581
612
  raw_data=response.data,
582
613
  )
583
614
 
@@ -166,6 +166,32 @@ class TikTokClient(SocialMediaPlatform):
166
166
  except PlatformError:
167
167
  return False
168
168
 
169
+ # ==================== Validation ====================
170
+
171
+ def _validate_create_post_request(self, request: PostCreateRequest) -> None:
172
+ """Validate TikTok post creation request.
173
+
174
+ TikTok Requirements:
175
+ - Video URL is ALWAYS required (TikTok is a video-only platform)
176
+ - Video duration: 3 seconds to 10 minutes
177
+ - open_id must be in credentials.additional_data
178
+
179
+ Args:
180
+ request: Post creation request to validate.
181
+
182
+ Raises:
183
+ ValidationError: If validation fails.
184
+ """
185
+ if not request.media_urls:
186
+ raise ValidationError(
187
+ "A video URL is required to create a TikTok post. "
188
+ "TikTok is a video platform - image-only or text-only posts are not supported.",
189
+ platform=self.platform_name,
190
+ field="media_urls",
191
+ )
192
+
193
+ # ==================== TikTok-specific Methods ====================
194
+
169
195
  async def query_creator_info(self) -> CreatorInfo:
170
196
  """Query creator info before posting.
171
197
 
@@ -203,11 +229,8 @@ class TikTokClient(SocialMediaPlatform):
203
229
  if not self._media_manager or not self.api_client:
204
230
  raise RuntimeError("Client must be used as async context manager")
205
231
 
206
- if not request.media_urls:
207
- raise ValidationError(
208
- "A video URL is required to create a TikTok post.",
209
- platform=self.platform_name,
210
- )
232
+ # Validate request
233
+ self._validate_create_post_request(request)
211
234
 
212
235
  # 1. Query creator info (required before posting)
213
236
  if not self._creator_info:
@@ -232,12 +255,21 @@ class TikTokClient(SocialMediaPlatform):
232
255
  )
233
256
 
234
257
  # 4. Return minimal Post object without fetching details
258
+ video_id = upload_result.video_id or upload_result.publish_id
259
+ url = (
260
+ HttpUrl(f"https://www.tiktok.com/@{self.credentials.username}/video/{video_id}")
261
+ if self.credentials.username and video_id
262
+ else None
263
+ )
264
+
235
265
  return Post(
236
- post_id=upload_result.video_id or upload_result.publish_id,
266
+ post_id=video_id,
237
267
  platform=self.platform_name,
238
268
  content=request.content,
239
269
  status=PostStatus.PUBLISHED,
240
270
  created_at=datetime.now(),
271
+ author_id=self.credentials.additional_data.get("open_id"),
272
+ url=url,
241
273
  raw_data={
242
274
  "publish_id": upload_result.publish_id,
243
275
  "video_id": upload_result.video_id,
@@ -458,16 +490,25 @@ class TikTokClient(SocialMediaPlatform):
458
490
  # TikTok uses 'id' field for video ID
459
491
  video_id = video_data.get("id", video_data.get("video_id", ""))
460
492
 
461
- # Handle share_url - use placeholder if empty
493
+ # Handle share_url - construct URL from username if share_url not available
462
494
  share_url = video_data.get("share_url", "")
463
- media_url = (
464
- HttpUrl(share_url) if share_url else HttpUrl("https://www.tiktok.com/")
465
- )
495
+ if share_url:
496
+ post_url: HttpUrl | None = HttpUrl(share_url)
497
+ elif self.credentials.username and video_id:
498
+ post_url = HttpUrl(
499
+ f"https://www.tiktok.com/@{self.credentials.username}/video/{video_id}"
500
+ )
501
+ else:
502
+ post_url = None
503
+
504
+ # MediaAttachment.url requires a non-null HttpUrl, use placeholder if needed
505
+ media_url = post_url if post_url else HttpUrl("https://www.tiktok.com/")
466
506
 
467
507
  return Post(
468
508
  post_id=video_id,
469
509
  platform=self.platform_name,
470
510
  content=video_data.get("title", video_data.get("video_description", "")),
511
+ url=post_url,
471
512
  media=[
472
513
  MediaAttachment(
473
514
  media_id=video_id,
@@ -185,29 +185,68 @@ class TwitterClient(SocialMediaPlatform):
185
185
  except tweepy.TweepyException:
186
186
  return False
187
187
 
188
+ # ==================== Validation ====================
189
+
190
+ def _validate_create_post_request(self, request: PostCreateRequest) -> None:
191
+ """Validate Twitter post creation request.
192
+
193
+ Twitter Requirements:
194
+ - Must have content OR media (at least one)
195
+ - Content max 280 characters (enforced by Twitter API)
196
+ - Max 4 images or 1 video per tweet
197
+
198
+ Args:
199
+ request: Post creation request to validate.
200
+
201
+ Raises:
202
+ ValidationError: If validation fails.
203
+ """
204
+ if not request.content and not request.media_urls and not request.media_ids:
205
+ raise ValidationError(
206
+ "Tweet must contain content or media",
207
+ platform=self.platform_name,
208
+ field="content",
209
+ )
210
+
188
211
  # ==================== Post CRUD Methods ====================
189
212
 
190
213
  async def create_post(self, request: PostCreateRequest) -> Post:
191
214
  """Create and publish a tweet.
192
215
 
216
+ Twitter Requirements:
217
+ - Must have content OR media (at least one)
218
+ - Content max 280 characters (enforced by Twitter API)
219
+ - Max 4 images or 1 video per tweet
220
+
193
221
  Args:
194
- request: Post creation request.
222
+ request: Post creation request. Supports:
223
+ - content: Tweet text (max 280 chars)
224
+ - media_urls: URLs of media to attach
225
+ - reply_to_post_id: Tweet ID to reply to (for threads)
226
+ - quote_post_id: Tweet ID to quote
195
227
 
196
228
  Returns:
197
- Created Post object.
229
+ Post object with:
230
+ - post_id: Twitter tweet ID
231
+ - platform: "twitter"
232
+ - content: Tweet text
233
+ - status: PostStatus.PUBLISHED
234
+ - created_at: Creation timestamp
235
+ - author_id: None (not available without extra API call)
236
+ - url: None (not returned in create response)
237
+ - raw_data: {"tweet_id": ...}
198
238
 
199
239
  Raises:
200
240
  ValidationError: If request is invalid.
201
241
  MediaUploadError: If media upload fails.
242
+ RateLimitError: If Twitter rate limit is exceeded.
243
+ PlatformError: For other Twitter API errors.
202
244
  """
203
245
  if not self._tweepy_client:
204
246
  raise RuntimeError("Client must be used as async context manager")
205
247
 
206
- if not request.content and not request.media_urls:
207
- raise ValidationError(
208
- "Tweet must contain content or media",
209
- platform=self.platform_name,
210
- )
248
+ # Validate request
249
+ self._validate_create_post_request(request)
211
250
 
212
251
  try:
213
252
  media_ids = []
@@ -241,6 +280,13 @@ class TwitterClient(SocialMediaPlatform):
241
280
  response = self._tweepy_client.create_tweet(**tweet_params, user_auth=False)
242
281
  tweet_id = response.data["id"] # type: ignore[index]
243
282
 
283
+ # Construct Twitter URL if username is available
284
+ url = (
285
+ HttpUrl(f"https://x.com/{self.credentials.username}/status/{tweet_id}")
286
+ if self.credentials.username
287
+ else HttpUrl(f"https://x.com/web/status/{tweet_id}")
288
+ )
289
+
244
290
  # Return minimal Post object without fetching details
245
291
  return Post(
246
292
  post_id=tweet_id,
@@ -248,6 +294,9 @@ class TwitterClient(SocialMediaPlatform):
248
294
  content=request.content or "",
249
295
  status=PostStatus.PUBLISHED,
250
296
  created_at=datetime.now(),
297
+ author_id=None, # Not available without extra API call
298
+ url=url,
299
+ raw_data={"tweet_id": tweet_id},
251
300
  )
252
301
 
253
302
  except tweepy.TweepyException as e:
@@ -741,10 +790,19 @@ class TwitterClient(SocialMediaPlatform):
741
790
 
742
791
  metrics = tweet.public_metrics or {}
743
792
 
793
+ # Construct Twitter URL if username is available
794
+ tweet_id = str(tweet.id)
795
+ url = (
796
+ HttpUrl(f"https://x.com/{self.credentials.username}/status/{tweet_id}")
797
+ if self.credentials.username
798
+ else HttpUrl(f"https://x.com/web/status/{tweet_id}")
799
+ )
800
+
744
801
  return Post(
745
- post_id=str(tweet.id),
802
+ post_id=tweet_id,
746
803
  platform=self.platform_name,
747
804
  content=tweet.text,
805
+ url=url,
748
806
  media=media,
749
807
  status=PostStatus.PUBLISHED,
750
808
  created_at=tweet.created_at or datetime.now(),
@@ -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:
File without changes