2025-05-26 14:11:01 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# ATProtoSocial (Bluesky) uses a Firehose model for streaming.
|
|
|
|
|
# This typically involves connecting to a WebSocket endpoint and receiving events.
|
|
|
|
|
# The atproto SDK provides tools for this.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ATProtoSocialStreaming:
|
|
|
|
|
def __init__(self, session: ATProtoSocialSession, stream_type: str, params: dict[str, Any] | None = None) -> None:
|
|
|
|
|
self.session = session
|
|
|
|
|
self.stream_type = stream_type # e.g., 'user', 'public', 'hashtag' - will need mapping to Firehose concepts
|
|
|
|
|
self.params = params or {}
|
|
|
|
|
self._handler: Callable[[dict[str, Any]], Coroutine[Any, Any, None]] | None = None
|
|
|
|
|
self._connection_task: asyncio.Task[None] | None = None
|
|
|
|
|
self._should_stop = False
|
|
|
|
|
# self._client = None # This would be an instance of atproto.firehose.FirehoseSubscribeReposClient or similar
|
|
|
|
|
|
|
|
|
|
# TODO: Map stream_type and params to ATProto Firehose subscription needs.
|
|
|
|
|
# For example, 'user' might mean subscribing to mentions, replies, follows for the logged-in user.
|
|
|
|
|
# This would likely involve filtering the general repo firehose for relevant events,
|
|
|
|
|
# or using a more specific subscription if available for user-level events.
|
|
|
|
|
|
|
|
|
|
async def _connect(self) -> None:
|
|
|
|
|
"""Internal method to connect to the ATProtoSocial Firehose."""
|
|
|
|
|
# from atproto import AsyncClient
|
|
|
|
|
# from atproto.firehose import FirehoseSubscribeReposClient, parse_subscribe_repos_message
|
|
|
|
|
# from atproto.xrpc_client.models import get_or_create, ids, models
|
|
|
|
|
|
|
|
|
|
logger.info(f"ATProtoSocial streaming: Connecting to Firehose for user {self.session.user_id}, stream type {self.stream_type}")
|
|
|
|
|
self._should_stop = False
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# TODO: Replace with actual atproto SDK usage
|
|
|
|
|
# client = self.session.util.get_client() # Get authenticated client from session utils
|
|
|
|
|
# if not client or not client.me: # Check if client is authenticated
|
|
|
|
|
# logger.error("ATProtoSocial client not authenticated. Cannot start Firehose.")
|
|
|
|
|
# return
|
|
|
|
|
|
|
|
|
|
# self._firehose_client = FirehoseSubscribeReposClient(params=None, base_uri=self.session.api_base_url) # Adjust base_uri if needed
|
|
|
|
|
|
|
|
|
|
# async def on_message_handler(message: models.ComAtprotoSyncSubscribeRepos.Message) -> None:
|
|
|
|
|
# if self._should_stop:
|
|
|
|
|
# await self._firehose_client.stop() # Ensure client stops if flag is set
|
|
|
|
|
# return
|
|
|
|
|
|
|
|
|
|
# # This is a simplified example. Real implementation needs to:
|
|
|
|
|
# # 1. Determine the type of message (commit, handle, info, migrate, tombstone)
|
|
|
|
|
# # 2. For commits, unpack operations to find posts, likes, reposts, follows, etc.
|
|
|
|
|
# # 3. Filter these events to be relevant to the user (e.g., mentions, replies to user, new posts from followed users)
|
|
|
|
|
# # 4. Format the data into a structure that self._handle_event expects.
|
|
|
|
|
# # This filtering can be complex.
|
|
|
|
|
|
|
|
|
|
# # Example: if it's a commit and contains a new post that mentions the user
|
|
|
|
|
# # if isinstance(message, models.ComAtprotoSyncSubscribeRepos.Commit):
|
|
|
|
|
# # # This part is highly complex due to CAR CIBOR decoding
|
|
|
|
|
# # # Operations need to be extracted from the commit block
|
|
|
|
|
# # # For each op, check if it's a create, and if the record is a post
|
|
|
|
|
# # # Then, check if the post's text or facets mention the current user.
|
|
|
|
|
# # # This is a placeholder for that logic.
|
|
|
|
|
# # logger.debug(f"Firehose commit from {message.repo} at {message.time}")
|
|
|
|
|
# # # Example of processing ops (pseudo-code, actual decoding is more involved):
|
|
|
|
|
# # # ops = message.ops
|
|
|
|
|
# # # for op in ops:
|
|
|
|
|
# # # if op.action == 'create' and op.path.endswith('/app.bsky.feed.post/...'):
|
|
|
|
|
# # # record_data = ... # decode op.cid from message.blocks
|
|
|
|
|
# # # if self.session.util.is_mention_of_me(record_data):
|
|
|
|
|
# # # event_data = self.session.util.format_post_event(record_data)
|
|
|
|
|
# # # await self._handle_event("mention", event_data)
|
|
|
|
|
|
|
|
|
|
# # For now, we'll just log that a message was received
|
|
|
|
|
# logger.debug(f"ATProtoSocial Firehose message received: {message.__class__.__name__}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# await self._firehose_client.start(on_message_handler)
|
2025-05-30 16:16:21 +00:00
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
# Placeholder loop to simulate receiving events
|
|
|
|
|
while not self._should_stop:
|
|
|
|
|
await asyncio.sleep(1)
|
|
|
|
|
# In a real implementation, this loop wouldn't exist; it'd be driven by the SDK's event handler.
|
|
|
|
|
# To simulate an event:
|
|
|
|
|
# if self._handler:
|
|
|
|
|
# mock_event = {"type": "placeholder_event", "data": {"text": "Hello from mock stream"}}
|
|
|
|
|
# await self._handler(mock_event) # Call the registered handler
|
|
|
|
|
|
|
|
|
|
logger.info(f"ATProtoSocial streaming: Placeholder loop for {self.session.user_id} stopped.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
logger.info(f"ATProtoSocial streaming task for user {self.session.user_id} was cancelled.")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"ATProtoSocial streaming error for user {self.session.user_id}: {e}", exc_info=True)
|
|
|
|
|
# Optional: implement retry logic here or in the start_streaming method
|
|
|
|
|
if not self._should_stop:
|
|
|
|
|
await asyncio.sleep(30) # Wait before trying to reconnect (if auto-reconnect is desired)
|
|
|
|
|
if not self._should_stop: # Check again before restarting
|
|
|
|
|
self._connection_task = asyncio.create_task(self._connect())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
# if self._firehose_client:
|
|
|
|
|
# await self._firehose_client.stop()
|
|
|
|
|
logger.info(f"ATProtoSocial streaming connection closed for user {self.session.user_id}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_event(self, event_type: str, data: dict[str, Any]) -> None:
|
|
|
|
|
"""
|
|
|
|
|
Internal method to process an event from the stream and pass it to the session's handler.
|
|
|
|
|
"""
|
|
|
|
|
if self._handler:
|
|
|
|
|
try:
|
|
|
|
|
# The data should be transformed into a common format expected by session.handle_streaming_event
|
|
|
|
|
# This is where ATProtoSocial-specific event data is mapped to Approve's internal event structure.
|
|
|
|
|
# For example, an ATProtoSocial 'mention' event needs to be structured similarly to
|
|
|
|
|
# how a Mastodon 'mention' event would be.
|
|
|
|
|
await self.session.handle_streaming_event(event_type, data)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error handling ATProtoSocial streaming event type {event_type}: {e}", exc_info=True)
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(f"ATProtoSocial streaming: No handler registered for session {self.session.user_id}, event: {event_type}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def start_streaming(self, handler: Callable[[dict[str, Any]], Coroutine[Any, Any, None]]) -> None:
|
|
|
|
|
"""Starts the streaming connection."""
|
|
|
|
|
if self._connection_task and not self._connection_task.done():
|
|
|
|
|
logger.warning(f"ATProtoSocial streaming already active for user {self.session.user_id}.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._handler = handler # This handler is what session.py's handle_streaming_event calls
|
|
|
|
|
self._should_stop = False
|
|
|
|
|
logger.info(f"ATProtoSocial streaming: Starting for user {self.session.user_id}, type: {self.stream_type}")
|
|
|
|
|
self._connection_task = asyncio.create_task(self._connect())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def stop_streaming(self) -> None:
|
|
|
|
|
"""Stops the streaming connection."""
|
|
|
|
|
logger.info(f"ATProtoSocial streaming: Stopping for user {self.session.user_id}")
|
|
|
|
|
self._should_stop = True
|
|
|
|
|
# if self._firehose_client: # Assuming the SDK has a stop method
|
|
|
|
|
# await self._firehose_client.stop()
|
|
|
|
|
|
|
|
|
|
if self._connection_task:
|
|
|
|
|
if not self._connection_task.done():
|
|
|
|
|
self._connection_task.cancel()
|
|
|
|
|
try:
|
|
|
|
|
await self._connection_task
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
logger.info(f"ATProtoSocial streaming task successfully cancelled for {self.session.user_id}.")
|
|
|
|
|
self._connection_task = None
|
|
|
|
|
self._handler = None
|
|
|
|
|
logger.info(f"ATProtoSocial streaming stopped for user {self.session.user_id}.")
|
|
|
|
|
|
|
|
|
|
def is_alive(self) -> bool:
|
|
|
|
|
"""Checks if the streaming connection is currently active."""
|
|
|
|
|
# return self._connection_task is not None and not self._connection_task.done() and self._firehose_client and self._firehose_client.is_connected
|
|
|
|
|
return self._connection_task is not None and not self._connection_task.done() # Simplified check
|
|
|
|
|
|
|
|
|
|
def get_stream_type(self) -> str:
|
|
|
|
|
return self.stream_type
|
|
|
|
|
|
|
|
|
|
def get_params(self) -> dict[str, Any]:
|
|
|
|
|
return self.params
|
|
|
|
|
|
|
|
|
|
# TODO: Add methods specific to ATProtoSocial streaming if necessary,
|
|
|
|
|
# e.g., methods to modify subscription details on the fly if the API supports it.
|
|
|
|
|
# For Bluesky Firehose, this might not be applicable as you usually connect and filter client-side.
|
|
|
|
|
# However, if there were different Firehose endpoints (e.g., one for public posts, one for user-specific events),
|
|
|
|
|
# this class might manage multiple connections or re-establish with new parameters.
|
2025-05-30 16:16:21 +00:00
|
|
|
|
2025-05-26 14:11:01 +00:00
|
|
|
# Example of how events might be processed (highly simplified):
|
|
|
|
|
# This would be called by the on_message_handler in _connect
|
|
|
|
|
# async def _process_firehose_message(self, message: models.ComAtprotoSyncSubscribeRepos.Message):
|
|
|
|
|
# if isinstance(message, models.ComAtprotoSyncSubscribeRepos.Commit):
|
|
|
|
|
# # Decode CAR file in message.blocks to get ops
|
|
|
|
|
# # For each op (create, update, delete of a record):
|
|
|
|
|
# # record = get_record_from_blocks(message.blocks, op.cid)
|
|
|
|
|
# # if op.path.startswith("app.bsky.feed.post"): # It's a post
|
|
|
|
|
# # # Check if it's a new post, a reply, a quote, etc.
|
|
|
|
|
# # # Check for mentions of the current user
|
|
|
|
|
# # # Example:
|
|
|
|
|
# # if self.session.util.is_mention_of_me(record):
|
|
|
|
|
# # formatted_event = self.session.util.format_post_as_notification(record, "mention")
|
|
|
|
|
# # await self._handle_event("mention", formatted_event)
|
|
|
|
|
# # elif op.path.startswith("app.bsky.graph.follow"):
|
|
|
|
|
# # # Check if it's a follow of the current user
|
|
|
|
|
# # if record.subject == self.session.util.get_my_did(): # Assuming get_my_did() exists
|
|
|
|
|
# # formatted_event = self.session.util.format_follow_as_notification(record)
|
|
|
|
|
# # await self._handle_event("follow", formatted_event)
|
|
|
|
|
# # # Handle likes (app.bsky.feed.like), reposts (app.bsky.feed.repost), etc.
|
|
|
|
|
# pass
|
|
|
|
|
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Handle):
|
|
|
|
|
# # Handle DID to handle mapping updates if necessary
|
|
|
|
|
# logger.debug(f"Handle update: {message.handle} now points to {message.did} at {message.time}")
|
|
|
|
|
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Migrate):
|
|
|
|
|
# logger.info(f"Repo migration: {message.did} migrating from {message.migrateTo} at {message.time}")
|
|
|
|
|
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Tombstone):
|
|
|
|
|
# logger.info(f"Repo tombstone: {message.did} at {message.time}")
|
|
|
|
|
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Info):
|
|
|
|
|
# logger.info(f"Firehose info: {message.name} - {message.message}")
|
|
|
|
|
# else:
|
|
|
|
|
# logger.debug(f"Unknown Firehose message type: {message.__class__.__name__}")
|