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,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