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

@@ -0,0 +1,3 @@
from .session import Session
__all__ = ["Session"]

View File

@@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from datetime import datetime
from approve.translation import translate as _
from approve.util import parse_iso_datetime # For parsing ISO timestamps
if TYPE_CHECKING:
from approve.sessions.blueski.session import Session as BlueskiSession
from atproto.xrpc_client import models # For type hinting ATProto models
logger = logging.getLogger(__name__)
# For SUPPORTED_LANG_CHOICES in composeDialog.py
SUPPORTED_LANG_CHOICES_COMPOSE = {
_("English"): "en", _("Spanish"): "es", _("French"): "fr", _("German"): "de",
_("Japanese"): "ja", _("Portuguese"): "pt", _("Russian"): "ru", _("Chinese"): "zh",
}
class BlueskiCompose:
MAX_CHARS = 300
MAX_MEDIA_ATTACHMENTS = 4
MAX_LANGUAGES = 3
MAX_IMAGE_SIZE_BYTES = 1_000_000
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 Blueski."""
return {
"max_chars": self.MAX_CHARS,
"max_media_attachments": self.MAX_MEDIA_ATTACHMENTS,
"supports_content_warning": True,
"supports_scheduled_posts": False,
"supported_media_types": self.supported_media_types,
"max_media_size_bytes": self.max_image_size_bytes,
"supports_alternative_text": True,
"sensitive_reasons_options": self.session.get_sensitive_reason_options(),
"supports_language_selection": True,
"max_languages": self.MAX_LANGUAGES,
"supports_quoting": True,
"supports_polls": False,
}
async def get_quote_text(self, message_id: str, url: str) -> str | None:
return ""
async def get_reply_text(self, message_id: str, author_handle: str) -> str | None:
if not author_handle.startswith("@"):
return f"@{author_handle} "
return f"{author_handle} "
def get_text_formatting_rules(self) -> dict[str, Any]:
return {
"markdown_enabled": False,
"custom_emojis_enabled": False,
"max_length": self.MAX_CHARS,
"line_break_char": "\n",
"link_format": "Full URL (e.g., https://example.com)",
"mention_format": "@handle.bsky.social",
"tag_format": "#tag (becomes a facet link)",
}
def is_media_type_supported(self, mime_type: str) -> bool:
return mime_type.lower() in self.supported_media_types
def get_max_schedule_date(self) -> str | None:
return None
def get_poll_configuration(self) -> dict[str, Any] | None:
return None
def compose_post_for_display(self, post_data: dict[str, Any], session_settings: dict[str, Any] | None = None) -> str:
"""
Composes a string representation of a Bluesky post for display in UI timelines.
"""
if not post_data or not isinstance(post_data, dict):
return _("Invalid post data.")
author_info = post_data.get("author", {})
record = post_data.get("record", {})
embed_data = post_data.get("embed")
viewer_state = post_data.get("viewer", {})
display_name = author_info.get("displayName", "") or author_info.get("handle", _("Unknown User"))
handle = author_info.get("handle", _("unknown.handle"))
post_text = getattr(record, 'text', '') if not isinstance(record, dict) else record.get('text', '')
created_at_str = getattr(record, 'createdAt', '') if not isinstance(record, dict) else record.get('createdAt', '')
timestamp_str = ""
if created_at_str:
try:
dt_obj = parse_iso_datetime(created_at_str)
timestamp_str = dt_obj.strftime("%I:%M %p - %b %d, %Y") if dt_obj else created_at_str
except Exception as e:
logger.debug(f"Could not parse timestamp {created_at_str}: {e}")
timestamp_str = created_at_str
header = f"{display_name} (@{handle}) - {timestamp_str}"
labels = post_data.get("labels", [])
spoiler_text = None
is_sensitive_post = False
if labels:
for label_obj in labels:
label_val = getattr(label_obj, 'val', '') if not isinstance(label_obj, dict) else label_obj.get('val', '')
if label_val == "!warn":
is_sensitive_post = True
elif label_val in ["porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
is_sensitive_post = True
if not spoiler_text: spoiler_text = _("Sensitive Content: {label}").format(label=label_val)
elif label_val.startswith("warn:") and len(label_val) > 5:
spoiler_text = label_val.split("warn:", 1)[-1].strip()
is_sensitive_post = True
post_text_display = post_text
if spoiler_text:
post_text_display = f"CW: {spoiler_text}\n\n{post_text}"
elif is_sensitive_post and not spoiler_text:
post_text_display = f"CW: {_('Sensitive Content')}\n\n{post_text}"
embed_display = ""
if embed_data:
embed_type = getattr(embed_data, '$type', '')
if not embed_type and isinstance(embed_data, dict): embed_type = embed_data.get('$type', '')
if embed_type in ['app.bsky.embed.images#view', 'app.bsky.embed.images']:
images = getattr(embed_data, 'images', []) if hasattr(embed_data, 'images') else embed_data.get('images', [])
if images:
img_count = len(images)
alt_texts_present = any(getattr(img, 'alt', '') for img in images if hasattr(img, 'alt')) or \
any(img_dict.get('alt', '') for img_dict in images if isinstance(img_dict, dict))
embed_display += f"\n[{img_count} Image"
if img_count > 1: embed_display += "s"
if alt_texts_present: embed_display += _(" (Alt text available)")
embed_display += "]"
elif embed_type in ['app.bsky.embed.record#view', 'app.bsky.embed.record']:
record_embed_data = getattr(embed_data, 'record', None) if hasattr(embed_data, 'record') else embed_data.get('record', None)
record_embed_type = getattr(record_embed_data, '$type', '')
if not record_embed_type and isinstance(record_embed_data, dict): record_embed_type = record_embed_data.get('$type', '')
if record_embed_type == 'app.bsky.embed.record#viewNotFound':
embed_display += f"\n[{_('Quoted post not found or unavailable')}]"
elif record_embed_type == 'app.bsky.embed.record#viewBlocked':
embed_display += f"\n[{_('Content from the quoted account is blocked')}]"
elif record_embed_data and (isinstance(record_embed_data, dict) or hasattr(record_embed_data, 'author')):
quote_author_info = getattr(record_embed_data, 'author', record_embed_data.get('author'))
quote_value = getattr(record_embed_data, 'value', record_embed_data.get('value'))
if quote_author_info and quote_value:
quote_author_handle = getattr(quote_author_info, 'handle', 'unknown')
quote_text_content = getattr(quote_value, 'text', '') if not isinstance(quote_value, dict) else quote_value.get('text', '')
quote_text_snippet = (quote_text_content[:75] + "...") if quote_text_content else _("post content")
embed_display += f"\n[ {_('Quote by')} @{quote_author_handle}: \"{quote_text_snippet}\" ]"
else:
embed_display += f"\n[{_('Quoted Post')}]"
elif embed_type in ['app.bsky.embed.external#view', 'app.bsky.embed.external']:
external_data = getattr(embed_data, 'external', None) if hasattr(embed_data, 'external') else embed_data.get('external', None)
if external_data:
ext_uri = getattr(external_data, 'uri', _('External Link'))
ext_title = getattr(external_data, 'title', '') or ext_uri
embed_display += f"\n[{_('Link')}: {ext_title}]"
reply_context_str = ""
actual_record = post_data.get("record", {})
reply_ref = getattr(actual_record, 'reply', None) if not isinstance(actual_record, dict) else actual_record.get('reply')
if reply_ref:
reply_context_str = f"[{_('In reply to a post')}] "
counts_str_parts = []
reply_count = post_data.get("replyCount", 0)
repost_count = post_data.get("repostCount", 0)
like_count = post_data.get("likeCount", 0)
if reply_count > 0: counts_str_parts.append(f"{_('Replies')}: {reply_count}")
if repost_count > 0: counts_str_parts.append(f"{_('Reposts')}: {repost_count}")
if like_count > 0: counts_str_parts.append(f"{_('Likes')}: {like_count}")
viewer_liked_uri = viewer_state.get("like") if isinstance(viewer_state, dict) else getattr(viewer_state, 'like', None)
viewer_reposted_uri = viewer_state.get("repost") if isinstance(viewer_state, dict) else getattr(viewer_state, 'repost', None)
if viewer_liked_uri: counts_str_parts.append(f"({_('Liked by you')})")
if viewer_reposted_uri: counts_str_parts.append(f"({_('Reposted by you')})")
counts_line = ""
if counts_str_parts:
counts_line = "\n" + " | ".join(counts_str_parts)
full_display = f"{header}\n{reply_context_str}{post_text_display}{embed_display}{counts_line}"
return full_display.strip()
def compose_notification_for_display(self, notif_data: dict[str, Any]) -> str:
"""
Composes a string representation of a Bluesky notification for display.
Args:
notif_data: A dictionary representing the notification,
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'.
The 'title' usually already contains the core action.
Returns:
A formatted string for display.
"""
if not notif_data or not isinstance(notif_data, dict):
return _("Invalid notification data.")
title = notif_data.get('title', _("Notification"))
body = notif_data.get('body', '')
author_name = notif_data.get('author_name') # Author of the action (e.g. who liked)
timestamp_dt = notif_data.get('timestamp_dt') # datetime object
timestamp_str = ""
if timestamp_dt and isinstance(timestamp_dt, datetime):
try:
timestamp_str = timestamp_dt.strftime("%I:%M %p - %b %d, %Y")
except Exception as e:
logger.debug(f"Could not format notification timestamp {timestamp_dt}: {e}")
timestamp_str = str(timestamp_dt)
display_parts = []
if timestamp_str:
display_parts.append(f"[{timestamp_str}]")
# Title already contains good info like "UserX liked your post"
display_parts.append(title)
if body: # Body might be text of a reply/mention/quote
# Truncate body if too long for a list display
body_snippet = (body[:100] + "...") if len(body) > 103 else body
display_parts.append(f"\"{body_snippet}\"")
return " ".join(display_parts).strip()

View File

@@ -0,0 +1,417 @@
from __future__ import annotations
import logging
from typing import Any
import wx
from sessions import base
from sessions import session_exceptions as Exceptions
import output
import application
log = logging.getLogger("sessions.blueskiSession")
# Optional import of atproto. Code handles absence gracefully.
try:
from atproto import Client as AtpClient # type: ignore
except Exception: # ImportError or missing deps
AtpClient = None # type: ignore
class Session(base.baseSession):
"""Minimal Bluesky (atproto) session for TWBlue.
Provides basic authorisation, login, and posting support to unblock
the integration while keeping compatibility with TWBlue's session API.
"""
name = "Bluesky"
KIND = "blueski"
def __init__(self, *args, **kwargs):
super(Session, self).__init__(*args, **kwargs)
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("blueski", {}).get("handle"))
or (getattr(getattr(self, "api", None), "me", None) and self.api.me.handle)
)
if handle:
return handle
except Exception:
pass
return self.name
def _ensure_client(self):
if AtpClient is None:
raise RuntimeError(
"The 'atproto' package is not installed. Install it to use Bluesky."
)
if self.api is None:
self.api = AtpClient()
return self.api
def login(self, verify_credentials=True):
self._ensure_settings_namespace()
if self.settings.get("blueski") is None:
raise Exceptions.RequireCredentialsSessionError
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
try:
# Ensure db exists (can be set to None on logout paths)
if not isinstance(self.db, dict):
self.db = {}
# Ensure general settings have a default for boost confirmations like Mastodon
try:
if "general" in self.settings and self.settings["general"].get("boost_mode") is None:
self.settings["general"]["boost_mode"] = "ask"
except Exception:
pass
api = self._ensure_client()
# Prefer resuming session if we have one
if session_string:
try:
api.import_session_string(session_string)
except Exception:
# Fall back to login below
pass
if not getattr(api, "me", None):
# Fresh login
api.login(handle, app_password)
# Cache basics
if getattr(api, "me", None) is None:
raise RuntimeError("Bluesky SDK client has no 'me' after login")
self.db["user_name"] = api.me.handle
self.db["user_id"] = api.me.did
# Persist DID in settings for session manager display
self.settings["blueski"]["did"] = api.me.did
# Export session for future reuse
try:
self.settings["blueski"]["session_string"] = api.export_session_string()
except Exception:
pass
self.settings.write()
self.logged = True
log.debug("Logged in to Bluesky as %s", api.me.handle)
except Exception:
log.exception("Bluesky login failed")
self.logged = False
def authorise(self):
self._ensure_settings_namespace()
if self.logged:
raise Exceptions.AlreadyAuthorisedError("Already authorised.")
# Ask for handle
dlg = wx.TextEntryDialog(
None,
_("Enter your Bluesky handle (e.g., username.bsky.social)"),
_("Bluesky Login"),
)
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return
handle = dlg.GetValue().strip()
dlg.Destroy()
# Ask for app password
pwd = wx.PasswordEntryDialog(
None,
_("Enter your Bluesky App Password (from Settings > App passwords)"),
_("Bluesky Login"),
)
if pwd.ShowModal() != wx.ID_OK:
pwd.Destroy()
return
app_password = pwd.GetValue().strip()
pwd.Destroy()
# Create session folder and config, then attempt login
self.create_session_folder()
self.get_configuration()
self.settings["blueski"]["handle"] = handle
self.settings["blueski"]["app_password"] = app_password
self.settings.write()
try:
self.login()
except Exceptions.RequireCredentialsSessionError:
return
except Exception:
log.exception("Authorisation failed")
wx.MessageBox(
_("We could not log in to Bluesky. Please verify your handle and app password."),
_("Login error"), wx.ICON_ERROR
)
return
return True
def get_message_url(self, message_id, context=None):
# message_id may be full at:// URI or rkey
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("/")
rkey = parts[-1]
return f"https://bsky.app/profile/{handle}/post/{rkey}"
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.
# Prefer convenience if available
uri = None
text = message or ""
# Naive CW handling: prepend CW label to text if provided
if cw_text:
text = f"CW: {cw_text}\n\n{text}" if text else f"CW: {cw_text}"
# Build base record
record: dict[str, Any] = {
"$type": "app.bsky.feed.post",
"text": text,
}
# createdAt
try:
record["createdAt"] = api.get_current_time_iso()
except Exception:
pass
# languages
langs = kwargs.get("langs") or kwargs.get("languages")
if isinstance(langs, (list, tuple)) and langs:
record["langs"] = list(langs)
# Helper to build a StrongRef (uri+cid) for a given post URI
def _get_strong_ref(uri: str):
try:
# Try typed models first
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
posts = getattr(posts_res, "posts", None) or []
except Exception:
try:
posts_res = api.app.bsky.feed.get_posts(uris=[uri])
posts = getattr(posts_res, "posts", None) or []
except Exception:
posts = []
if posts:
post0 = posts[0]
post_uri = getattr(post0, "uri", uri)
post_cid = getattr(post0, "cid", None) or (post0.get("cid") if isinstance(post0, dict) else None)
if post_cid:
return {"uri": post_uri, "cid": post_cid}
return None
# Upload images if provided
embed_images = []
if files:
for f in files:
path = f
alt = ""
if isinstance(f, dict):
path = f.get("path") or f.get("file")
alt = f.get("alt") or f.get("alt_text") or ""
if not path:
continue
try:
with open(path, "rb") as fp:
data = fp.read()
# Try typed upload
try:
up = api.com.atproto.repo.upload_blob(data)
blob_ref = getattr(up, "blob", None) or getattr(up, "data", None) or up
except Exception:
# Some SDK variants expose upload via api.upload_blob
up = api.upload_blob(data)
blob_ref = getattr(up, "blob", None) or getattr(up, "data", None) or up
if blob_ref:
embed_images.append({
"image": blob_ref,
"alt": alt or "",
})
except Exception:
log.exception("Error uploading media for Bluesky post")
continue
# Quote post (takes precedence over images)
quote_uri = kwargs.get("quote_uri") or kwargs.get("quote")
if quote_uri:
strong = _get_strong_ref(quote_uri)
if strong:
record["embed"] = {
"$type": "app.bsky.embed.record",
"record": strong,
}
embed_images = [] # Ignore images when quoting
if embed_images and not record.get("embed"):
record["embed"] = {
"$type": "app.bsky.embed.images",
"images": embed_images,
}
# Helper: normalize various incoming identifiers to an at:// URI
def _normalize_to_uri(identifier: str) -> str | None:
try:
if not isinstance(identifier, str):
return None
if identifier.startswith("at://"):
return identifier
if "bsky.app/profile/" in identifier and "/post/" in identifier:
# 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["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}"
except Exception:
pass
return None
# Reply-to handling (sets correct root/parent strong refs)
if reply_to:
# Resolve to proper at:// uri when possible
reply_uri = _normalize_to_uri(reply_to) or reply_to
parent_ref = _get_strong_ref(reply_uri)
root_ref = parent_ref
# Try to fetch thread to find actual root for deep replies
try:
# atproto SDK usually exposes get_post_thread
thread_res = None
try:
thread_res = api.app.bsky.feed.get_post_thread({"uri": reply_uri})
except Exception:
# Try typed model call variant if available
from atproto import models as at_models # type: ignore
params = at_models.AppBskyFeedGetPostThread.Params(uri=reply_uri)
thread_res = api.app.bsky.feed.get_post_thread(params)
thread = getattr(thread_res, "thread", None)
# Walk to the root if present
node = thread
while node and getattr(node, "parent", None):
node = getattr(node, "parent")
root_uri = getattr(node, "post", None)
if root_uri:
root_uri = getattr(root_uri, "uri", None)
if root_uri and isinstance(root_uri, str):
maybe_root = _get_strong_ref(root_uri)
if maybe_root:
root_ref = maybe_root
except Exception:
# If anything fails, keep parent as root for a simple two-level reply
pass
if parent_ref:
record["reply"] = {
"root": root_ref or parent_ref,
"parent": parent_ref,
}
# Fallback to convenience if available
try:
if hasattr(api, "send_post") and not embed_images and not langs and not cw_text:
res = api.send_post(text)
uri = getattr(res, "uri", None) or getattr(res, "cid", None)
else:
out = api.com.atproto.repo.create_record({
"repo": api.me.did,
"collection": "app.bsky.feed.post",
"record": record,
})
uri = getattr(out, "uri", None)
except Exception:
log.exception("Error creating Bluesky post record")
uri = None
if not uri:
raise RuntimeError("Post did not return a URI")
# Store last post id if useful
self.db.setdefault("sent", [])
self.db["sent"].append(dict(id=uri, text=message))
self.save_persistent_data()
return uri
except Exception:
log.exception("Error sending Bluesky post")
output.speak(_("An error occurred while posting to Bluesky."), True)
return None
def repost(self, post_uri: str, post_cid: str | None = None) -> str | None:
"""Create a simple repost of a given post. Returns URI of the repost record or None."""
if not self.logged:
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
try:
api = self._ensure_client()
def _get_strong_ref(uri: str):
try:
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
posts = getattr(posts_res, "posts", None) or []
except Exception:
try:
posts_res = api.app.bsky.feed.get_posts(uris=[uri])
posts = getattr(posts_res, "posts", None) or []
except Exception:
posts = []
if posts:
post0 = posts[0]
s_uri = getattr(post0, "uri", uri)
s_cid = getattr(post0, "cid", None) or (post0.get("cid") if isinstance(post0, dict) else None)
if s_cid:
return {"uri": s_uri, "cid": s_cid}
return None
if not post_cid:
strong = _get_strong_ref(post_uri)
if not strong:
return None
post_uri = strong["uri"]
post_cid = strong["cid"]
out = api.com.atproto.repo.create_record({
"repo": api.me.did,
"collection": "app.bsky.feed.repost",
"record": {
"$type": "app.bsky.feed.repost",
"subject": {"uri": post_uri, "cid": post_cid},
"createdAt": getattr(api, "get_current_time_iso", lambda: None)() or None,
},
})
return getattr(out, "uri", None)
except Exception:
log.exception("Error creating Bluesky repost record")
return None

View File

@@ -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.blueski.session import Session as BlueskiSession
logger = logging.getLogger(__name__)
# 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 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 {}
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 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"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("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
# 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"Blueski 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"Blueski streaming: Placeholder loop for {self.session.user_id} stopped.")
except asyncio.CancelledError:
logger.info(f"Blueski streaming task for user {self.session.user_id} was cancelled.")
except Exception as e:
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)
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"Blueski 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 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 Blueski streaming event type {event_type}: {e}", exc_info=True)
else:
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"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"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"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()
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"Blueski streaming task successfully cancelled for {self.session.user_id}.")
self._connection_task = None
self._handler = None
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."""
# 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 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),
# 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__}")

View File

@@ -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.blueski.session import Session as BlueskiSession
logger = logging.getLogger(__name__)
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 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 Blueski templates
}
if context:
base_data.update(context)
# 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
# # 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 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/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 Blueski notification types to their respective template paths.
"""
# 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 Blueski events.
# Example:
# return {
# "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:
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 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 Blueski.
User actions are typically buttons or forms displayed on a user's profile.
"""
# TODO: Define templates for Blueski user actions
# Example:
# return {
# "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 Blueski.
These actions might appear on lists of users (e.g., followers, following).
"""
# TODO: Define templates for Blueski user list actions
# Example:
# return {
# "follow_all_visible": "sessions/blueski/list_actions/follow_all_button.html",
# }
return None # Placeholder
# 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.
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 Blueski messages have varied structures
# that require different display logic.
# if message_type == "quote_post":
# return "sessions/blueski/cards/quote_post.html"
return None # Default to standard message card if not specified

File diff suppressed because it is too large Load Diff