marqetive-lib 0.1.6__py3-none-any.whl → 0.1.8__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.
@@ -1,13 +1,16 @@
1
- """LinkedIn API client implementation.
1
+ """LinkedIn API client implementation using the Community Management API.
2
2
 
3
3
  This module provides a concrete implementation of the SocialMediaPlatform
4
- ABC for LinkedIn, using the LinkedIn Marketing API and Share API.
4
+ ABC for LinkedIn, using the LinkedIn Community Management API (REST endpoints).
5
+ Supports organization page management, posts, comments, reactions, and social metadata.
5
6
 
6
- API Documentation: https://learn.microsoft.com/en-us/linkedin/
7
+ API Documentation: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/
7
8
  """
8
9
 
10
+ import contextlib
9
11
  from datetime import datetime
10
12
  from typing import Any, cast
13
+ from urllib.parse import quote
11
14
 
12
15
  import httpx
13
16
  from pydantic import HttpUrl
@@ -32,14 +35,28 @@ from marqetive.core.models import (
32
35
  PostUpdateRequest,
33
36
  )
34
37
  from marqetive.platforms.linkedin.media import LinkedInMediaManager, MediaAsset
38
+ from marqetive.platforms.linkedin.models import (
39
+ CommentsState,
40
+ Organization,
41
+ OrganizationType,
42
+ PostSortBy,
43
+ Reaction,
44
+ ReactionType,
45
+ SocialMetadata,
46
+ )
35
47
 
36
48
 
37
49
  class LinkedInClient(SocialMediaPlatform):
38
- """LinkedIn API client.
50
+ """LinkedIn API client using the Community Management API.
39
51
 
40
52
  This client implements the SocialMediaPlatform interface for LinkedIn,
41
- using the LinkedIn Share API and Marketing API. It supports creating
42
- posts (shares), managing comments, and uploading media.
53
+ using the LinkedIn Community Management API (REST endpoints). It supports:
54
+ - Creating and managing posts (including updates)
55
+ - Comments with nested replies and mentions
56
+ - Reactions (Like, Celebrate, Love, Insightful, Support, Funny)
57
+ - Social metadata and engagement metrics
58
+ - Organization (Company Page) management
59
+ - Media uploads (images, videos, documents)
43
60
 
44
61
  Note:
45
62
  - Requires LinkedIn Developer app with appropriate permissions
@@ -47,40 +64,59 @@ class LinkedInClient(SocialMediaPlatform):
47
64
  - Supports both personal profiles and organization pages
48
65
  - Rate limits vary by API endpoint
49
66
 
67
+ Required Permissions:
68
+ - w_organization_social: Post/comment/react on organization pages
69
+ - r_organization_social: Read organization posts/comments
70
+ - rw_organization_admin: Organization management (optional)
71
+ - w_member_social: Post on personal profile
72
+
50
73
  Example:
51
74
  >>> credentials = AuthCredentials(
52
75
  ... platform="linkedin",
53
76
  ... access_token="your_access_token",
54
- ... user_id="urn:li:person:abc123"
77
+ ... user_id="urn:li:organization:12345" # or urn:li:person:abc123
55
78
  ... )
56
79
  >>> async with LinkedInClient(credentials) as client:
80
+ ... # Create a post
57
81
  ... request = PostCreateRequest(
58
82
  ... content="Excited to share our latest update!",
59
83
  ... link="https://example.com"
60
84
  ... )
61
85
  ... post = await client.create_post(request)
86
+ ...
87
+ ... # Add a reaction
88
+ ... await client.add_reaction(post.post_id, ReactionType.LIKE)
89
+ ...
90
+ ... # Get engagement metrics
91
+ ... metadata = await client.get_social_metadata(post.post_id)
62
92
  """
63
93
 
94
+ # Default API version in YYYYMM format
95
+ DEFAULT_API_VERSION = "202511"
96
+
64
97
  def __init__(
65
98
  self,
66
99
  credentials: AuthCredentials,
67
100
  timeout: float = 30.0,
68
- api_version: str = "v2",
101
+ api_version: str | None = None,
69
102
  progress_callback: ProgressCallback | None = None,
70
103
  ) -> None:
71
104
  """Initialize LinkedIn client.
72
105
 
73
106
  Args:
74
- credentials: LinkedIn authentication credentials
75
- timeout: Request timeout in seconds
76
- api_version: LinkedIn API version
107
+ credentials: LinkedIn authentication credentials. Must include
108
+ user_id as URN (urn:li:person:xxx or urn:li:organization:xxx).
109
+ timeout: Request timeout in seconds.
110
+ api_version: LinkedIn API version in YYYYMM format (e.g., "202511").
111
+ Defaults to the latest supported version.
77
112
  progress_callback: Optional callback for progress updates during
78
113
  long-running operations like media uploads.
79
114
 
80
115
  Raises:
81
- PlatformAuthError: If credentials are invalid
116
+ PlatformAuthError: If credentials are invalid.
82
117
  """
