A community based topic aggregation platform built on atproto
1"""
2Coves API Client for posting to communities.
3
4Handles authentication and posting via XRPC.
5"""
6import logging
7import requests
8from typing import Dict, List, Optional
9from atproto import Client
10
11logger = logging.getLogger(__name__)
12
13
14class CovesClient:
15 """
16 Client for posting to Coves communities via XRPC.
17
18 Handles:
19 - Authentication with aggregator credentials
20 - Creating posts in communities (social.coves.community.post.create)
21 - External embed formatting
22 """
23
24 def __init__(self, api_url: str, handle: str, password: str, pds_url: Optional[str] = None):
25 """
26 Initialize Coves client.
27
28 Args:
29 api_url: Coves AppView URL for posting (e.g., "http://localhost:8081")
30 handle: Aggregator handle (e.g., "kagi-news.coves.social")
31 password: Aggregator password/app password
32 pds_url: Optional PDS URL for authentication (defaults to api_url)
33 """
34 self.api_url = api_url
35 self.pds_url = pds_url or api_url # Auth through PDS, post through AppView
36 self.handle = handle
37 self.password = password
38 self.client = Client(base_url=self.pds_url) # Use PDS for auth
39 self._authenticated = False
40
41 def authenticate(self):
42 """
43 Authenticate with Coves API.
44
45 Uses com.atproto.server.createSession directly to avoid
46 Bluesky-specific endpoints that don't exist on Coves PDS.
47
48 Raises:
49 Exception: If authentication fails
50 """
51 try:
52 logger.info(f"Authenticating as {self.handle}")
53
54 # Use createSession directly (avoid app.bsky.actor.getProfile)
55 session = self.client.com.atproto.server.create_session(
56 {"identifier": self.handle, "password": self.password}
57 )
58
59 # Manually set session (skip profile fetch)
60 self.client._session = session
61 self._authenticated = True
62 self.did = session.did
63
64 logger.info(f"Authentication successful (DID: {self.did})")
65 except Exception as e:
66 logger.error(f"Authentication failed: {e}")
67 raise
68
69 def create_post(
70 self,
71 community_handle: str,
72 content: str,
73 facets: List[Dict],
74 title: Optional[str] = None,
75 embed: Optional[Dict] = None
76 ) -> str:
77 """
78 Create a post in a community.
79
80 Args:
81 community_handle: Community handle (e.g., "world-news.coves.social")
82 content: Post content (rich text)
83 facets: Rich text facets (formatting, links)
84 title: Optional post title
85 embed: Optional external embed
86
87 Returns:
88 AT Proto URI of created post (e.g., "at://did:plc:.../social.coves.post/...")
89
90 Raises:
91 Exception: If post creation fails
92 """
93 if not self._authenticated:
94 self.authenticate()
95
96 try:
97 # Prepare post data for social.coves.community.post.create endpoint
98 post_data = {
99 "community": community_handle,
100 "content": content,
101 "facets": facets
102 }
103
104 # Add title if provided
105 if title:
106 post_data["title"] = title
107
108 # Add embed if provided
109 if embed:
110 post_data["embed"] = embed
111
112 # Use Coves-specific endpoint (not direct PDS write)
113 # This provides validation, authorization, and business logic
114 logger.info(f"Creating post in community: {community_handle}")
115
116 # Make direct HTTP request to XRPC endpoint
117 url = f"{self.api_url}/xrpc/social.coves.community.post.create"
118 headers = {
119 "Authorization": f"Bearer {self.client._session.access_jwt}",
120 "Content-Type": "application/json"
121 }
122
123 response = requests.post(url, json=post_data, headers=headers, timeout=30)
124
125 # Log detailed error if request fails
126 if not response.ok:
127 error_body = response.text
128 logger.error(f"Post creation failed ({response.status_code}): {error_body}")
129 response.raise_for_status()
130
131 result = response.json()
132 post_uri = result["uri"]
133 logger.info(f"Post created: {post_uri}")
134 return post_uri
135
136 except Exception as e:
137 logger.error(f"Failed to create post: {e}")
138 raise
139
140 def create_external_embed(
141 self,
142 uri: str,
143 title: str,
144 description: str,
145 thumb: Optional[str] = None
146 ) -> Dict:
147 """
148 Create external embed object for hot-linked content.
149
150 Args:
151 uri: External URL (story link)
152 title: Story title
153 description: Story description/summary
154 thumb: Optional thumbnail image URL
155
156 Returns:
157 External embed dictionary
158 """
159 embed = {
160 "$type": "social.coves.embed.external",
161 "external": {
162 "uri": uri,
163 "title": title,
164 "description": description
165 }
166 }
167
168 if thumb:
169 embed["external"]["thumb"] = thumb
170
171 return embed
172
173 def _get_timestamp(self) -> str:
174 """
175 Get current timestamp in ISO 8601 format.
176
177 Returns:
178 ISO timestamp string
179 """
180 from datetime import datetime, timezone
181 return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")