This commit is contained in:
Jesús Pavón Abián
2026-01-10 19:46:53 +01:00
55 changed files with 1504 additions and 407 deletions

View File

@@ -9,7 +9,7 @@ from approve.translation import translate as _
from approve.util import parse_iso_datetime # For parsing ISO timestamps
if TYPE_CHECKING:
from approve.sessions.atprotosocial.session import Session as ATProtoSocialSession
from approve.sessions.blueski.session import Session as BlueskiSession
from atproto.xrpc_client import models # For type hinting ATProto models
logger = logging.getLogger(__name__)
@@ -21,19 +21,19 @@ SUPPORTED_LANG_CHOICES_COMPOSE = {
}
class ATProtoSocialCompose:
class BlueskiCompose:
MAX_CHARS = 300
MAX_MEDIA_ATTACHMENTS = 4
MAX_LANGUAGES = 3
MAX_IMAGE_SIZE_BYTES = 1_000_000
def __init__(self, session: ATProtoSocialSession) -> None:
def __init__(self, session: BlueskiSession) -> 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."""
"""Returns configuration for the compose panel specific to Blueski."""
return {
"max_chars": self.MAX_CHARS,
"max_media_attachments": self.MAX_MEDIA_ATTACHMENTS,
@@ -206,7 +206,7 @@ class ATProtoSocialCompose:
Args:
notif_data: A dictionary representing the notification,
typically from ATProtoSocialSession._handle_*_notification methods
typically from BlueskiSession._handle_*_notification methods
which create an approve.notifications.Notification object and then
convert it to dict or pass relevant parts.
Expected keys: 'title', 'body', 'author_name', 'timestamp_dt', 'kind'.

View File

@@ -10,7 +10,7 @@ from sessions import session_exceptions as Exceptions
import output
import application
log = logging.getLogger("sessions.atprotosocialSession")
log = logging.getLogger("sessions.blueskiSession")
# Optional import of atproto. Code handles absence gracefully.
try:
@@ -27,26 +27,45 @@ class Session(base.baseSession):
"""
name = "Bluesky"
KIND = "atprotosocial"
KIND = "blueski"
def __init__(self, *args, **kwargs):
super(Session, self).__init__(*args, **kwargs)
self.config_spec = "atprotosocial.defaults"
self.type = "atprotosocial"
self.config_spec = "blueski.defaults"
self.type = "blueski"
self.char_limit = 300
self.api = None
def _ensure_settings_namespace(self) -> None:
"""Migrate legacy atprotosocial settings to blueski namespace."""
try:
if not self.settings:
return
if self.settings.get("blueski") is None and self.settings.get("atprotosocial") is not None:
self.settings["blueski"] = dict(self.settings["atprotosocial"])
try:
del self.settings["atprotosocial"]
except Exception:
pass
try:
self.settings.write()
except Exception:
pass
except Exception:
log.exception("Failed to migrate legacy Blueski settings")
def get_name(self):
"""Return a human-friendly, stable account name for UI.
Prefer the user's handle if available so accounts are uniquely
identifiable, falling back to a generic network name otherwise.
"""
self._ensure_settings_namespace()
try:
# Prefer runtime DB, then persisted settings, then SDK client
handle = (
self.db.get("user_name")
or (self.settings and self.settings.get("atprotosocial", {}).get("handle"))
or (self.settings and self.settings.get("blueski", {}).get("handle"))
or (getattr(getattr(self, "api", None), "me", None) and self.api.me.handle)
)
if handle:
@@ -65,11 +84,12 @@ class Session(base.baseSession):
return self.api
def login(self, verify_credentials=True):
if self.settings.get("atprotosocial") is None:
self._ensure_settings_namespace()
if self.settings.get("blueski") is None:
raise Exceptions.RequireCredentialsSessionError
handle = self.settings["atprotosocial"].get("handle")
app_password = self.settings["atprotosocial"].get("app_password")
session_string = self.settings["atprotosocial"].get("session_string")
handle = self.settings["blueski"].get("handle")
app_password = self.settings["blueski"].get("app_password")
session_string = self.settings["blueski"].get("session_string")
if not handle or (not app_password and not session_string):
self.logged = False
raise Exceptions.RequireCredentialsSessionError
@@ -100,10 +120,10 @@ class Session(base.baseSession):
self.db["user_name"] = api.me.handle
self.db["user_id"] = api.me.did
# Persist DID in settings for session manager display
self.settings["atprotosocial"]["did"] = api.me.did
self.settings["blueski"]["did"] = api.me.did
# Export session for future reuse
try:
self.settings["atprotosocial"]["session_string"] = api.export_session_string()
self.settings["blueski"]["session_string"] = api.export_session_string()
except Exception:
pass
self.settings.write()
@@ -114,6 +134,7 @@ class Session(base.baseSession):
self.logged = False
def authorise(self):
self._ensure_settings_namespace()
if self.logged:
raise Exceptions.AlreadyAuthorisedError("Already authorised.")
# Ask for handle
@@ -141,8 +162,8 @@ class Session(base.baseSession):
# Create session folder and config, then attempt login
self.create_session_folder()
self.get_configuration()
self.settings["atprotosocial"]["handle"] = handle
self.settings["atprotosocial"]["app_password"] = app_password
self.settings["blueski"]["handle"] = handle
self.settings["blueski"]["app_password"] = app_password
self.settings.write()
try:
self.login()
@@ -159,7 +180,8 @@ class Session(base.baseSession):
def get_message_url(self, message_id, context=None):
# message_id may be full at:// URI or rkey
handle = self.db.get("user_name") or self.settings["atprotosocial"].get("handle", "")
self._ensure_settings_namespace()
handle = self.db.get("user_name") or self.settings["blueski"].get("handle", "")
rkey = message_id
if isinstance(message_id, str) and message_id.startswith("at://"):
parts = message_id.split("/")
@@ -169,6 +191,7 @@ class Session(base.baseSession):
def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs):
if not self.logged:
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
self._ensure_settings_namespace()
try:
api = self._ensure_client()
# Basic text-only post for now. Attachments and CW can be extended later.
@@ -273,8 +296,8 @@ class Session(base.baseSession):
# Accept full web URL and try to resolve via get_post_thread below
return identifier
# Accept bare rkey case by constructing a guess using own handle
handle = self.db.get("user_name") or self.settings["atprotosocial"].get("handle")
did = self.db.get("user_id") or self.settings["atprotosocial"].get("did")
handle = self.db.get("user_name") or self.settings["blueski"].get("handle")
did = self.db.get("user_id") or self.settings["blueski"].get("did")
if handle and did and len(identifier) in (13, 14, 15):
# rkey length is typically ~13 chars base32
return f"at://{did}/app.bsky.feed.post/{identifier}"

