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>
680 lines
28 KiB
Python
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 |