""" ActivityPub client for communicating with fediverse servers """ import requests import json import threading import time import logging from typing import Dict, List, Optional, Any, Callable from urllib.parse import urljoin, urlparse from datetime import datetime try: import websocket WEBSOCKET_AVAILABLE = True except ImportError: WEBSOCKET_AVAILABLE = False from models.post import Post from models.user import User class ActivityPubClient: """Main ActivityPub client for fediverse communication""" def __init__(self, instance_url: str, access_token: Optional[str] = None): self.instance_url = instance_url.rstrip('/') self.access_token = access_token self.session = requests.Session() self.logger = logging.getLogger('bifrost.activitypub') # Set up headers self.session.headers.update({ 'User-Agent': 'Bifrost/1.0.0 (Accessible Fediverse Client)', 'Accept': 'application/json', 'Content-Type': 'application/json' }) if access_token: self.session.headers['Authorization'] = f'Bearer {access_token}' # Streaming support self.ws = None self.streaming_thread = None self.streaming_active = False self.stream_callbacks = {} self.streaming_supported = True # Assume supported until proven otherwise def _make_request(self, method: str, endpoint: str, params: Optional[Dict] = None, data: Optional[Dict] = None, files: Optional[Dict] = None) -> Dict: """Make an authenticated request to the API""" url = urljoin(self.instance_url, endpoint) try: if method.upper() == 'GET': response = self.session.get(url, params=params, timeout=30) elif method.upper() == 'POST': if files: # For file uploads, don't set Content-Type header headers = {k: v for k, v in self.session.headers.items() if k != 'Content-Type'} response = self.session.post(url, data=data, files=files, headers=headers, timeout=30) else: response = self.session.post(url, json=data, timeout=30) elif method.upper() == 'PUT': response = self.session.put(url, json=data, timeout=30) elif method.upper() == 'DELETE': response = self.session.delete(url, timeout=30) else: raise ValueError(f"Unsupported HTTP method: {method}") response.raise_for_status() # Handle different success responses if response.status_code in [200, 201, 202]: if response.content: try: return response.json() except json.JSONDecodeError: # Some endpoints might return non-JSON on success return {"success": True, "status_code": response.status_code} return {"success": True, "status_code": response.status_code} return {} except requests.exceptions.RequestException as e: raise Exception(f"API request failed: {e}") except json.JSONDecodeError as e: raise Exception(f"Invalid JSON response: {e}") def verify_credentials(self) -> Dict: """Verify account credentials""" return self._make_request('GET', '/api/v1/accounts/verify_credentials') def get_timeline(self, timeline_type: str = 'home', limit: int = 40, max_id: Optional[str] = None, since_id: Optional[str] = None) -> List[Dict]: """Get timeline posts""" # Map timeline types to correct endpoints if timeline_type == 'local': endpoint = '/api/v1/timelines/public' params = {'limit': limit, 'local': 'true'} elif timeline_type == 'federated': endpoint = '/api/v1/timelines/public' params = {'limit': limit, 'local': 'false'} else: # home timeline endpoint = f'/api/v1/timelines/{timeline_type}' params = {'limit': limit} if max_id: params['max_id'] = max_id if since_id: params['since_id'] = since_id return self._make_request('GET', endpoint, params=params) def get_status(self, status_id: str) -> Dict: """Get a single status by ID""" endpoint = f'/api/v1/statuses/{status_id}' return self._make_request('GET', endpoint) def get_status_context(self, status_id: str) -> Dict: """Get context (replies/ancestors) for a status""" endpoint = f'/api/v1/statuses/{status_id}/context' return self._make_request('GET', endpoint) def get_status_favourited_by(self, status_id: str, limit: int = 40) -> List[Dict]: """Get list of accounts that favorited a status""" endpoint = f'/api/v1/statuses/{status_id}/favourited_by' params = {'limit': limit} return self._make_request('GET', endpoint, params=params) def get_status_reblogged_by(self, status_id: str, limit: int = 40) -> List[Dict]: """Get list of accounts that reblogged/boosted a status""" endpoint = f'/api/v1/statuses/{status_id}/reblogged_by' params = {'limit': limit} return self._make_request('GET', endpoint, params=params) def post_status(self, content: str, visibility: str = 'public', content_warning: Optional[str] = None, in_reply_to_id: Optional[str] = None, media_ids: Optional[List[str]] = None, content_type: str = 'text/plain', poll: Optional[Dict] = None) -> Dict: """Post a new status""" data = { 'status': content, 'visibility': visibility } # Add content type for instances that support it (Pleroma, GoToSocial) if content_type == 'text/markdown': data['content_type'] = 'text/markdown' if content_warning: data['spoiler_text'] = content_warning if in_reply_to_id: data['in_reply_to_id'] = in_reply_to_id if media_ids: data['media_ids'] = media_ids if poll: data['poll'] = poll return self._make_request('POST', '/api/v1/statuses', data=data) def delete_status(self, status_id: str) -> Dict: """Delete a status""" endpoint = f'/api/v1/statuses/{status_id}' return self._make_request('DELETE', endpoint) def favourite_status(self, status_id: str) -> Dict: """Favourite a status""" endpoint = f'/api/v1/statuses/{status_id}/favourite' return self._make_request('POST', endpoint) def unfavourite_status(self, status_id: str) -> Dict: """Unfavourite a status""" endpoint = f'/api/v1/statuses/{status_id}/unfavourite' return self._make_request('POST', endpoint) def reblog_status(self, status_id: str) -> Dict: """Reblog/boost a status""" endpoint = f'/api/v1/statuses/{status_id}/reblog' return self._make_request('POST', endpoint) def unreblog_status(self, status_id: str) -> Dict: """Unreblog/unboost a status""" endpoint = f'/api/v1/statuses/{status_id}/unreblog' return self._make_request('POST', endpoint) def get_notifications(self, limit: int = 20, max_id: Optional[str] = None, types: Optional[List[str]] = None) -> List[Dict]: """Get notifications""" params = {'limit': limit} if max_id: params['max_id'] = max_id if types: params['types[]'] = types return self._make_request('GET', '/api/v1/notifications', params=params) def get_account(self, account_id: str) -> Dict: """Get account information""" endpoint = f'/api/v1/accounts/{account_id}' return self._make_request('GET', endpoint) def follow_account(self, account_id: str) -> Dict: """Follow an account""" endpoint = f'/api/v1/accounts/{account_id}/follow' return self._make_request('POST', endpoint) def unfollow_account(self, account_id: str) -> Dict: """Unfollow an account""" endpoint = f'/api/v1/accounts/{account_id}/unfollow' return self._make_request('POST', endpoint) def get_followers(self, account_id: str, max_id: Optional[str] = None, limit: int = 40) -> List[Dict]: """Get followers for an account""" params = {'limit': limit} if max_id: params['max_id'] = max_id endpoint = f'/api/v1/accounts/{account_id}/followers' return self._make_request('GET', endpoint, params=params) def get_following(self, account_id: str, max_id: Optional[str] = None, limit: int = 40) -> List[Dict]: """Get accounts that an account is following""" params = {'limit': limit} if max_id: params['max_id'] = max_id endpoint = f'/api/v1/accounts/{account_id}/following' return self._make_request('GET', endpoint, params=params) def search_accounts(self, query: str, limit: int = 10) -> List[Dict]: """Search for accounts by username""" # Ensure limit is a valid integer for Pleroma compatibility limit = max(1, min(int(limit), 80)) params = { 'q': query, 'type': 'accounts', 'limit': limit, 'resolve': True # Enable remote user resolution } try: # Try v2 search first (preferred) result = self._make_request('GET', '/api/v2/search', params=params) return result.get('accounts', []) except Exception: try: # Fallback to v1 accounts search (Pleroma-friendly) fallback_params = { 'q': query, 'limit': limit, 'resolve': True } return self._make_request('GET', '/api/v1/accounts/search', params=fallback_params) except Exception: # Return empty list if both fail return [] def search(self, query: str, account_id: Optional[str] = None, max_id: Optional[str] = None, min_id: Optional[str] = None, type_filter: Optional[str] = None, limit: int = 20) -> Dict: """Search for content""" params = { 'q': query, 'limit': limit } if account_id: params['account_id'] = account_id if max_id: params['max_id'] = max_id if min_id: params['min_id'] = min_id if type_filter: params['type'] = type_filter return self._make_request('GET', '/api/v2/search', params=params) def upload_media(self, file_path: str, description: Optional[str] = None) -> Dict: """Upload a media file""" with open(file_path, 'rb') as f: files = {'file': f} data = {} if description: data['description'] = description return self._make_request('POST', '/api/v1/media', data=data, files=files) def get_instance_info(self) -> Dict: """Get instance information""" return self._make_request('GET', '/api/v1/instance') def get_custom_emojis(self) -> List[Dict]: """Get custom emojis for this instance""" return self._make_request('GET', '/api/v1/custom_emojis') def edit_status(self, status_id: str, content: str, visibility: str = 'public', content_warning: Optional[str] = None, media_ids: Optional[List[str]] = None, content_type: str = 'text/plain') -> Dict: """Edit an existing status""" data = { 'status': content, 'visibility': visibility } if content_type == 'text/markdown': data['content_type'] = 'text/markdown' if content_warning: data['spoiler_text'] = content_warning if media_ids: data['media_ids'] = media_ids endpoint = f'/api/v1/statuses/{status_id}' return self._make_request('PUT', endpoint, data=data) def get_relationship(self, account_id: str) -> Dict: """Get relationship with an account""" params = {'id': account_id} result = self._make_request('GET', '/api/v1/accounts/relationships', params=params) return result[0] if result else {} def get_conversations(self, limit: int = 20, max_id: Optional[str] = None, since_id: Optional[str] = None, min_id: Optional[str] = None) -> List[Dict]: """Get direct message conversations""" params = {'limit': limit} if max_id: params['max_id'] = max_id if since_id: params['since_id'] = since_id if min_id: params['min_id'] = min_id return self._make_request('GET', '/api/v1/conversations', params=params) def mark_conversation_read(self, conversation_id: str) -> Dict: """Mark a conversation as read""" endpoint = f'/api/v1/conversations/{conversation_id}/read' return self._make_request('POST', endpoint) def delete_conversation(self, conversation_id: str) -> Dict: """Remove conversation from list""" endpoint = f'/api/v1/conversations/{conversation_id}' return self._make_request('DELETE', endpoint) def get_pleroma_chats(self, limit: int = 20, max_id: Optional[str] = None) -> List[Dict]: """Get Pleroma chat conversations (Pleroma-specific)""" params = {'limit': limit} if max_id: params['max_id'] = max_id try: return self._make_request('GET', '/api/v1/pleroma/chats', params=params) except Exception: # Pleroma chats not supported, return empty list return [] def get_pleroma_chat_messages(self, chat_id: str, limit: int = 20, max_id: Optional[str] = None) -> List[Dict]: """Get messages from a Pleroma chat conversation""" params = {'limit': limit} if max_id: params['max_id'] = max_id endpoint = f'/api/v1/pleroma/chats/{chat_id}/messages' return self._make_request('GET', endpoint, params=params) def send_pleroma_chat_message(self, chat_id: str, content: str, media_id: Optional[str] = None) -> Dict: """Send a message to a Pleroma chat conversation""" data = {'content': content} if media_id: data['media_id'] = media_id endpoint = f'/api/v1/pleroma/chats/{chat_id}/messages' return self._make_request('POST', endpoint, data=data) def get_bookmarks(self, limit: int = 20, max_id: Optional[str] = None, since_id: Optional[str] = None, min_id: Optional[str] = None) -> List[Dict]: """Get bookmarked posts""" params = {'limit': limit} if max_id: params['max_id'] = max_id if since_id: params['since_id'] = since_id if min_id: params['min_id'] = min_id return self._make_request('GET', '/api/v1/bookmarks', params=params) def bookmark_status(self, status_id: str) -> Dict: """Bookmark a status""" endpoint = f'/api/v1/statuses/{status_id}/bookmark' return self._make_request('POST', endpoint) def unbookmark_status(self, status_id: str) -> Dict: """Remove bookmark from a status""" endpoint = f'/api/v1/statuses/{status_id}/unbookmark' return self._make_request('POST', endpoint) def create_poll(self, options: List[str], expires_in: int, multiple: bool = False, hide_totals: bool = False) -> Dict: """Create a poll (used with post_status)""" return { 'options': options, 'expires_in': expires_in, 'multiple': multiple, 'hide_totals': hide_totals } def vote_in_poll(self, poll_id: str, choices: List[int]) -> Dict: """Vote in a poll""" endpoint = f'/api/v1/polls/{poll_id}/votes' data = {'choices': choices} return self._make_request('POST', endpoint, data=data) def get_account_statuses(self, account_id: str, limit: int = 40, max_id: Optional[str] = None, since_id: Optional[str] = None, exclude_reblogs: bool = False, exclude_replies: bool = False, only_media: bool = False, pinned: bool = False) -> List[Dict]: """Get account's statuses/posts""" params = {'limit': limit} if max_id: params['max_id'] = max_id if since_id: params['since_id'] = since_id if exclude_reblogs: params['exclude_reblogs'] = 'true' if exclude_replies: params['exclude_replies'] = 'true' if only_media: params['only_media'] = 'true' if pinned: params['pinned'] = 'true' endpoint = f'/api/v1/accounts/{account_id}/statuses' return self._make_request('GET', endpoint, params=params) def block_account(self, account_id: str) -> Dict: """Block an account""" endpoint = f'/api/v1/accounts/{account_id}/block' return self._make_request('POST', endpoint) def unblock_account(self, account_id: str) -> Dict: """Unblock an account""" endpoint = f'/api/v1/accounts/{account_id}/unblock' return self._make_request('POST', endpoint) def mute_account(self, account_id: str, notifications: bool = True) -> Dict: """Mute an account""" endpoint = f'/api/v1/accounts/{account_id}/mute' data = {'notifications': notifications} return self._make_request('POST', endpoint, data=data) def unmute_account(self, account_id: str) -> Dict: """Unmute an account""" endpoint = f'/api/v1/accounts/{account_id}/unmute' return self._make_request('POST', endpoint) def get_blocked_accounts(self, limit: int = 40, max_id: Optional[str] = None) -> List[Dict]: """Get list of blocked accounts""" params = {'limit': limit} if max_id: params['max_id'] = max_id return self._make_request('GET', '/api/v1/blocks', params=params) def get_muted_accounts(self, limit: int = 40, max_id: Optional[str] = None) -> List[Dict]: """Get list of muted accounts""" params = {'limit': limit} if max_id: params['max_id'] = max_id return self._make_request('GET', '/api/v1/mutes', params=params) def get_custom_emojis(self) -> List[Dict]: """Get list of custom emojis available on this instance""" return self._make_request('GET', '/api/v1/custom_emojis') def get_instance_info(self) -> Dict: """Get instance information including upload limits""" return self._make_request('GET', '/api/v1/instance') def update_media_attachment(self, media_id: str, description: str = None, focus: str = None) -> Dict: """Update media attachment with description (alt text) and focus""" data = {} if description is not None: data['description'] = description if focus is not None: data['focus'] = focus return self._make_request('PUT', f'/api/v1/media/{media_id}', data=data) def start_streaming(self, timeline_type: str = 'home', callback: Optional[Callable] = None): """Start streaming timeline updates via Server-Sent Events (SSE)""" if not self.access_token: raise AuthenticationError("Access token required for streaming") if self.streaming_active: self.stop_streaming() # Store callback if callback: self.stream_callbacks[timeline_type] = callback # Build SSE streaming URL with proper endpoint # Most servers use the specific timeline endpoint format if timeline_type == 'home': stream_url = f"{self.instance_url}/api/v1/streaming/user" elif timeline_type == 'local': stream_url = f"{self.instance_url}/api/v1/streaming/public/local" elif timeline_type == 'federated': stream_url = f"{self.instance_url}/api/v1/streaming/public" elif timeline_type == 'notifications': stream_url = f"{self.instance_url}/api/v1/streaming/user/notification" else: stream_url = f"{self.instance_url}/api/v1/streaming/user" # fallback to user timeline # Set up streaming parameters (access_token only) params = {'access_token': self.access_token} # Start SSE streaming in separate thread self.streaming_active = True self.streaming_thread = threading.Thread( target=self._sse_stream_worker, args=(stream_url, params, timeline_type), daemon=True ) self.streaming_thread.start() self.logger.info(f"Started SSE streaming for {timeline_type} timeline") def _sse_stream_worker(self, stream_url, params, timeline_type): """Worker thread for SSE streaming""" try: headers = { 'Authorization': f'Bearer {self.access_token}', 'Accept': 'text/event-stream', 'Cache-Control': 'no-cache', 'User-Agent': 'Bifrost/1.0.0 (Accessible Fediverse Client)' } self.logger.debug(f"Connecting to SSE stream: {stream_url}") response = requests.get(stream_url, headers=headers, params=params, stream=True, timeout=30) if response.status_code == 200: self.logger.info(f"Streaming connected for {timeline_type}") for line in response.iter_lines(decode_unicode=True): if not self.streaming_active: break if line: self.logger.debug(f"SSE line: {line[:100]}...") self._handle_sse_line(line, timeline_type) else: error_details = response.text[:500] if response.text else "No error details" self.logger.error(f"Streaming failed: HTTP {response.status_code}, URL: {stream_url}, Params: {params}, Error: {error_details}") # Try different fallback URL formats for GoToSocial fallback_urls = [ # Standard Mastodon format (f"{self.instance_url}/api/v1/streaming", {'stream': timeline_type, 'access_token': self.access_token}), # GoToSocial might use different format (f"{self.instance_url}/api/v1/streaming/{timeline_type}", {'access_token': self.access_token}), # Alternative auth format (f"{self.instance_url}/api/v1/streaming", {'stream': timeline_type}), # Token in header only ] for fallback_url, fallback_params in fallback_urls: self.logger.debug(f"Trying fallback: {fallback_url}, Params: {fallback_params}") # For the header-only version, don't pass token in params if 'access_token' not in fallback_params: fallback_headers = headers.copy() else: fallback_headers = headers response = requests.get(fallback_url, headers=fallback_headers, params=fallback_params, stream=True, timeout=30) if response.status_code == 200: self.logger.info(f"Fallback streaming connected for {timeline_type}") for line in response.iter_lines(decode_unicode=True): if not self.streaming_active: break if line: self.logger.debug(f"SSE line: {line[:100]}...") self._handle_sse_line(line, timeline_type) return # Success, exit the method else: self.logger.warning(f"Fallback failed: HTTP {response.status_code} - {response.text[:200]}...") self.logger.warning("All streaming endpoints failed - server likely doesn't support streaming") # Mark this server as non-streaming for future reference self.streaming_supported = False except requests.exceptions.RequestException as e: self.logger.error(f"Streaming connection error: {e}") except Exception as e: self.logger.error(f"Streaming error: {e}") finally: self.logger.info("Streaming connection closed") self.streaming_active = False def _handle_sse_line(self, line, timeline_type): """Handle a single SSE line""" try: # SSE format: "event: eventname" or "data: {...}" if line.startswith('event:'): self.current_event = line[6:].strip() elif line.startswith('data:'): data = line[5:].strip() if data and hasattr(self, 'current_event'): event_data = { 'event': self.current_event, 'payload': data } self._handle_stream_message(json.dumps(event_data), timeline_type) except Exception as e: self.logger.error(f"Error handling SSE line: {e}") def stop_streaming(self): """Stop the streaming connection""" self.streaming_active = False if hasattr(self, 'ws') and self.ws: self.ws.close() self.ws = None if self.streaming_thread and self.streaming_thread.is_alive(): self.streaming_thread.join(timeout=5) def _handle_stream_message(self, message: str, timeline_type: str): """Handle incoming streaming message""" try: data = json.loads(message) # Handle different event types if data.get('event') == 'update': # New post post_data = json.loads(data.get('payload', '{}')) if timeline_type in self.stream_callbacks: self.stream_callbacks[timeline_type]('new_post', post_data) elif data.get('event') == 'notification': # New notification notification_data = json.loads(data.get('payload', '{}')) if 'notification' in self.stream_callbacks: self.stream_callbacks['notification']('new_notification', notification_data) elif data.get('event') == 'delete': # Post deleted status_id = data.get('payload') if timeline_type in self.stream_callbacks: self.stream_callbacks[timeline_type]('delete_post', status_id) except json.JSONDecodeError as e: self.logger.error(f"Failed to parse streaming message: {e}") except Exception as e: self.logger.error(f"Error handling streaming message: {e}") def is_streaming_active(self) -> bool: """Check if streaming is currently active""" return self.streaming_active and self.ws is not None class AuthenticationError(Exception): """Raised when authentication fails""" pass class RateLimitError(Exception): """Raised when rate limit is exceeded""" pass class ServerError(Exception): """Raised when server returns an error""" pass