mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-06 09:27:33 +01:00
Commit
This commit is contained in:
@@ -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'.
|
||||
@@ -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}"
|
||||
@@ -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),
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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://", "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user