Files
bifrost/src/activitypub/client.py
Storm Dragon 8b9187e23f Implement comprehensive professional logging system
Added complete logging infrastructure for AI-only development debugging:

**Core Logging Features:**
- Command-line debug flags: -d (console) and -d filename (file output)
- Custom log format: "message - severity - timestamp"
- Professional Python logging with hierarchical loggers (bifrost.module)
- Clean separation: debug mode vs production (warnings/errors only)

**Comprehensive Coverage - Replaced 55+ Print Statements:**
- timeline_view.py: Timeline operations, new content detection, sound events
- main_window.py: Auto-refresh system, streaming mode, UI events
- activitypub/client.py: API calls, streaming connections, server detection
- audio/sound_manager.py: Sound playback, pack loading, volume control
- error_manager.py: Centralized error handling with proper log levels
- All remaining modules: Complete print statement elimination

**Enhanced Auto-Refresh Debugging:**
- Fixed repetitive refresh interval logging (only logs on changes)
- Added detailed auto-refresh execution tracing with timing
- New content detection logging with post ID tracking
- Sound event logging showing which sounds play and why

**Sound System Visibility:**
- Complete audio event logging with file paths and volumes
- Sound pack loading and fallback detection
- Audio playback success/failure with detailed error context

**Documentation Updates:**
- README.md: Complete debug system documentation for users
- CLAUDE.md: Professional logging guidelines for AI development
- Comprehensive usage examples and troubleshooting guides

This logging system provides essential debugging capabilities for
the AI-only development constraint, enabling systematic issue
resolution without human code intervention.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 23:32:55 -04:00

680 lines
28 KiB
Python

"""
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