View File

@@ -5,17 +5,17 @@ import logging
from typing import TYPE_CHECKING, Any, Callable, Coroutine
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
fromapprove.sessions.blueski.session import Session as BlueskiSession
logger = logging.getLogger(__name__)
# ATProtoSocial (Bluesky) uses a Firehose model for streaming.
# Blueski (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:
class BlueskiStreaming:
def __init__(self, session: BlueskiSession, 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 {}
@@ -30,19 +30,19 @@ class ATProtoSocialStreaming:
# 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."""
"""Internal method to connect to the Blueski 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}")
logger.info(f"Blueski 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.")
# logger.error("Blueski 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
@@ -77,7 +77,7 @@ class ATProtoSocialStreaming:
# # # 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__}")
# logger.debug(f"Blueski Firehose message received: {message.__class__.__name__}")
# await self._firehose_client.start(on_message_handler)
@@ -91,13 +91,13 @@ class ATProtoSocialStreaming:
# 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.")
logger.info(f"Blueski 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.")
logger.info(f"Blueski 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)
logger.error(f"Blueski 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)
@@ -108,7 +108,7 @@ class ATProtoSocialStreaming:
finally:
# if self._firehose_client:
# await self._firehose_client.stop()
logger.info(f"ATProtoSocial streaming connection closed for user {self.session.user_id}.")
logger.info(f"Blueski streaming connection closed for user {self.session.user_id}.")
async def _handle_event(self, event_type: str, data: dict[str, Any]) -> None:
@@ -118,31 +118,31 @@ class ATProtoSocialStreaming:
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
# This is where Blueski-specific event data is mapped to Approve's internal event structure.
# For example, an Blueski '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)
logger.error(f"Error handling Blueski 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}")
logger.warning(f"Blueski 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}.")
logger.warning(f"Blueski 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}")
logger.info(f"Blueski 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}")
logger.info(f"Blueski 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()
@@ -153,10 +153,10 @@ class ATProtoSocialStreaming:
try:
await self._connection_task
except asyncio.CancelledError:
logger.info(f"ATProtoSocial streaming task successfully cancelled for {self.session.user_id}.")
logger.info(f"Blueski 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}.")
logger.info(f"Blueski streaming stopped for user {self.session.user_id}.")
def is_alive(self) -> bool:
"""Checks if the streaming connection is currently active."""
@@ -169,7 +169,7 @@ class ATProtoSocialStreaming:
def get_params(self) -> dict[str, Any]:
return self.params
# TODO: Add methods specific to ATProtoSocial streaming if necessary,
# TODO: Add methods specific to Blueski 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),

View File

@@ -6,30 +6,30 @@ from typing import TYPE_CHECKING, Any
fromapprove.translation import translate as _
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
fromapprove.sessions.blueski.session import Session as BlueskiSession
logger = logging.getLogger(__name__)
class ATProtoSocialTemplates:
def __init__(self, session: ATProtoSocialSession) -> None:
class BlueskiTemplates:
def __init__(self, session: BlueskiSession) -> 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.
Returns data required for rendering a specific template for Blueski.
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
# Add any other common data needed by Blueski templates
}
if context:
base_data.update(context)
# TODO: Implement specific data fetching for different ATProtoSocial templates
# TODO: Implement specific data fetching for different Blueski templates
# Example:
# if template_name == "profile_summary.html":
# # profile_info = await self.session.util.get_my_profile_info() # Assuming such a method exists
@@ -44,27 +44,27 @@ class ATProtoSocialTemplates:
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)
"""Returns the path to the message card template for Blueski."""
# This template would define how a single Blueski 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/blueski/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.
Returns a map of Blueski notification types to their respective template paths.
"""
# TODO: Define templates for different ATProtoSocial notification types
# TODO: Define templates for different Blueski 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.
# when processing Blueski 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'
# "mention": "sessions/blueski/notifications/mention.html",
# "reply": "sessions/blueski/notifications/reply.html",
# "follow": "sessions/blueski/notifications/follow.html",
# "like": "sessions/blueski/notifications/like.html", # Bluesky uses 'like'
# "repost": "sessions/blueski/notifications/repost.html", # Bluesky uses 'repost'
# # ... other notification types
# }
# Using generic templates as placeholders:
@@ -77,37 +77,37 @@ class ATProtoSocialTemplates:
}
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"
"""Returns the path to the settings template for Blueski, if any."""
# This template would be used to render Blueski-specific settings in the UI.
# return "sessions/blueski/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.
Returns a map of user action identifiers to their template paths for Blueski.
User actions are typically buttons or forms displayed on a user's profile.
"""
# TODO: Define templates for ATProtoSocial user actions
# TODO: Define templates for Blueski 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
# "view_profile_on_bsky": "sessions/blueski/actions/view_profile_button.html",
# "send_direct_message": "sessions/blueski/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.
Returns a map of user list action identifiers to their template paths for Blueski.
These actions might appear on lists of users (e.g., followers, following).
"""
# TODO: Define templates for ATProtoSocial user list actions
# TODO: Define templates for Blueski user list actions
# Example:
# return {
# "follow_all_visible": "sessions/atprotosocial/list_actions/follow_all_button.html",
# "follow_all_visible": "sessions/blueski/list_actions/follow_all_button.html",
# }
return None # Placeholder
# Add any other template-related helper methods specific to ATProtoSocial.
# Add any other template-related helper methods specific to Blueski.
# For example, methods to get templates for specific types of content (images, polls)
# if they need special rendering.
@@ -116,8 +116,8 @@ class ATProtoSocialTemplates:
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
# TODO: Define specific templates if Blueski messages have varied structures
# that require different display logic.
# if message_type == "quote_post":
# return "sessions/atprotosocial/cards/quote_post.html"
# return "sessions/blueski/cards/quote_post.html"
return None # Default to standard message card if not specified