83
- base_url = f"https://api.linkedin.com/{api_version}"
118
+ # Use REST API base URL for Community Management API
119
+ base_url = "https://api.linkedin.com/rest"
84
120
  super().__init__(
85
121
  platform_name="linkedin",
86
122
  credentials=credentials,
@@ -91,11 +127,24 @@ class LinkedInClient(SocialMediaPlatform):
91
127
  self.author_urn = (
92
128
  credentials.user_id
93
129
  ) # urn:li:person:xxx or urn:li:organization:xxx
94
- self.api_version = api_version
130
+ self.linkedin_version = api_version or self.DEFAULT_API_VERSION
95
131
 
96
132
  # Media manager (initialized in __aenter__)
97
133
  self._media_manager: LinkedInMediaManager | None = None
98
134
 
135
+ def _build_auth_headers(self) -> dict[str, str]:
136
+ """Build authentication headers for LinkedIn REST API.
137
+
138
+ Returns:
139
+ Dictionary of headers including LinkedIn-specific version headers.
140
+ """
141
+ return {
142
+ "Authorization": f"Bearer {self.credentials.access_token}",
143
+ "Linkedin-Version": self.linkedin_version,
144
+ "X-Restli-Protocol-Version": "2.0.0",
145
+ "Content-Type": "application/json",
146
+ }
147
+
99
148
  async def __aenter__(self) -> "LinkedInClient":
100
149
  """Async context manager entry."""
101
150
  await super().__aenter__()
@@ -110,7 +159,7 @@ class LinkedInClient(SocialMediaPlatform):
110
159
  self._media_manager = LinkedInMediaManager(
111
160
  person_urn=self.author_urn,
112
161
  access_token=self.credentials.access_token,
113
- api_version=self.api_version,
162
+ linkedin_version=self.linkedin_version,
114
163
  timeout=self.timeout,
115
164
  )
116
165
 
@@ -184,18 +233,29 @@ class LinkedInClient(SocialMediaPlatform):
184
233
 
185
234
  try:
186
235
  # Verify credentials by fetching user profile
187
- await self.api_client.get("/me")
188
- return True
236
+ # Note: /userinfo is the standard endpoint for the REST API
237
+ if not self.api_client._client:
238
+ raise RuntimeError("API client not initialized")
239
+
240
+ response = await self.api_client._client.get(
241
+ "https://api.linkedin.com/v2/userinfo",
242
+ headers=self._build_auth_headers(),
243
+ )
244
+ return response.status_code == 200
189
245
  except httpx.HTTPError:
190
246
  return False
191
247
 
192
248
  # ==================== Post CRUD Methods ====================
193
249
 
194
250
  async def create_post(self, request: PostCreateRequest) -> Post:
195
- """Create and publish a LinkedIn post (share).
251
+ """Create and publish a LinkedIn post.
252
+
253
+ Uses the Community Management API to create posts on personal profiles
254
+ or organization pages.
196
255
 
197
256
  Args:
198
- request: Post creation request.
257
+ request: Post creation request. Use additional_data for LinkedIn-specific
258
+ options like visibility, distribution, and call-to-action.
199
259
 
200
260
  Returns:
201
261
  Created Post object.
@@ -203,6 +263,17 @@ class LinkedInClient(SocialMediaPlatform):
203
263
  Raises:
204
264
  ValidationError: If request is invalid.
205
265
  MediaUploadError: If media upload fails.
266
+
267
+ Example:
268
+ >>> request = PostCreateRequest(
269
+ ... content="Check out our new product!",
270
+ ... link="https://example.com/product",
271
+ ... additional_data={
272
+ ... "visibility": "PUBLIC",
273
+ ... "call_to_action": "LEARN_MORE"
274
+ ... }
275
+ ... )
276
+ >>> post = await client.create_post(request)
206
277
  """
207
278
  if not self.api_client:
208
279
  raise RuntimeError("Client must be used as async context manager")
@@ -223,49 +294,81 @@ class LinkedInClient(SocialMediaPlatform):
223
294
  )
224
295
 
225
296
  try:
226
- # Build share payload
227
- share_payload: dict[str, Any] = {
297
+ # Build REST API payload structure
298
+ post_payload: dict[str, Any] = {
228
299
  "author": self.author_urn,
229
- "lifecycleState": "PUBLISHED",
230
- "specificContent": {
231
- "com.linkedin.ugc.ShareContent": {
232
- "shareCommentary": {"text": request.content},
233
- "shareMediaCategory": "NONE",
234
- }
300
+ "commentary": request.content,
301
+ "visibility": request.additional_data.get("visibility", "PUBLIC"),
302
+ "distribution": {
303
+ "feedDistribution": request.additional_data.get(
304
+ "feed_distribution", "MAIN_FEED"
305
+ ),
306
+ "targetEntities": request.additional_data.get(
307
+ "target_entities", []
308
+ ),
309
+ "thirdPartyDistributionChannels": [],
235
310
  },
236
- "visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
311
+ "lifecycleState": "PUBLISHED",
312
+ "isReshareDisabledByAuthor": request.additional_data.get(
313
+ "disable_reshare", False
314
+ ),
237
315
  }
238
316
 
239
- # Add media if provided
317
+ # Add media content if provided
240
318
  if request.media_ids:
241
- share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
242
- "shareMediaCategory"
243
- ] = "IMAGE"
244
- share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
245
- "media"
246
- ] = [
247
- {"status": "READY", "media": media_id}
248
- for media_id in request.media_ids
249
- ]
250
-
251
- # Add link if provided
252
- if request.link:
253
- share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
254
- "shareMediaCategory"
255
- ] = "ARTICLE"
256
- share_payload["specificContent"]["com.linkedin.ugc.ShareContent"][
257
- "media"
258
- ] = [
259
- {
260
- "status": "READY",
261
- "originalUrl": request.link,
319
+ # Determine media type from URN prefix
320
+ media_id = request.media_ids[0]
321
+ post_payload["content"] = {
322
+ "media": {
323
+ "id": media_id,
324
+ "title": request.additional_data.get("media_title"),
325
+ "altText": request.additional_data.get("media_alt_text"),
262
326
  }
263
- ]
264
-
265
- # Create the share
266
- response = await self.api_client.post("/ugcPosts", data=share_payload)
267
-
268
- post_id = response.data["id"]
327
+ }
328
+ # Remove None values
329
+ post_payload["content"]["media"] = {
330
+ k: v
331
+ for k, v in post_payload["content"]["media"].items()
332
+ if v is not None
333
+ }
334
+
335
+ # Add article/link if provided (and no media)
336
+ elif request.link:
337
+ post_payload["content"] = {
338
+ "article": {
339
+ "source": request.link,
340
+ "title": request.additional_data.get("article_title"),
341
+ "description": request.additional_data.get(
342
+ "article_description"
343
+ ),
344
+ }
345
+ }
346
+ # Add thumbnail if provided
347
+ if thumbnail := request.additional_data.get("article_thumbnail"):
348
+ post_payload["content"]["article"]["thumbnail"] = thumbnail
349
+ # Remove None values
350
+ post_payload["content"]["article"] = {
351
+ k: v
352
+ for k, v in post_payload["content"]["article"].items()
353
+ if v is not None
354
+ }
355
+
356
+ # Add call-to-action if provided
357
+ if cta := request.additional_data.get("call_to_action"):
358
+ post_payload["contentCallToActionLabel"] = cta
359
+ if landing_page := request.additional_data.get("landing_page"):
360
+ post_payload["contentLandingPage"] = landing_page
361
+
362
+ # Create the post
363
+ response = await self.api_client.post("/posts", data=post_payload)
364
+
365
+ # Post ID is returned in x-restli-id header or response body
366
+ post_id = response.data.get("id") or response.headers.get("x-restli-id")
367
+ if not post_id:
368
+ raise PlatformError(
369
+ "Failed to get post ID from response",
370
+ platform=self.platform_name,
371
+ )
269
372
 
270
373
  # Fetch full post details
271
374
  return await self.get_post(post_id)
@@ -280,7 +383,7 @@ class LinkedInClient(SocialMediaPlatform):
280
383
  """Retrieve a LinkedIn post by ID.
281
384
 
282
385
  Args:
283
- post_id: LinkedIn post URN (e.g., urn:li:share:123).
386
+ post_id: LinkedIn post URN (e.g., urn:li:share:123 or urn:li:ugcPost:123).
284
387
 
285
388
  Returns:
286
389
  Post object with current data.
@@ -292,7 +395,12 @@ class LinkedInClient(SocialMediaPlatform):
292
395
  raise RuntimeError("Client must be used as async context manager")
293
396
 
294
397
  try:
295
- response = await self.api_client.get(f"/ugcPosts/{post_id}")
398
+ # URL-encode the post URN
399
+ encoded_post_id = quote(post_id, safe="")
400
+ response = await self.api_client.get(
401
+ f"/posts/{encoded_post_id}",
402
+ params={"viewContext": "AUTHOR"},
403
+ )
296
404
  data = response.data
297
405
  return self._parse_post(data)
298
406
 
@@ -315,25 +423,95 @@ class LinkedInClient(SocialMediaPlatform):
315
423
 
316
424
  async def update_post(
317
425
  self,
318
- post_id: str, # noqa: ARG002
319
- request: PostUpdateRequest, # noqa: ARG002
426
+ post_id: str,
427
+ request: PostUpdateRequest,
320
428
  ) -> Post:
321
429
  """Update a LinkedIn post.
322
430
 
323
- Note: LinkedIn has limited support for editing posts. Only certain
324
- fields can be updated, and there are time restrictions.
431
+ The Community Management API supports updating certain fields of published posts:
432
+ - commentary (post text)
433
+ - contentCallToActionLabel
434
+ - contentLandingPage
435
+ - lifecycleState
436
+ - adContext.dscName, adContext.dscStatus (for sponsored content)
325
437
 
326
438
  Args:
327
439
  post_id: LinkedIn post URN.
328
- request: Post update request.
440
+ request: Post update request. Use additional_data for LinkedIn-specific
441
+ fields like call_to_action and landing_page.
442
+
443
+ Returns:
444
+ Updated Post object.
329
445
 
330
446
  Raises:
331
- PlatformError: If post cannot be edited.
447
+ PostNotFoundError: If post doesn't exist.
448
+ ValidationError: If update data is invalid.
449
+
450
+ Example:
451
+ >>> request = PostUpdateRequest(
452
+ ... content="Updated post content!",
453
+ ... additional_data={"call_to_action": "LEARN_MORE"}
454
+ ... )
455
+ >>> post = await client.update_post("urn:li:share:123", request)
332
456
  """
333
- raise PlatformError(
334
- "LinkedIn does not support editing published posts",
335
- platform=self.platform_name,
336
- )
457
+ if not self.api_client:
458
+ raise RuntimeError("Client must be used as async context manager")
459
+
460
+ if not self.api_client._client:
461
+ raise RuntimeError("API client not initialized")
462
+
463
+ try:
464
+ # Build PARTIAL_UPDATE payload
465
+ patch_payload: dict[str, Any] = {"patch": {"$set": {}}}
466
+
467
+ if request.content is not None:
468
+ patch_payload["patch"]["$set"]["commentary"] = request.content
469
+
470
+ # Handle additional LinkedIn-specific fields
471
+ additional = getattr(request, "additional_data", {}) or {}
472
+ if cta := additional.get("call_to_action"):
473
+ patch_payload["patch"]["$set"]["contentCallToActionLabel"] = cta
474
+ if landing_page := additional.get("landing_page"):
475
+ patch_payload["patch"]["$set"]["contentLandingPage"] = landing_page
476
+ if lifecycle := additional.get("lifecycle_state"):
477
+ patch_payload["patch"]["$set"]["lifecycleState"] = lifecycle
478
+
479
+ # Handle ad context updates if provided
480
+ if ad_context := additional.get("ad_context"):
481
+ patch_payload["patch"]["adContext"] = {"$set": ad_context}
482
+
483
+ # Make the PARTIAL_UPDATE request
484
+ encoded_post_id = quote(post_id, safe="")
485
+ headers = {
486
+ **self._build_auth_headers(),
487
+ "X-RestLi-Method": "PARTIAL_UPDATE",
488
+ }
489
+
490
+ await self.api_client._client.post(
491
+ f"{self.base_url}/posts/{encoded_post_id}",
492
+ json=patch_payload,
493
+ headers=headers,
494
+ )
495
+
496
+ # Fetch and return the updated post
497
+ return await self.get_post(post_id)
498
+
499
+ except httpx.HTTPStatusError as e:
500
+ if e.response.status_code == 404:
501
+ raise PostNotFoundError(
502
+ post_id=post_id,
503
+ platform=self.platform_name,
504
+ status_code=404,
505
+ ) from e
506
+ raise PlatformError(
507
+ f"Failed to update post: {e}",
508
+ platform=self.platform_name,
509
+ ) from e
510
+ except httpx.HTTPError as e:
511
+ raise PlatformError(
512
+ f"Failed to update post: {e}",
513
+ platform=self.platform_name,
514
+ ) from e
337
515
 
338
516
  async def delete_post(self, post_id: str) -> bool:
339
517
  """Delete a LinkedIn post.
@@ -351,11 +529,14 @@ class LinkedInClient(SocialMediaPlatform):
351
529
  raise RuntimeError("Client must be used as async context manager")
352
530
 
353
531
  try:
354
- # LinkedIn uses DELETE method for removing posts
355
532
  if not self.api_client._client:
356
533
  raise RuntimeError("API client not initialized")
357
534
 
358
- await self.api_client._client.delete(f"{self.base_url}/ugcPosts/{post_id}")
535
+ encoded_post_id = quote(post_id, safe="")
536
+ await self.api_client._client.delete(
537
+ f"{self.base_url}/posts/{encoded_post_id}",
538
+ headers=self._build_auth_headers(),
539
+ )
359
540
  return True
360
541
 
361
542
  except httpx.HTTPStatusError as e:
@@ -375,6 +556,79 @@ class LinkedInClient(SocialMediaPlatform):
375
556
  platform=self.platform_name,
376
557
  ) from e
377
558
 
559
+ async def list_posts(
560
+ self,
561
+ author_urn: str | None = None,
562
+ limit: int = 10,
563
+ offset: int = 0,
564
+ sort_by: PostSortBy = "LAST_MODIFIED",
565
+ ) -> list[Post]:
566
+ """List posts by author.
567
+
568
+ Retrieves posts created by a specific person or organization.
569
+
570
+ Args:
571
+ author_urn: Person or organization URN. Defaults to the client's author_urn.
572
+ limit: Maximum number of posts to retrieve (max 100).
573
+ offset: Number of posts to skip for pagination.
574
+ sort_by: Sort order - "LAST_MODIFIED" or "CREATED".
575
+
576
+ Returns:
577
+ List of Post objects.
578
+
579
+ Example:
580
+ >>> posts = await client.list_posts(limit=20, sort_by="CREATED")
581
+ >>> org_posts = await client.list_posts(
582
+ ... author_urn="urn:li:organization:12345",
583
+ ... limit=50
584
+ ... )
585
+ """
586
+ if not self.api_client:
587
+ raise RuntimeError("Client must be used as async context manager")
588
+
589
+ if not self.api_client._client:
590
+ raise RuntimeError("API client not initialized")
591
+
592
+ try:
593
+ author = author_urn or self.author_urn
594
+ if not author:
595
+ raise PlatformError(
596
+ "Author URN is required for listing posts",
597
+ platform=self.platform_name,
598
+ )
599
+ encoded_author = quote(author, safe="")
600
+
601
+ # Use FINDER method
602
+ headers = {
603
+ **self._build_auth_headers(),
604
+ "X-RestLi-Method": "FINDER",
605
+ }
606
+
607
+ response = await self.api_client._client.get(
608
+ f"{self.base_url}/posts",
609
+ params={
610
+ "author": encoded_author,
611
+ "q": "author",
612
+ "count": min(limit, 100),
613
+ "start": offset,
614
+ "sortBy": sort_by,
615
+ },
616
+ headers=headers,
617
+ )
618
+
619
+ posts = []
620
+ data = response.json()
621
+ for post_data in data.get("elements", []):
622
+ posts.append(self._parse_post(post_data))
623
+
624
+ return posts
625
+
626
+ except httpx.HTTPError as e:
627
+ raise PlatformError(
628
+ f"Failed to list posts: {e}",
629
+ platform=self.platform_name,
630
+ ) from e
631
+
378
632
  # ==================== Comment Methods ====================
379
633
 
380
634
  async def get_comments(
@@ -386,7 +640,7 @@ class LinkedInClient(SocialMediaPlatform):
386
640
  """Retrieve comments for a LinkedIn post.
387
641
 
388
642
  Args:
389
- post_id: LinkedIn post URN.
643
+ post_id: LinkedIn post URN (share or ugcPost).
390
644
  limit: Maximum number of comments to retrieve.
391
645
  offset: Number of comments to skip.
392
646
 
@@ -397,9 +651,11 @@ class LinkedInClient(SocialMediaPlatform):
397
651
  raise RuntimeError("Client must be used as async context manager")
398
652
 
399
653
  try:
400
- # LinkedIn Social API endpoint for comments
654
+ # URL-encode the post URN
655
+ encoded_post_id = quote(post_id, safe="")
656
+
401
657
  response = await self.api_client.get(
402
- f"/socialActions/{post_id}/comments",
658
+ f"/socialActions/{encoded_post_id}/comments",
403
659
  params={
404
660
  "count": limit,
405
661
  "start": offset,
@@ -418,18 +674,38 @@ class LinkedInClient(SocialMediaPlatform):
418
674
  platform=self.platform_name,
419
675
  ) from e
420
676
 
421
- async def create_comment(self, post_id: str, content: str) -> Comment:
677
+ async def create_comment(
678
+ self,
679
+ post_id: str,
680
+ content: str,
681
+ parent_comment_id: str | None = None,
682
+ image_id: str | None = None,
683
+ ) -> Comment:
422
684
  """Add a comment to a LinkedIn post.
423
685
 
686
+ Supports nested comments (replies) by specifying a parent_comment_id.
687
+
424
688
  Args:
425
- post_id: LinkedIn post URN.
689
+ post_id: LinkedIn post URN (share or ugcPost).
426
690
  content: Text content of the comment.
691
+ parent_comment_id: URN of parent comment for nested replies.
692
+ image_id: URN of an image to attach to the comment.
427
693
 
428
694
  Returns:
429
695
  Created Comment object.
430
696
 
431
697
  Raises:
432
698
  ValidationError: If comment content is invalid.
699
+
700
+ Example:
701
+ >>> # Top-level comment
702
+ >>> comment = await client.create_comment(post_id, "Great post!")
703
+ >>> # Reply to a comment
704
+ >>> reply = await client.create_comment(
705
+ ... post_id,
706
+ ... "Thanks!",
707
+ ... parent_comment_id=comment.comment_id
708
+ ... )
433
709
  """
434
710
  if not self.api_client:
435
711
  raise RuntimeError("Client must be used as async context manager")
@@ -450,22 +726,34 @@ class LinkedInClient(SocialMediaPlatform):
450
726
  )
451
727
 
452
728
  try:
453
- comment_payload = {
729
+ encoded_post_id = quote(post_id, safe="")
730
+
731
+ comment_payload: dict[str, Any] = {
454
732
  "actor": self.author_urn,
455
733
  "message": {"text": content},
456
734
  "object": post_id,
457
735
  }
458
736
 
737
+ # Add parent comment for nested replies
738
+ if parent_comment_id:
739
+ comment_payload["parentComment"] = parent_comment_id
740
+
741
+ # Add image content if provided
742
+ if image_id:
743
+ comment_payload["content"] = [{"entity": {"image": image_id}}]
744
+
459
745
  response = await self.api_client.post(
460
- f"/socialActions/{post_id}/comments",
746
+ f"/socialActions/{encoded_post_id}/comments",
461
747
  data=comment_payload,
462
748
  )
463
749
 
464
- comment_id = response.data["id"]
750
+ # Get comment ID from response
751
+ comment_id = response.data.get("id") or response.headers.get("x-restli-id")
465
752
 
466
753
  # Fetch full comment details
754
+ encoded_comment_id = quote(str(comment_id), safe="")
467
755
  comment_response = await self.api_client.get(
468
- f"/socialActions/{post_id}/comments/{comment_id}"
756
+ f"/socialActions/{encoded_post_id}/comments/{encoded_comment_id}"
469
757
  )
470
758
 
471
759
  return self._parse_comment(comment_response.data, post_id)
@@ -476,11 +764,86 @@ class LinkedInClient(SocialMediaPlatform):
476
764
  platform=self.platform_name,
477
765
  ) from e
478
766
 
479
- async def delete_comment(self, comment_id: str) -> bool:
767
+ async def update_comment(
768
+ self,
769
+ post_id: str,
770
+ comment_id: str,
771
+ content: str,
772
+ ) -> Comment:
773
+ """Update a LinkedIn comment.
774
+
775
+ Only the text content can be updated. Attributes (mentions) can also be modified.
776
+
777
+ Args:
778
+ post_id: LinkedIn post URN (share or ugcPost).
779
+ comment_id: Comment ID (not the full URN).
780
+ content: New text content for the comment.
781
+
782
+ Returns:
783
+ Updated Comment object.
784
+
785
+ Raises:
786
+ ValidationError: If comment content is invalid.
787
+ """
788
+ if not self.api_client:
789
+ raise RuntimeError("Client must be used as async context manager")
790
+
791
+ if not self.api_client._client:
792
+ raise RuntimeError("API client not initialized")
793
+
794
+ if not content or len(content) == 0:
795
+ raise ValidationError(
796
+ "Comment content cannot be empty",
797
+ platform=self.platform_name,
798
+ field="content",
799
+ )
800
+
801
+ if len(content) > 1250:
802
+ raise ValidationError(
803
+ f"Comment exceeds 1250 characters ({len(content)} characters)",
804
+ platform=self.platform_name,
805
+ field="content",
806
+ )
807
+
808
+ try:
809
+ encoded_post_id = quote(post_id, safe="")
810
+ encoded_comment_id = quote(comment_id, safe="")
811
+ encoded_actor = quote(self._ensure_author_urn(), safe="")
812
+
813
+ # Build PARTIAL_UPDATE payload
814
+ patch_payload = {"patch": {"message": {"$set": {"text": content}}}}
815
+
816
+ headers = {
817
+ **self._build_auth_headers(),
818
+ "X-RestLi-Method": "PARTIAL_UPDATE",
819
+ }
820
+
821
+ await self.api_client._client.post(
822
+ f"{self.base_url}/socialActions/{encoded_post_id}/comments/{encoded_comment_id}",
823
+ params={"actor": encoded_actor},
824
+ json=patch_payload,
825
+ headers=headers,
826
+ )
827
+
828
+ # Fetch and return the updated comment
829
+ comment_response = await self.api_client.get(
830
+ f"/socialActions/{encoded_post_id}/comments/{encoded_comment_id}"
831
+ )
832
+
833
+ return self._parse_comment(comment_response.data, post_id)
834
+
835
+ except httpx.HTTPError as e:
836
+ raise PlatformError(
837
+ f"Failed to update comment: {e}",
838
+ platform=self.platform_name,
839
+ ) from e
840
+
841
+ async def delete_comment(self, comment_id: str, post_id: str | None = None) -> bool:
480
842
  """Delete a LinkedIn comment.
481
843
 
482
844
  Args:
483
- comment_id: LinkedIn comment URN.
845
+ comment_id: LinkedIn comment ID or full URN.
846
+ post_id: LinkedIn post URN. Required if comment_id is not a full URN.
484
847
 
485
848
  Returns:
486
849
  True if deletion was successful.
@@ -492,10 +855,22 @@ class LinkedInClient(SocialMediaPlatform):
492
855
  if not self.api_client._client:
493
856
  raise RuntimeError("API client not initialized")
494
857
 
495
- # Extract post ID from comment URN if needed
496
- # Note: This is simplified; actual implementation may vary
858
+ encoded_actor = quote(self._ensure_author_urn(), safe="")
859
+
860
+ # If post_id is provided, use the socialActions endpoint
861
+ if post_id:
862
+ encoded_post_id = quote(post_id, safe="")
863
+ encoded_comment_id = quote(comment_id, safe="")
864
+ url = f"{self.base_url}/socialActions/{encoded_post_id}/comments/{encoded_comment_id}"
865
+ else:
866
+ # Try to use the comment URN directly
867
+ encoded_comment_id = quote(comment_id, safe="")
868
+ url = f"{self.base_url}/comments/{encoded_comment_id}"
869
+
497
870
  await self.api_client._client.delete(
498
- f"{self.base_url}/comments/{comment_id}"
871
+ url,
872
+ params={"actor": encoded_actor},
873
+ headers=self._build_auth_headers(),
499
874
  )
500
875
  return True
501
876
 
@@ -563,6 +938,17 @@ class LinkedInClient(SocialMediaPlatform):
563
938
  field="media_type",
564
939
  )
565
940
 
941
+ # Determine the URL for the media attachment
942
+ # Use download_url if available, original URL if it's http(s), or construct from asset ID
943
+ if asset.download_url:
944
+ media_url_final = asset.download_url
945
+ elif media_url.startswith(("http://", "https://")):
946
+ media_url_final = media_url
947
+ else:
948
+ # Construct a LinkedIn asset URL for local file uploads
949
+ # This uses the asset URN as part of the URL
950
+ media_url_final = f"https://media.linkedin.com/asset/{asset.asset_id}"
951
+
566
952
  return MediaAttachment(
567
953
  media_id=asset.asset_id,
568
954
  media_type=(
@@ -574,10 +960,13 @@ class LinkedInClient(SocialMediaPlatform):
574
960
  else MediaType.IMAGE
575
961
  ) # Document
576
962
  ),
577
- url=cast(HttpUrl, media_url),
963
+ url=cast(HttpUrl, media_url_final),
578
964
  alt_text=alt_text,
579
965
  )
580
966
 
967
+ except ValidationError:
968
+ # Let validation errors propagate as-is
969
+ raise
581
970
  except Exception as e:
582
971
  raise MediaUploadError(
583
972
  f"Failed to upload media: {e}",
@@ -665,44 +1054,539 @@ class LinkedInClient(SocialMediaPlatform):
665
1054
 
666
1055
  return await self._media_manager.upload_document(file_path, title=title)
667
1056
 
1057
+ # ==================== Reactions API Methods ====================
1058
+
1059
+ async def get_reactions(self, entity_urn: str, limit: int = 50) -> list[Reaction]:
1060
+ """Get reactions on a post or comment.
1061
+
1062
+ Args:
1063
+ entity_urn: URN of the entity (share, ugcPost, or comment).
1064
+ limit: Maximum number of reactions to retrieve.
1065
+
1066
+ Returns:
1067
+ List of Reaction objects.
1068
+
1069
+ Example:
1070
+ >>> reactions = await client.get_reactions("urn:li:share:12345")
1071
+ """
1072
+ if not self.api_client:
1073
+ raise RuntimeError("Client must be used as async context manager")
1074
+
1075
+ if not self.api_client._client:
1076
+ raise RuntimeError("API client not initialized")
1077
+
1078
+ try:
1079
+ encoded_entity = quote(entity_urn, safe="")
1080
+
1081
+ response = await self.api_client._client.get(
1082
+ f"{self.base_url}/reactions/(entity:{encoded_entity})",
1083
+ params={
1084
+ "q": "entity",
1085
+ "count": limit,
1086
+ },
1087
+ headers=self._build_auth_headers(),
1088
+ )
1089
+
1090
+ reactions = []
1091
+ data = response.json()
1092
+ for reaction_data in data.get("elements", []):
1093
+ reactions.append(
1094
+ Reaction(
1095
+ actor=reaction_data.get("actor", ""),
1096
+ entity=entity_urn,
1097
+ reaction_type=ReactionType(
1098
+ reaction_data.get("reactionType", "LIKE")
1099
+ ),
1100
+ created_at=(
1101
+ datetime.fromtimestamp(reaction_data["created"] / 1000)
1102
+ if "created" in reaction_data
1103
+ else None
1104
+ ),
1105
+ raw_data=reaction_data,
1106
+ )
1107
+ )
1108
+
1109
+ return reactions
1110
+
1111
+ except httpx.HTTPError as e:
1112
+ raise PlatformError(
1113
+ f"Failed to get reactions: {e}",
1114
+ platform=self.platform_name,
1115
+ ) from e
1116
+
1117
+ async def add_reaction(
1118
+ self,
1119
+ entity_urn: str,
1120
+ reaction_type: ReactionType = ReactionType.LIKE,
1121
+ ) -> bool:
1122
+ """Add a reaction to a post or comment.
1123
+
1124
+ Args:
1125
+ entity_urn: URN of the entity (share, ugcPost, or comment).
1126
+ reaction_type: Type of reaction to add.
1127
+
1128
+ Returns:
1129
+ True if reaction was added successfully.
1130
+
1131
+ Example:
1132
+ >>> await client.add_reaction("urn:li:share:12345", ReactionType.PRAISE)
1133
+ """
1134
+ if not self.api_client:
1135
+ raise RuntimeError("Client must be used as async context manager")
1136
+
1137
+ if not self.api_client._client:
1138
+ raise RuntimeError("API client not initialized")
1139
+
1140
+ try:
1141
+ encoded_actor = quote(self._ensure_author_urn(), safe="")
1142
+
1143
+ # Build reaction payload
1144
+ payload = {
1145
+ "root": entity_urn,
1146
+ "reactionType": reaction_type.value,
1147
+ }
1148
+
1149
+ await self.api_client._client.post(
1150
+ f"{self.base_url}/reactions",
1151
+ params={"actor": encoded_actor},
1152
+ json=payload,
1153
+ headers=self._build_auth_headers(),
1154
+ )
1155
+
1156
+ return True
1157
+
1158
+ except httpx.HTTPError as e:
1159
+ raise PlatformError(
1160
+ f"Failed to add reaction: {e}",
1161
+ platform=self.platform_name,
1162
+ ) from e
1163
+
1164
+ async def remove_reaction(self, entity_urn: str) -> bool:
1165
+ """Remove your reaction from a post or comment.
1166
+
1167
+ Args:
1168
+ entity_urn: URN of the entity (share, ugcPost, or comment).
1169
+
1170
+ Returns:
1171
+ True if reaction was removed successfully.
1172
+
1173
+ Example:
1174
+ >>> await client.remove_reaction("urn:li:share:12345")
1175
+ """
1176
+ if not self.api_client:
1177
+ raise RuntimeError("Client must be used as async context manager")
1178
+
1179
+ if not self.api_client._client:
1180
+ raise RuntimeError("API client not initialized")
1181
+
1182
+ try:
1183
+ encoded_actor = quote(self._ensure_author_urn(), safe="")
1184
+ encoded_entity = quote(entity_urn, safe="")
1185
+
1186
+ await self.api_client._client.delete(
1187
+ f"{self.base_url}/reactions/(actor:{encoded_actor},entity:{encoded_entity})",
1188
+ headers=self._build_auth_headers(),
1189
+ )
1190
+
1191
+ return True
1192
+
1193
+ except httpx.HTTPError as e:
1194
+ raise PlatformError(
1195
+ f"Failed to remove reaction: {e}",
1196
+ platform=self.platform_name,
1197
+ ) from e
1198
+
1199
+ # ==================== Social Metadata API Methods ====================
1200
+
1201
+ async def get_social_metadata(self, entity_urn: str) -> SocialMetadata:
1202
+ """Get social metadata (engagement summary) for a post or comment.
1203
+
1204
+ Returns aggregated engagement data including reaction counts by type
1205
+ and comment counts.
1206
+
1207
+ Args:
1208
+ entity_urn: URN of the entity (share, ugcPost, or comment).
1209
+
1210
+ Returns:
1211
+ SocialMetadata object with engagement summary.
1212
+
1213
+ Example:
1214
+ >>> metadata = await client.get_social_metadata("urn:li:share:12345")
1215
+ >>> print(f"Likes: {metadata.reaction_summaries.get(ReactionType.LIKE, 0)}")
1216
+ >>> print(f"Comments: {metadata.comment_count}")
1217
+ """
1218
+ if not self.api_client:
1219
+ raise RuntimeError("Client must be used as async context manager")
1220
+
1221
+ try:
1222
+ encoded_entity = quote(entity_urn, safe="")
1223
+
1224
+ response = await self.api_client.get(f"/socialMetadata/{encoded_entity}")
1225
+ data = response.data
1226
+
1227
+ # Parse reaction summaries
1228
+ reaction_summaries: dict[ReactionType, int] = {}
1229
+ for reaction_type, summary in data.get("reactionSummaries", {}).items():
1230
+ with contextlib.suppress(ValueError):
1231
+ reaction_summaries[ReactionType(reaction_type)] = summary.get(
1232
+ "count", 0
1233
+ )
1234
+
1235
+ # Parse comment summary
1236
+ comment_summary = data.get("commentSummary", {})
1237
+
1238
+ return SocialMetadata(
1239
+ entity=data.get("entity", entity_urn),
1240
+ reaction_summaries=reaction_summaries,
1241
+ comment_count=comment_summary.get("count", 0),
1242
+ top_level_comment_count=comment_summary.get("topLevelCount", 0),
1243
+ comments_state=CommentsState(data.get("commentsState", "OPEN")),
1244
+ raw_data=data,
1245
+ )
1246
+
1247
+ except httpx.HTTPError as e:
1248
+ raise PlatformError(
1249
+ f"Failed to get social metadata: {e}",
1250
+ platform=self.platform_name,
1251
+ ) from e
1252
+
1253
+ async def set_comments_enabled(self, post_urn: str, enabled: bool) -> bool:
1254
+ """Enable or disable comments on a post.
1255
+
1256
+ WARNING: Disabling comments will DELETE all existing comments on the post.
1257
+
1258
+ Args:
1259
+ post_urn: URN of the post (share or ugcPost).
1260
+ enabled: True to enable comments, False to disable.
1261
+
1262
+ Returns:
1263
+ True if the operation was successful.
1264
+
1265
+ Example:
1266
+ >>> # Disable comments (deletes existing comments!)
1267
+ >>> await client.set_comments_enabled("urn:li:share:12345", False)
1268
+ """
1269
+ if not self.api_client:
1270
+ raise RuntimeError("Client must be used as async context manager")
1271
+
1272
+ if not self.api_client._client:
1273
+ raise RuntimeError("API client not initialized")
1274
+
1275
+ try:
1276
+ encoded_post = quote(post_urn, safe="")
1277
+ encoded_actor = quote(self._ensure_author_urn(), safe="")
1278
+
1279
+ payload = {
1280
+ "patch": {
1281
+ "$set": {
1282
+ "commentsState": "OPEN" if enabled else "CLOSED",
1283
+ }
1284
+ }
1285
+ }
1286
+
1287
+ await self.api_client._client.post(
1288
+ f"{self.base_url}/socialMetadata/{encoded_post}",
1289
+ params={"actor": encoded_actor},
1290
+ json=payload,
1291
+ headers=self._build_auth_headers(),
1292
+ )
1293
+
1294
+ return True
1295
+
1296
+ except httpx.HTTPError as e:
1297
+ raise PlatformError(
1298
+ f"Failed to update comments state: {e}",
1299
+ platform=self.platform_name,
1300
+ ) from e
1301
+
1302
+ # ==================== Organization Management Methods ====================
1303
+
1304
+ async def get_organization(self, org_id: str) -> Organization:
1305
+ """Get organization (Company Page) details.
1306
+
1307
+ Requires administrator access to the organization for full details.
1308
+
1309
+ Args:
1310
+ org_id: Organization ID (numeric) or URN (urn:li:organization:12345).
1311
+
1312
+ Returns:
1313
+ Organization object with company details.
1314
+
1315
+ Example:
1316
+ >>> org = await client.get_organization("12345")
1317
+ >>> print(f"Company: {org.name}")
1318
+ """
1319
+ if not self.api_client:
1320
+ raise RuntimeError("Client must be used as async context manager")
1321
+
1322
+ if not self.api_client._client:
1323
+ raise RuntimeError("API client not initialized")
1324
+
1325
+ try:
1326
+ # Extract numeric ID if URN is provided
1327
+ if org_id.startswith("urn:li:organization:"):
1328
+ numeric_id = org_id.split(":")[-1]
1329
+ else:
1330
+ numeric_id = org_id
1331
+
1332
+ response = await self.api_client._client.get(
1333
+ f"{self.base_url}/organizations/{numeric_id}",
1334
+ headers=self._build_auth_headers(),
1335
+ )
1336
+
1337
+ data = response.json()
1338
+ return self._parse_organization(data)
1339
+
1340
+ except httpx.HTTPStatusError as e:
1341
+ if e.response.status_code == 404:
1342
+ raise PlatformError(
1343
+ f"Organization not found: {org_id}",
1344
+ platform=self.platform_name,
1345
+ ) from e
1346
+ if e.response.status_code == 403:
1347
+ raise PlatformAuthError(
1348
+ f"Not authorized to access organization: {org_id}",
1349
+ platform=self.platform_name,
1350
+ ) from e
1351
+ raise PlatformError(
1352
+ f"Failed to get organization: {e}",
1353
+ platform=self.platform_name,
1354
+ ) from e
1355
+ except httpx.HTTPError as e:
1356
+ raise PlatformError(
1357
+ f"Failed to get organization: {e}",
1358
+ platform=self.platform_name,
1359
+ ) from e
1360
+
1361
+ async def get_organization_by_vanity(self, vanity_name: str) -> Organization:
1362
+ """Find an organization by its vanity name (URL slug).
1363
+
1364
+ Args:
1365
+ vanity_name: Organization's vanity name (e.g., "linkedin" for
1366
+ linkedin.com/company/linkedin).
1367
+
1368
+ Returns:
1369
+ Organization object.
1370
+
1371
+ Example:
1372
+ >>> org = await client.get_organization_by_vanity("linkedin")
1373
+ """
1374
+ if not self.api_client:
1375
+ raise RuntimeError("Client must be used as async context manager")
1376
+
1377
+ if not self.api_client._client:
1378
+ raise RuntimeError("API client not initialized")
1379
+
1380
+ try:
1381
+ response = await self.api_client._client.get(
1382
+ f"{self.base_url}/organizations",
1383
+ params={
1384
+ "q": "vanityName",
1385
+ "vanityName": vanity_name,
1386
+ },
1387
+ headers=self._build_auth_headers(),
1388
+ )
1389
+
1390
+ data = response.json()
1391
+ elements = data.get("elements", [])
1392
+
1393
+ if not elements:
1394
+ raise PlatformError(
1395
+ f"Organization not found with vanity name: {vanity_name}",
1396
+ platform=self.platform_name,
1397
+ )
1398
+
1399
+ return self._parse_organization(elements[0])
1400
+
1401
+ except httpx.HTTPError as e:
1402
+ raise PlatformError(
1403
+ f"Failed to find organization: {e}",
1404
+ platform=self.platform_name,
1405
+ ) from e
1406
+
1407
+ async def get_organization_followers(self, org_id: str) -> int:
1408
+ """Get the follower count for an organization.
1409
+
1410
+ Args:
1411
+ org_id: Organization ID (numeric) or URN.
1412
+
1413
+ Returns:
1414
+ Number of followers.
1415
+
1416
+ Example:
1417
+ >>> followers = await client.get_organization_followers("12345")
1418
+ >>> print(f"Followers: {followers}")
1419
+ """
1420
+ if not self.api_client:
1421
+ raise RuntimeError("Client must be used as async context manager")
1422
+
1423
+ if not self.api_client._client:
1424
+ raise RuntimeError("API client not initialized")
1425
+
1426
+ try:
1427
+ # Build organization URN if needed
1428
+ if org_id.startswith("urn:li:organization:"):
1429
+ org_urn = org_id
1430
+ else:
1431
+ org_urn = f"urn:li:organization:{org_id}"
1432
+
1433
+ encoded_urn = quote(org_urn, safe="")
1434
+
1435
+ response = await self.api_client._client.get(
1436
+ f"{self.base_url}/networkSizes/{encoded_urn}",
1437
+ params={"edgeType": "COMPANY_FOLLOWED_BY_MEMBER"},
1438
+ headers=self._build_auth_headers(),
1439
+ )
1440
+
1441
+ data = response.json()
1442
+ return data.get("firstDegreeSize", 0)
1443
+
1444
+ except httpx.HTTPError as e:
1445
+ raise PlatformError(
1446
+ f"Failed to get organization followers: {e}",
1447
+ platform=self.platform_name,
1448
+ ) from e
1449
+
1450
+ def _parse_organization(self, data: dict[str, Any]) -> Organization:
1451
+ """Parse LinkedIn API response into Organization model.
1452
+
1453
+ Args:
1454
+ data: Raw API response data.
1455
+
1456
+ Returns:
1457
+ Organization object.
1458
+ """
1459
+ # Build organization URN
1460
+ org_id = data.get("id")
1461
+ if org_id and not str(org_id).startswith("urn:"):
1462
+ org_id = f"urn:li:organization:{org_id}"
1463
+
1464
+ # Extract localized name
1465
+ localized_name = data.get("localizedName", "")
1466
+ if not localized_name:
1467
+ # Try to get from name field
1468
+ name_obj = data.get("name", {})
1469
+ if isinstance(name_obj, dict):
1470
+ # Get first locale
1471
+ localized = name_obj.get("localized", {})
1472
+ if localized:
1473
+ localized_name = next(iter(localized.values()), "")
1474
+ else:
1475
+ localized_name = str(name_obj)
1476
+
1477
+ # Extract logo URL
1478
+ logo_url = None
1479
+ logo_v2 = data.get("logoV2", {})
1480
+ if logo_v2:
1481
+ # Try to get the original image URL
1482
+ original = logo_v2.get("original", "")
1483
+ if original:
1484
+ logo_url = original
1485
+
1486
+ # Map organization type
1487
+ org_type = None
1488
+ primary_type = data.get("primaryOrganizationType")
1489
+ if primary_type:
1490
+ with contextlib.suppress(ValueError):
1491
+ org_type = OrganizationType(primary_type)
1492
+
1493
+ return Organization(
1494
+ id=str(org_id) if org_id else "",
1495
+ name=(
1496
+ data.get("name", localized_name)
1497
+ if isinstance(data.get("name"), str)
1498
+ else localized_name
1499
+ ),
1500
+ localized_name=localized_name,
1501
+ vanity_name=data.get("vanityName"),
1502
+ logo_url=logo_url,
1503
+ follower_count=data.get("followerCount"),
1504
+ primary_type=org_type,
1505
+ website_url=data.get("websiteUrl"),
1506
+ description=data.get("description"),
1507
+ industry=data.get("industry"),
1508
+ raw_data=data,
1509
+ )
1510
+
668
1511
  # ==================== Helper Methods ====================
669
1512
 
1513
+ def _ensure_author_urn(self) -> str:
1514
+ """Ensure author URN is set and return it.
1515
+
1516
+ Returns:
1517
+ The author URN string.
1518
+
1519
+ Raises:
1520
+ PlatformAuthError: If author URN is not set.
1521
+ """
1522
+ if not self.author_urn:
1523
+ raise PlatformAuthError(
1524
+ "Author URN (user_id) is required but not set in credentials",
1525
+ platform=self.platform_name,
1526
+ )
1527
+ return self.author_urn
1528
+
670
1529
  def _parse_post(self, data: dict[str, Any]) -> Post:
671
1530
  """Parse LinkedIn API response into Post model.
