diff --git a/src/controller/atprotosocial/__init__.py b/src/controller/atprotosocial/__init__.py new file mode 100644 index 00000000..f98ba158 --- /dev/null +++ b/src/controller/atprotosocial/__init__.py @@ -0,0 +1,3 @@ +from .handler import Handler + +__all__ = ["Handler"] diff --git a/src/controller/atprotosocial/handler.py b/src/controller/atprotosocial/handler.py new file mode 100644 index 00000000..98f062d4 --- /dev/null +++ b/src/controller/atprotosocial/handler.py @@ -0,0 +1,489 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +fromapprove.controller.base import BaseHandler +fromapprove.sessions import SessionStoreInterface + +if TYPE_CHECKING: + fromapprove.config import Config + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + +logger = logging.getLogger(__name__) + + +class Handler(BaseHandler): + SESSION_KIND = "atprotosocial" + + def __init__(self, session_store: SessionStoreInterface, config: Config) -> None: + super().__init__(session_store, config) + self.main_controller = wx.GetApp().mainController # Get a reference to the mainController + # Define menu labels specific to ATProtoSocial + self.menus = { + "menubar_item": _("&Post"), # Top-level menu for posting actions + "compose": _("&New Post"), # New post/skeet + "share": _("&Repost"), # Equivalent of Boost/Retweet + "fav": _("&Like"), # Equivalent of Favorite + "unfav": _("&Unlike"), + # "dm": None, # Disable Direct Message if not applicable + # Add other menu items that need relabeling or enabling/disabling + } + # self.item_menu is another attribute used in mainController.update_menus + # It seems to be the label for the second main menu (originally "&Tweet") + self.item_menu = _("&Post") # Changes the top-level "Tweet" menu label to "Post" + + def _get_session(self, user_id: str) -> ATProtoSocialSession: + session = self.session_store.get_session_by_user_id(user_id, self.SESSION_KIND) + if not session: + # It's possible the session is still being created, especially during initial setup. + # Try to get it from the global sessions store if it was just added. + from sessions import sessions as global_sessions + if user_id in global_sessions and global_sessions[user_id].KIND == self.SESSION_KIND: + return global_sessions[user_id] # type: ignore[return-value] + raise ValueError(f"No ATProtoSocial session found for user {user_id}") + return session # type: ignore[return-value] # We are checking kind + + def create_buffers(self, user_id: str) -> None: + """Creates the default set of buffers for an ATProtoSocial session.""" + session = self._get_session(user_id) + if not session: + logger.error(f"Cannot create buffers for ATProtoSocial user {user_id}: session not found.") + return + + logger.info(f"Creating default buffers for ATProtoSocial user {user_id} ({session.label})") + + # Home Timeline Buffer + self.main_controller.add_buffer( + buffer_type="home_timeline", # Generic type, panel will adapt based on session kind + user_id=user_id, + name=_("{label} Home").format(label=session.label), + session_kind=self.SESSION_KIND + ) + + # Notifications Buffer + self.main_controller.add_buffer( + buffer_type="notifications", # Generic type + user_id=user_id, + name=_("{label} Notifications").format(label=session.label), + session_kind=self.SESSION_KIND + ) + + # Own Posts (Profile) Buffer - using "user_posts" which is often generic + # self.main_controller.add_buffer( + # buffer_type="user_posts", + # user_id=user_id, # User whose posts to show (self in this case) + # target_user_id=session.util.get_own_did(), # Pass own DID as target + # name=_("{label} My Posts").format(label=session.label), + # session_kind=self.SESSION_KIND + # ) + # Mentions buffer might be part of notifications or a separate stream/filter later. + + # Ensure these buffers are shown if it's a new setup + # This part might be handled by mainController.add_buffer or session startup logic + # For now, just creating them. The UI should make them visible. + + # --- Action Handlers (called by mainController based on menu interactions) --- + + async def repost_item(self, session: ATProtoSocialSession, item_uri: str) -> dict[str, Any]: + """Handles reposting an item.""" + if not session.is_ready(): + return {"status": "error", "message": _("Session not ready.")} + try: + success = await session.util.repost_post(item_uri) # Assuming repost_post in utils + if success: + return {"status": "success", "message": _("Post reposted successfully.")} + else: + return {"status": "error", "message": _("Failed to repost post.")} + except NotificationError as e: + return {"status": "error", "message": e.message} + except Exception as e: + logger.error(f"Error reposting item {item_uri}: {e}", exc_info=True) + return {"status": "error", "message": _("An unexpected error occurred while reposting.")} + + async def like_item(self, session: ATProtoSocialSession, item_uri: str) -> dict[str, Any]: + """Handles liking an item.""" + if not session.is_ready(): + return {"status": "error", "message": _("Session not ready.")} + try: + like_uri = await session.util.like_post(item_uri) # Assuming like_post in utils + if like_uri: + return {"status": "success", "message": _("Post liked successfully."), "like_uri": like_uri} + else: + return {"status": "error", "message": _("Failed to like post.")} + except NotificationError as e: + return {"status": "error", "message": e.message} + except Exception as e: + logger.error(f"Error liking item {item_uri}: {e}", exc_info=True) + return {"status": "error", "message": _("An unexpected error occurred while liking the post.")} + + async def unlike_item(self, session: ATProtoSocialSession, like_uri: str) -> dict[str, Any]: # like_uri is the URI of the like record + """Handles unliking an item.""" + if not session.is_ready(): + return {"status": "error", "message": _("Session not ready.")} + try: + # Unlike typically requires the URI of the like record itself, not the post. + # The UI or calling context needs to store this (e.g. from viewer_state of the post). + success = await session.util.delete_like(like_uri) # Assuming delete_like in utils + if success: + return {"status": "success", "message": _("Like removed successfully.")} + else: + return {"status": "error", "message": _("Failed to remove like.")} + except NotificationError as e: + return {"status": "error", "message": e.message} + except Exception as e: + logger.error(f"Error unliking item with like URI {like_uri}: {e}", exc_info=True) + return {"status": "error", "message": _("An unexpected error occurred while unliking.")} + + + async def handle_action(self, action_name: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None: + """Handles actions specific to ATProtoSocial integration.""" + logger.debug( + f"Handling ATProtoSocial action '{action_name}' for user {user_id} with payload: {payload}" + ) + # session = self._get_session(user_id) + + # TODO: Implement action handlers based on ATProtoSocial's capabilities + # Example: + # if action_name == "get_profile_info": + # # profile_data = await session.util.get_profile_info(payload.get("handle")) + # # return {"profile": profile_data} + # elif action_name == "follow_user": + # # await session.util.follow_user(payload.get("user_id_to_follow")) + # # return {"status": "success", "message": "User followed"} + # else: + # logger.warning(f"Unknown ATProtoSocial action: {action_name}") + # return {"error": f"Unknown action: {action_name}"} + return None # Placeholder + + async def handle_message_command( + self, command: str, user_id: str, message_id: str, payload: dict[str, Any] + ) -> dict[str, Any] | None: + """Handles commands related to specific messages for ATProtoSocial.""" + logger.debug( + f"Handling ATProtoSocial message command '{command}' for user {user_id}, message {message_id} with payload: {payload}" + ) + # session = self._get_session(user_id) + + # TODO: Implement message command handlers + # Example: + # if command == "get_post_details": + # # post_details = await session.util.get_post_by_id(message_id) + # # return {"details": post_details} + # elif command == "like_post": + # # await session.util.like_post(message_id) + # # return {"status": "success", "message": "Post liked"} + # else: + # logger.warning(f"Unknown ATProtoSocial message command: {command}") + # return {"error": f"Unknown message command: {command}"} + return None # Placeholder + +fromapprove.translation import translate as _ # For user-facing messages + + async def handle_user_command( + self, command: str, user_id: str, target_user_id: str, payload: dict[str, Any] + ) -> dict[str, Any] | None: + """Handles commands related to specific users for ATProtoSocial.""" + logger.debug( + f"Handling ATProtoSocial user command '{command}' for user {user_id}, target user {target_user_id} with payload: {payload}" + ) + session = self._get_session(user_id) + if not session.is_ready(): + return {"status": "error", "message": _("ATProtoSocial session is not active or authenticated.")} + + # target_user_id is expected to be the DID of the user to act upon. + if not target_user_id: + return {"status": "error", "message": _("Target user DID not provided.")} + + success = False + message = _("Action could not be completed.") # Default error message + + try: + if command == "follow_user": + success = await session.util.follow_user(target_user_id) + message = _("User followed successfully.") if success else _("Failed to follow user.") + elif command == "unfollow_user": + success = await session.util.unfollow_user(target_user_id) + message = _("User unfollowed successfully.") if success else _("Failed to unfollow user.") + elif command == "mute_user": + success = await session.util.mute_user(target_user_id) + message = _("User muted successfully.") if success else _("Failed to mute user.") + elif command == "unmute_user": + success = await session.util.unmute_user(target_user_id) + message = _("User unmuted successfully.") if success else _("Failed to unmute user.") + elif command == "block_user": + block_uri = await session.util.block_user(target_user_id) # Returns URI or None + success = bool(block_uri) + message = _("User blocked successfully.") if success else _("Failed to block user.") + elif command == "unblock_user": + success = await session.util.unblock_user(target_user_id) + message = _("User unblocked successfully.") if success else _("Failed to unblock user, or user was not blocked.") + else: + logger.warning(f"Unknown ATProtoSocial user command: {command}") + return {"status": "error", "message": _("Unknown action: {command}").format(command=command)} + + return {"status": "success" if success else "error", "message": message} + + except NotificationError as e: # Catch specific errors raised from utils + logger.error(f"ATProtoSocial user command '{command}' failed for target {target_user_id}: {e.message}") + return {"status": "error", "message": e.message} + except Exception as e: + logger.error(f"Unexpected error during ATProtoSocial user command '{command}' for target {target_user_id}: {e}", exc_info=True) + return {"status": "error", "message": _("An unexpected server error occurred.")} + + # --- UI Related Action Handlers (called by mainController) --- + + async def user_details(self, buffer: Any) -> None: # buffer is typically a timeline panel + """Shows user profile details for the selected user in the buffer.""" + session = buffer.session + if not session or session.KIND != self.SESSION_KIND or not session.is_ready(): + output.speak(_("Active ATProtoSocial session not found or not ready."), True) + return + + user_ident = None + if hasattr(buffer, "get_selected_item_author_details"): # Method in panel to get author of selected post + author_details = buffer.get_selected_item_author_details() + if author_details and isinstance(author_details, dict): + user_ident = author_details.get("did") or author_details.get("handle") + + if not user_ident: + # Fallback or if no item selected, prompt for user + # For now, just inform user if no selection. A dialog prompt could be added. + output.speak(_("Please select an item or user to view details."), True) + # TODO: Add wx.TextEntryDialog to ask for user handle/DID if none selected + return + + try: + profile_data = await session.util.get_user_profile(user_ident) + if profile_data: + # TODO: Integrate with a wx dialog for displaying profile. + # For now, show a simple message box with some details. + # Example: from src.wxUI.dialogs.mastodon.showUserProfile import UserProfileDialog + # profile_dialog = UserProfileDialog(self.main_controller.view, session, profile_data_dict) + # profile_dialog.Show() + formatted_info = f"User: {profile_data.displayName} (@{profile_data.handle})\n" + formatted_info += f"DID: {profile_data.did}\n" + formatted_info += f"Followers: {profile_data.followersCount or 0}\n" + formatted_info += f"Following: {profile_data.followsCount or 0}\n" + formatted_info += f"Posts: {profile_data.postsCount or 0}\n" + formatted_info += f"Bio: {profile_data.description or ''}" + wx.MessageBox(formatted_info, _("User Profile (ATProtoSocial)"), wx.OK | wx.ICON_INFORMATION, self.main_controller.view) + else: + output.speak(_("Could not fetch profile for {user_ident}.").format(user_ident=user_ident), True) + except Exception as e: + logger.error(f"Error fetching/displaying profile for {user_ident}: {e}", exc_info=True) + output.speak(_("Error displaying profile: {error}").format(error=str(e)), True) + + + async def open_user_timeline(self, main_controller: Any, session: ATProtoSocialSession, user_payload: Any | None) -> None: + """Opens a new buffer for a specific user's posts.""" + user_ident = None + if isinstance(user_payload, dict): # Assuming user_payload is a dict from get_selected_item_author_details + user_ident = user_payload.get("did") or user_payload.get("handle") + elif isinstance(user_payload, str): # Direct DID or Handle string + user_ident = user_payload + + if not user_ident: # Prompt if not found + dialog = wx.TextEntryDialog(main_controller.view, _("Enter user DID or handle:"), _("View User Timeline")) + if dialog.ShowModal() == wx.ID_OK: + user_ident = dialog.GetValue() + dialog.Destroy() + if not user_ident: + return + + # Fetch profile to get canonical handle/DID for buffer name, and to ensure user exists + try: + profile = await session.util.get_user_profile(user_ident) + if not profile: + output.speak(_("User {user_ident} not found.").format(user_ident=user_ident), True) + return + + buffer_name = _("{user_handle}'s Posts").format(user_handle=profile.handle) + buffer_id = f"atp_user_feed_{profile.did}" # Unique ID for the buffer + + # Check if buffer already exists + # existing_buffer = main_controller.search_buffer_by_id_or_properties(id=buffer_id) # Hypothetical method + # For now, assume it might create duplicates if not handled by add_buffer logic + + main_controller.add_buffer( + buffer_type="user_timeline", # This type will need a corresponding panel + user_id=session.uid, # The session user_id + name=buffer_name, + session_kind=self.SESSION_KIND, + target_user_did=profile.did, # Store target DID for the panel to use + target_user_handle=profile.handle + ) + except Exception as e: + logger.error(f"Error opening user timeline for {user_ident}: {e}", exc_info=True) + output.speak(_("Failed to open user timeline: {error}").format(error=str(e)), True) + + + async def open_followers_timeline(self, main_controller: Any, session: ATProtoSocialSession, user_payload: Any | None) -> None: + """Opens a new buffer for a user's followers.""" + user_ident = None + if isinstance(user_payload, dict): + user_ident = user_payload.get("did") or user_payload.get("handle") + elif isinstance(user_payload, str): + user_ident = user_payload + + if not user_ident: + dialog = wx.TextEntryDialog(main_controller.view, _("Enter user DID or handle to view followers:"), _("View Followers")) + if dialog.ShowModal() == wx.ID_OK: + user_ident = dialog.GetValue() + dialog.Destroy() + if not user_ident: + return + + try: + profile = await session.util.get_user_profile(user_ident) # Ensure user exists, get DID + if not profile: + output.speak(_("User {user_ident} not found.").format(user_ident=user_ident), True) + return + + buffer_name = _("Followers of {user_handle}").format(user_handle=profile.handle) + main_controller.add_buffer( + buffer_type="user_list_followers", # Needs specific panel type + user_id=session.uid, + name=buffer_name, + session_kind=self.SESSION_KIND, + target_user_did=profile.did + ) + except Exception as e: + logger.error(f"Error opening followers list for {user_ident}: {e}", exc_info=True) + output.speak(_("Failed to open followers list: {error}").format(error=str(e)), True) + + + async def open_following_timeline(self, main_controller: Any, session: ATProtoSocialSession, user_payload: Any | None) -> None: + """Opens a new buffer for users a user is following.""" + user_ident = None + if isinstance(user_payload, dict): + user_ident = user_payload.get("did") or user_payload.get("handle") + elif isinstance(user_payload, str): + user_ident = user_payload + + if not user_ident: + dialog = wx.TextEntryDialog(main_controller.view, _("Enter user DID or handle to view their following list:"), _("View Following")) + if dialog.ShowModal() == wx.ID_OK: + user_ident = dialog.GetValue() + dialog.Destroy() + if not user_ident: + return + + try: + profile = await session.util.get_user_profile(user_ident) # Ensure user exists, get DID + if not profile: + output.speak(_("User {user_ident} not found.").format(user_ident=user_ident), True) + return + + buffer_name = _("Following by {user_handle}").format(user_handle=profile.handle) + main_controller.add_buffer( + buffer_type="user_list_following", # Needs specific panel type + user_id=session.uid, + name=buffer_name, + session_kind=self.SESSION_KIND, + target_user_did=profile.did + ) + except Exception as e: + logger.error(f"Error opening following list for {user_ident}: {e}", exc_info=True) + output.speak(_("Failed to open following list: {error}").format(error=str(e)), True) + + + async def get_settings_inputs(self, user_id: str | None = None) -> list[dict[str, Any]]: + """Returns settings inputs for ATProtoSocial, potentially user-specific.""" + # This typically delegates to the Session class's method + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + + current_config = {} + if user_id: + # Fetch existing config for the user if available to pre-fill values + # This part depends on how config is stored and accessed. + # For example, if Session class has a method to get its current config: + try: + session = self._get_session(user_id) + current_config = session.get_configurable_values_for_user(user_id) + except ValueError: # No session means no specific config yet for this user + pass + except Exception as e: + logger.error(f"Error fetching current ATProtoSocial config for user {user_id}: {e}") + + + return ATProtoSocialSession.get_settings_inputs(user_id=user_id, current_config=current_config) + + async def update_settings(self, user_id: str, settings_data: dict[str, Any]) -> dict[str, Any]: + """Updates settings for ATProtoSocial for a given user.""" + logger.info(f"Updating ATProtoSocial settings for user {user_id}") + + # This is a simplified example. In a real scenario, you'd validate `settings_data` + # and then update the configuration, possibly re-initializing the session or + # informing it of the changes. + + # config_manager = self.config.sessions.atprotosocial[user_id] + # for key, value in settings_data.items(): + # if hasattr(config_manager, key): + # await config_manager[key].set(value) + # else: + # logger.warning(f"Attempted to set unknown ATProtoSocial setting '{key}' for user {user_id}") + + # # Optionally, re-initialize or notify the session if it's active + # try: + # session = self._get_session(user_id) + # await session.stop() # Stop if it might be using old settings + # # Re-fetch config for the session or update it directly + # # session.api_base_url = settings_data.get("api_base_url", session.api_base_url) + # # session.access_token = settings_data.get("access_token", session.access_token) + # if session.active: # Or based on some logic if it should auto-restart + # await session.start() + # logger.info(f"Successfully updated and re-initialized ATProtoSocial session for user {user_id}") + # except ValueError: + # logger.info(f"ATProtoSocial session for user {user_id} not found or not active, settings saved but session not restarted.") + # except Exception as e: + # logger.error(f"Error re-initializing ATProtoSocial session for user {user_id} after settings update: {e}") + # return {"status": "error", "message": f"Settings saved, but failed to restart session: {e}"} + + # For now, just a placeholder response + return {"status": "success", "message": "ATProtoSocial settings updated (implementation pending)."} + + @classmethod + def get_auth_inputs(cls, user_id: str | None = None) -> list[dict[str, Any]]: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + # current_config = {} # fetch if needed + return ATProtoSocialSession.get_auth_inputs(user_id=user_id) # current_config=current_config + + @classmethod + async def test_connection(cls, settings: dict[str, Any]) -> tuple[bool, str]: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + return await ATProtoSocialSession.test_connection(settings) + + @classmethod + def get_user_actions(cls) -> list[dict[str, Any]]: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + return ATProtoSocialSession.get_user_actions() + + @classmethod + def get_user_list_actions(cls) -> list[dict[str, Any]]: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + return ATProtoSocialSession.get_user_list_actions() + + @classmethod + def get_config_description(cls) -> str | None: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + return ATProtoSocialSession.get_config_description() + + @classmethod + def get_auth_type(cls) -> str: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + return ATProtoSocialSession.get_auth_type() + + @classmethod + def get_logo_path(cls) -> str | None: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + return ATProtoSocialSession.get_logo_path() + + @classmethod + def get_session_kind(cls) -> str: + return cls.SESSION_KIND + + @classmethod + def get_dependencies(cls) -> list[str]: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + return ATProtoSocialSession.get_dependencies() diff --git a/src/controller/atprotosocial/messages.py b/src/controller/atprotosocial/messages.py new file mode 100644 index 00000000..998999be --- /dev/null +++ b/src/controller/atprotosocial/messages.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +# fromapprove.controller.mastodon import messages as mastodon_messages # Example, if adapting +fromapprove.translation import translate as _ + +if TYPE_CHECKING: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted import + +logger = logging.getLogger(__name__) + +# This file would typically contain functions to generate complex message bodies or +# interactive components for ATProtoSocial, similar to how it might be done for Mastodon. +# Since ATProtoSocial's interactive features (beyond basic posts) are still evolving +# or client-dependent (like polls), this might be less complex initially. + +# Example: If ATProtoSocial develops a standard for "cards" or interactive messages, +# functions to create those would go here. For now, we can imagine placeholders. + +def format_welcome_message(session: ATProtoSocialSession) -> dict[str, Any]: + """ + Generates a welcome message for a new ATProtoSocial session. + This is just a placeholder and example. + """ + # user_profile = session.util.get_own_profile_info() # Assuming this method exists and is async or cached + # handle = user_profile.get("handle", _("your ATProtoSocial account")) if user_profile else _("your ATProtoSocial account") + handle = session.util.get_own_username() or _("your ATProtoSocial account") + + + return { + "text": _("Welcome to Approve for ATProtoSocial! Your account {handle} is connected.").format(handle=handle), + # "blocks": [ # If ATProtoSocial supports a block kit like Slack or Discord + # { + # "type": "section", + # "text": { + # "type": "mrkdwn", # Or ATProtoSocial's equivalent + # "text": _("Welcome to Approve for ATProtoSocial! Your account *{handle}* is connected.").format(handle=handle) + # } + # }, + # { + # "type": "actions", + # "elements": [ + # { + # "type": "button", + # "text": {"type": "plain_text", "text": _("Post your first Skeet")}, + # "action_id": "atprotosocial_compose_new_post" # Example action ID + # } + # ] + # } + # ] + } + +def format_error_message(error_description: str, details: str | None = None) -> dict[str, Any]: + """ + Generates a standardized error message. + """ + message = {"text": f":warning: Error: {error_description}"} # Basic text message + # if details: + # message["blocks"] = [ + # { + # "type": "section", + # "text": {"type": "mrkdwn", "text": f":warning: *Error:* {error_description}\n{details}"} + # } + # ] + return message + +# More functions could be added here as ATProtoSocial's capabilities become clearer +# or as specific formatting needs for Approve arise. For example: +# - Formatting a post for display with all its embeds and cards. +# - Generating help messages specific to ATProtoSocial features. +# - Creating interactive messages for polls (if supported via some convention). + +# Example of adapting a function that might exist in mastodon_messages: +# def build_post_summary_message(session: ATProtoSocialSession, post_uri: str, post_content: dict) -> dict[str, Any]: +# """ +# Builds a summary message for an ATProtoSocial post. +# """ +# author_handle = post_content.get("author", {}).get("handle", "Unknown user") +# text_preview = post_content.get("text", "")[:100] # First 100 chars of text +# # url = session.get_message_url(post_uri) # Assuming this method exists +# url = f"https://bsky.app/profile/{author_handle}/post/{post_uri.split('/')[-1]}" # Construct a URL + +# return { +# "text": _("Post by {author_handle}: {text_preview}... ({url})").format( +# author_handle=author_handle, text_preview=text_preview, url=url +# ), +# # Potentially with "blocks" for richer formatting if the platform supports it +# } + +logger.info("ATProtoSocial messages module loaded (placeholders).") diff --git a/src/controller/atprotosocial/settings.py b/src/controller/atprotosocial/settings.py new file mode 100644 index 00000000..7568d189 --- /dev/null +++ b/src/controller/atprotosocial/settings.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +fromapprove.forms import Form, SubmitField, TextAreaField, TextField +fromapprove.translation import translate as _ + +if TYPE_CHECKING: + fromapprove.config import ConfigSectionProxy + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted + +logger = logging.getLogger(__name__) + +# This file is for defining forms and handling for ATProtoSocial-specific settings +# that might be more complex than simple key-value pairs handled by Session.get_settings_inputs. +# For ATProtoSocial, initial settings might be simple (handle, app password), +# but this structure allows for expansion. + + +class ATProtoSocialSettingsForm(Form): + """ + A settings form for ATProtoSocial sessions. + This would mirror the kind of settings found in Session.get_settings_inputs + but using the WTForms-like Form structure for more complex validation or layout. + """ + # Example fields - these should align with what ATProtoSocialSession.get_settings_inputs defines + # and what ATProtoSocialSession.get_configurable_values expects for its config. + + # instance_url = TextField( + # _("Instance URL"), + # default="https://bsky.social", # Default PDS for Bluesky + # description=_("The base URL of your ATProtoSocial PDS instance (e.g., https://bsky.social)."), + # validators=[], # Add validators if needed, e.g., URL validator + # ) + handle = TextField( + _("Bluesky Handle"), + description=_("Your Bluesky user handle (e.g., @username.bsky.social or username.bsky.social)."), + validators=[], # e.g., DataRequired() + ) + app_password = TextField( # Consider PasswordField if sensitive and your Form class supports it + _("App Password"), + description=_("Your Bluesky App Password. Generate this in your Bluesky account settings."), + validators=[], # e.g., DataRequired() + ) + # Add more fields as needed for ATProtoSocial configuration. + # For example, if there were specific notification settings, content filters, etc. + + submit = SubmitField(_("Save ATProtoSocial Settings")) + + +async def get_settings_form( + user_id: str, + session: ATProtoSocialSession | None = None, + config: ConfigSectionProxy | None = None, # User-specific config for ATProtoSocial +) -> ATProtoSocialSettingsForm: + """ + Creates and pre-populates the ATProtoSocial settings form. + """ + form_data = {} + if session: # If a session exists, use its current config + # form_data["instance_url"] = session.config_get("api_base_url", "https://bsky.social") + form_data["handle"] = session.config_get("handle", "") + # App password should not be pre-filled for security. + form_data["app_password"] = "" + elif config: # Fallback to persisted config if no active session + # form_data["instance_url"] = config.api_base_url.get("https://bsky.social") + form_data["handle"] = config.handle.get("") + form_data["app_password"] = "" + + form = ATProtoSocialSettingsForm(formdata=None, **form_data) # formdata=None for initial display + return form + + +async def process_settings_form( + form: ATProtoSocialSettingsForm, + user_id: str, + session: ATProtoSocialSession | None = None, # Pass if update should affect live session + config: ConfigSectionProxy | None = None, # User-specific config for ATProtoSocial +) -> bool: + """ + Processes the submitted ATProtoSocial settings form and updates configuration. + Returns True if successful, False otherwise. + """ + if not form.validate(): # Assuming form has a validate method + logger.warning(f"ATProtoSocial settings form validation failed for user {user_id}: {form.errors}") + return False + + if not config and session: # Try to get config via session if not directly provided + # This depends on how ConfigSectionProxy is obtained. + # config = approve.config.config.sessions.atprotosocial[user_id] # Example path + pass # Needs actual way to get config proxy + + if not config: + logger.error(f"Cannot process ATProtoSocial settings for user {user_id}: no config proxy available.") + return False + + try: + # Update the configuration values + # await config.api_base_url.set(form.instance_url.data) + await config.handle.set(form.handle.data) + await config.app_password.set(form.app_password.data) # Ensure this is stored securely + + logger.info(f"ATProtoSocial settings updated for user {user_id}.") + + # If there's an active session, it might need to be reconfigured or restarted + if session: + logger.info(f"Requesting ATProtoSocial session re-initialization for user {user_id} due to settings change.") + # await session.stop() # Stop it + # # Update session instance with new values directly or rely on it re-reading config + # session.api_base_url = form.instance_url.data + # session.handle = form.handle.data + # # App password should be handled carefully, session might need to re-login + # await session.start() # Restart with new settings + # Or, more simply, the session might have a reconfigure method: + # await session.reconfigure(new_settings_dict) + pass # Placeholder for session reconfiguration logic + + return True + except Exception as e: + logger.error(f"Error saving ATProtoSocial settings for user {user_id}: {e}", exc_info=True) + return False + +# Any additional ATProtoSocial-specific settings views or handlers would go here. +# For instance, if ATProtoSocial had features like "Relays" or "Feed Generators" +# that needed UI configuration within Approve, those forms and handlers could be defined here. + +logger.info("ATProtoSocial settings module loaded (placeholders).") diff --git a/src/controller/atprotosocial/templateEditor.py b/src/controller/atprotosocial/templateEditor.py new file mode 100644 index 00000000..97ae8696 --- /dev/null +++ b/src/controller/atprotosocial/templateEditor.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +# fromapprove.controller.mastodon import templateEditor as mastodon_template_editor # If adapting +fromapprove.translation import translate as _ + +if TYPE_CHECKING: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted + +logger = logging.getLogger(__name__) + +# This file would handle the logic for a template editor specific to ATProtoSocial. +# A template editor allows users to customize how certain information or messages +# from ATProtoSocial are displayed in Approve. + +# For ATProtoSocial, this might be less relevant initially if its content structure +# is simpler than Mastodon's, or if user-customizable templates are not a primary feature. +# However, having the structure allows for future expansion. + +# Example: Customizing the format of a "new follower" notification, or how a "skeet" is displayed. + +class ATProtoSocialTemplateEditor: + def __init__(self, session: ATProtoSocialSession) -> None: + self.session = session + # self.user_id = session.user_id + # self.config_prefix = f"sessions.atprotosocial.{self.user_id}.templates." # Example config path + + def get_editable_templates(self) -> list[dict[str, Any]]: + """ + Returns a list of templates that the user can edit for ATProtoSocial. + Each entry should describe the template, its purpose, and current value. + """ + # This would typically fetch template definitions from a default set + # and override with any user-customized versions from config. + + # Example structure for an editable template: + # templates = [ + # { + # "id": "new_follower_notification", # Unique ID for this template + # "name": _("New Follower Notification Format"), + # "description": _("Customize how new follower notifications from ATProtoSocial are displayed."), + # "default_template": "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on ATProtoSocial!", + # "current_template": self._get_template_content("new_follower_notification"), + # "variables": [ # Available variables for this template + # {"name": "actor.displayName", "description": _("Display name of the new follower")}, + # {"name": "actor.handle", "description": _("Handle of the new follower")}, + # {"name": "actor.url", "description": _("URL to the new follower's profile")}, + # ], + # "category": "notifications", # For grouping in UI + # }, + # # Add more editable templates for ATProtoSocial here + # ] + # return templates + return [] # Placeholder - no editable templates defined yet for ATProtoSocial + + def _get_template_content(self, template_id: str) -> str: + """ + Retrieves the current content of a specific template, either user-customized or default. + """ + # config_key = self.config_prefix + template_id + # default_value = self._get_default_template_content(template_id) + # return approve.config.config.get_value(config_key, default_value) # Example config access + return self._get_default_template_content(template_id) # Placeholder + + def _get_default_template_content(self, template_id: str) -> str: + """ + Returns the default content for a given template ID. + """ + # This could be hardcoded or loaded from a defaults file. + # if template_id == "new_follower_notification": + # return "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on ATProtoSocial!" + # # ... other default templates + return "" # Placeholder + + async def save_template_content(self, template_id: str, content: str) -> bool: + """ + Saves the user-customized content for a specific template. + """ + # config_key = self.config_prefix + template_id + # try: + # await approve.config.config.set_value(config_key, content) # Example config access + # logger.info(f"ATProtoSocial template '{template_id}' saved for user {self.user_id}.") + # return True + # except Exception as e: + # logger.error(f"Error saving ATProtoSocial template '{template_id}' for user {self.user_id}: {e}") + # return False + return False # Placeholder + + def get_template_preview(self, template_id: str, custom_content: str | None = None) -> str: + """ + Generates a preview of a template using sample data. + If custom_content is provided, it's used instead of the saved template. + """ + # content_to_render = custom_content if custom_content is not None else self._get_template_content(template_id) + # sample_data = self._get_sample_data_for_template(template_id) + + # try: + # # Use a templating engine (like Jinja2) to render the preview + # # from jinja2 import Template + # # template = Template(content_to_render) + # # preview = template.render(**sample_data) + # # return preview + # return f"Preview for '{template_id}': {content_to_render}" # Basic placeholder + # except Exception as e: + # logger.error(f"Error generating preview for ATProtoSocial template '{template_id}': {e}") + # return _("Error generating preview.") + return _("Template previews not yet implemented for ATProtoSocial.") # Placeholder + + def _get_sample_data_for_template(self, template_id: str) -> dict[str, Any]: + """ + Returns sample data appropriate for previewing a specific template. + """ + # if template_id == "new_follower_notification": + # return { + # "actor": { + # "displayName": "Test User", + # "handle": "testuser.bsky.social", + # "url": "https://bsky.app/profile/testuser.bsky.social" + # } + # } + # # ... other sample data + return {} # Placeholder + +# Functions to be called by the main controller/handler for template editor actions. + +async def get_editor_config(session: ATProtoSocialSession) -> dict[str, Any]: + """ + Get the configuration needed to display the template editor for ATProtoSocial. + """ + editor = ATProtoSocialTemplateEditor(session) + return { + "editable_templates": editor.get_editable_templates(), + "help_text": _("Customize ATProtoSocial message formats. Use variables shown for each template."), + } + +async def save_template(session: ATProtoSocialSession, template_id: str, content: str) -> bool: + """ + Save a modified template for ATProtoSocial. + """ + editor = ATProtoSocialTemplateEditor(session) + return await editor.save_template_content(template_id, content) + +async def get_template_preview_html(session: ATProtoSocialSession, template_id: str, content: str) -> str: + """ + Get an HTML preview for a template with given content. + """ + editor = ATProtoSocialTemplateEditor(session) + return editor.get_template_preview(template_id, custom_content=content) + + +logger.info("ATProtoSocial template editor module loaded (placeholders).") diff --git a/src/controller/atprotosocial/userActions.py b/src/controller/atprotosocial/userActions.py new file mode 100644 index 00000000..64b4278a --- /dev/null +++ b/src/controller/atprotosocial/userActions.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +fromapprove.translation import translate as _ +# fromapprove.controller.mastodon import userActions as mastodon_user_actions # If adapting + +if TYPE_CHECKING: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted + +logger = logging.getLogger(__name__) + +# This file defines user-specific actions that can be performed on ATProtoSocial entities, +# typically represented as buttons or links in the UI, often on user profiles or posts. + +# For ATProtoSocial, actions might include: +# - Viewing a user's profile on Bluesky/ATProtoSocial instance. +# - Following/Unfollowing a user. +# - Muting/Blocking a user. +# - Reporting a user. +# - Fetching a user's latest posts. + +# These actions are often presented in a context menu or as direct buttons. +# The `get_user_actions` method in the ATProtoSocialSession class would define these. +# This file would contain the implementation or further handling logic if needed, +# or if actions are too complex for simple lambda/method calls in the session class. + +# Example structure for defining an action: +# (This might be more detailed if actions require forms or multi-step processes) + +# def view_profile_action(session: ATProtoSocialSession, user_id: str) -> dict[str, Any]: +# """ +# Generates data for a "View Profile on ATProtoSocial" action. +# user_id here would be the ATProtoSocial DID or handle. +# """ +# # profile_url = f"https://bsky.app/profile/{user_id}" # Example, construct from handle or DID +# # This might involve resolving DID to handle or vice-versa if only one is known. +# # handle = await session.util.get_username_from_user_id(user_id) or user_id +# # profile_url = f"https://bsky.app/profile/{handle}" + +# return { +# "id": "atprotosocial_view_profile", +# "label": _("View Profile on Bluesky"), +# "icon": "external-link-alt", # FontAwesome icon name +# "action_type": "link", # "link", "modal", "api_call" +# "url": profile_url, # For "link" type +# # "api_endpoint": "/api/atprotosocial/user_action", # For "api_call" +# # "payload": {"action": "view_profile", "target_user_id": user_id}, +# "confirmation_required": False, +# } + + +# async def follow_user_action_handler(session: ATProtoSocialSession, target_user_id: str) -> dict[str, Any]: +# """ +# Handles the 'follow_user' action for ATProtoSocial. +# target_user_id should be the DID of the user to follow. +# """ +# # success = await session.util.follow_user(target_user_id) +# # if success: +# # return {"status": "success", "message": _("User {target_user_id} followed.").format(target_user_id=target_user_id)} +# # else: +# # return {"status": "error", "message": _("Failed to follow user {target_user_id}.").format(target_user_id=target_user_id)} +# return {"status": "pending", "message": "Follow action not implemented yet."} + + +# The list of available actions is typically defined in the Session class, +# e.g., ATProtoSocialSession.get_user_actions(). That method would return a list +# of dictionaries, and this file might provide handlers for more complex actions +# if they aren't simple API calls defined directly in the session's util. + +# For now, this file can be a placeholder if most actions are simple enough +# to be handled directly by the session.util methods or basic handler routes. + +logger.info("ATProtoSocial userActions module loaded (placeholders).") diff --git a/src/controller/atprotosocial/userList.py b/src/controller/atprotosocial/userList.py new file mode 100644 index 00000000..6294963d --- /dev/null +++ b/src/controller/atprotosocial/userList.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, AsyncGenerator + +fromapprove.translation import translate as _ +# fromapprove.controller.mastodon import userList as mastodon_user_list # If adapting + +if TYPE_CHECKING: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted + # Define a type for what a user entry in a list might look like for ATProtoSocial + ATProtoSocialUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."} + +logger = logging.getLogger(__name__) + +# This file is responsible for fetching and managing lists of users from ATProtoSocial. +# Examples include: +# - Followers of a user +# - Users a user is following +# - Users who liked or reposted a post +# - Users in a specific list or feed (if ATProtoSocial supports user lists like Twitter/Mastodon) +# - Search results for users + +# The structure will likely involve: +# - A base class or functions for paginating through user lists from the ATProtoSocial API. +# - Specific functions for each type of user list. +# - Formatting ATProtoSocial user data into a consistent structure for UI display. + +async def fetch_followers( + session: ATProtoSocialSession, + user_id: str, # DID of the user whose followers to fetch + limit: int = 20, + cursor: str | None = None +) -> AsyncGenerator[ATProtoSocialUserListItem, None]: + """ + Asynchronously fetches a list of followers for a given ATProtoSocial user. + user_id is the DID of the target user. + Yields user data dictionaries. + """ + # client = await session.util._get_client() # Get authenticated client + # if not client: + # logger.warning(f"ATProtoSocial client not available for fetching followers of {user_id}.") + # return + + # current_cursor = cursor + # try: + # while True: + # # response = await client.app.bsky.graph.get_followers( + # # models.AppBskyGraphGetFollowers.Params( + # # actor=user_id, + # # limit=min(limit, 100), # ATProto API might have its own max limit per request (e.g. 100) + # # cursor=current_cursor + # # ) + # # ) + # # if not response or not response.followers: + # # break + + # # for user_profile_view in response.followers: + # # yield session.util._format_profile_data(user_profile_view) # Use a utility to standardize format + + # # current_cursor = response.cursor + # # if not current_cursor or len(response.followers) < limit : # Or however the API indicates end of list + # # break + + # # This is a placeholder loop for demonstration + # if current_cursor == "simulated_end_cursor": break # Stop after one simulated page + # for i in range(limit): + # if current_cursor and int(current_cursor) + i >= 25: # Simulate total 25 followers + # current_cursor = "simulated_end_cursor" + # break + # yield { + # "did": f"did:plc:follower{i + (int(current_cursor) if current_cursor else 0)}", + # "handle": f"follower{i + (int(current_cursor) if current_cursor else 0)}.bsky.social", + # "displayName": f"Follower {i + (int(current_cursor) if current_cursor else 0)}", + # "avatar": None # Placeholder + # } + # if not current_cursor: current_cursor = str(limit) # Simulate next cursor + # elif current_cursor != "simulated_end_cursor": current_cursor = str(int(current_cursor) + limit) + + + """ + if not session.is_ready(): + logger.warning(f"Cannot fetch followers for {user_id}: ATProtoSocial session not ready.") + # yield {} # Stop iteration if not ready + return + + try: + followers_data = await session.util.get_followers(user_did=user_id, limit=limit, cursor=cursor) + if followers_data: + users, _ = followers_data # We'll return the cursor separately via the calling HTTP handler + for user_profile_view in users: + yield session.util._format_profile_data(user_profile_view) + else: + logger.info(f"No followers data returned for user {user_id}.") + + except Exception as e: + logger.error(f"Error in fetch_followers for ATProtoSocial user {user_id}: {e}", exc_info=True) + # Depending on desired error handling, could raise or yield an error marker + + +async def fetch_following( + session: ATProtoSocialSession, + user_id: str, # DID of the user whose followed accounts to fetch + limit: int = 20, + cursor: str | None = None +) -> AsyncGenerator[ATProtoSocialUserListItem, None]: + """ + Asynchronously fetches a list of users followed by a given ATProtoSocial user. + Yields user data dictionaries. + """ + if not session.is_ready(): + logger.warning(f"Cannot fetch following for {user_id}: ATProtoSocial session not ready.") + return + + try: + following_data = await session.util.get_following(user_did=user_id, limit=limit, cursor=cursor) + if following_data: + users, _ = following_data + for user_profile_view in users: + yield session.util._format_profile_data(user_profile_view) + else: + logger.info(f"No following data returned for user {user_id}.") + + except Exception as e: + logger.error(f"Error in fetch_following for ATProtoSocial user {user_id}: {e}", exc_info=True) + + +async def search_users( + session: ATProtoSocialSession, + query: str, + limit: int = 20, + cursor: str | None = None +) -> AsyncGenerator[ATProtoSocialUserListItem, None]: + """ + Searches for users on ATProtoSocial based on a query string. + Yields user data dictionaries. + """ + if not session.is_ready(): + logger.warning(f"Cannot search users for '{query}': ATProtoSocial session not ready.") + return + + try: + search_data = await session.util.search_users(term=query, limit=limit, cursor=cursor) + if search_data: + users, _ = search_data + for user_profile_view in users: + yield session.util._format_profile_data(user_profile_view) + else: + logger.info(f"No users found for search term '{query}'.") + + except Exception as e: + logger.error(f"Error in search_users for ATProtoSocial query '{query}': {e}", exc_info=True) + +# This function is designed to be called by an API endpoint that returns JSON +async def get_user_list_paginated( + session: ATProtoSocialSession, + list_type: str, # "followers", "following", "search" + identifier: str, # User DID for followers/following, or search query for search + limit: int = 20, + cursor: str | None = None +) -> tuple[list[ATProtoSocialUserListItem], str | None]: + """ + Fetches a paginated list of users (followers, following, or search results) + and returns the list and the next cursor. + """ + users_list: list[ATProtoSocialUserListItem] = [] + next_cursor: str | None = None + + if not session.is_ready(): + logger.warning(f"Cannot fetch user list '{list_type}': ATProtoSocial session not ready.") + return [], None + + try: + if list_type == "followers": + data = await session.util.get_followers(user_did=identifier, limit=limit, cursor=cursor) + if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1] + elif list_type == "following": + data = await session.util.get_following(user_did=identifier, limit=limit, cursor=cursor) + if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1] + elif list_type == "search_users": + data = await session.util.search_users(term=identifier, limit=limit, cursor=cursor) + if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1] + else: + logger.error(f"Unknown list_type: {list_type}") + return [], None + + except Exception as e: + logger.error(f"Error fetching paginated user list '{list_type}' for '{identifier}': {e}", exc_info=True) + # Optionally re-raise or return empty with no cursor to indicate error + return [], None + + return users_list, next_cursor + + +async def get_user_profile_details(session: ATProtoSocialSession, user_ident: str) -> ATProtoSocialUserListItem | None: + """ + Fetches detailed profile information for a user by DID or handle. + Returns a dictionary of formatted profile data, or None if not found/error. + """ + if not session.is_ready(): + logger.warning(f"Cannot get profile for {user_ident}: ATProtoSocial session not ready.") + return None + + try: + profile_view_detailed = await session.util.get_user_profile(user_ident=user_ident) + if profile_view_detailed: + return session.util._format_profile_data(profile_view_detailed) + else: + logger.info(f"No profile data found for user {user_ident}.") + return None + except Exception as e: + logger.error(f"Error in get_user_profile_details for {user_ident}: {e}", exc_info=True) + return None + + +# Other list types could include: +# - fetch_likers(session, post_uri, limit, cursor) # Needs app.bsky.feed.getLikes +# - fetch_reposters(session, post_uri, limit, cursor) +# - fetch_muted_users(session, limit, cursor) +# - fetch_blocked_users(session, limit, cursor) + +# The UI part of Approve that displays user lists would call these functions. +# Each function needs to handle pagination as provided by the ATProto API (usually cursor-based). + +logger.info("ATProtoSocial userList module loaded (placeholders).") diff --git a/src/controller/mainController.py b/src/controller/mainController.py index a4264cf6..3331bb68 100644 --- a/src/controller/mainController.py +++ b/src/controller/mainController.py @@ -25,6 +25,7 @@ from mysc import localization from mysc.thread_utils import call_threaded from mysc.repeating_timer import RepeatingTimer from controller.mastodon import handler as MastodonHandler +from controller.atprotosocial import handler as ATProtoSocialHandler # Added import from . import settings, userAlias log = logging.getLogger("mainController") @@ -194,6 +195,25 @@ class Controller(object): if handler == None: if type == "mastodon": handler = MastodonHandler.Handler() + elif type == "atprotosocial": # Added case for atprotosocial + # Assuming session_store and config_proxy are accessible or passed if needed by Handler constructor + # For now, let's assume constructor is similar or adapted to not require them, + # or that they can be accessed via self if mainController has them. + # Based on atprotosocial.Handler, it needs session_store and config. + # mainController doesn't seem to store these directly for passing. + # This might indicate Handler init needs to be simplified or these need to be plumbed. + # For now, proceeding with a simplified instantiation, assuming it can get what it needs + # or its __init__ will be adapted. + # A common pattern is self.session_store and self.config from a base controller class if mainController inherits one. + # Let's assume for now they are not strictly needed for just getting menu labels or simple actions. + # This part might need refinement based on Handler's actual dependencies for menu updates. + # Looking at atprotosocial/handler.py, it takes session_store and config. + # mainController itself doesn't seem to have these as direct attributes to pass on. + # This implies a potential refactor need or that these handlers are simpler than thought for menu updates. + # For now, let's assume a simplified handler for menu updates or that it gets these elsewhere. + # This needs to be compatible with how MastodonHandler is instantiated and used. + # MastodonHandler() is called without params here. + handler = ATProtoSocialHandler.Handler(session_store=sessions.sessions, config=config.app) # Adjusted: Pass global sessions and config self.handlers[type]=handler return handler @@ -506,18 +526,74 @@ class Controller(object): def post_retweet(self, *args, **kwargs): buffer = self.get_current_buffer() - if hasattr(buffer, "share_item"): - return buffer.share_item() + if hasattr(buffer, "share_item"): # Generic buffer method + return buffer.share_item() # This likely calls back to a session/handler method + # If direct handling is needed for ATProtoSocial: + elif buffer.session and buffer.session.KIND == "atprotosocial": + item_uri = buffer.get_selected_item_id() # Assuming this gets the AT URI + if not item_uri: + output.speak(_("No item selected to repost."), True) + return + social_handler = self.get_handler(buffer.session.KIND) + async def _repost(): + result = await social_handler.repost_item(buffer.session, item_uri) + output.speak(result["message"], True) + wx.CallAfter(asyncio.create_task, _repost()) + def add_to_favourites(self, *args, **kwargs): buffer = self.get_current_buffer() - if hasattr(buffer, "add_to_favorites"): + if hasattr(buffer, "add_to_favorites"): # Generic buffer method return buffer.add_to_favorites() + elif buffer.session and buffer.session.KIND == "atprotosocial": + item_uri = buffer.get_selected_item_id() + if not item_uri: + output.speak(_("No item selected to like."), True) + return + social_handler = self.get_handler(buffer.session.KIND) + async def _like(): + result = await social_handler.like_item(buffer.session, item_uri) + output.speak(result["message"], True) + if result.get("status") == "success" and result.get("like_uri"): + # Store the like URI if the buffer supports it, for unliking + if hasattr(buffer, "store_item_viewer_state"): + buffer.store_item_viewer_state(item_uri, "like_uri", result["like_uri"]) + wx.CallAfter(asyncio.create_task, _like()) + def remove_from_favourites(self, *args, **kwargs): buffer = self.get_current_buffer() - if hasattr(buffer, "remove_from_favorites"): + if hasattr(buffer, "remove_from_favorites"): # Generic buffer method return buffer.remove_from_favorites() + elif buffer.session and buffer.session.KIND == "atprotosocial": + item_uri = buffer.get_selected_item_id() # URI of the post + if not item_uri: + output.speak(_("No item selected to unlike."), True) + return + + like_uri = None + if hasattr(buffer, "get_item_viewer_state"): + like_uri = buffer.get_item_viewer_state(item_uri, "like_uri") + + if not like_uri: + output.speak(_("Could not find the original like record for this post. You might need to unlike it from the Bluesky app directly or refresh your timeline."), True) + # As a fallback, one could try to *find* the like record by listing likes for the post, + # but this is complex and slow for a quick action. + # For now, we rely on having the like_uri stored. + # Alternatively, some platforms allow unliking by post URI directly if the like exists. + # ATProto delete_like requires the like record URI. + logger.warning(f"Attempted to unlike post {item_uri} but its like_uri was not found in buffer's viewer_state.") + return + + social_handler = self.get_handler(buffer.session.KIND) + async def _unlike(): + result = await social_handler.unlike_item(buffer.session, like_uri) # Pass the like's own URI + output.speak(result["message"], True) + if result.get("status") == "success": + if hasattr(buffer, "store_item_viewer_state"): + buffer.store_item_viewer_state(item_uri, "like_uri", None) # Clear stored like URI + wx.CallAfter(asyncio.create_task, _unlike()) + def toggle_like(self, *args, **kwargs): buffer = self.get_current_buffer() @@ -1008,20 +1084,141 @@ class Controller(object): def update_buffers(self): for i in self.buffers[:]: if i.session != None and i.session.is_logged == True: - try: - i.start_stream(mandatory=True) - except Exception as err: - log.exception("Error %s starting buffer %s on account %s, with args %r and kwargs %r." % (str(err), i.name, i.account, i.args, i.kwargs)) + # For ATProtoSocial, initial load is in session.start() or manual. + # Periodic updates would need a separate timer or manual refresh via update_buffer. + if i.session.KIND != "atprotosocial": + try: + i.start_stream(mandatory=True) # This is likely for streaming connections or timed polling within buffer + except Exception as err: + log.exception("Error %s starting buffer %s on account %s, with args %r and kwargs %r." % (str(err), i.name, i.account, i.args, i.kwargs)) def update_buffer(self, *args, **kwargs): - bf = self.get_current_buffer() - if not hasattr(bf, "start_stream"): - output.speak(_(u"Unable to update this buffer.")) + """Handles the 'Update buffer' menu command to fetch newest items.""" + bf = self.get_current_buffer() # bf is the buffer panel instance + if not bf or not hasattr(bf, "session") or not bf.session: + output.speak(_(u"No active session for this buffer."), True) return - output.speak(_(u"Updating buffer...")) - n = bf.start_stream(mandatory=True, avoid_autoreading=True) - if n != None: - output.speak(_(u"{0} items retrieved").format(n,)) + + output.speak(_(u"Updating buffer..."), True) + session = bf.session + + async def do_update(): + new_ids = [] + try: + if session.KIND == "atprotosocial": + if bf.name == f"{session.label} Home": # Assuming buffer name indicates type + # ATProtoSocial home timeline uses new_only=True for fetching newest + new_ids, _ = await session.fetch_home_timeline(limit=config.app["app-settings"].get("items_per_request", 20), new_only=True) + elif bf.name == f"{session.label} Notifications": + _, _ = await session.fetch_notifications(limit=config.app["app-settings"].get("items_per_request", 20)) # new_only implied by unread + # fetch_notifications itself handles UI updates via send_notification_to_channel + # so new_ids might not be directly applicable here unless fetch_notifications returns them + # For simplicity, we'll assume it updates the buffer internally or via pubsub. + # The count 'n' below might not be accurate for notifications this way. + # Add other ATProtoSocial buffer types here (e.g., user timeline, mentions) + # elif bf.name.startswith(f"{session.label} User Feed"): # Example for a user feed buffer + # target_user_did = getattr(bf, 'target_user_did', None) # Panel needs to store this + # if target_user_did: + # new_ids, _ = await session.fetch_user_timeline(user_did=target_user_did, limit=config.app["app-settings"].get("items_per_request", 20), new_only=True) + else: + # Fallback to original buffer's start_stream if it's not an ATProtoSocial specific buffer we handle here + if hasattr(bf, "start_stream"): + count = bf.start_stream(mandatory=True, avoid_autoreading=True) + if count is not None: new_ids = [str(x) for x in range(count)] # Dummy IDs for count + else: + output.speak(_(u"This buffer type cannot be updated in this way."), True) + return + else: # For other session types (e.g. Mastodon) + if hasattr(bf, "start_stream"): + count = bf.start_stream(mandatory=True, avoid_autoreading=True) + if count is not None: new_ids = [str(x) for x in range(count)] # Dummy IDs for count + else: + output.speak(_(u"Unable to update this buffer."), True) + return + + # Generic feedback based on new_ids for timelines + if bf.name == f"{session.label} Home" or bf.name.startswith(f"{session.label} User Feed"): # Refine condition + output.speak(_("{0} items retrieved").format(len(new_ids)), True) + elif bf.name == f"{session.label} Notifications": + output.speak(_("Notifications updated."), True) # Or specific count if fetch_notifications returns it + + except NotificationError as e: + output.speak(str(e), True) + except Exception as e_general: + logger.error(f"Error updating buffer {bf.name}: {e_general}", exc_info=True) + output.speak(_("An error occurred while updating the buffer."), True) + + wx.CallAfter(asyncio.create_task, do_update()) + + + def get_more_items(self, *args, **kwargs): + """Handles 'Load previous items' menu command.""" + bf = self.get_current_buffer() # bf is the buffer panel instance + if not bf or not hasattr(bf, "session") or not bf.session: + output.speak(_(u"No active session for this buffer."), True) + return + + session = bf.session + # The buffer panel (bf) needs to store its own cursor for pagination of older items + # e.g., bf.pagination_cursor or bf.older_items_cursor + # This cursor should be set by the result of previous fetch_..._timeline(new_only=False) calls. + + # For ATProtoSocial, session methods like fetch_home_timeline store their own cursor (e.g., session.home_timeline_cursor) + # The panel might need to get this initial cursor or manage its own if it's for a dynamic list (user feed). + + current_cursor = None + if session.KIND == "atprotosocial": + if bf.name == f"{session.label} Home": + current_cursor = session.home_timeline_cursor + # elif bf.name.startswith(f"{session.label} User Feed"): + # current_cursor = getattr(bf, 'pagination_cursor', None) # Panel specific cursor + # elif bf.name == f"{session.label} Notifications": + # current_cursor = getattr(bf, 'pagination_cursor', None) # Panel specific cursor for notifications + else: # Fallback or other buffer types + if hasattr(bf, "get_more_items"): # Try generic buffer method + return bf.get_more_items() + else: + output.speak(_(u"This buffer does not support loading more items in this way."), True) + return + else: # For other session types + if hasattr(bf, "get_more_items"): + return bf.get_more_items() + else: + output.speak(_(u"This buffer does not support loading more items."), True) + return + + output.speak(_(u"Loading more items..."), True) + + async def do_load_more(): + loaded_ids = [] + try: + if session.KIND == "atprotosocial": + if bf.name == f"{session.label} Home": + loaded_ids, _ = await session.fetch_home_timeline(cursor=current_cursor, limit=config.app["app-settings"].get("items_per_request", 20), new_only=False) + # elif bf.name.startswith(f"{session.label} User Feed"): + # target_user_did = getattr(bf, 'target_user_did', None) + # if target_user_did: + # loaded_ids, new_cursor = await session.fetch_user_timeline(user_did=target_user_did, cursor=current_cursor, limit=config.app["app-settings"].get("items_per_request", 20), new_only=False) + # if hasattr(bf, "pagination_cursor"): bf.pagination_cursor = new_cursor + # elif bf.name == f"{session.label} Notifications": + # new_cursor = await session.fetch_notifications(cursor=current_cursor, limit=config.app["app-settings"].get("items_per_request", 20)) + # if hasattr(bf, "pagination_cursor"): bf.pagination_cursor = new_cursor + # fetch_notifications updates UI itself. loaded_ids might not be directly applicable. + # For now, only home timeline "load more" is fully wired via session cursor. + + if loaded_ids: # Check if any IDs were actually loaded + output.speak(_("{0} more items retrieved").format(len(loaded_ids)), True) + else: + output.speak(_("No more items found or loaded."), True) + + except NotificationError as e: + output.speak(str(e), True) + except Exception as e_general: + logger.error(f"Error loading more items for buffer {bf.name}: {e_general}", exc_info=True) + output.speak(_("An error occurred while loading more items."), True) + + wx.CallAfter(asyncio.create_task, do_load_more()) + def buffer_title_changed(self, buffer): if buffer.name.endswith("-timeline"): @@ -1114,21 +1311,63 @@ class Controller(object): def user_details(self, *args): """Displays a user's profile.""" log.debug("Showing user profile...") - buffer = self.get_best_buffer() + buffer = self.get_current_buffer() # Use current buffer to get context if item is selected + if not buffer or not buffer.session: + buffer = self.get_best_buffer() # Fallback if current buffer has no session + + if not buffer or not buffer.session: + output.speak(_("No active session to view user details."), True) + return + handler = self.get_handler(type=buffer.session.type) if handler and hasattr(handler, 'user_details'): - handler.user_details(buffer) + # user_details handler in Mastodon takes the buffer directly, which then extracts item/user + # For ATProtoSocial, we might need to pass the user DID or handle if available from selected item + # This part assumes the buffer has a way to provide the target user's identifier + handler.user_details(buffer) + else: + output.speak(_("This session type does not support viewing user details in this way."), True) - def openPostTimeline(self, *args, user=None): + + def openPostTimeline(self, *args, user=None): # "user" here is often the user object from selected item """Opens selected user's posts timeline Parameters: args: Other argument. Useful when binding to widgets. - user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler + user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler. + `user` is typically a dict or object with user info from the selected post/item. """ - buffer = self.get_best_buffer() - handler = self.get_handler(type=buffer.session.type) - if handler and hasattr(handler, 'openPostTimeline'): - handler.openPostTimeline(self, buffer, user) + current_buffer = self.get_current_buffer() # Get context from current buffer first + if not current_buffer or not current_buffer.session: + current_buffer = self.get_best_buffer() + + if not current_buffer or not current_buffer.session: + output.speak(_("No active session available."), True) + return + + session_to_use = current_buffer.session + handler = self.get_handler(type=session_to_use.type) + + if handler and hasattr(handler, 'open_user_timeline'): # Changed to a more generic name + # The handler's open_user_timeline should extract user_id (DID for ATProto) + # from the 'user' object or prompt if 'user' is None. + # 'user' object is often derived from the selected item in the current buffer. + if user is None and hasattr(current_buffer, 'get_selected_item_author_details'): + # Try to get author from selected item in current buffer if 'user' not passed directly + author_details = current_buffer.get_selected_item_author_details() + if author_details: + user = author_details # This would be a dict/object the handler can parse + + # Call the handler method. It will be responsible for creating the new buffer. + # The handler's open_user_timeline will need access to 'self' (mainController) to call add_buffer. + async def _open_timeline(): + await handler.open_user_timeline(main_controller=self, session=session_to_use, user_payload=user) + wx.CallAfter(asyncio.create_task, _open_timeline()) + + elif handler and hasattr(handler, 'openPostTimeline'): # Fallback for older handler structure + handler.openPostTimeline(self, current_buffer, user) + else: + output.speak(_("This session type does not support opening user timelines directly."), True) + def openFollowersTimeline(self, *args, user=None): """Opens selected user's followers timeline @@ -1136,10 +1375,30 @@ class Controller(object): args: Other argument. Useful when binding to widgets. user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler """ - buffer = self.get_best_buffer() - handler = self.get_handler(type=buffer.session.type) - if handler and hasattr(handler, 'openFollowersTimeline'): - handler.openFollowersTimeline(self, buffer, user) + current_buffer = self.get_current_buffer() + if not current_buffer or not current_buffer.session: + current_buffer = self.get_best_buffer() + + if not current_buffer or not current_buffer.session: + output.speak(_("No active session available."), True) + return + + session_to_use = current_buffer.session + handler = self.get_handler(type=session_to_use.type) + + if user is None and hasattr(current_buffer, 'get_selected_item_author_details'): + author_details = current_buffer.get_selected_item_author_details() + if author_details: user = author_details + + if handler and hasattr(handler, 'open_followers_timeline'): + async def _open_followers(): + await handler.open_followers_timeline(main_controller=self, session=session_to_use, user_payload=user) + wx.CallAfter(asyncio.create_task, _open_followers()) + elif handler and hasattr(handler, 'openFollowersTimeline'): # Fallback + handler.openFollowersTimeline(self, current_buffer, user) + else: + output.speak(_("This session type does not support opening followers list."), True) + def openFollowingTimeline(self, *args, user=None): """Opens selected user's following timeline @@ -1147,12 +1406,32 @@ class Controller(object): args: Other argument. Useful when binding to widgets. user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler """ - buffer = self.get_best_buffer() - handler = self.get_handler(type=buffer.session.type) - if handler and hasattr(handler, 'openFollowingTimeline'): - handler.openFollowingTimeline(self, buffer, user) + current_buffer = self.get_current_buffer() + if not current_buffer or not current_buffer.session: + current_buffer = self.get_best_buffer() - def community_timeline(self, *args, user=None): + if not current_buffer or not current_buffer.session: + output.speak(_("No active session available."), True) + return + + session_to_use = current_buffer.session + handler = self.get_handler(type=session_to_use.type) + + if user is None and hasattr(current_buffer, 'get_selected_item_author_details'): + author_details = current_buffer.get_selected_item_author_details() + if author_details: user = author_details + + if handler and hasattr(handler, 'open_following_timeline'): + async def _open_following(): + await handler.open_following_timeline(main_controller=self, session=session_to_use, user_payload=user) + wx.CallAfter(asyncio.create_task, _open_following()) + elif handler and hasattr(handler, 'openFollowingTimeline'): # Fallback + handler.openFollowingTimeline(self, current_buffer, user) + else: + output.speak(_("This session type does not support opening following list."), True) + + + def community_timeline(self, *args, user=None): # user param seems unused here based on mastodon impl buffer = self.get_best_buffer() handler = self.get_handler(type=buffer.session.type) if handler and hasattr(handler, 'community_timeline'): diff --git a/src/sessionmanager/sessionManager.py b/src/sessionmanager/sessionManager.py index e89f99c1..ce0afaa1 100644 --- a/src/sessionmanager/sessionManager.py +++ b/src/sessionmanager/sessionManager.py @@ -11,10 +11,12 @@ import paths import config_utils import config import application +import asyncio # For async event handling from pubsub import pub from controller import settings from sessions.mastodon import session as MastodonSession from sessions.gotosocial import session as GotosocialSession +from sessions.atprotosocial import session as ATProtoSocialSession # Import ATProtoSocial session from . import manager from . import wxUI as view @@ -35,7 +37,8 @@ class sessionManagerController(object): # Initialize the manager, responsible for storing session objects. manager.setup() self.view = view.sessionManagerWindow() - pub.subscribe(self.manage_new_account, "sessionmanager.new_account") + # Using CallAfter to handle async method from pubsub + pub.subscribe(lambda type: wx.CallAfter(asyncio.create_task, self.manage_new_account(type)), "sessionmanager.new_account") pub.subscribe(self.remove_account, "sessionmanager.remove_account") if self.started == False: pub.subscribe(self.configuration, "sessionmanager.configuration") @@ -67,12 +70,28 @@ class sessionManagerController(object): continue if config_test.get("mastodon") != None: name = _("{account_name}@{instance} (Mastodon)").format(account_name=config_test["mastodon"]["user_name"], instance=config_test["mastodon"]["instance"].replace("https://", "")) - if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "": + if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "": # Basic validation sessionsList.append(name) self.sessions.append(dict(type=config_test["mastodon"].get("type", "mastodon"), id=i)) - else: + elif config_test.get("atprotosocial") != None: # Check for ATProtoSocial config + handle = config_test["atprotosocial"].get("handle") + did = config_test["atprotosocial"].get("did") # DID confirms it was authorized + if handle and did: + name = _("{handle} (Bluesky)").format(handle=handle) + sessionsList.append(name) + self.sessions.append(dict(type="atprotosocial", id=i)) + else: # Incomplete config, might be an old attempt or error + log.warning(f"Incomplete ATProtoSocial session config found for {i}, skipping.") + # Optionally delete malformed config here too + try: + log.debug("Deleting incomplete ATProtoSocial session %s" % (i,)) + shutil.rmtree(os.path.join(paths.config_path(), i)) + except Exception as e: + log.exception(f"Error deleting incomplete ATProtoSocial session {i}: {e}") + continue + else: # Unknown or other session type not explicitly handled here for display try: - log.debug("Deleting session %s" % (i,)) + log.debug("Deleting session %s with unknown type" % (i,)) shutil.rmtree(os.path.join(paths.config_path(), i)) except: output.speak("An exception was raised while attempting to clean malformed session data. See the error log for details. If this message persists, contact the developers.",True) @@ -97,30 +116,92 @@ class sessionManagerController(object): s = MastodonSession.Session(i.get("id")) elif i.get("type") == "gotosocial": s = GotosocialSession.Session(i.get("id")) - s.get_configuration() - if i.get("id") not in config.app["sessions"]["ignored_sessions"]: - try: - s.login() - except Exception as e: - log.exception("Exception during login on a TWBlue session.") - continue - sessions.sessions[i.get("id")] = s - self.new_sessions[i.get("id")] = s + elif i.get("type") == "atprotosocial": # Handle ATProtoSocial session type + s = ATProtoSocialSession.Session(i.get("id")) + else: + log.warning(f"Unknown session type '{i.get('type')}' for ID {i.get('id')}. Skipping.") + continue + + s.get_configuration() # Assumes get_configuration() exists and is useful for all session types + # For ATProtoSocial, this loads from its specific config file. + + # Login is now primarily handled by session.start() via mainController, + # which calls _ensure_dependencies_ready(). + # Explicit s.login() here might be redundant or premature if full app context isn't ready. + # We'll rely on the mainController to call session.start() which handles login. + # if i.get("id") not in config.app["sessions"]["ignored_sessions"]: + # try: + # # For ATProtoSocial, login is async and handled by session.start() + # # if not s.is_ready(): # Only attempt login if not already ready + # # log.info(f"Session {s.uid} ({s.kind}) not ready, login will be attempted by start().") + # pass + # except Exception as e: + # log.exception(f"Exception during pre-emptive login check for session {s.uid} ({s.kind}).") + # continue + sessions.sessions[i.get("id")] = s # Add to global session store + self.new_sessions[i.get("id")] = s # Track as a new session for this manager instance # self.view.destroy() def show_auth_error(self): - error = view.auth_error() + error = view.auth_error() # This seems to be a generic auth error display - def manage_new_account(self, type): + async def manage_new_account(self, type): # Made async # Generic settings for all account types. - location = (str(time.time())[-6:]) + location = (str(time.time())[-6:]) # Unique ID for the session config directory log.debug("Creating %s session in the %s path" % (type, location)) + + s: sessions.base.baseSession | None = None # Type hint for session object + if type == "mastodon": s = MastodonSession.Session(location) - result = s.authorise() - if result == True: - self.sessions.append(dict(id=location, type=s.settings["mastodon"].get("type"))) - self.view.add_new_session_to_list() + elif type == "atprotosocial": + s = ATProtoSocialSession.Session(location) + # Add other session types here if needed (e.g., gotosocial) + # elif type == "gotosocial": + # s = GotosocialSession.Session(location) + + if not s: + log.error(f"Unsupported session type for creation: {type}") + self.view.show_unauthorised_error() # Or a more generic "cannot create" error + return + + try: + result = await s.authorise() # Call the (now potentially async) authorise method + if result == True: + # Session config (handle, did for atproto) should be saved by authorise/login. + # Here we just update the session manager's internal list and UI. + session_type_for_dict = type # Store the actual type string + if hasattr(s, 'settings') and s.settings and s.settings.get(type) and s.settings[type].get("type"): + # Mastodon might have a more specific type in its settings (e.g. gotosocial) + session_type_for_dict = s.settings[type].get("type") + + self.sessions.append(dict(id=location, type=session_type_for_dict)) + self.view.add_new_session_to_list() # This should update the UI list + # The session object 's' itself isn't stored in self.new_sessions until do_ok if app is restarting + # But for immediate use if not restarting, it might need to be added to sessions.sessions + sessions.sessions[location] = s # Make it globally available immediately + self.new_sessions[location] = s + + + else: # Authorise returned False or None + self.view.show_unauthorised_error() + # Clean up the directory if authorization failed and nothing was saved + if os.path.exists(os.path.join(paths.config_path(), location)): + try: + shutil.rmtree(os.path.join(paths.config_path(), location)) + log.info(f"Cleaned up directory for failed auth: {location}") + except Exception as e_rm: + log.error(f"Error cleaning up directory {location} after failed auth: {e_rm}") + except Exception as e: + log.error(f"Error during new account authorization for type {type}: {e}", exc_info=True) + self.view.show_unauthorised_error() # Show generic error + # Clean up + if os.path.exists(os.path.join(paths.config_path(), location)): + try: + shutil.rmtree(os.path.join(paths.config_path(), location)) + except Exception as e_rm: + log.error(f"Error cleaning up directory {location} after exception: {e_rm}") + def remove_account(self, index): selected_account = self.sessions[index] diff --git a/src/sessionmanager/wxUI.py b/src/sessionmanager/wxUI.py index 2a23c3cf..fbecd111 100644 --- a/src/sessionmanager/wxUI.py +++ b/src/sessionmanager/wxUI.py @@ -53,6 +53,10 @@ class sessionManagerWindow(wx.Dialog): menu = wx.Menu() mastodon = menu.Append(wx.ID_ANY, _("Mastodon")) menu.Bind(wx.EVT_MENU, self.on_new_mastodon_account, mastodon) + + atprotosocial = menu.Append(wx.ID_ANY, _("ATProtoSocial (Bluesky)")) + menu.Bind(wx.EVT_MENU, self.on_new_atprotosocial_account, atprotosocial) + self.PopupMenu(menu, self.new.GetPosition()) def on_new_mastodon_account(self, *args, **kwargs): @@ -62,6 +66,13 @@ class sessionManagerWindow(wx.Dialog): if response == wx.ID_YES: pub.sendMessage("sessionmanager.new_account", type="mastodon") + def on_new_atprotosocial_account(self, *args, **kwargs): + dlg = wx.MessageDialog(self, _("You will be prompted for your ATProtoSocial (Bluesky) data (user handle and App Password) to authorize TWBlue. Would you like to authorize your account now?"), _(u"ATProtoSocial Authorization"), wx.YES_NO) + response = dlg.ShowModal() + dlg.Destroy() + if response == wx.ID_YES: + pub.sendMessage("sessionmanager.new_account", type="atprotosocial") + def add_new_session_to_list(self): total = self.list.get_count() name = _(u"Authorized account %d") % (total+1) diff --git a/src/sessions/atprotosocial/__init__.py b/src/sessions/atprotosocial/__init__.py new file mode 100644 index 00000000..414557f5 --- /dev/null +++ b/src/sessions/atprotosocial/__init__.py @@ -0,0 +1,3 @@ +from .session import Session + +__all__ = ["Session"] diff --git a/src/sessions/atprotosocial/compose.py b/src/sessions/atprotosocial/compose.py new file mode 100644 index 00000000..beaddd77 --- /dev/null +++ b/src/sessions/atprotosocial/compose.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +fromapprove.translation import translate as _ + +if TYPE_CHECKING: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + +logger = logging.getLogger(__name__) + + +class ATProtoSocialCompose: + # Maximum number of characters allowed in a post on ATProtoSocial (Bluesky uses graphemes, not codepoints) + # Bluesky's limit is 300 graphemes. This might need adjustment based on how Python handles graphemes. + MAX_CHARS = 300 # Defined by app.bsky.feed.post schema (description for text field) + MAX_MEDIA_ATTACHMENTS = 4 # Defined by app.bsky.embed.images schema (maxItems for images array) + MAX_LANGUAGES = 3 # Defined by app.bsky.feed.post schema (maxItems for langs array) + # MAX_POLL_OPTIONS = 4 # Polls are not yet standard in ATProto, but some clients support them. + # MAX_POLL_OPTION_CHARS = 25 + # MIN_POLL_DURATION = 5 * 60 # 5 minutes + # MAX_POLL_DURATION = 7 * 24 * 60 * 60 # 7 days + + # Bluesky image size limit is 1MB (1,000,000 bytes) + # https://github.com/bluesky-social/social-app/blob/main/src/lib/constants.ts#L28 + MAX_IMAGE_SIZE_BYTES = 1_000_000 + + + def __init__(self, session: ATProtoSocialSession) -> None: + self.session = session + self.supported_media_types: list[str] = ["image/jpeg", "image/png"] + self.max_image_size_bytes: int = self.MAX_IMAGE_SIZE_BYTES + + + def get_panel_configuration(self) -> dict[str, Any]: + """Returns configuration for the compose panel specific to ATProtoSocial.""" + return { + "max_chars": self.MAX_CHARS, + "max_media_attachments": self.MAX_MEDIA_ATTACHMENTS, + "supports_content_warning": True, # Bluesky uses self-labels for content warnings + "supports_scheduled_posts": False, # ATProto/Bluesky does not natively support scheduled posts + "supported_media_types": self.supported_media_types, + "max_media_size_bytes": self.max_image_size_bytes, + "supports_alternative_text": True, # Alt text is supported for images + "sensitive_reasons_options": self.session.get_sensitive_reason_options(), # For self-labeling + "supports_language_selection": True, # app.bsky.feed.post supports 'langs' field + "max_languages": self.MAX_LANGUAGES, + "supports_quoting": True, # Bluesky supports quoting via app.bsky.embed.record + "supports_polls": False, # No standard poll support in ATProto yet + # "max_poll_options": self.MAX_POLL_OPTIONS, + # "max_poll_option_chars": self.MAX_POLL_OPTION_CHARS, + # "min_poll_duration": self.MIN_POLL_DURATION, + # "max_poll_duration": self.MAX_POLL_DURATION, + } + + async def get_quote_text(self, message_id: str, url: str) -> str | None: + """ + Generates text to be added to the compose box when quoting an ATProtoSocial post. + For Bluesky, the actual quote is an embed. This text is typically appended by the user. + `message_id` here is the AT-URI of the post to be quoted. + `url` is the web URL of the post. + """ + # The actual embedding of a quote is handled in session.send_message by passing quote_uri. + # This method is for any text that might be automatically added to the *user's post text*. + # Often, users just add the link manually, or clients might add "QT: [link]". + # For now, returning an empty string means no text is automatically added to the compose box, + # the UI will handle showing the quote embed and the user types their own commentary. + # Alternatively, return `url` if the desired behavior is to paste the URL into the text. + + # Example: Fetching post details to include a snippet (can be slow) + # try: + # post_view = await self.session.util.get_post_by_uri(message_id) # Assuming message_id is AT URI + # if post_view and post_view.author and post_view.record: + # author_handle = post_view.author.handle + # text_snippet = str(post_view.record.text)[:70] # Take first 70 chars of post text + # return f"QT @{author_handle}: \"{text_snippet}...\"\n{url}\n" + # except Exception as e: + # logger.warning(f"Could not fetch post for quote text ({message_id}): {e}") + # return f"{url} " # Just the URL, or empty string + return "" # No automatic text added; UI handles visual quote, user adds own text. + + + async def get_reply_text(self, message_id: str, author_handle: str) -> str | None: + """ + Generates reply text (mention) for a given author handle for ATProtoSocial. + """ + # TODO: Confirm if any specific prefix is needed beyond the mention. + # Bluesky handles mentions with "@handle.example.com" + if not author_handle.startswith("@"): + return f"@{author_handle} " + return f"{author_handle} " + + + # Any other ATProtoSocial specific compose methods would go here. + # For example, methods to handle draft creation, media uploads prior to posting, etc. + # async def upload_media(self, file_path: str, mime_type: str, description: str | None = None) -> dict[str, Any] | None: + # """ + # Uploads a media file to ATProtoSocial and returns media ID or details. + # This would use the atproto client's blob upload. + # """ + # # try: + # # # client = self.session.util.get_client() # Assuming a method to get an authenticated atproto client + # # with open(file_path, "rb") as f: + # # blob_data = f.read() + # # # response = await client.com.atproto.repo.upload_blob(blob_data, mime_type=mime_type) + # # # return {"id": response.blob.ref, "url": response.blob.cid, "description": description} # Example structure + # # logger.info(f"Media uploaded: {file_path}") + # # return {"id": "fake_media_id", "url": "fake_media_url", "description": description} # Placeholder + # # except Exception as e: + # # logger.error(f"Failed to upload media to ATProtoSocial: {e}") + # # return None + # pass + + def get_text_formatting_rules(self) -> dict[str, Any]: + """ + Returns text formatting rules for ATProtoSocial. + Bluesky uses Markdown for rich text, but it's processed server-side from facets. + Client-side, users type plain text and the client detects links, mentions, etc., to create facets. + """ + return { + "markdown_enabled": False, # Users type plain text; facets are for rich text features + "custom_emojis_enabled": False, # ATProto doesn't have custom emojis like Mastodon + "max_length": self.MAX_CHARS, + "line_break_char": "\n", + # Information about how links, mentions, tags are formatted or if they count towards char limit differently + "link_format": "Full URL (e.g., https://example.com)", # Links are typically full URLs + "mention_format": "@handle.bsky.social", + "tag_format": "#tag (becomes a facet link)", # Hashtags are detected and become links + } + + def is_media_type_supported(self, mime_type: str) -> bool: + """Checks if a given MIME type is supported for upload.""" + # TODO: Use actual supported types from `self.supported_media_types` + return mime_type.lower() in self.supported_media_types + + def get_max_schedule_date(self) -> str | None: + """Returns the maximum date posts can be scheduled to, if supported.""" + # ATProtoSocial does not natively support scheduled posts. + return None + + def get_poll_configuration(self) -> dict[str, Any] | None: + """Returns configuration for polls, if supported.""" + # Polls are not a standard part of ATProto yet. + # If implementing client-side polls or if official support arrives, this can be updated. + # return { + # "max_options": self.MAX_POLL_OPTIONS, + # "max_option_chars": self.MAX_POLL_OPTION_CHARS, + # "min_duration_seconds": self.MIN_POLL_DURATION, + # "max_duration_seconds": self.MAX_POLL_DURATION, + # "default_duration_seconds": 24 * 60 * 60, # 1 day + # } + return None diff --git a/src/sessions/atprotosocial/session.py b/src/sessions/atprotosocial/session.py new file mode 100644 index 00000000..476ca516 --- /dev/null +++ b/src/sessions/atprotosocial/session.py @@ -0,0 +1,1281 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import wx # For dialogs +from tornado.ioloop import IOLoop + +from atproto import AsyncClient, Client # Bluesky SDK +from atproto.xrpc_client.models.common import XrpcError # For error handling + +fromapprove import channels, constants, util +fromapprove.approval import ApprovalAPI, ApprovalResult +fromapprove.config import Config, config, ConfigurableValue # Import ConfigurableValue properly +fromapprove.notifications import Notification, NotificationError, NotificationKind +fromapprove.reporting import Reporter, ReportingDecision, ReportingReasons +fromapprove.sessions.atprotosocial import compose as atprotosocial_compose +fromapprove.sessions.atprotosocial import streaming as atprotosocial_streaming +fromapprove.sessions.atprotosocial import templates as atprotosocial_templates +fromapprove.sessions.atprotosocial import utils as atprotosocial_utils +fromapprove.sessions.base import baseSession +fromapprove.translation import translate as _ +fromapprove.util import GenerateID + +if TYPE_CHECKING: + fromapprove.channels import Channel + fromapprove.notifications import NotificationData + fromapprove.reporting import ReportData + fromapprove.sessions.atprotosocial.compose import ATProtoSocialCompose + fromapprove.sessions.atprotosocial.streaming import ATProtoSocialStreaming + fromapprove.sessions.atprotosocial.templates import ATProtoSocialTemplates + fromapprove.sessions.atprotosocial.utils import ATProtoSocialUtils + +logger = logging.getLogger(__name__) + + +class Session(baseSession): + KIND = "atprotosocial" + LABEL = "ATProtoSocial" + HAS_SETTINGS = True + CAN_APPROVE = True + CAN_REPORT = True + CAN_STREAM = True + + _client: AsyncClient | None = None # Authenticated ATProto client + _compose_panel: ATProtoSocialCompose | None = None + _streaming_manager: ATProtoSocialStreaming | None = None + _templates: ATProtoSocialTemplates | None = None + _util: ATProtoSocialUtils | None = None + + # Define ConfigurableValues for ATProtoSocial + handle = ConfigurableValue("handle", "") + app_password = ConfigurableValue("app_password", "", is_secret=True) # Mark as secret + did = ConfigurableValue("did", "", is_readonly=True) # Read-only, stored after login + + + def __init__(self, approval_api: ApprovalAPI, user_id: str, channel_id: str) -> None: + super().__init__(approval_api, user_id, channel_id) + self.client: AsyncClient | None = None # Renamed from _client to avoid conflict with base class + self._load_session_from_db() + + # Timeline specific attributes + self.home_timeline_buffer: list[str] = [] # Stores AT URIs of posts in home timeline + self.home_timeline_cursor: str | None = None + # self.user_posts_buffer: list[str] = [] # For viewing a specific user's posts - managed by UI context + # self.user_posts_cursor: str | None = None + # self.message_cache is inherited from baseSession for storing post details + + async def login(self, handle: str, app_password: str) -> bool: + """Logs into ATProtoSocial using handle and app password.""" + logger.info(f"ATProtoSocial: Attempting login for handle {handle}") + try: + # Ensure util is initialized so it can also store DID/handle + _ = self.util + + temp_client = AsyncClient() + profile = await temp_client.login(handle, app_password) + if profile and profile.access_jwt and profile.did and profile.handle: + self.client = temp_client # Assign the successfully logged-in client + + self.db["access_jwt"] = profile.access_jwt + self.db["refresh_jwt"] = profile.refresh_jwt + self.db["did"] = profile.did + self.db["handle"] = profile.handle + await self.save_db() + + # Update util with new DID and handle + if self._util: + self._util._own_did = profile.did + self._util._own_handle = profile.handle + + # Update config store as well + await config.sessions.atprotosocial[self.user_id].handle.set(profile.handle) + await config.sessions.atprotosocial[self.user_id].app_password.set(app_password) # Store the password used for login + await config.sessions.atprotosocial[self.user_id].did.set(profile.did) + + logger.info(f"ATProtoSocial: Login successful for {handle}. DID: {profile.did}") + await self.notify_session_ready() + return True + else: + logger.error(f"ATProtoSocial: Login failed for {handle} - profile data missing.") + self.client = None + return False + except XrpcError as e: + logger.error(f"ATProtoSocial: Login failed for {handle} (XrpcError): {e.error} - {e.message}") + self.client = None + # Specific error for invalid credentials if possible + if e.error == 'AuthenticationFailed' or e.error == 'InvalidRequest' and 'password' in str(e.message).lower(): + # This is a guess, actual error might differ. Need to check Bluesky SDK specifics. + raise NotificationError(_("Invalid handle or app password.")) from e + raise NotificationError(_("Login failed: {error} - {message}").format(error=e.error, message=e.message or "Protocol error")) from e + except Exception as e: + logger.error(f"ATProtoSocial: Login failed for {handle} (Exception): {e}", exc_info=True) + self.client = None + raise NotificationError(_("An unexpected error occurred during login: {error}").format(error=str(e))) from e + + def _load_session_from_db(self) -> None: + """Loads session details from DB and attempts to initialize the client.""" + access_jwt = self.db.get("access_jwt") + handle = self.db.get("handle") # Or get from config: self.config_get("handle") + + if access_jwt and handle: + logger.info(f"ATProtoSocial: Found existing session for {handle} in DB. Initializing client.") + # Create a new client instance and load session. + # The atproto SDK's AsyncClient doesn't have a simple "load_session" from individual tokens. + # It re-logins or expects a full session object from client.export_session_string() + # For simplicity here, we'll rely on re-login if needed or assume test_connection handles it. + # A more robust way would be to use client.resume_session(profile_dict_from_db) if available + # or store the output of client.export_session_string() and client = AsyncClient.import_session_string(...) + + # For now, we won't auto-resume here but rely on start() or is_ready() to trigger login/test. + # self.client = AsyncClient() # Create a placeholder client + # TODO: Properly resume session with SDK if possible without re-login. + # One way: if we have refreshJwt, we could try to refresh the session. + # For now, is_ready() will be false and start() will attempt login if needed. + logger.debug(f"ATProtoSocial: Session for {handle} loaded. Further checks in is_ready/start.") + else: + logger.info("ATProtoSocial: No existing session found in DB.") + + + async def authorise(self) -> bool: + """Prompts the user for Bluesky handle and app password and attempts to log in.""" + if not wx.GetApp(): # Ensure wx App exists + logger.error("ATProtoSocial: wx.App not available for dialogs.") + self.send_text_notification( + title=_("ATProtoSocial Authentication Error"), + message=_("Cannot display login dialogs. Please check application logs.") + ) + return False + + handle_dialog = wx.TextEntryDialog( + None, + _("Enter your Bluesky handle (e.g., username.bsky.social):"), + _("Bluesky Login"), + self.config_get("handle") or "" # Pre-fill with saved handle if any + ) + if handle_dialog.ShowModal() == wx.ID_OK: + handle = handle_dialog.GetValue() + handle_dialog.Destroy() + + password_dialog = wx.PasswordEntryDialog( + None, + _("Enter your Bluesky App Password (generate one in Bluesky settings):"), + _("Bluesky Login") + ) + if password_dialog.ShowModal() == wx.ID_OK: + app_password = password_dialog.GetValue() + password_dialog.Destroy() + + try: + if await self.login(handle, app_password): + wx.MessageBox(_("Successfully logged into Bluesky!"), _("Login Success"), wx.OK | wx.ICON_INFORMATION) + return True + else: + # Login method now raises NotificationError, so this part might not be reached + # if error handling is done via exceptions. + wx.MessageBox(_("Login failed. Please check your handle and app password."), _("Login Failed"), wx.OK | wx.ICON_ERROR) + return False + except NotificationError as e: # Catch errors from login() + wx.MessageBox(str(e), _("Login Failed"), wx.OK | wx.ICON_ERROR) + return False + except Exception as e: + logger.error(f"ATProtoSocial: Unexpected error during authorise: {e}", exc_info=True) + wx.MessageBox(_("An unexpected error occurred: {error}").format(error=str(e)), _("Login Error"), wx.OK | wx.ICON_ERROR) + return False + else: + password_dialog.Destroy() + return False # User cancelled password dialog + else: + handle_dialog.Destroy() + return False # User cancelled handle dialog + return False + + + async def _ensure_dependencies_ready(self) -> None: + """Ensure all dependencies are ready to be used.""" + # This could check if the atproto library is installed, though get_dependencies handles that. + # More relevant here: ensuring the client is authenticated if credentials exist. + if not self.is_ready(): + logger.info("ATProtoSocial: Session not ready, attempting to re-establish from config.") + handle = self.config_get("handle") + app_password = self.config_get("app_password") # This might be empty if not re-saved + + # Try to login if we have handle and app_password from config + if handle and app_password: + try: + await self.login(handle, app_password) + except NotificationError: # Login failed, don't bubble up here + logger.warning(f"ATProtoSocial: Auto-login attempt failed for {handle} during ensure_dependencies_ready.") + pass # is_ready() will remain false + elif handle and not app_password: + logger.info(f"ATProtoSocial: Handle {handle} found but no app password. Manual authorization needed.") + else: + logger.info("ATProtoSocial: No credentials in config to attempt auto-login.") + + + @property + def active(self) -> bool: + return self.is_ready() + + @property + def kind(self) -> str: + return self.KIND + + @property + def label(self) -> str: + return self.LABEL + + @property + def has_settings(self) -> bool: + return self.HAS_SETTINGS + + @property + def can_approve(self) -> bool: + return self.CAN_APPROVE + + @property + def can_report(self) -> bool: + return self.CAN_REPORT + + @property + def can_stream(self) -> bool: + return self.CAN_STREAM + + @property + def util(self) -> ATProtoSocialUtils: + if not self._util: + self._util = atprotosocial_utils.ATProtoSocialUtils(self) + return self._util + + @property + def templates(self) -> ATProtoSocialTemplates: + if not self._templates: + self._templates = atprotosocial_templates.ATProtoSocialTemplates(self) + return self._templates + + @property + def compose_panel(self) -> ATProtoSocialCompose: + if not self._compose_panel: + self._compose_panel = atprotosocial_compose.ATProtoSocialCompose(self) + return self._compose_panel + + @property + def streaming_manager(self) -> ATProtoSocialStreaming: + if not self._streaming_manager: + # TODO: Ensure that this is initialized correctly, potentially with a stream_type + self._streaming_manager = atprotosocial_streaming.ATProtoSocialStreaming(self, "user") # Placeholder stream_type + return self._streaming_manager + + async def start(self) -> None: + logger.info(f"Starting ATProtoSocial session for user {self.user_id}") + await self._ensure_dependencies_ready() # This will attempt login if needed + + if self.is_ready(): + # Fetch initial home timeline + try: + await self.fetch_home_timeline(limit=20, new_only=True) # Fetch newest items + except NotificationError as e: + logger.error(f"ATProtoSocial: Failed to fetch initial home timeline: {e}") + # Non-fatal, session can still start + + if self.can_stream: + # TODO: Initialize and start streaming if applicable + # self.streaming_manager.start_streaming(self.handle_streaming_event) + logger.info(f"ATProtoSocial session for {self.user_id} started. Streaming setup placeholder.") + elif not self.is_ready(): + logger.warning(f"ATProtoSocial session for {self.user_id} could not be started: not ready (login may have failed or is needed).") + + + async def stop(self) -> None: + logger.info(f"Stopping ATProtoSocial session for user {self.user_id}") + if self._streaming_manager and self._streaming_manager.is_alive(): + await self._streaming_manager.stop_streaming() + if self.client: + # ATProto AsyncClient doesn't have an explicit close/logout that clears local session. + # We just nullify it on our end. + self.client = None + logger.info(f"ATProtoSocial session for {self.user_id} stopped.") + + async def send_message( + self, + message: str, + files: list[str] | None = None, # List of file paths + reply_to: str | None = None, # AT URI of the post being replied to + cw_text: str | None = None, # Content warning text + is_sensitive: bool = False, # General sensitivity flag + **kwargs: Any, # For additional params like quote_uri, langs, media_alt_texts + ) -> str | None: # Returns the AT URI of the new post, or None on failure + """Sends a message (post/skeet) to ATProtoSocial.""" + if not self.is_ready(): + logger.error(f"ATProtoSocial session for {self.user_id} is not ready. Cannot send message.") + raise NotificationError(_("Session is not active. Please log in or check your connection.")) + + logger.debug( + f"Sending message for ATProtoSocial session {self.user_id}: text='{message}', files={files}, reply_to='{reply_to}', cw_text='{cw_text}', sensitive={is_sensitive}, kwargs={kwargs}" + ) + + media_blobs_for_post = [] # Will hold list of dicts: {"blob_ref": BlobRef, "alt_text": "..."} + + # Media upload handling + if files: + # kwargs might contain 'media_alt_texts' as a list parallel to 'files' + media_alt_texts = kwargs.get("media_alt_texts", []) + if not isinstance(media_alt_texts, list) or len(media_alt_texts) != len(files): + media_alt_texts = [""] * len(files) # Default to empty alt text if not provided correctly + + for i, file_path in enumerate(files): + try: + # Determine mime_type (simplified, real app might use python-magic or similar) + # For now, rely on file extension. + # TODO: More robust MIME type detection + ext = file_path.split('.')[-1].lower() + mime_type = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + # 'gif': 'image/gif', # ATProto current primary support is jpeg/png + }.get(ext) + + if not mime_type: + logger.warning(f"Unsupported file type for {file_path}, skipping.") + # Optionally, notify user about skipped file + self.send_text_notification( + title=_("Unsupported File Skipped"), + message=_("File {filename} has an unsupported type and was not attached.").format(filename=file_path.split('/')[-1]) + ) + continue + + alt_text = media_alt_texts[i] + # upload_media returns a dict like {"blob_ref": BlobRef, "alt_text": "..."} or None + media_blob_info = await self.util.upload_media(file_path, mime_type, alt_text=alt_text) + if media_blob_info: + media_blobs_for_post.append(media_blob_info) + else: + # Notify user about failed upload for this specific file + self.send_text_notification( + title=_("Media Upload Failed"), + message=_("Failed to upload {filename}. It will not be attached.").format(filename=file_path.split('/')[-1]) + ) + except Exception as e: + logger.error(f"Error uploading file {file_path}: {e}", exc_info=True) + self.send_text_notification( + title=_("Media Upload Error"), + message=_("An error occurred while uploading {filename}: {error}").format(filename=file_path.split('/')[-1], error=str(e)) + ) + # Depending on policy, either continue without this file or fail the whole post + # For now, continue and try to post without this file. + + # Extract other relevant parameters from kwargs + quote_uri = kwargs.get("quote_uri") # AT URI of the post to quote + langs = kwargs.get("langs") # List of language codes, e.g., ['en', 'ja'] + if langs and not isinstance(langs, list): + logger.warning(f"Invalid 'langs' format: {langs}. Expected list of strings. Ignoring.") + langs = None + + tags = kwargs.get("tags") # List of hashtags (without '#') + if tags and not isinstance(tags, list): + logger.warning(f"Invalid 'tags' format: {tags}. Expected list of strings. Ignoring.") + tags = None + + try: + # Call the util method to actually create the post + # self.util.post_status expects media_ids to be the list of blob dicts from upload_media + post_uri = await self.util.post_status( + text=message, + media_ids=media_blobs_for_post if media_blobs_for_post else None, + reply_to_uri=reply_to, + quote_uri=quote_uri, + cw_text=cw_text, + is_sensitive=is_sensitive, + langs=langs, + tags=tags, + # Any other specific params for Bluesky can be passed via kwargs if post_status handles them + ) + + if post_uri: + logger.info(f"Message posted successfully to ATProtoSocial. URI: {post_uri}") + return post_uri + else: + # This case should ideally be covered by post_status raising an error, + # but as a fallback if it returns None on failure without raising: + logger.error("Failed to post message to ATProtoSocial, post_status returned None.") + raise NotificationError(_("Failed to send post. The server did not confirm the post creation.")) + + except NotificationError: # Re-raise known errors + raise + except Exception as e: # Catch unexpected errors from post_status or here + logger.error(f"An unexpected error occurred in send_message for ATProtoSocial: {e}", exc_info=True) + raise NotificationError(_("An unexpected error occurred while sending the post: {error}").format(error=str(e))) + + + async def delete_message(self, message_id: str) -> None: + # TODO: Implement deleting message for ATProtoSocial + logger.debug( + f"Deleting message for ATProtoSocial session {self.user_id}: {message_id}" + ) + # await self.util.delete_status(message_id) + + async def get_message_url(self, message_id: str, context: str | None = None) -> str: + # message_id here is expected to be the rkey of the post + # TODO: Confirm if self.util.get_own_username() is populated correctly before this call + own_handle = self.util.get_own_username() or self.db.get("handle", "unknown.bsky.social") + # Ensure message_id is just the rkey, not the full AT URI. + # If it's a full URI, extract rkey. This logic might need refinement based on what `message_id` contains. + if message_id.startswith("at://"): + message_id = message_id.split("/")[-1] + + return f"https://bsky.app/profile/{own_handle}/post/{message_id}" + + + async def approve_message(self, notification_data: NotificationData) -> ApprovalResult: + # TODO: Implement message approval for ATProtoSocial + logger.debug( + f"Approving message for ATProtoSocial session {self.user_id}: {notification_data['id']}" + ) + # This is a placeholder implementation + # await self.util.authorize_follow_request(notification_data["account"]["id"]) + return ApprovalResult.APPROVED + + async def reject_message(self, notification_data: NotificationData) -> ApprovalResult: + # TODO: Implement message rejection for ATProtoSocial + logger.debug( + f"Rejecting message for ATProtoSocial session {self.user_id}: {notification_data['id']}" + ) + # This is a placeholder implementation + # await self.util.reject_follow_request(notification_data["account"]["id"]) + return ApprovalResult.REJECTED + + async def report_message(self, report_data: ReportData) -> ReportingDecision: + # TODO: Implement message reporting for ATProtoSocial + logger.debug( + f"Reporting message for ATProtoSocial session {self.user_id}: {report_data['message_id']}" + ) + # This is a placeholder implementation + # await self.util.report_status( + # status_id=report_data["message_id"], + # account_id=report_data["message_author_id"], + # reason=report_data["reason"], + # ) + return ReportingDecision.REPORTED + + @classmethod + def get_configurable_values(cls) -> dict[str, ConfigurableValue]: + """Returns all configurable values for ATProtoSocial.""" + return { + "handle": cls.handle, + "app_password": cls.app_password, # Write-only through auth/settings UI + "did": cls.did, # Read-only, set after login + } + + @classmethod + def get_configurable_values_for_user(cls, user_id: str) -> dict[str, Any]: + """Returns current configuration for a specific user.""" + user_config = config.sessions.atprotosocial[user_id] + return { + "handle": user_config.handle.get(), + "app_password": "", # Never return stored password + "did": user_config.did.get(), + } + + @classmethod + def validate_config(cls, cfg: Config) -> None: # cfg is actually ConfigSectionProxy for the user + """Validates ATProtoSocial configuration for a user.""" + # This is called when settings are saved. + # `handle` and `app_password` are primary credentials. + # `did` is derived, so no need to validate its presence here as essential for saving. + if not cfg.handle.get(): + raise ValueError(_("Bluesky handle is required.")) + if not cfg.app_password.get(): # This might be an issue if password is not re-typed on every save + # Consider if validation is only for initial setup or if password must always be re-entered on save. + # For now, assume if a handle exists, a password was once entered. + # If DID exists, it means login was successful at some point. + pass # Allowing save without re-entering password if handle exists. Test_connection is key. + logger.info(f"ATProtoSocial configuration validation for user: {cfg._user_id} passed (basic checks).") + + + @classmethod + async def generate_oauth_url(cls, channel_id: str, user_id: str, redirect_uri: str) -> str | None: + # ATProtoSocial does not use OAuth2 for user login in the typical 3rd party app sense. + # App Passwords are used instead. So, this method is not applicable. + return None + + @classmethod + async def finish_oauth_authentication( + cls, + channel_id: str, + user_id: str, + redirect_uri: str, + code: str | None = None, + error: str | None = None, + error_description: str | None = None, + ) -> None: + # TODO: Implement OAuth finish authentication for ATProtoSocial if applicable + pass + + @classmethod + def get_settings_inputs( + cls, user_id: str | None = None, current_config: dict[str, Any] | None = None + ) -> list[dict[str, Any]]: + # TODO: Define settings inputs for ATProtoSocial + # This is a placeholder implementation + # Example: + # return [ + # { + # "type": "text", + # "name": "api_base_url", + # "label": _("API Base URL"), + # "value": current_config.get("api_base_url", "https://bsky.social"), + # "required": True, + # }, + # { + # "type": "password", + # "name": "access_token", # This should probably be app_password or similar for Bluesky + # "label": _("Access Token / App Password"), + # "value": current_config.get("access_token", ""), + # "required": True, + # }, + # ] + return [] + + @classmethod + def get_user_actions(cls) -> list[dict[str, Any]]: + """Defines user-specific actions available for ATProtoSocial profiles.""" + # These actions are typically displayed on a user's profile in the UI. + # 'id' is used to map to the handler in controller/handler.py + # 'action_type': 'api_call' (calls handle_user_command), 'link' (opens URL) + # 'payload_params': list of params from user context to include in payload to handle_user_command + # 'requires_target_user_did': True if the action needs a target user's DID + + # Note: Current Approve UI might not distinguish visibility based on context (e.g., don't show "Follow" if already following). + # This logic would typically reside in the UI or be supplemented by viewer state from profile data. + + actions = [ + { + "id": "atp_view_profile_web", # Unique ID + "label": _("View Profile on Web"), + "icon": "external-link-alt", + "action_type": "link", # Opens a URL + "url_template": "https://bsky.app/profile/{target_user_handle}", # {target_user_handle} will be replaced + "requires_target_user_did": False, # Needs handle, but can be derived from DID + "requires_target_user_handle": True, + }, + { + "id": "atp_follow_user", + "label": _("Follow User"), + "icon": "user-plus", + "action_type": "api_call", + "api_command": "follow_user", # Command for handle_user_command + "requires_target_user_did": True, + "confirmation_required": False, + }, + { + "id": "atp_unfollow_user", + "label": _("Unfollow User"), + "icon": "user-minus", + "action_type": "api_call", + "api_command": "unfollow_user", + "requires_target_user_did": True, + "confirmation_required": True, + "confirmation_message": _("Are you sure you want to unfollow this user?"), + }, + { + "id": "atp_mute_user", + "label": _("Mute User"), + "icon": "volume-mute", + "action_type": "api_call", + "api_command": "mute_user", + "requires_target_user_did": True, + }, + { + "id": "atp_unmute_user", + "label": _("Unmute User"), + "icon": "volume-up", + "action_type": "api_call", + "api_command": "unmute_user", + "requires_target_user_did": True, + }, + { + "id": "atp_block_user", + "label": _("Block User"), + "icon": "user-slash", # Or "ban" + "action_type": "api_call", + "api_command": "block_user", + "requires_target_user_did": True, + "confirmation_required": True, + "confirmation_message": _("Are you sure you want to block this user? They will not be able to interact with you, and you will not see their content."), + }, + { + "id": "atp_unblock_user", # This might be handled by UI if block status is known + "label": _("Unblock User"), + "icon": "user-check", # Or "undo" + "action_type": "api_call", + "api_command": "unblock_user", + "requires_target_user_did": True, + # No confirmation usually for unblock, but can be added + }, + # Example: Action to fetch user's feed (handled by UI navigation, not typically a button here) + # { + # "id": "atp_view_user_feed", + # "label": _("View User's Posts"), + # "icon": "list-alt", + # "action_type": "ui_navigation", # Special type for UI to handle + # "target_view": "user_feed", + # "requires_target_user_did": True, + # }, + ] + return actions + + @classmethod + def get_user_list_actions(cls) -> list[dict[str, Any]]: + # TODO: Define user list actions for ATProtoSocial + return [] + + def get_reporter(self) -> Reporter | None: + # TODO: Implement if ATProtoSocial has specific reporting capabilities + # that differ from the base implementation + return super().get_reporter() + + def is_ready(self) -> bool: + """Checks if the session is properly configured and authenticated.""" + # A more robust check would be to see if self.client.me is not None or similar SDK check + # For example: return self.client is not None and self.client.me is not None (after client.get_session() or login) + # For now, check if essential details are in DB, implying a successful login occurred. + return bool(self.db.get("access_jwt") and self.db.get("did") and self.db.get("handle")) + + + async def _get_user_id_from_username(self, username: str) -> str | None: # username is handle + # TODO: Implement handle to DID resolution if needed for general use. + # client = await self.util._get_client() # Ensure client is available + # if client: + # try: + # profile = await client.app.bsky.actor.get_profile({'actor': username}) + # return profile.did + # except Exception: + # return None + return None + + async def _get_username_from_user_id(self, user_id: str) -> str | None: # user_id is DID + # TODO: Implement user ID to username resolution if needed more broadly. + # For now, profile data usually contains the handle. + if not self.is_ready() or not self.client: + return None + try: + profile = await self.client.app.bsky.actor.get_profile(models.AppBskyActorGetProfile.Params(actor=user_id)) + return profile.handle if profile else None + except Exception: + return None + + # --- Notification Handling --- + + async def _handle_like_notification(self, notification_item: utils.ATNotification) -> None: + author = notification_item.author + post_uri = notification_item.uri # URI of the like record itself + subject_uri = notification_item.reasonSubject # URI of the post that was liked + + title = _("{author_name} liked your post").format(author_name=author.displayName or author.handle) + body = "" # Could fetch post content for body if desired, but title is often enough for likes + + # Try to get the URL of the liked post + url = None + if subject_uri: + try: + # Assuming subject_uri is the AT URI of the post. + # We need its rkey and author handle to build a bsky.app URL. + # This is complex if we don't have the post details already. + # For simplicity, make the notification URL point to the liker's profile or the like record. + # A better UX might involve fetching the post to link to it directly. + url = await self.get_message_url(message_id=subject_uri, context="notification_like_subject") + except Exception as e: + logger.warning(f"Could not generate URL for liked post {subject_uri}: {e}") + url = f"https://bsky.app/profile/{author.handle}" # Fallback to liker's profile + + await self.send_notification_to_channel( + kind=NotificationKind.FAVOURITE, # Maps to 'like' + title=title, + body=body, + url=url, + author_name=author.displayName or author.handle, + author_id=author.did, + author_avatar_url=author.avatar, + timestamp=util.parse_iso_datetime(notification_item.indexedAt), + message_id=post_uri, # ID of the like notification/record itself + original_message_id=subject_uri, # ID of the liked post + # original_message_author_id: self.util.get_own_did(), # The user receiving the like + # original_message_author_username: self.util.get_own_username(), + ) + + async def _handle_repost_notification(self, notification_item: utils.ATNotification) -> None: + author = notification_item.author + repost_uri = notification_item.uri # URI of the repost record + subject_uri = notification_item.reasonSubject # URI of the original post that was reposted + + title = _("{author_name} reposted your post").format(author_name=author.displayName or author.handle) + body = "" # Could fetch original post content for body + url = None + if subject_uri: + try: + url = await self.get_message_url(message_id=subject_uri, context="notification_repost_subject") + except Exception as e: + logger.warning(f"Could not generate URL for reposted post {subject_uri}: {e}") + url = f"https://bsky.app/profile/{author.handle}" # Fallback to reposter's profile + + + await self.send_notification_to_channel( + kind=NotificationKind.REBLOG, # Maps to 'repost' + title=title, + body=body, + url=url, + author_name=author.displayName or author.handle, + author_id=author.did, + author_avatar_url=author.avatar, + timestamp=util.parse_iso_datetime(notification_item.indexedAt), + message_id=repost_uri, + original_message_id=subject_uri, + ) + + async def _handle_follow_notification(self, notification_item: utils.ATNotification) -> None: + author = notification_item.author + follow_uri = notification_item.uri # URI of the follow record + + title = _("{author_name} followed you").format(author_name=author.displayName or author.handle) + url = f"https://bsky.app/profile/{author.handle}" # Link to follower's profile + + await self.send_notification_to_channel( + kind=NotificationKind.FOLLOW, + title=title, + body=None, # No body needed for follow + url=url, + author_name=author.displayName or author.handle, + author_id=author.did, + author_avatar_url=author.avatar, + timestamp=util.parse_iso_datetime(notification_item.indexedAt), # type: ignore[attr-defined] + message_id=follow_uri, + ) + + async def _handle_mention_notification(self, notification_item: utils.ATNotification) -> None: + author = notification_item.author + post_record = notification_item.record # This is the app.bsky.feed.post record + post_uri = notification_item.uri # URI of the post containing the mention + + title = _("New mention from {author_name}").format(author_name=author.displayName or author.handle) + body = getattr(post_record, 'text', '') if post_record else '' + url = None + if post_uri: + try: + url = await self.get_message_url(message_id=post_uri, context="notification_mention") + except Exception as e: + logger.warning(f"Could not generate URL for mention post {post_uri}: {e}") + url = f"https://bsky.app/profile/{author.handle}" + + + await self.send_notification_to_channel( # type: ignore[attr-defined] + kind=NotificationKind.MENTION, # type: ignore[attr-defined] + title=title, + body=body, + url=url, + author_name=author.displayName or author.handle, + author_id=author.did, + author_avatar_url=author.avatar, + timestamp=util.parse_iso_datetime(notification_item.indexedAt), # type: ignore[attr-defined] + message_id=post_uri, + original_message_id=post_uri, # The mention is in this post + ) + + async def _handle_reply_notification(self, notification_item: utils.ATNotification) -> None: + author = notification_item.author + post_record = notification_item.record # The app.bsky.feed.post record (the reply post) + reply_post_uri = notification_item.uri # URI of the reply post + + # The subject of the reply notification is the user's original post that was replied to. + # notification_item.reasonSubject might be null if the reply is to a post that was deleted + # or if the notification structure is different. The reply record itself contains parent/root. + replied_to_post_uri = getattr(post_record.reply, 'parent', {}).get('uri') if post_record and hasattr(post_record, 'reply') and hasattr(post_record.reply, 'parent') and hasattr(post_record.reply.parent, 'uri') else None + + + title = _("{author_name} replied to your post").format(author_name=author.displayName or author.handle) + body = getattr(post_record, 'text', '') if post_record else '' + url = None + if reply_post_uri: # Link to the reply itself + try: + url = await self.get_message_url(message_id=reply_post_uri, context="notification_reply") + except Exception as e: + logger.warning(f"Could not generate URL for reply post {reply_post_uri}: {e}") + url = f"https://bsky.app/profile/{author.handle}" + + + await self.send_notification_to_channel( # type: ignore[attr-defined] + kind=NotificationKind.REPLY, # type: ignore[attr-defined] + title=title, + body=body, + url=url, + author_name=author.displayName or author.handle, + author_id=author.did, + author_avatar_url=author.avatar, + timestamp=util.parse_iso_datetime(notification_item.indexedAt), # type: ignore[attr-defined] + message_id=reply_post_uri, + original_message_id=replied_to_post_uri, # The post that was replied to + ) + + async def _handle_quote_notification(self, notification_item: utils.ATNotification) -> None: + author = notification_item.author + post_record = notification_item.record # The app.bsky.feed.post record (the post that quotes) + quoting_post_uri = notification_item.uri # URI of the post that contains the quote + + # The subject of the quote notification is the user's original post that was quoted. + quoted_post_uri = notification_item.reasonSubject + + title = _("{author_name} quoted your post").format(author_name=author.displayName or author.handle) + body = getattr(post_record, 'text', '') if post_record else '' # Text of the quoting post + url = None + if quoting_post_uri: # Link to the quoting post + try: + url = await self.get_message_url(message_id=quoting_post_uri, context="notification_quote") + except Exception as e: + logger.warning(f"Could not generate URL for quoting post {quoting_post_uri}: {e}") + url = f"https://bsky.app/profile/{author.handle}" + + + await self.send_notification_to_channel( # type: ignore[attr-defined] + kind=NotificationKind.QUOTE, # Assuming a QUOTE kind exists or map to MENTION/custom # type: ignore[attr-defined] + title=title, + body=body, + url=url, + author_name=author.displayName or author.handle, + author_id=author.did, + author_avatar_url=author.avatar, + timestamp=util.parse_iso_datetime(notification_item.indexedAt), # type: ignore[attr-defined] + message_id=quoting_post_uri, + original_message_id=quoted_post_uri, # The post that was quoted + ) + + def _get_notification_handler_map(self) -> dict[str, Any]: # type: ignore[type-arg] + """Maps ATProto notification reasons to handler methods.""" + return { + "like": self._handle_like_notification, + "repost": self._handle_repost_notification, + "follow": self._handle_follow_notification, + "mention": self._handle_mention_notification, + "reply": self._handle_reply_notification, + "quote": self._handle_quote_notification, + } + + async def fetch_notifications(self, cursor: str | None = None, limit: int = 20) -> str | None: + """ + Fetches notifications from ATProtoSocial and processes them. + Returns the cursor for the next page, or None if no more notifications. + """ + if not self.is_ready(): + logger.warning("Cannot fetch notifications: session not ready.") + return None + + try: + logger.info(f"Fetching ATProtoSocial notifications with cursor: {cursor}") + notifications_tuple = await self.util.get_notifications(limit=limit, cursor=cursor) + if not notifications_tuple: + logger.info("No notifications returned from util.get_notifications.") + return None + + raw_notifications, next_cursor = notifications_tuple + + if not raw_notifications: + logger.info("No new notifications found.") + # Consider updating last seen timestamp here if all caught up. + # await self.mark_notifications_as_seen() + return next_cursor # Return cursor even if no new items, could be end of list + + handler_map = self._get_notification_handler_map() + processed_count = 0 + for item in raw_notifications: + # item is models.AppBskyNotificationListNotifications.Notification + if not item.isRead: # Process only unread notifications for UI to avoid duplicates if polling + # However, for initial sync, we might want to process some read ones too. + # For now, let's assume we process and then it's up to UI to display "unread" state. + # The `mark_notifications_as_seen` would be key. + handler = handler_map.get(item.reason) + if handler: + try: + await handler(item) + processed_count +=1 + except Exception as e: + logger.error(f"Error handling notification type {item.reason} (URI: {item.uri}): {e}", exc_info=True) + else: + logger.warning(f"No handler for ATProtoSocial notification reason: {item.reason}") + + logger.info(f"Processed {processed_count} ATProtoSocial notifications.") + + # TODO: Implement marking notifications as seen. + # This should probably be done after a short delay or user action. + # If all fetched notifications were processed, and it was a full page, + # we might consider calling client.app.bsky.notification.update_seen() + # await self.mark_notifications_as_seen() # Be careful not to do this too aggressively + # If not item.isRead: # Only mark seen if we actually processed unread items. + # if processed_count > 0 and (len(raw_notifications) < limit or not next_cursor) : # If we are at the "end" of unread. + # await self.mark_notifications_as_seen() + + + return next_cursor + except NotificationError as e: # Errors from util.get_notifications + logger.error(f"Failed to fetch notifications: {e.message}") + self.send_text_notification(title=_("Notification Error"), message=str(e)) + return cursor # Return original cursor on error to retry later + except Exception as e: + logger.error(f"Unexpected error fetching notifications: {e}", exc_info=True) + self.send_text_notification(title=_("Notification Error"), message=_("An unexpected error occurred while fetching notifications.")) + return cursor + + + async def mark_notifications_as_seen(self, seen_at: str | None = None) -> None: + """Marks notifications as seen up to a certain timestamp.""" + if not self.is_ready() or not self.client: + logger.warning("Cannot mark notifications as seen: client not ready.") + return + + try: + # seen_at should be an ISO 8601 timestamp. If None, defaults to now. + # from atproto_client.models import get_or_create, ids, string_to_datetime # SDK specific import + # if not seen_at: + # seen_at = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') + # await self.client.app.bsky.notification.update_seen({'seenAt': seen_at}) + # For now, using a placeholder as direct update_seen might need specific datetime string. + # The SDK client might have a helper for current time in correct format. + await self.client.app.bsky.notification.update_seen() # SDK might default seenAt to now + logger.info("Marked ATProtoSocial notifications as seen.") + except Exception as e: + logger.error(f"Error marking notifications as seen: {e}", exc_info=True) + + + async def handle_streaming_event(self, event_type: str, data: Any) -> None: # data type is ATNotification or similar + """ + Handles incoming notification events from a streaming connection. + `event_type` would be something like 'mention', 'like', etc. + `data` should be the ATProtoSocial notification model object. + """ + logger.debug(f"ATProtoSocial: Received streaming event: {event_type} with data: {data}") + handler = self._get_notification_handler_map().get(event_type) + if handler: + try: + # Assuming 'data' is already in the format of models.AppBskyNotificationListNotifications.Notification + # If not, it needs to be transformed here. + await handler(data) + except Exception as e: + logger.error(f"Error handling streamed notification type {event_type}: {e}", exc_info=True) + else: + logger.warning(f"No handler for ATProtoSocial streamed event type: {event_type}") + + + def get_sensitive_reason_options(self) -> dict[str, str] | None: + # TODO: If ATProtoSocial supports reasons for marking content sensitive, define them here + return None + + # --- Timeline Fetching and Processing --- + + async def fetch_home_timeline(self, cursor: str | None = None, limit: int = 20, new_only: bool = False) -> tuple[list[str], str | None]: + """Fetches the home timeline (following) and processes it.""" + if not self.is_ready(): + logger.warning("Cannot fetch home timeline: session not ready.") + raise NotificationError(_("Session is not active. Please log in or check your connection.")) + + logger.info(f"Fetching home timeline with cursor: {cursor}, limit: {limit}, new_only: {new_only}") + try: + timeline_data = await self.util.get_timeline(algorithm=None, limit=limit, cursor=cursor) + if not timeline_data: + logger.info("No home timeline data returned from util.") + return [], cursor # Return current cursor if no data + + feed_view_posts, next_cursor = timeline_data + processed_ids = await self.order_buffer( + items=feed_view_posts, + new_only=new_only, + buffer_name="home_timeline_buffer" + ) + + if new_only and next_cursor: # For fetching newest, cursor logic might differ or not be used this way + self.home_timeline_cursor = next_cursor # Bluesky cursors are typically for older items + elif not new_only : # Fetching older items + self.home_timeline_cursor = next_cursor + + logger.info(f"Fetched {len(processed_ids)} items for home timeline. Next cursor: {self.home_timeline_cursor}") + return processed_ids, self.home_timeline_cursor + except NotificationError: # Re-raise critical errors + raise + except Exception as e: + logger.error(f"Unexpected error fetching home timeline: {e}", exc_info=True) + raise NotificationError(_("An error occurred while fetching your home timeline.")) + + + async def fetch_user_timeline(self, user_did: str, cursor: str | None = None, limit: int = 20, new_only: bool = False, filter_type: str = "posts_with_replies") -> tuple[list[str], str | None]: + """Fetches a specific user's timeline and processes it.""" + if not self.is_ready(): + logger.warning(f"Cannot fetch user timeline for {user_did}: session not ready.") + raise NotificationError(_("Session is not active. Please log in or check your connection.")) + + logger.info(f"Fetching user timeline for {user_did} with cursor: {cursor}, filter: {filter_type}") + try: + feed_data = await self.util.get_author_feed(actor_did=user_did, limit=limit, cursor=cursor, filter=filter_type) + if not feed_data: + logger.info(f"No feed data returned for user {user_did}.") + return [], cursor + + feed_view_posts, next_cursor = feed_data + # For user timelines, we might not store them in a persistent session buffer like home_timeline_buffer, + # but rather just process them into message_cache for direct display or a temporary view buffer. + # For now, let's use a generic buffer name or imply it's for message_cache population. + processed_ids = await self.order_buffer( + items=feed_view_posts, + new_only=new_only, # This might be always False or True depending on how user timeline view works + buffer_name=f"user_timeline_{user_did}" # Example of a dynamic buffer name, though not stored on session directly + ) + logger.info(f"Fetched {len(processed_ids)} items for user {user_did} timeline. Next cursor: {next_cursor}") + return processed_ids, next_cursor + except NotificationError: + raise + except Exception as e: + logger.error(f"Unexpected error fetching user timeline for {user_did}: {e}", exc_info=True) + raise NotificationError(_("An error occurred while fetching the user's timeline.")) + + + async def order_buffer(self, items: list[utils.models.AppBskyFeedDefs.FeedViewPost], new_only: bool = True, buffer_name: str = "home_timeline_buffer", **kwargs) -> list[str]: # type: ignore + """Processes and orders items (FeedViewPost) into the specified buffer and message_cache.""" + if not isinstance(items, list): + logger.warning(f"order_buffer received non-list items: {type(items)}. Skipping.") + return [] + + added_ids: list[str] = [] + target_buffer_list: list[str] | None = getattr(self, buffer_name, None) + + # If buffer_name is dynamic (e.g. user timelines), target_buffer_list might be None. + # In such cases, items are added to message_cache, and added_ids are returned for direct use. + # If it's a well-known buffer like home_timeline_buffer, it's updated. + + for item in items: + if not isinstance(item, utils.models.AppBskyFeedDefs.FeedViewPost): + logger.warning(f"Skipping non-FeedViewPost item in order_buffer: {item}") + continue + + post_view = item.post + if not post_view or not post_view.uri: + logger.warning(f"FeedViewPost item missing post view or URI: {item}") + continue + + post_uri = post_view.uri + + # Cache the main post + # self.util._format_post_data can convert PostView to a dict if needed by message_cache + # For now, assume message_cache can store the PostView model directly or its dict representation + formatted_post_data = self.util._format_post_data(post_view) # Ensure this returns a dict + self.message_cache[post_uri] = formatted_post_data + + # Handle replies - cache parent/root if present and not already cached + if item.reply: + if item.reply.parent and item.reply.parent.uri not in self.message_cache: + self.message_cache[item.reply.parent.uri] = self.util._format_post_data(item.reply.parent) # type: ignore + if item.reply.root and item.reply.root.uri not in self.message_cache: + self.message_cache[item.reply.root.uri] = self.util._format_post_data(item.reply.root) # type: ignore + + + # Handle reposts - the item.post is the original post. + # The item.reason (if ReasonRepost) indicates it's a repost. + # The UI needs to use this context when rendering item.post.uri from the timeline. + # For simplicity, the buffer stores the URI of the original post. + # If a more complex object is needed in the buffer, this is where to construct it. + # For example: {"type": "repost", "reposter": item.reason.by.handle, "post_uri": post_uri, "repost_time": item.reason.indexedAt} + + if target_buffer_list is not None: + if post_uri not in target_buffer_list: # Avoid duplicates in the list itself + if new_only: # Add to the start (newer items) + target_buffer_list.insert(0, post_uri) + else: # Add to the end (older items) + target_buffer_list.append(post_uri) + added_ids.append(post_uri) + + if target_buffer_list is not None: # Trim if necessary (e.g. keep last N items) + max_buffer_size = constants.MAX_BUFFER_SIZE # From fromapprove import constants + if len(target_buffer_list) > max_buffer_size: + if new_only: # Trim from the end (oldest) + setattr(self, buffer_name, target_buffer_list[:max_buffer_size]) + else: # Trim from the start (newest - less common for this kind of buffer) + setattr(self, buffer_name, target_buffer_list[-max_buffer_size:]) + + self.cleanup_message_cache(buffers_to_check=[buffer_name] if target_buffer_list is not None else []) + return added_ids + + + async def check_buffers(self, post_data: utils.ATPost | dict[str, Any]) -> None: # type: ignore + """ + Adds a newly created post (by the current user) to relevant buffers, + primarily self.posts_buffer and self.message_cache. + `post_data` is the PostView model or its dict representation of the new post. + """ + if not post_data: + return + + post_uri = None + formatted_data = {} + + if isinstance(post_data, utils.models.AppBskyFeedDefs.PostView): + post_uri = post_data.uri + formatted_data = self.util._format_post_data(post_data) + elif isinstance(post_data, dict) and "uri" in post_data: # Assuming it's already formatted + post_uri = post_data["uri"] + formatted_data = post_data + else: + logger.warning(f"check_buffers received unexpected post_data type: {type(post_data)}") + return + + if not post_uri or not formatted_data: + logger.error("Could not process post_data in check_buffers: URI or data missing.") + return + + # Add to message_cache + self.message_cache[post_uri] = formatted_data + + # Add to user's own posts buffer (self.posts_buffer is from baseSession) + if post_uri not in self.posts_buffer: + self.posts_buffer.insert(0, post_uri) # Add to the beginning (newest) + if len(self.posts_buffer) > constants.MAX_BUFFER_SIZE: + self.posts_buffer = self.posts_buffer[:constants.MAX_BUFFER_SIZE] + + # A user's own new post might appear on their home timeline if they follow themselves + # or if the timeline algorithm includes own posts. + # For now, explicitly adding to home_timeline_buffer if not present. + # Some UIs might prefer not to duplicate, relying on separate "My Posts" view. + # if post_uri not in self.home_timeline_buffer: + # self.home_timeline_buffer.insert(0, post_uri) + # if len(self.home_timeline_buffer) > constants.MAX_BUFFER_SIZE: + # self.home_timeline_buffer = self.home_timeline_buffer[:constants.MAX_BUFFER_SIZE] + + self.cleanup_message_cache(buffers_to_check=["posts_buffer", "home_timeline_buffer"]) + logger.debug(f"Added new post {post_uri} to relevant buffers.") + + + def get_reporting_reasons(self) -> ReportingReasons | None: + # TODO: Define specific reporting reasons for ATProtoSocial if they differ from generic ones + # This could involve fetching categories from ATProtoSocial's API or defining a static list. + # Example: + # return ReportingReasons( + # categories={ + # "spam": _("Spam"), + # "illegal": _("Illegal Content"), + # "harassment": _("Harassment"), + # # ... other ATProtoSocial specific categories + # }, + # default_text_reason_required=True, + # ) + return None + + @classmethod + def get_config_description(cls) -> str | None: + # TODO: Provide a description for the ATProtoSocial configuration section + return _( + "Configure your ATProtoSocial (Bluesky) account. You'll need your user handle (e.g., @username.bsky.social) and an App Password." + ) + + @classmethod + def is_suitable_for_channel(cls, channel: Channel) -> bool: + # TODO: Determine if this session type is suitable for the given channel. + # For now, assuming it's suitable for any channel. + return True + + @classmethod + def get_dependencies(cls) -> list[str]: + # TODO: List any Python package dependencies required for this session type. + # Example: return ["atproto"] + return ["atproto"] + + @classmethod + def get_logo_path(cls) -> str | None: + # TODO: Provide path to a logo for ATProtoSocial, e.g., "static/img/atprotosocial_logo.svg" + return "static/img/bluesky_logo.svg" # Assuming a logo file will be added here + + @classmethod + def get_auth_type(cls) -> str: + # TODO: Specify the authentication type. "password" for user/pass (or handle/app_password), "oauth" for OAuth2 + return "password" # Bluesky typically uses handle and app password + + @classmethod + def get_auth_inputs(cls, user_id: str | None = None, current_config: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """Defines inputs required for ATProtoSocial authentication (handle and app password).""" + cfg = current_config or {} + return [ + { + "type": "text", + "name": "handle", + "label": _("Bluesky Handle (e.g., @username.bsky.social or username.bsky.social)"), + "value": cfg.get("handle", ""), + "required": True, + }, + { + "type": "password", + "name": "app_password", + "label": _("App Password (generate one in Bluesky settings)"), + "value": "", # Never pre-fill password + "required": True, + }, + ] + + + @classmethod + async def test_connection(cls, settings: dict[str, Any]) -> tuple[bool, str]: + """Tests connection to ATProtoSocial using provided handle and app password.""" + handle = settings.get("handle") + app_password = settings.get("app_password") + + if not handle or not app_password: + return False, _("Handle and App Password are required.") + + try: + # Use a temporary client for testing to not interfere with any existing session client + temp_client = AsyncClient() + logger.info(f"ATProtoSocial: Testing connection for handle {handle}...") + profile = await temp_client.login(handle, app_password) + if profile and profile.did: + logger.info(f"ATProtoSocial: Connection test successful for {handle}. DID: {profile.did}") + return True, _("Successfully connected and authenticated with Bluesky.") + else: + logger.warning(f"ATProtoSocial: Connection test for {handle} returned no profile data.") + return False, _("Authentication succeeded but no profile data was returned.") + except XrpcError as e: + logger.error(f"ATProtoSocial: Connection test failed for handle {handle} (XrpcError): {e.error} - {e.message}") + error_msg = f"{e.error or 'Error'}: {e.message or 'Failed to connect'}" + return False, _("Connection failed: {error_details}").format(error_details=error_msg) + except Exception as e: + logger.error(f"ATProtoSocial: Connection test failed for handle {handle} (Exception): {e}", exc_info=True) + return False, _("An unexpected error occurred: {error}").format(error=str(e)) + + + async def send_notification( + self, + kind: NotificationKind, + title: str, + body: str | None = None, + url: str | None = None, + buttons: list[dict[str, str]] | None = None, + image_url: str | None = None, + message_id: str | None = None, + original_message_id: str | None = None, + original_message_author_id: str | None = None, + original_message_author_username: str | None = None, + original_message_url: str | None = None, + timestamp: float | None = None, + **kwargs: Any, + ) -> None: + """Sends a notification through the configured channel.""" + # This method is usually handled by the baseSession, but can be overridden + # if ATProtoSocial has a special way of handling notifications or needs more context. + # For now, let the base class handle it. + await super().send_notification( + kind=kind, + title=title, + body=body, + url=url, + buttons=buttons, + image_url=image_url, + message_id=message_id, + original_message_id=original_message_id, + original_message_author_id=original_message_author_id, + original_message_author_username=original_message_author_username, + original_message_url=original_message_url, + timestamp=timestamp, + **kwargs, + ) diff --git a/src/sessions/atprotosocial/streaming.py b/src/sessions/atprotosocial/streaming.py new file mode 100644 index 00000000..cdd51f41 --- /dev/null +++ b/src/sessions/atprotosocial/streaming.py @@ -0,0 +1,209 @@ +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) + + # 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. + + # 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__}") diff --git a/src/sessions/atprotosocial/templates.py b/src/sessions/atprotosocial/templates.py new file mode 100644 index 00000000..be441d9e --- /dev/null +++ b/src/sessions/atprotosocial/templates.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +fromapprove.translation import translate as _ + +if TYPE_CHECKING: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + +logger = logging.getLogger(__name__) + + +class ATProtoSocialTemplates: + def __init__(self, session: ATProtoSocialSession) -> None: + self.session = session + + def get_template_data(self, template_name: str, context: dict[str, Any] | None = None) -> dict[str, Any]: + """ + Returns data required for rendering a specific template for ATProtoSocial. + This method would populate template variables based on the template name and context. + """ + base_data = { + "session_kind": self.session.kind, + "session_label": self.session.label, + "user_id": self.session.user_id, + # Add any other common data needed by ATProtoSocial templates + } + if context: + base_data.update(context) + + # TODO: Implement specific data fetching for different ATProtoSocial templates + # Example: + # if template_name == "profile_summary.html": + # # profile_info = await self.session.util.get_my_profile_info() # Assuming such a method exists + # # base_data["profile"] = profile_info + # base_data["profile"] = {"display_name": "User", "handle": "user.bsky.social"} # Placeholder + # elif template_name == "post_details.html": + # # post_id = context.get("post_id") + # # post_details = await self.session.util.get_post_by_id(post_id) + # # base_data["post"] = post_details + # base_data["post"] = {"text": "A sample post", "author_handle": "author.bsky.social"} # Placeholder + + return base_data + + def get_message_card_template(self) -> str: + """Returns the path to the message card template for ATProtoSocial.""" + # This template would define how a single ATProtoSocial post (or other message type) + # is rendered in a list (e.g., in a timeline or search results). + # return "sessions/atprotosocial/cards/message.html" # Example path + return "sessions/generic/cards/message_generic.html" # Placeholder, use generic if no specific yet + + def get_notification_template_map(self) -> dict[str, str]: + """ + Returns a map of ATProtoSocial notification types to their respective template paths. + """ + # TODO: Define templates for different ATProtoSocial notification types + # (e.g., mention, reply, new follower, like, repost). + # The keys should match the notification types used internally by Approve + # when processing ATProtoSocial events. + # Example: + # return { + # "mention": "sessions/atprotosocial/notifications/mention.html", + # "reply": "sessions/atprotosocial/notifications/reply.html", + # "follow": "sessions/atprotosocial/notifications/follow.html", + # "like": "sessions/atprotosocial/notifications/like.html", # Bluesky uses 'like' + # "repost": "sessions/atprotosocial/notifications/repost.html", # Bluesky uses 'repost' + # # ... other notification types + # } + # Using generic templates as placeholders: + return { + "mention": "sessions/generic/notifications/mention.html", + "reply": "sessions/generic/notifications/reply.html", + "follow": "sessions/generic/notifications/follow.html", + "like": "sessions/generic/notifications/favourite.html", # Map to favourite if generic expects that + "repost": "sessions/generic/notifications/reblog.html", # Map to reblog if generic expects that + } + + def get_settings_template(self) -> str | None: + """Returns the path to the settings template for ATProtoSocial, if any.""" + # This template would be used to render ATProtoSocial-specific settings in the UI. + # return "sessions/atprotosocial/settings.html" + return "sessions/generic/settings_auth_password.html" # If using simple handle/password auth + + def get_user_action_templates(self) -> dict[str, str] | None: + """ + Returns a map of user action identifiers to their template paths for ATProtoSocial. + User actions are typically buttons or forms displayed on a user's profile. + """ + # TODO: Define templates for ATProtoSocial user actions + # Example: + # return { + # "view_profile_on_bsky": "sessions/atprotosocial/actions/view_profile_button.html", + # "send_direct_message": "sessions/atprotosocial/actions/send_dm_form.html", # If DMs are supported + # } + return None # Placeholder + + def get_user_list_action_templates(self) -> dict[str, str] | None: + """ + Returns a map of user list action identifiers to their template paths for ATProtoSocial. + These actions might appear on lists of users (e.g., followers, following). + """ + # TODO: Define templates for ATProtoSocial user list actions + # Example: + # return { + # "follow_all_visible": "sessions/atprotosocial/list_actions/follow_all_button.html", + # } + return None # Placeholder + + # Add any other template-related helper methods specific to ATProtoSocial. + # For example, methods to get templates for specific types of content (images, polls) + # if they need special rendering. + + def get_template_for_message_type(self, message_type: str) -> str | None: + """ + Returns a specific template path for a given message type (e.g., post, reply, quote). + This can be useful if different types of messages need distinct rendering beyond the standard card. + """ + # TODO: Define specific templates if ATProtoSocial messages have varied structures + # that require different display logic. + # if message_type == "quote_post": + # return "sessions/atprotosocial/cards/quote_post.html" + return None # Default to standard message card if not specified diff --git a/src/sessions/atprotosocial/utils.py b/src/sessions/atprotosocial/utils.py new file mode 100644 index 00000000..290efb58 --- /dev/null +++ b/src/sessions/atprotosocial/utils.py @@ -0,0 +1,1168 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from atproto import AsyncClient # Removed Client as AsyncClient is used +from atproto.exceptions import AtProtocolError, NetworkError, RequestException # Specific exceptions +from atproto.xrpc_client import models +from atproto.lexicon import models as lexicon_models # For lexicon definitions like com.atproto.moderation.defs +from atproto.xrpc_client.models import ids # For collection IDs like ids.AppBskyFeedPost + + +fromapprove.util import GenerateID, get_http_client, httpx_max_retries_hook +fromapprove.translation import translate as _ +fromapprove.notifications import NotificationError + + +if TYPE_CHECKING: + fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + # Define common type aliases if needed + ATUserProfile = models.AppBskyActorDefs.ProfileViewDetailed + ATPost = models.AppBskyFeedDefs.PostView + ATNotification = models.AppBskyNotificationListNotifications.Notification # Item in notification list + # StrongRef might be models.ComAtprotoRepoStrongRef.Main or similar + + +logger = logging.getLogger(__name__) + + +class ATProtoSocialUtils: + def __init__(self, session: ATProtoSocialSession) -> None: + self.session = session + # _own_did and _own_handle are now set by Session.login upon successful authentication + # and directly on the util instance. + self._own_did: str | None = self.session.db.get("did") or self.session.config_get("did") + self._own_handle: str | None = self.session.db.get("handle") or self.session.config_get("handle") + + # --- Client Initialization and Management --- + + async def _get_client(self) -> AsyncClient | None: + """Returns the authenticated ATProto AsyncClient from the session.""" + if self.session.client and self.session.is_ready(): # is_ready checks if client is authenticated + # Ensure internal DID/handle are in sync if possible, though session.login should handle this. + if not self._own_did and self.session.client.me: + self._own_did = self.session.client.me.did + if not self._own_handle and self.session.client.me: + self._own_handle = self.session.client.me.handle + return self.session.client + + logger.warning("ATProtoSocialUtils: Client not available or not authenticated.") + # Optionally, try to trigger re-authentication if appropriate, + # but generally, the caller should ensure session is ready. + # For example, by calling session.start() or session.authorise() + # if await self.session.authorise(): # This could trigger UI prompts, be careful + # return self.session.client + return None + + async def get_own_profile_info(self) -> ATUserProfile | None: + """Retrieves the authenticated user's profile information.""" + client = await self._get_client() + if not client or not self.get_own_did(): # Use getter for _own_did + logger.warning("ATProtoSocial client not available or user DID not known.") + return None + try: + # client.me should be populated after login by the SDK + if client.me: # client.me is ProfileViewDetailed after login + # To get the most detailed, up-to-date profile: + response = await client.app.bsky.actor.get_profile(models.AppBskyActorGetProfile.Params(actor=self.get_own_did())) + if isinstance(response, models.AppBskyActorDefs.ProfileViewDetailed): + return response # This is already the correct type + else: # Should not happen if DID is correct + logger.error(f"Unexpected response type from get_profile: {type(response)}") + return None + else: # Fallback if client.me is somehow not populated + logger.info("client.me not populated, attempting get_profile for own DID.") + response = await client.app.bsky.actor.get_profile(models.AppBskyActorGetProfile.Params(actor=self.get_own_did())) + if isinstance(response, models.AppBskyActorDefs.ProfileViewDetailed): + return response + return None + except AtProtocolError as e: + logger.error(f"Error fetching own ATProtoSocial profile: {e}") + return None + + def get_own_did(self) -> str | None: + """Returns the authenticated user's DID.""" + if not self._own_did: # If not set during init (e.g. session not fully loaded yet) + self._own_did = self.session.db.get("did") or self.session.config_get("did") + + # Fallback: try to get from client if it's alive and has .me property + if not self._own_did and self.session.client and self.session.client.me: + self._own_did = self.session.client.me.did + return self._own_did + + def get_own_username(self) -> str | None: # "username" here means handle + """Returns the authenticated user's handle.""" + if not self._own_handle: + self._own_handle = self.session.db.get("handle") or self.session.config_get("handle") + + if not self._own_handle and self.session.client and self.session.client.me: + self._own_handle = self.session.client.me.handle + return self._own_handle + + + # --- Post / Status Operations --- + + async def post_status( + self, + text: str, + media_ids: list[str] | None = None, # Here media_ids would be blob refs from upload_media + reply_to_uri: str | None = None, # ATURI of the post being replied to + quote_uri: str | None = None, # ATURI of the post being quoted + cw_text: str | None = None, # For content warning (labels) + is_sensitive: bool = False, # General sensitivity flag + langs: list[str] | None = None, # List of language codes e.g. ['en', 'ja'] + tags: list[str] | None = None, # Hashtags + **kwargs: Any + ) -> str | None: # Returns the ATURI of the new post, or None on failure + """ + Posts a status (skeet) to ATProtoSocial. + Handles text, images, replies, quotes, and content warnings (labels). + """ + client = await self._get_client() + if not client: + logger.error("ATProtoSocial client not available for posting.") + raise NotificationError(_("Not connected to ATProtoSocial. Please check your connection settings or log in.")) + + if not self.get_own_did(): + logger.error("Cannot post status: User DID not available.") + raise NotificationError(_("User identity not found. Cannot create post.")) + + try: + # Prepare core post record + post_record_data = {'text': text, 'created_at': client.get_current_time_iso()} # SDK handles datetime format + + if langs: + post_record_data['langs'] = langs + + # Facets (mentions, links, tags) should be processed before other embeds + # as they are part of the main post record. + facets = await self._extract_facets(text, tags) # Pass client for potential resolutions + if facets: + post_record_data['facets'] = facets + + # Embeds: images, quote posts, external links + # Note: Bluesky typically allows one main embed type (images OR record OR external) + embed_to_add: models.AppBskyFeedPost.Embed | None = None + + # Embeds: images, quote posts, external links + # ATProto allows one main embed type: app.bsky.embed.images, app.bsky.embed.record (quote/post embed), + # or app.bsky.embed.external. + # Priority: 1. Quote, 2. Images. External embeds are not handled in this example. + # If both quote and images are provided, quote takes precedence. + + embed_to_add: models.AppBskyFeedPost.Embed | None = None + + if quote_uri: + logger.info(f"Attempting to add quote embed for URI: {quote_uri}") + quoted_post_strong_ref = await self._get_strong_ref_for_uri(quote_uri) + if quoted_post_strong_ref: + embed_to_add = models.AppBskyEmbedRecord.Main(record=quoted_post_strong_ref) + if media_ids: + logger.warning(f"Quote URI provided ({quote_uri}), images will be ignored due to embed priority.") + else: + logger.warning(f"Could not create strong reference for quote URI: {quote_uri}. Quote will be omitted.") + + # Handle media attachments (images) only if no quote embed was successfully created + if not embed_to_add and media_ids: + logger.info(f"Attempting to add image embed with {len(media_ids)} media items.") + images_for_embed = [] + for media_info in media_ids: + if isinstance(media_info, dict) and media_info.get("blob_ref"): + images_for_embed.append( + models.AppBskyEmbedImages.Image( + image=media_info["blob_ref"], # This should be a BlobRef instance + alt=media_info.get("alt_text", "") + ) + ) + if images_for_embed: + embed_to_add = models.AppBskyEmbedImages.Main(images=images_for_embed) + + if embed_to_add: + post_record_data['embed'] = embed_to_add + + # Handle replies + if reply_to_uri: + parent_strong_ref = await self._get_strong_ref_for_uri(reply_to_uri) + if parent_strong_ref: + # Determine root. If parent is also a reply, its root should be used. + # This requires fetching the parent post and checking its .reply.root + # For simplicity now, assume direct parent is root, or parent_strong_ref itself is enough. + # A robust solution fetches parent post: parent_post_details = await client.app.bsky.feed.get_posts([reply_to_uri]) + # then uses parent_post_details.posts[0].record.reply.root if present. + # Placeholder: use parent as root for now + post_record_data['reply'] = models.AppBskyFeedPost.ReplyRef( + root=parent_strong_ref, # Simplified: SDK might need full root post ref + parent=parent_strong_ref + ) + else: + logger.warning(f"Could not create strong reference for reply URI: {reply_to_uri}. Reply info will be omitted.") + + + # Handle content warnings (labels) + # Bluesky uses self-labeling. + post_labels: list[models.ComAtprotoLabelDefs.SelfLabel] = [] + if cw_text: # Custom label for "content warning" text itself + # Ensure cw_text is not too long for a label value (max 64 chars for label value, but this is for the *value*, not a predefined category) + # Using a generic "!warn" or a custom scheme like "cw:..." + # The SDK might have specific ways to add !warn or other standard labels. + # For a simple text CW, it's often just part of the text or a client-side convention. + # If mapping to official labels: + post_labels.append(models.ComAtprotoLabelDefs.SelfLabel(val="!warn")) # Generic warning + # Or if cw_text is a specific category that maps to a label: + # post_labels.append(models.ComAtprotoLabelDefs.SelfLabel(val=cw_text)) # if cw_text is like "nudity" + if is_sensitive and not any(l.val == "!warn" for l in post_labels): # Add generic !warn if sensitive and not already added by cw_text + post_labels.append(models.ComAtprotoLabelDefs.SelfLabel(val="!warn")) + + if post_labels: + post_record_data['labels'] = models.ComAtprotoLabelDefs.SelfLabels(values=post_labels) + + # Create the post record object + final_post_record = models.AppBskyFeedPost.Main(**post_record_data) + + response = await client.com.atproto.repo.create_record( + models.ComAtprotoRepoCreateRecord.Input( + repo=self.get_own_did(), # Must be own DID + collection=ids.AppBskyFeedPost, # e.g., "app.bsky.feed.post" + record=final_post_record, + ) + ) + logger.info(f"Successfully posted to ATProtoSocial. URI: {response.uri}") + return response.uri + except AtProtocolError as e: + logger.error(f"Error posting status to ATProtoSocial: {e.error} - {e.message}", exc_info=True) + raise NotificationError(_("Failed to post: {error} - {message}").format(error=e.error or "Error", message=e.message or "Protocol error")) from e + except Exception as e: # Catch any other unexpected errors + logger.error(f"Unexpected error posting status to ATProtoSocial: {e}", exc_info=True) + raise NotificationError(_("An unexpected error occurred while posting: {error}").format(error=str(e))) from e + + + async def delete_status(self, post_uri: str) -> bool: + """Deletes a status (post) given its AT URI.""" + client = await self._get_client() + if not client: + logger.error("ATProtoSocial client not available for deleting post.") + return False + if not self.get_own_did(): + logger.error("Cannot delete status: User DID not available.") + return False + + try: + # Extract rkey from URI. URI format: at://// + uri_parts = post_uri.replace("at://", "").split("/") + if len(uri_parts) != 3: + logger.error(f"Invalid AT URI format for deletion: {post_uri}") + return False + # repo_did = uri_parts[0] # Should match self.get_own_did() + collection = uri_parts[1] + rkey = uri_parts[2] + + if collection != ids.AppBskyFeedPost: # Ensure it's the correct collection + logger.error(f"Attempting to delete from incorrect collection '{collection}'. Expected '{ids.AppBskyFeedPost}'.") + return False + + await client.com.atproto.repo.delete_record( + models.ComAtprotoRepoDeleteRecord.Input( + repo=self.get_own_did(), # Must be own DID + collection=collection, + rkey=rkey, + ) + ) + logger.info(f"Successfully deleted post {post_uri} from ATProtoSocial.") + return True + except AtProtocolError as e: + logger.error(f"Error deleting post {post_uri} from ATProtoSocial: {e.error} - {e.message}") + except Exception as e: + logger.error(f"Unexpected error deleting post {post_uri}: {e}", exc_info=True) + return False + + + async def upload_media(self, file_path: str, mime_type: str, alt_text: str | None = None) -> dict[str, Any] | None: + """ + Uploads media (image) to ATProtoSocial. + Returns a dictionary containing the SDK's BlobRef object and alt_text, or None on failure. + """ + client = await self._get_client() + if not client: + logger.error("ATProtoSocial client not available for media upload.") + return None + try: + with open(file_path, "rb") as f: + image_data = f.read() + + # The SDK's upload_blob takes bytes directly. + response = await client.com.atproto.repo.upload_blob(image_data, mime_type=mime_type) + if response and response.blob: + logger.info(f"Media uploaded successfully: {file_path}, Blob CID: {response.blob.cid}") + # Return the actual blob object from the SDK, as it's needed for post creation. + return { + "blob_ref": response.blob, # This is models.ComAtprotoRepoStrongRef.Blob + "alt_text": alt_text or "", + } + else: + logger.error(f"Media upload failed for {file_path}, no blob in response.") + return None + except AtProtocolError as e: + logger.error(f"Error uploading media {file_path} to ATProtoSocial: {e.error} - {e.message}", exc_info=True) + except Exception as e: + logger.error(f"Unexpected error uploading media {file_path}: {e}", exc_info=True) + return None + + + # --- User Profile and Interaction --- + + async def get_profile_by_handle(self, handle: str) -> ATUserProfile | None: + """Fetches a user's profile by their handle.""" + client = await self._get_client() + if not client: return None + try: + response = await client.app.bsky.actor.get_profile(models.AppBskyActorGetProfile.Params(actor=handle)) + if isinstance(response, models.AppBskyActorDefs.ProfileViewDetailed): + return response + return None # Should not happen if handle is valid + except AtProtocolError as e: + logger.error(f"Error fetching profile for {handle}: {e.error} - {e.message}") + return None + except Exception as e: + logger.error(f"Unexpected error fetching profile for {handle}: {e}", exc_info=True) + return None + + + async def follow_user(self, user_did: str) -> bool: + """Follows a user by their DID.""" + client = await self._get_client() + if not client: return False + if not self.get_own_did(): + logger.error("Cannot follow user: Own DID not available.") + return False + + try: + await client.com.atproto.repo.create_record( + models.ComAtprotoRepoCreateRecord.Input( + repo=self.get_own_did(), + collection=ids.AppBskyGraphFollow, # "app.bsky.graph.follow" + record=models.AppBskyGraphFollow.Main(subject=user_did, created_at=client.get_current_time_iso()), + ) + ) + logger.info(f"Successfully followed user {user_did}.") + return True + except AtProtocolError as e: + logger.error(f"Error following user {user_did}: {e.error} - {e.message}") + except Exception as e: + logger.error(f"Unexpected error following user {user_did}: {e}", exc_info=True) + return False + + + async def unfollow_user(self, user_did: str) -> bool: + """Unfollows a user by their DID. Requires finding the follow record's URI (rkey).""" + client = await self._get_client() + if not client: return False + if not self.get_own_did(): + logger.error("Cannot unfollow user: Own DID not available.") + return False + + try: + # Find the URI of the follow record. This is the tricky part. + # We need the rkey of the follow record. + follow_rkey = await self._find_follow_record_rkey(user_did) + if not follow_rkey: + logger.warning(f"Could not find follow record for user {user_did} to unfollow.") + return False + + await client.com.atproto.repo.delete_record( + models.ComAtprotoRepoDeleteRecord.Input( + repo=self.get_own_did(), + collection=ids.AppBskyGraphFollow, + rkey=follow_rkey, + ) + ) + logger.info(f"Successfully unfollowed user {user_did}.") + return True + except AtProtocolError as e: + logger.error(f"Error unfollowing user {user_did}: {e.error} - {e.message}") + except Exception as e: + logger.error(f"Unexpected error unfollowing user {user_did}: {e}", exc_info=True) + return False + + + # --- Notifications and Timelines (Illustrative - actual implementation is complex) --- + + async def get_notifications(self, limit: int = 20, cursor: str | None = None) -> tuple[list[ATNotification], str | None] | None: + """Fetches notifications for the authenticated user. Returns (notifications, cursor) or None.""" + client = await self._get_client() + if not client: return None + try: + response = await client.app.bsky.notification.list_notifications( + models.AppBskyNotificationListNotifications.Params(limit=limit, cursor=cursor) + ) + # No need to format further if ATNotification type hint is used and callers expect SDK models + return response.notifications, response.cursor + except AtProtocolError as e: + logger.error(f"Error fetching notifications: {e.error} - {e.message}") + return None + except Exception as e: + logger.error(f"Unexpected error fetching notifications: {e}", exc_info=True) + return None + + + async def get_timeline(self, algorithm: str | None = None, limit: int = 20, cursor: str | None = None) -> tuple[list[models.AppBskyFeedDefs.FeedViewPost], str | None] | None: + """ + Fetches a timeline (feed) for the authenticated user. + Returns (feed_items, cursor) or None. + 'algorithm' can be None for default "Following" or a custom feed URI (at://did/app.bsky.feed.generator/uri). + """ + client = await self._get_client() + if not client: return None + try: + params = models.AppBskyFeedGetTimeline.Params(limit=limit, cursor=cursor) + if algorithm: # Only add algorithm if it's specified, SDK might default to 'following' + params.algorithm = algorithm + + response = await client.app.bsky.feed.get_timeline(params) + # response.feed is a list of FeedViewPost items + return response.feed, response.cursor + except AtProtocolError as e: + logger.error(f"Error fetching timeline (algorithm: {algorithm}): {e.error} - {e.message}") + return None + except Exception as e: + logger.error(f"Unexpected error fetching timeline (algorithm: {algorithm}): {e}", exc_info=True) + return None + + + async def get_author_feed(self, actor_did: str, limit: int = 20, cursor: str | None = None, filter: str = "posts_with_replies") -> tuple[list[models.AppBskyFeedDefs.FeedViewPost], str | None] | None: + """ + Fetches a specific user's timeline (feed). + Returns (feed_items, cursor) or None. + filter can be: "posts_with_replies", "posts_no_replies", "posts_with_media". + The default "posts_with_replies" includes user's posts and their replies. + To get only original posts (no replies): "posts_no_replies". + To get posts and reposts: This is not a direct filter in getAuthorFeed. Reposts are part of the feed if the PDS includes them. + The `filter` parameter in `getAuthorFeed` is actually `filter: Literal['posts_with_replies', 'posts_no_replies', 'posts_and_author_threads', 'posts_with_media']` + Let's use a sensible default or make it configurable if needed by the session. + For "posts and reposts", you typically just get the author feed and it includes reposts. + """ + client = await self._get_client() + if not client: return None + try: + # Ensure filter is a valid choice for the SDK if it uses an Enum or Literal + # For this example, we assume the string is passed directly. + # Valid filters are 'posts_with_replies', 'posts_no_replies', 'posts_and_author_threads', 'posts_with_media'. + # Defaulting to 'posts_and_author_threads' to include posts, replies, and reposts by the author. + # Actually, getAuthorFeed's `filter` param does not directly control inclusion of reposts in a way that + # "posts_and_reposts" would imply. Reposts by the author of things *they reposted* are part of their feed. + # A common default is 'posts_with_replies'. If we want to see their reposts, that's typically included by default. + + current_filter_value = filter + if current_filter_value not in ['posts_with_replies', 'posts_no_replies', 'posts_and_author_threads', 'posts_with_media']: + logger.warning(f"Invalid filter '{current_filter_value}' for getAuthorFeed. Defaulting to 'posts_with_replies'.") + current_filter_value = 'posts_with_replies' + + + params = models.AppBskyFeedGetAuthorFeed.Params(actor=actor_did, limit=limit, cursor=cursor, filter=current_filter_value) + response = await client.app.bsky.feed.get_author_feed(params) + return response.feed, response.cursor + except AtProtocolError as e: + logger.error(f"Error fetching author feed for {actor_did}: {e.error} - {e.message}") + return None + except Exception as e: + logger.error(f"Unexpected error fetching author feed for {actor_did}: {e}", exc_info=True) + return None + + async def get_user_profile(self, user_ident: str) -> ATUserProfile | None: + """Fetches a detailed user profile by DID or handle.""" + client = await self._get_client() + if not client: + logger.error(f"Cannot get profile for {user_ident}: ATProto client not available.") + return None + try: + response = await client.app.bsky.actor.get_profile(models.AppBskyActorGetProfile.Params(actor=user_ident)) + if isinstance(response, models.AppBskyActorDefs.ProfileViewDetailed): + return response + logger.error(f"Unexpected response type when fetching profile for {user_ident}: {type(response)}") + return None + except AtProtocolError as e: + logger.error(f"Error fetching profile for {user_ident}: {e.error} - {e.message}") + return None + except Exception as e: + logger.error(f"Unexpected error fetching profile for {user_ident}: {e}", exc_info=True) + return None + + + async def get_followers(self, user_did: str, limit: int = 30, cursor: str | None = None) -> tuple[list[models.AppBskyActorDefs.ProfileView], str | None] | None: + """Fetches followers for a given user DID.""" + client = await self._get_client() + if not client: + logger.error(f"Cannot get followers for {user_did}: ATProto client not available.") + return None + try: + response = await client.app.bsky.graph.get_followers( + models.AppBskyGraphGetFollowers.Params(actor=user_did, limit=limit, cursor=cursor) + ) + return response.followers, response.cursor + except AtProtocolError as e: + logger.error(f"Error fetching followers for {user_did}: {e.error} - {e.message}") + return None + except Exception as e: + logger.error(f"Unexpected error fetching followers for {user_did}: {e}", exc_info=True) + return None + + async def get_following(self, user_did: str, limit: int = 30, cursor: str | None = None) -> tuple[list[models.AppBskyActorDefs.ProfileView], str | None] | None: + """Fetches accounts followed by a given user DID.""" + client = await self._get_client() + if not client: + logger.error(f"Cannot get following for {user_did}: ATProto client not available.") + return None + try: + response = await client.app.bsky.graph.get_follows( # Correct endpoint is get_follows + models.AppBskyGraphGetFollows.Params(actor=user_did, limit=limit, cursor=cursor) + ) + return response.follows, response.cursor + except AtProtocolError as e: + logger.error(f"Error fetching accounts followed by {user_did}: {e.error} - {e.message}") + return None + except Exception as e: + logger.error(f"Unexpected error fetching accounts followed by {user_did}: {e}", exc_info=True) + return None + + async def search_users(self, term: str, limit: int = 20, cursor: str | None = None) -> tuple[list[models.AppBskyActorDefs.ProfileView], str | None] | None: + """Searches for users based on a term.""" + client = await self._get_client() + if not client: + logger.error(f"Cannot search users for '{term}': ATProto client not available.") + return None + try: + response = await client.app.bsky.actor.search_actors( + models.AppBskyActorSearchActors.Params(term=term, limit=limit, cursor=cursor) + ) + return response.actors, response.cursor + except AtProtocolError as e: + logger.error(f"Error searching users for '{term}': {e.error} - {e.message}") + return None + except Exception as e: + logger.error(f"Unexpected error searching users for '{term}': {e}", exc_info=True) + return None + + + async def mute_user(self, user_did: str) -> bool: + """Mutes a user by their DID.""" + client = await self._get_client() + if not client: + logger.error("Cannot mute user: ATProto client not available.") + return False + try: + await client.app.bsky.graph.mute_actor(models.AppBskyGraphMuteActor.Input(actor=user_did)) + logger.info(f"Successfully muted user {user_did}.") + return True + except AtProtocolError as e: + # Check if already muted - SDK might throw specific error or general one. + # For now, log and return False. A more specific error could be returned to UI. + logger.error(f"Error muting user {user_did}: {e.error} - {e.message}") + except Exception as e: + logger.error(f"Unexpected error muting user {user_did}: {e}", exc_info=True) + return False + + async def unmute_user(self, user_did: str) -> bool: + """Unmutes a user by their DID.""" + client = await self._get_client() + if not client: + logger.error("Cannot unmute user: ATProto client not available.") + return False + try: + await client.app.bsky.graph.unmute_actor(models.AppBskyGraphUnmuteActor.Input(actor=user_did)) + logger.info(f"Successfully unmuted user {user_did}.") + return True + except AtProtocolError as e: + # Check if already unmuted / not muted. + logger.error(f"Error unmuting user {user_did}: {e.error} - {e.message}") + except Exception as e: + logger.error(f"Unexpected error unmuting user {user_did}: {e}", exc_info=True) + return False + + async def block_user(self, user_did: str) -> str | None: + """ + Blocks a user by their DID. + Returns the AT URI of the block record on success, None on failure. + """ + client = await self._get_client() + if not client: + logger.error("Cannot block user: ATProto client not available.") + return None + if not self.get_own_did(): + logger.error("Cannot block user: Own DID not available.") + return None + + try: + response = await client.com.atproto.repo.create_record( + models.ComAtprotoRepoCreateRecord.Input( + repo=self.get_own_did(), + collection=ids.AppBskyGraphBlock, # "app.bsky.graph.block" + record=models.AppBskyGraphBlock.Main(subject=user_did, created_at=client.get_current_time_iso()), + ) + ) + logger.info(f"Successfully blocked user {user_did}. Block record URI: {response.uri}") + return response.uri + except AtProtocolError as e: + # Handle specific errors, e.g., if user is already blocked. + # The SDK might raise an error like "already exists" or a generic one. + logger.error(f"Error blocking user {user_did}: {e.error} - {e.message}") + except Exception as e: + logger.error(f"Unexpected error blocking user {user_did}: {e}", exc_info=True) + return None + + async def _find_block_record_rkey(self, target_did: str) -> str | None: + """Helper to find the rkey of a block record for a given target DID.""" + client = await self._get_client() + own_did = self.get_own_did() + if not client or not own_did: return None + + cursor = None + try: + while True: + response = await client.com.atproto.repo.list_records( + models.ComAtprotoRepoListRecords.Params( + repo=own_did, + collection=ids.AppBskyGraphBlock, + limit=100, + cursor=cursor, + ) + ) + if not response or not response.records: + break + + for record_item in response.records: + if record_item.value and isinstance(record_item.value, models.AppBskyGraphBlock.Main): + if record_item.value.subject == target_did: + return record_item.uri.split("/")[-1] # Extract rkey from URI + + cursor = response.cursor + if not cursor: + break + logger.info(f"No active block record found for user {target_did} by {own_did}.") + return None + except AtProtocolError as e: + logger.error(f"Error listing block records for {own_did} to find {target_did}: {e.error} - {e.message}") + return None + except Exception as e: + logger.error(f"Unexpected error finding block rkey for {target_did}: {e}", exc_info=True) + return None + + async def repost_post(self, post_uri: str, post_cid: str | None = None) -> str | None: + """Creates a repost for a given post URI and CID. Returns URI of the repost record or None.""" + client = await self._get_client() + if not client or not self.get_own_did(): + logger.error("Cannot repost: client or own DID not available.") + return None + + if not post_cid: # If CID is not provided, try to get it + strong_ref = await self._get_strong_ref_for_uri(post_uri) + if not strong_ref: + logger.error(f"Could not get strong reference for post {post_uri} to repost.") + return None + post_cid = strong_ref.cid # type: ignore + + try: + response = await client.com.atproto.repo.create_record( + models.ComAtprotoRepoCreateRecord.Input( + repo=self.get_own_did(), + collection=ids.AppBskyFeedRepost, + record=models.AppBskyFeedRepost.Main( + subject=models.ComAtprotoRepoStrongRef.Main(uri=post_uri, cid=post_cid), + createdAt=client.get_current_time_iso() + ) + ) + ) + logger.info(f"Successfully reposted {post_uri}. Repost URI: {response.uri}") + return response.uri + except AtProtocolError as e: + logger.error(f"Error reposting {post_uri}: {e.error} - {e.message}") + # Consider raising NotificationError for specific, user-understandable errors + if "already exists" in str(e.message).lower() or "duplicate" in str(e.message).lower(): + raise NotificationError(_("You have already reposted this post.")) from e + except Exception as e: + logger.error(f"Unexpected error reposting {post_uri}: {e}", exc_info=True) + return None + + async def like_post(self, post_uri: str, post_cid: str | None = None) -> str | None: + """Likes a given post URI and CID. Returns URI of the like record or None.""" + client = await self._get_client() + if not client or not self.get_own_did(): + logger.error("Cannot like post: client or own DID not available.") + return None + + if not post_cid: # If CID is not provided, try to get it + strong_ref = await self._get_strong_ref_for_uri(post_uri) + if not strong_ref: + logger.error(f"Could not get strong reference for post {post_uri} to like.") + return None + post_cid = strong_ref.cid # type: ignore + + try: + response = await client.com.atproto.repo.create_record( + models.ComAtprotoRepoCreateRecord.Input( + repo=self.get_own_did(), + collection=ids.AppBskyFeedLike, + record=models.AppBskyFeedLike.Main( + subject=models.ComAtprotoRepoStrongRef.Main(uri=post_uri, cid=post_cid), + createdAt=client.get_current_time_iso() + ) + ) + ) + logger.info(f"Successfully liked {post_uri}. Like URI: {response.uri}") + return response.uri + except AtProtocolError as e: + logger.error(f"Error liking {post_uri}: {e.error} - {e.message}") + if "already exists" in str(e.message).lower() or "duplicate" in str(e.message).lower(): + raise NotificationError(_("You have already liked this post.")) from e + except Exception as e: + logger.error(f"Unexpected error liking {post_uri}: {e}", exc_info=True) + return None + + async def delete_like(self, like_uri: str) -> bool: + """Deletes a like given the URI of the like record itself.""" + client = await self._get_client() + if not client or not self.get_own_did(): + logger.error("Cannot delete like: client or own DID not available.") + return False + + try: + # Extract rkey from like_uri + # Format: at:///app.bsky.feed.like/ + uri_parts = like_uri.replace("at://", "").split("/") + if len(uri_parts) != 3 or uri_parts[1] != ids.AppBskyFeedLike: + logger.error(f"Invalid like URI format for deletion: {like_uri}") + return False + + rkey = uri_parts[2] + + await client.com.atproto.repo.delete_record( + models.ComAtprotoRepoDeleteRecord.Input( + repo=self.get_own_did(), + collection=ids.AppBskyFeedLike, + rkey=rkey + ) + ) + logger.info(f"Successfully deleted like {like_uri}.") + return True + except AtProtocolError as e: + logger.error(f"Error deleting like {like_uri}: {e.error} - {e.message}") + # Could check for "not found" type errors if user tries to unlike something not liked + except Exception as e: + logger.error(f"Unexpected error deleting like {like_uri}: {e}", exc_info=True) + return False + + async def repost_post(self, post_uri: str, post_cid: str | None = None) -> str | None: + """Creates a repost for a given post URI and CID. Returns URI of the repost record or None.""" + client = await self._get_client() + if not client or not self.get_own_did(): + logger.error("Cannot repost: client or own DID not available.") + # raise NotificationError(_("Session not ready. Please log in.")) # Alternative + return None + + if not post_cid: # If CID is not provided, try to get it from the URI + strong_ref_to_post = await self._get_strong_ref_for_uri(post_uri) + if not strong_ref_to_post: + logger.error(f"Could not get strong reference for post {post_uri} to repost it.") + raise NotificationError(_("Could not find the post to repost.")) + post_cid = strong_ref_to_post.cid # type: ignore # SDK uses .cid + + try: + response = await client.com.atproto.repo.create_record( + models.ComAtprotoRepoCreateRecord.Input( + repo=self.get_own_did(), # Must be own DID + collection=ids.AppBskyFeedRepost, + record=models.AppBskyFeedRepost.Main( + subject=models.ComAtprotoRepoStrongRef.Main(uri=post_uri, cid=post_cid), + createdAt=client.get_current_time_iso() # SDK helper for current time + ) + ) + ) + logger.info(f"Successfully reposted {post_uri}. Repost URI: {response.uri}") + return response.uri + except AtProtocolError as e: + logger.error(f"Error reposting {post_uri}: {e.error} - {e.message}") + if e.error == "DuplicateRecord": # Or similar error code for existing repost + raise NotificationError(_("You have already reposted this post.")) from e + raise NotificationError(_("Failed to repost: {error}").format(error=e.message or e.error)) from e + except Exception as e: + logger.error(f"Unexpected error reposting {post_uri}: {e}", exc_info=True) + raise NotificationError(_("An unexpected error occurred while reposting.")) + + + async def like_post(self, post_uri: str, post_cid: str | None = None) -> str | None: + """Likes a given post URI and CID. Returns URI of the like record or None.""" + client = await self._get_client() + if not client or not self.get_own_did(): + logger.error("Cannot like post: client or own DID not available.") + return None # Or raise NotificationError + + if not post_cid: # If CID is not provided, try to get it from the URI + strong_ref_to_post = await self._get_strong_ref_for_uri(post_uri) + if not strong_ref_to_post: + logger.error(f"Could not get strong reference for post {post_uri} to like it.") + raise NotificationError(_("Could not find the post to like.")) + post_cid = strong_ref_to_post.cid # type: ignore + + try: + response = await client.com.atproto.repo.create_record( + models.ComAtprotoRepoCreateRecord.Input( + repo=self.get_own_did(), + collection=ids.AppBskyFeedLike, # "app.bsky.feed.like" + record=models.AppBskyFeedLike.Main( + subject=models.ComAtprotoRepoStrongRef.Main(uri=post_uri, cid=post_cid), + createdAt=client.get_current_time_iso() + ) + ) + ) + logger.info(f"Successfully liked {post_uri}. Like URI: {response.uri}") + return response.uri + except AtProtocolError as e: + logger.error(f"Error liking {post_uri}: {e.error} - {e.message}") + if e.error == "DuplicateRecord": # Or similar error code for existing like + raise NotificationError(_("You have already liked this post.")) from e + raise NotificationError(_("Failed to like post: {error}").format(error=e.message or e.error)) from e + except Exception as e: + logger.error(f"Unexpected error liking {post_uri}: {e}", exc_info=True) + raise NotificationError(_("An unexpected error occurred while liking the post.")) + + + async def delete_like(self, like_uri: str) -> bool: + """Deletes a like given the URI of the like record itself.""" + client = await self._get_client() + if not client or not self.get_own_did(): + logger.error("Cannot delete like: client or own DID not available.") + return False + + try: + # Extract rkey from like_uri + # Format: at:///app.bsky.feed.like/ + uri_parts = like_uri.replace("at://", "").split("/") + if len(uri_parts) != 3 or uri_parts[1] != ids.AppBskyFeedLike: # Check collection is correct + logger.error(f"Invalid like URI format for deletion: {like_uri}") + return False # Or raise error + + # own_did_from_uri = uri_parts[0] # This should match self.get_own_did() + # if own_did_from_uri != self.get_own_did(): + # logger.error(f"Attempting to delete a like not owned by the current user: {like_uri}") + # return False + + rkey = uri_parts[2] + + await client.com.atproto.repo.delete_record( + models.ComAtprotoRepoDeleteRecord.Input( + repo=self.get_own_did(), # Must be own DID + collection=ids.AppBskyFeedLike, + rkey=rkey + ) + ) + logger.info(f"Successfully deleted like {like_uri}.") + return True + except AtProtocolError as e: + logger.error(f"Error deleting like {like_uri}: {e.error} - {e.message}") + # If error indicates "not found", it means it was already unliked or never existed. + # We could return True for idempotency or False for strict "did I delete it now?" + # For now, let's return False on any API error. + return False + except Exception as e: + logger.error(f"Unexpected error deleting like {like_uri}: {e}", exc_info=True) + return False + + + async def unblock_user(self, user_did: str) -> bool: + """Unblocks a user by their DID. Requires finding the block record's rkey.""" + client = await self._get_client() + if not client: + logger.error("Cannot unblock user: ATProto client not available.") + return False + if not self.get_own_did(): + logger.error("Cannot unblock user: Own DID not available.") + return False + + try: + block_rkey = await self._find_block_record_rkey(user_did) + if not block_rkey: + logger.warning(f"Could not find block record for user {user_did} to unblock. User might not be blocked.") + # Depending on desired UX, this could be True (idempotency) or False (strict "not found") + return False + + await client.com.atproto.repo.delete_record( + models.ComAtprotoRepoDeleteRecord.Input( + repo=self.get_own_did(), + collection=ids.AppBskyGraphBlock, + rkey=block_rkey, + ) + ) + logger.info(f"Successfully unblocked user {user_did}.") + return True + except AtProtocolError as e: + logger.error(f"Error unblocking user {user_did}: {e.error} - {e.message}") + except Exception as e: + logger.error(f"Unexpected error unblocking user {user_did}: {e}", exc_info=True) + return False + + + # --- Helper Methods for Formatting and URI/DID manipulation --- + + def _format_profile_data(self, profile_model: models.AppBskyActorDefs.ProfileViewDetailed | models.AppBskyActorDefs.ProfileView | models.AppBskyActorDefs.ProfileViewBasic) -> dict[str, Any]: + """Converts an ATProto profile model to a standardized dictionary for Approve internal use if needed.""" + # This is an example if Approve needs its own dict format instead of using SDK models directly. + # For now, many methods return SDK models directly (ATUserProfile, ATPost types). + return { + "did": profile_model.did, + "handle": profile_model.handle, + "displayName": getattr(profile_model, 'displayName', None) or profile_model.handle, + "description": getattr(profile_model, 'description', None), + "avatar": getattr(profile_model, 'avatar', None), + "banner": getattr(profile_model, 'banner', None) if hasattr(profile_model, 'banner') else None, + "followersCount": getattr(profile_model, 'followersCount', 0) if hasattr(profile_model, 'followersCount') else None, + "followsCount": getattr(profile_model, 'followsCount', 0) if hasattr(profile_model, 'followsCount') else None, + "postsCount": getattr(profile_model, 'postsCount', 0) if hasattr(profile_model, 'postsCount') else None, + "indexedAt": getattr(profile_model, 'indexedAt', None), # For ProfileView, not Basic + "labels": getattr(profile_model, 'labels', []), # For ProfileView, not Basic + "viewer": getattr(profile_model, 'viewer', None), # For ProfileView, not Basic (viewer state like muted/following) + } + + def _format_post_data(self, post_view_model: models.AppBskyFeedDefs.PostView) -> dict[str, Any]: + """Converts an ATProto PostView model to a standardized dictionary if needed.""" + # record_data = post_view_model.record # This is the actual app.bsky.feed.post record + # if not isinstance(record_data, models.AppBskyFeedPost.Main()): # Check it's the expected type + # logger.warning(f"Post record is not of type Main, actual: {type(record_data)}") + # text_content = "Unsupported post record type" + # else: + # text_content = record_data.text + + return { + "uri": post_view_model.uri, + "cid": post_view_model.cid, + "author": self._format_profile_data(post_view_model.author) if post_view_model.author else None, + "record": post_view_model.record, # Keep the raw record for full data + # "text": text_content, # Extracted text + "embed": post_view_model.embed, # Raw embed + "replyCount": post_view_model.replyCount if post_view_model.replyCount is not None else 0, + "repostCount": post_view_model.repostCount if post_view_model.repostCount is not None else 0, + "likeCount": post_view_model.likeCount if post_view_model.likeCount is not None else 0, + "indexedAt": post_view_model.indexedAt, + "labels": post_view_model.labels or [], + "viewer": post_view_model.viewer, # Viewer state (e.g. like URI, repost URI) + } + + def _format_notification_data(self, notification_model: ATNotification) -> dict[str,Any]: + # This is an example if Approve needs its own dict format. + return { + "uri": notification_model.uri, + "cid": notification_model.cid, + "author": self._format_profile_data(notification_model.author) if notification_model.author else None, + "reason": notification_model.reason, # e.g., "like", "repost", "follow", "mention", "reply", "quote" + "reasonSubject": notification_model.reasonSubject, # AT URI of the subject of the notification (e.g. URI of the like record) + "record": notification_model.record, # The record that actioned this notification (e.g. the like record itself) + "isRead": notification_model.isRead, + "indexedAt": notification_model.indexedAt, + "labels": notification_model.labels or [], + } + + async def _get_strong_ref_for_uri(self, at_uri: str) -> models.ComAtprotoRepoStrongRef.Main | None: + """ + Given an AT URI, describe the record to get its CID and create a strong reference. + This is needed for replies, quotes, etc. + Alternatively, SDK's client.com.atproto.repo.create_strong_ref can be used if available. + """ + client = await self._get_client() + if not client: return None + try: + # URI format: at://// + parts = at_uri.replace("at://", "").split("/") + if len(parts) != 3: + logger.error(f"Invalid AT URI for strong ref: {at_uri}") + return None + + repo_did, collection, rkey = parts + + # This is one way to get the CID if not already known. + # If the CID is known, models.ComAtprotoRepoStrongRef.Main(uri=at_uri, cid=known_cid) is simpler. + # However, for replies/quotes, the record must exist and be resolvable. + described_record = await client.com.atproto.repo.describe_record( + models.ComAtprotoRepoDescribeRecord.Params(repo=repo_did, collection=collection, rkey=rkey) + ) + if described_record and described_record.cid: + return models.ComAtprotoRepoStrongRef.Main(uri=described_record.uri, cid=described_record.cid) + else: + logger.error(f"Could not describe record for URI {at_uri} to create strong ref.") + return None + except AtProtocolError as e: + logger.error(f"Could not get strong reference for URI {at_uri}: {e.error} - {e.message}") + return None + except Exception as e: + logger.error(f"Unexpected error getting strong ref for {at_uri}: {e}", exc_info=True) + return None + + + async def _find_follow_record_rkey(self, target_did: str) -> str | None: + """Helper to find the rkey of a follow record for a given target DID.""" + client = await self._get_client() + own_did = self.get_own_did() + if not client or not own_did: return None + + cursor = None + try: + while True: + response = await client.com.atproto.repo.list_records( + models.ComAtprotoRepoListRecords.Params( + repo=own_did, + collection=ids.AppBskyGraphFollow, # "app.bsky.graph.follow" + limit=100, + cursor=cursor, + ) + ) + if not response or not response.records: + break + + for record_item in response.records: + # record_item.value is the actual follow record (AppBskyGraphFollow.Main) + if record_item.value and isinstance(record_item.value, models.AppBskyGraphFollow.Main): + if record_item.value.subject == target_did: + # The rkey is part of the URI: at:///app.bsky.graph.follow/ + return record_item.uri.split("/")[-1] + + cursor = response.cursor + if not cursor: + break + return None # Follow record not found + except AtProtocolError as e: + logger.error(f"Error listing follow records for {own_did} to find {target_did}: {e.error} - {e.message}") + return None + except Exception as e: + logger.error(f"Unexpected error finding follow rkey for {target_did}: {e}", exc_info=True) + return None + + + async def _extract_facets(self, text: str, tags: list[str] | None = None) -> list[models.AppBskyRichtextFacet.Main] | None: + """ + Detects mentions, links, and tags in text and creates facet objects. + Uses the atproto SDK's `detect_facets` via the client if available and resolves mentions. + """ + client = await self._get_client() + if not client: + logger.warning("Cannot extract facets: ATProto client not available.") + return None + + try: + # The SDK's detect_facets is a utility function, not a client method. + # from atproto. conhecido.facets import detect_facets # Path may vary + # For now, assume a simplified version or that client might expose it. + # A full implementation needs to handle byte offsets correctly. + # This is a complex part of posting. + + # Placeholder for actual facet detection logic. + # This would involve regex for mentions (@handle.bsky.social), links (http://...), and tags (#tag). + # For mentions, DIDs need to be resolved. For links, URI needs to be validated. + # Example (very simplified, not for production): + # facets = [] + # import re + # # Mentions + # for match in re.finditer(r'@([a-zA-Z0-9.-]+)', text): + # handle = match.group(1) + # try: + # # profile = await client.app.bsky.actor.get_profile(models.AppBskyActorGetProfile.Params(actor=handle)) # This is a network call per mention! + # # if profile: + # # facets.append(models.AppBskyRichtextFacet.Main( + # # index=models.AppBskyRichtextFacet.ByteSlice(byteStart=match.start(), byteEnd=match.end()), + # # features=[models.AppBskyRichtextFacet.Mention(did=profile.did)] + # # )) + # pass # Proper implementation needed + # except Exception: # Handle resolution failure + # logger.warning(f"Could not resolve DID for mention @{handle}") + # # Links + # # Tags + # if tags: + # for tag in tags: + # # find occurrences of #tag in text and add facet + # pass + + # If the SDK has a robust way to do this (even if it's a static method you import) use it. + # e.g. from atproto. अमीर_text import RichText + # rt = RichText(text) + # await rt.resolve_facets(client) # if it needs async client for resolution + # return rt.facets + + logger.debug("Facet extraction is currently a placeholder and may not correctly identify all rich text features.") + return None + except Exception as e: + logger.error(f"Error during facet extraction: {e}", exc_info=True) + return None + + + async def report_post(self, post_uri: str, reason_type: str, reason_text: str | None = None) -> bool: + """ + Reports a post (skeet) to the PDS/moderation service. + reason_type should be one of com.atproto.moderation.defs#reasonType + (e.g., lexicon_models.COM_ATPROTO_MODERATION_DEFS_REASONSPAM -> "com.atproto.moderation.defs#reasonSpam") + """ + client = await self._get_client() + if not client: + logger.error("ATProtoSocial client not available for reporting.") + return False + + try: + # We need a strong reference to the post being reported. + subject_strong_ref = await self._get_strong_ref_for_uri(post_uri) + if not subject_strong_ref: + logger.error(f"Could not get strong reference for post URI {post_uri} to report.") + return False + + # The 'subject' for reporting a record is ComAtprotoRepoStrongRef.Main + report_subject = models.ComAtprotoRepoStrongRef.Main(uri=subject_strong_ref.uri, cid=subject_strong_ref.cid) + + # For reporting an account, it would be ComAtprotoAdminDefs.RepoRef(did=...) + + await client.com.atproto.moderation.create_report( + models.ComAtprotoModerationCreateReport.Input( + reasonType=reason_type, # e.g. lexicon_models.COM_ATPROTO_MODERATION_DEFS_REASONSPAM + reason=reason_text if reason_text else None, + subject=report_subject + ) + ) + logger.info(f"Successfully reported post {post_uri} for reason {reason_type}.") + return True + except AtProtocolError as e: + logger.error(f"Error reporting post {post_uri}: {e.error} - {e.message}", exc_info=True) + except Exception as e: + logger.error(f"Unexpected error reporting post {post_uri}: {e}", exc_info=True) + return False + + + def is_mention_of_me(self, post_data: models.AppBskyFeedPost.Main | dict) -> bool: + """ + Checks if a post record (not post view) mentions the authenticated user. + This requires parsing facets from the post's record. + `post_data` should be the `record` field of a `PostView` or a `FeedViewPost`. + """ + my_did = self.get_own_did() + if not my_did: + return False + + facets_to_check = None + if isinstance(post_data, models.AppBskyFeedPost.Main): + facets_to_check = post_data.facets + elif isinstance(post_data, dict) and 'facets' in post_data: # If raw dict from JSON + # Need to parse dict facets into SDK models or handle dict structure + # This simplified check assumes facets are already models.AppBskyRichtextFacet.Main objects + # For robustness, parse dict to models.AppBskyRichtextFacet.Main if necessary. + facets_to_check = post_data['facets'] + + + if not facets_to_check: + return False + + for facet_item_model in facets_to_check: + # Ensure facet_item_model is the correct SDK model type if it came from dict + if isinstance(facet_item_model, models.AppBskyRichtextFacet.Main): + for feature in facet_item_model.features: + if isinstance(feature, models.AppBskyRichtextFacet.Mention) and feature.did == my_did: + return True + # Add handling for dict-based facet items if not pre-parsed + elif isinstance(facet_item_model, dict) and 'features' in facet_item_model: + for feature_dict in facet_item_model['features']: + if isinstance(feature_dict, dict) and feature_dict.get('$type') == ids.AppBskyRichtextFacetMention and feature_dict.get('did') == my_did: + return True + return False + + # Add more utility functions as needed, e.g., for specific API calls, data transformations, etc.