View File

@@ -16,7 +16,7 @@ fromapprove.notifications import NotificationError
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
fromapprove.sessions.blueski.session import Session as BlueskiSession
# Define common type aliases if needed
ATUserProfile = models.AppBskyActorDefs.ProfileViewDetailed
ATPost = models.AppBskyFeedDefs.PostView
@@ -27,8 +27,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class ATProtoSocialUtils:
def __init__(self, session: ATProtoSocialSession) -> None:
class BlueskiUtils:
def __init__(self, session: BlueskiSession) -> None:
self.session = session
# _own_did and _own_handle are now set by Session.login upon successful authentication
# and directly on the util instance.
@@ -47,7 +47,7 @@ class ATProtoSocialUtils:
self._own_handle = self.session.client.me.handle
return self.session.client
logger.warning("ATProtoSocialUtils: Client not available or not authenticated.")
logger.warning("BlueskiUtils: 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()
@@ -59,7 +59,7 @@ class ATProtoSocialUtils:
"""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.")
logger.warning("Blueski client not available or user DID not known.")
return None
try:
# client.me should be populated after login by the SDK
@@ -78,7 +78,7 @@ class ATProtoSocialUtils:
return response
return None
except AtProtocolError as e:
logger.error(f"Error fetching own ATProtoSocial profile: {e}")
logger.error(f"Error fetching own Blueski profile: {e}")
return None
def get_own_did(self) -> str | None:
@@ -116,13 +116,13 @@ class ATProtoSocialUtils:
**kwargs: Any
) -> str | None: # Returns the ATURI of the new post, or None on failure
"""
Posts a status (skeet) to ATProtoSocial.
Posts a status (skeet) to Blueski.
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."))
logger.error("Blueski client not available for posting.")
raise NotificationError(_("Not connected to Blueski. Please check your connection settings or log in."))
if not self.get_own_did():
logger.error("Cannot post status: User DID not available.")
@@ -130,7 +130,7 @@ class ATProtoSocialUtils:
try:
# Prepare core post record
post_record_data = {'text': text, 'created_at': client.get_current_time_iso()} # SDK handles datetime format
post_record_data = {'text': text, 'createdAt': client.get_current_time_iso()} # SDK handles datetime format
if langs:
post_record_data['langs'] = langs
@@ -227,13 +227,13 @@ class ATProtoSocialUtils:
record=final_post_record,
)
)
logger.info(f"Successfully posted to ATProtoSocial. URI: {response.uri}")
logger.info(f"Successfully posted to Blueski. 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)
logger.error(f"Error posting status to Blueski: {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)
logger.error(f"Unexpected error posting status to Blueski: {e}", exc_info=True)
raise NotificationError(_("An unexpected error occurred while posting: {error}").format(error=str(e))) from e
@@ -241,7 +241,7 @@ class ATProtoSocialUtils:
"""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.")
logger.error("Blueski client not available for deleting post.")
return False
if not self.get_own_did():
logger.error("Cannot delete status: User DID not available.")
@@ -268,10 +268,10 @@ class ATProtoSocialUtils:
rkey=rkey,
)
)
logger.info(f"Successfully deleted post {post_uri} from ATProtoSocial.")
logger.info(f"Successfully deleted post {post_uri} from Blueski.")
return True
except AtProtocolError as e:
logger.error(f"Error deleting post {post_uri} from ATProtoSocial: {e.error} - {e.message}")
logger.error(f"Error deleting post {post_uri} from Blueski: {e.error} - {e.message}")
except Exception as e:
logger.error(f"Unexpected error deleting post {post_uri}: {e}", exc_info=True)
return False
@@ -279,12 +279,12 @@ class ATProtoSocialUtils:
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.
Uploads media (image) to Blueski.
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.")
logger.error("Blueski client not available for media upload.")
return None
try:
with open(file_path, "rb") as f:
@@ -303,7 +303,7 @@ class ATProtoSocialUtils:
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)
logger.error(f"Error uploading media {file_path} to Blueski: {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
@@ -341,7 +341,7 @@ class ATProtoSocialUtils:
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()),
record=models.AppBskyGraphFollow.Main(subject=user_did, createdAt=client.get_current_time_iso()),
)
)
logger.info(f"Successfully followed user {user_did}.")
@@ -596,7 +596,7 @@ class ATProtoSocialUtils:
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()),
record=models.AppBskyGraphBlock.Main(subject=user_did, createdAt=client.get_current_time_iso()),
)
)
logger.info(f"Successfully blocked user {user_did}. Block record URI: {response.uri}")
@@ -1098,7 +1098,7 @@ class ATProtoSocialUtils:
"""
client = await self._get_client()
if not client:
logger.error("ATProtoSocial client not available for reporting.")
logger.error("Blueski client not available for reporting.")
return False
try:

View File

@@ -17,6 +17,11 @@ def compose_post(post, db, settings, relative_times, show_screen_names, safe=Tru
text = _("Boosted from @{}: {}").format(post.reblog.account.acct, templates.process_text(post.reblog, safe=safe))
else:
text = templates.process_text(post, safe=safe)
# Handle quoted posts
if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None:
quoted_user = post.quote.quoted_status.account.acct
quoted_text = templates.process_text(post.quote.quoted_status, safe=safe)
text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text)
filtered = utils.evaluate_filters(post=post, current_context="home")
if filtered != None:
text = _("hidden by filter {}").format(filtered)

View File

@@ -248,6 +248,106 @@ class Session(base.baseSession):
pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, lang=language)
return
def edit_post(self, post_id, posts=[]):
""" Convenience function to edit a post. Only the first item in posts list is used as threads cannot be edited.
Note: According to Mastodon API, not all fields can be edited. Visibility, language, and reply context cannot be changed.
Args:
post_id: ID of the status to edit
posts: List with post data. Only first item is used.
Returns:
Updated status object or None on failure
"""
if len(posts) == 0:
log.warning("edit_post called with empty posts list")
return None
obj = posts[0]
text = obj.get("text")
if not text:
log.warning("edit_post called without text content")
return None
media_ids = []
media_attributes = []
try:
poll = None
# Handle poll attachments
if len(obj["attachments"]) == 1 and obj["attachments"][0]["type"] == "poll":
poll = self.api.make_poll(
options=obj["attachments"][0]["options"],
expires_in=obj["attachments"][0]["expires_in"],
multiple=obj["attachments"][0]["multiple"],
hide_totals=obj["attachments"][0]["hide_totals"]
)
log.debug("Editing post with poll (this will reset votes)")
# Handle media attachments
elif len(obj["attachments"]) > 0:
for i in obj["attachments"]:
# If attachment has an 'id', it's an existing media that we keep
if "id" in i:
media_ids.append(i["id"])
# If existing media has metadata to update, use generate_media_edit_attributes
if "description" in i or "focus" in i:
media_attr = self.api.generate_media_edit_attributes(
id=i["id"],
description=i.get("description"),
focus=i.get("focus")
)
media_attributes.append(media_attr)
# Otherwise it's a new file to upload
elif "file" in i:
description = i.get("description", "")
focus = i.get("focus", None)
media = self.api_call(
"media_post",
media_file=i["file"],
description=description,
focus=focus,
synchronous=True
)
media_ids.append(media.id)
log.debug("Uploaded new media with id: {}".format(media.id))
# Prepare parameters for status_update
update_params = {
"id": post_id,
"status": text,
"_sound": "tweet_send.ogg",
"sensitive": obj.get("sensitive", False),
"spoiler_text": obj.get("spoiler_text", None),
}
# Add optional parameters only if provided
if media_ids:
update_params["media_ids"] = media_ids
if media_attributes:
update_params["media_attributes"] = media_attributes
if poll:
update_params["poll"] = poll
# Call status_update API
log.debug("Editing post {} with params: {}".format(post_id, {k: v for k, v in update_params.items() if k not in ["_sound"]}))
item = self.api_call(call_name="status_update", **update_params)
if item:
log.info("Successfully edited post {}".format(post_id))
return item
except MastodonAPIError as e:
log.exception("Mastodon API error updating post {}: {}".format(post_id, str(e)))
output.speak(_("Error editing post: {}").format(str(e)))
pub.sendMessage("mastodon.error_edit", name=self.get_name(), post_id=post_id, error=str(e))
return None
except Exception as e:
log.exception("Unexpected error updating post {}: {}".format(post_id, str(e)))
output.speak(_("Error editing post: {}").format(str(e)))
return None
def get_name(self):
instance = self.settings["mastodon"]["instance"]
instance = instance.replace("https://", "")

View File

@@ -76,6 +76,13 @@ def render_post(post, template, settings, relative_times=False, offset_hours=0):
else:
text = process_text(post, safe=False)
safe_text = process_text(post)
# Handle quoted posts
if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None:
quoted_user = post.quote.quoted_status.account.acct
quoted_text = process_text(post.quote.quoted_status, safe=False)
quoted_safe_text = process_text(post.quote.quoted_status, safe=True)
text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text)
safe_text = safe_text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_safe_text)
filtered = utils.evaluate_filters(post=post, current_context="home")
if filtered != None:
text = _("hidden by filter {}").format(filtered)

