mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-06 17:37:33 +01:00
Commit
This commit is contained in:
3
src/sessions/blueski/__init__.py
Normal file
3
src/sessions/blueski/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .session import Session
|
||||
|
||||
__all__ = ["Session"]
|
||||
245
src/sessions/blueski/compose.py
Normal file
245
src/sessions/blueski/compose.py
Normal 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()
|
||||
417
src/sessions/blueski/session.py
Normal file
417
src/sessions/blueski/session.py
Normal 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
|
||||
209
src/sessions/blueski/streaming.py
Normal file
209
src/sessions/blueski/streaming.py
Normal 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__}")
|
||||
123
src/sessions/blueski/templates.py
Normal file
123
src/sessions/blueski/templates.py
Normal 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
|
||||
1168
src/sessions/blueski/utils.py
Normal file
1168
src/sessions/blueski/utils.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user