672
1531
 
1532
+ Supports both the new REST API format (Community Management API)
1533
+ and the legacy UGC API format for backwards compatibility.
1534
+
673
1535
  Args:
674
1536
  data: Raw API response data.
675
1537
 
676
1538
  Returns:
677
1539
  Post object.
678
1540
  """
679
- content = ""
680
- if "specificContent" in data:
1541
+ # New REST API format uses "commentary" directly
1542
+ content = data.get("commentary", "")
1543
+
1544
+ # Fallback to old UGC format if needed
1545
+ if not content and "specificContent" in data:
681
1546
  share_content = data["specificContent"].get(
682
1547
  "com.linkedin.ugc.ShareContent", {}
683
1548
  )
684
1549
  commentary = share_content.get("shareCommentary", {})
685
1550
  content = commentary.get("text", "")
686
1551
 
687
- # Extract timestamps
688
- created_timestamp = data.get("created", {}).get("time", 0)
1552
+ # Extract timestamps - REST API uses createdAt/publishedAt in milliseconds
1553
+ created_timestamp = data.get("createdAt") or data.get("publishedAt")
1554
+ if not created_timestamp:
1555
+ # Legacy format: nested object
1556
+ created_timestamp = data.get("created", {}).get("time", 0)
1557
+
689
1558
  created_at = (
690
1559
  datetime.fromtimestamp(created_timestamp / 1000)
691
1560
  if created_timestamp
692
1561
  else datetime.now()
693
1562
  )
694
1563
 
1564
+ # Extract updated timestamp if available
1565
+ updated_timestamp = data.get("lastModifiedAt")
1566
+ updated_at = (
1567
+ datetime.fromtimestamp(updated_timestamp / 1000)
1568
+ if updated_timestamp
1569
+ else None
1570
+ )
1571
+
1572
+ # Map lifecycle state to post status
1573
+ lifecycle_state = data.get("lifecycleState", "PUBLISHED")
1574
+ status_map = {
1575
+ "PUBLISHED": PostStatus.PUBLISHED,
1576
+ "DRAFT": PostStatus.DRAFT,
1577
+ "PUBLISH_REQUESTED": PostStatus.SCHEDULED,
1578
+ "PUBLISH_FAILED": PostStatus.FAILED,
1579
+ }
1580
+ status = status_map.get(lifecycle_state, PostStatus.DRAFT)
1581
+
695
1582
  return Post(
696
1583
  post_id=data["id"],
697
1584
  platform=self.platform_name,
698
1585
  content=content,
699
1586
  media=[], # Media parsing would go here
700
- status=(
701
- PostStatus.PUBLISHED
702
- if data.get("lifecycleState") == "PUBLISHED"
703
- else PostStatus.DRAFT
704
- ),
1587
+ status=status,
705
1588
  created_at=created_at,
1589
+ updated_at=updated_at,
706
1590
  author_id=data.get("author"),
707
1591
  raw_data=data,
708
1592
  )