View File

@@ -3,23 +3,47 @@ import demoji
from html.parser import HTMLParser
from datetime import datetime, timezone
url_re = re.compile('<a\s*href=[\'|"](.*?)[\'"].*?>')
url_re = re.compile(r'<a\s*href=[\'|"](.*?)[\'"].*?>')
class HTMLFilter(HTMLParser):
# Classes to ignore when parsing HTML
IGNORED_CLASSES = ["quote-inline"]
text = ""
first_paragraph = True
skip_depth = 0 # Track nesting depth of ignored elements
def handle_data(self, data):
self.text += data
# Only add data if we're not inside an ignored element
if self.skip_depth == 0:
self.text += data
def handle_starttag(self, tag, attrs):
if tag == "br":
self.text = self.text+"\n"
elif tag == "p":
if self.first_paragraph:
self.first_paragraph = False
else:
self.text = self.text+"\n\n"
# Check if this tag has a class that should be ignored
attrs_dict = dict(attrs)
tag_class = attrs_dict.get("class", "")
# Check if any ignored class is present in this tag
should_skip = any(ignored_class in tag_class for ignored_class in self.IGNORED_CLASSES)
if should_skip:
self.skip_depth += 1
elif self.skip_depth == 0: # Only process tags if we're not skipping
if tag == "br":
self.text = self.text+"\n"
elif tag == "p":
if self.first_paragraph:
self.first_paragraph = False
else:
self.text = self.text+"\n\n"
else:
# We're inside a skipped element, increment depth for nested tags
self.skip_depth += 1
def handle_endtag(self, tag):
# Decrement skip depth when closing any tag while skipping
if self.skip_depth > 0:
self.skip_depth -= 1
def html_filter(data):
f = HTMLFilter()