mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-08-18 02:48:11 +02:00
Merge branch 'next-gen' of github.com:mcv-software/twblue into next-gen
This commit is contained in:
@@ -59,7 +59,9 @@ class baseSession(object):
|
||||
if not os.path.exists(path):
|
||||
log.debug("Creating %s path" % (os.path.join(paths.config_path(), path),))
|
||||
os.mkdir(path)
|
||||
config.app["sessions"]["sessions"].append(id)
|
||||
if self.session_id not in config.app["sessions"]["sessions"]:
|
||||
config.app["sessions"]["sessions"].append(self.session_id)
|
||||
config.app.write()
|
||||
|
||||
def get_configuration(self):
|
||||
""" Get settings for a session."""
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .session import Session
|
||||
|
||||
__all__ = ["Session"]
|
||||
@@ -0,0 +1,478 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Compose functions for Bluesky content display in TWBlue.
|
||||
|
||||
These functions format API data into user-readable strings for display in
|
||||
list controls. They follow the TWBlue compose function pattern:
|
||||
compose_function(item, db, relative_times, show_screen_names, session)
|
||||
Returns a list of strings for display columns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import arrow
|
||||
import languageHandler
|
||||
from sessions.blueski import utils
|
||||
|
||||
log = logging.getLogger("sessions.blueski.compose")
|
||||
|
||||
|
||||
def compose_post(post, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky post into a list of strings for display.
|
||||
Format matches Mastodon: [user+", ", text, date+", ", source]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
"""Helper to get attribute from dict or object."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Resolve Post View or Feed View structure
|
||||
actual_post = g(post, "post", post)
|
||||
record = g(actual_post, "record", {})
|
||||
author = g(actual_post, "author", {})
|
||||
|
||||
# Original author info
|
||||
original_handle = g(author, "handle", "")
|
||||
original_display_name = g(author, "displayName") or g(author, "display_name") or original_handle or "Unknown"
|
||||
|
||||
# Check if this is a repost
|
||||
reason = g(post, "reason", None)
|
||||
is_repost = False
|
||||
reposter_handle = ""
|
||||
reposter_display_name = ""
|
||||
|
||||
if reason:
|
||||
rtype = g(reason, "$type") or g(reason, "py_type")
|
||||
if rtype and "reasonRepost" in rtype:
|
||||
is_repost = True
|
||||
by = g(reason, "by", {})
|
||||
reposter_handle = g(by, "handle", "")
|
||||
reposter_display_name = g(by, "displayName") or g(by, "display_name") or reposter_handle
|
||||
|
||||
# User column: show reposter if repost, otherwise original author (like Mastodon)
|
||||
if is_repost and reposter_handle:
|
||||
if show_screen_names:
|
||||
user_str = f"@{reposter_handle}"
|
||||
else:
|
||||
if reposter_display_name and reposter_display_name != reposter_handle:
|
||||
user_str = f"{reposter_display_name} (@{reposter_handle})"
|
||||
else:
|
||||
user_str = f"@{reposter_handle}"
|
||||
else:
|
||||
if show_screen_names:
|
||||
user_str = f"@{original_handle}"
|
||||
else:
|
||||
if original_display_name and original_display_name != original_handle:
|
||||
user_str = f"{original_display_name} (@{original_handle})"
|
||||
else:
|
||||
user_str = f"@{original_handle}"
|
||||
|
||||
# Text
|
||||
original_text = g(record, "text", "")
|
||||
|
||||
# Build text - if repost, format like Mastodon: "Reposted from @original: text"
|
||||
if is_repost:
|
||||
text = _("Reposted from @{}: {}").format(original_handle, original_text)
|
||||
else:
|
||||
text = original_text
|
||||
|
||||
reply_to_handle = utils.extract_reply_to_handle(post)
|
||||
if reply_to_handle:
|
||||
if text:
|
||||
text = _("Replying to @{}: {}").format(reply_to_handle, text)
|
||||
else:
|
||||
text = _("Replying to @{}").format(reply_to_handle)
|
||||
|
||||
# Check facets for links not visible in text and append them
|
||||
facets = g(record, "facets", []) or []
|
||||
hidden_urls = []
|
||||
for facet in facets:
|
||||
features = g(facet, "features", []) or []
|
||||
for feature in features:
|
||||
ftype = g(feature, "$type") or g(feature, "py_type") or ""
|
||||
if "link" in ftype.lower():
|
||||
uri = g(feature, "uri", "")
|
||||
if uri and uri not in text and uri not in hidden_urls:
|
||||
# Check if a truncated version is in text (e.g., "example.com/path...")
|
||||
# by checking if the domain is present
|
||||
domain_match = False
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(uri)
|
||||
domain = parsed.netloc.replace("www.", "")
|
||||
if domain and domain in text:
|
||||
domain_match = True
|
||||
except:
|
||||
pass
|
||||
if not domain_match:
|
||||
hidden_urls.append(uri)
|
||||
|
||||
if hidden_urls:
|
||||
text += " " + " ".join(f"[{url}]" for url in hidden_urls)
|
||||
|
||||
# Labels / Content Warning
|
||||
labels = g(actual_post, "labels", [])
|
||||
cw_text = ""
|
||||
for label in labels:
|
||||
val = g(label, "val", "")
|
||||
if val in ["!warn", "porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
|
||||
if not cw_text:
|
||||
cw_text = _("Sensitive Content")
|
||||
elif val.startswith("warn:"):
|
||||
cw_text = val.split("warn:", 1)[-1].strip()
|
||||
|
||||
if cw_text:
|
||||
text = f"CW: {cw_text}\n\n{text}"
|
||||
|
||||
# Embeds (Images, Links)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if embed:
|
||||
etype = g(embed, "$type") or g(embed, "py_type")
|
||||
|
||||
# Images
|
||||
if etype and ("images" in etype):
|
||||
images = g(embed, "images", [])
|
||||
if images:
|
||||
text += f" [{len(images)} {_('images')}]"
|
||||
|
||||
if etype and ("recordWithMedia" in etype):
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type")
|
||||
if mtype and "images" in mtype:
|
||||
images = g(media, "images", [])
|
||||
if images:
|
||||
text += f" [{len(images)} {_('images')}]"
|
||||
elif mtype and "external" in mtype:
|
||||
ext = g(media, "external", {})
|
||||
title = g(ext, "title", "")
|
||||
if title:
|
||||
text += f" [{_('Link')}: {title}]"
|
||||
elif etype and ("external" in etype):
|
||||
ext = g(embed, "external", {})
|
||||
title = g(ext, "title", "")
|
||||
if title:
|
||||
text += f" [{_('Link')}: {title}]"
|
||||
|
||||
quote_info = utils.extract_quoted_post_info(post)
|
||||
if quote_info:
|
||||
if quote_info["kind"] == "not_found":
|
||||
text += f" [{_('Quoted post not found')}]"
|
||||
elif quote_info["kind"] == "blocked":
|
||||
text += f" [{_('Quoted post blocked')}]"
|
||||
elif quote_info["kind"] == "feed":
|
||||
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
|
||||
else:
|
||||
q_handle = quote_info.get("handle", "unknown")
|
||||
q_text = quote_info.get("text", "")
|
||||
if q_text:
|
||||
text += " " + _("Quoting @{}: {}").format(q_handle, q_text)
|
||||
else:
|
||||
text += " " + _("Quoting @{}").format(q_handle)
|
||||
|
||||
# Add full URLs from quoted content when they are not visible in text.
|
||||
for uri in quote_info.get("urls", []):
|
||||
if uri and uri not in text:
|
||||
text += f" [{uri}]"
|
||||
|
||||
# Date
|
||||
indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "")
|
||||
ts_str = ""
|
||||
if indexed_at:
|
||||
try:
|
||||
ts = arrow.get(indexed_at)
|
||||
if relative_times:
|
||||
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
ts_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
ts_str = str(indexed_at)[:16].replace("T", " ")
|
||||
|
||||
# Source / Client
|
||||
source = "Bluesky"
|
||||
|
||||
# Format like Mastodon: add ", " after user and date
|
||||
return [user_str + ", ", text, ts_str + ", ", source]
|
||||
|
||||
|
||||
def compose_notification(notification, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky notification into a list of strings for display.
|
||||
Format matches Mastodon: [user, text, date]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Author of the notification (who performed the action)
|
||||
author = g(notification, "author", {})
|
||||
handle = g(author, "handle", "unknown")
|
||||
display_name = g(author, "displayName") or g(author, "display_name") or handle
|
||||
|
||||
if show_screen_names:
|
||||
user_str = f"@{handle}"
|
||||
else:
|
||||
if display_name and display_name != handle:
|
||||
user_str = f"{display_name} (@{handle})"
|
||||
else:
|
||||
user_str = f"@{handle}"
|
||||
|
||||
# Notification reason/type
|
||||
reason = g(notification, "reason", "unknown")
|
||||
|
||||
# Get post text - try multiple locations depending on notification type
|
||||
record = g(notification, "record", {})
|
||||
post_text = ""
|
||||
|
||||
# For mentions, replies, quotes: text is in the record itself
|
||||
post_text = g(record, "text", "")
|
||||
|
||||
# For likes and reposts: try to get the subject post text
|
||||
if not post_text and reason in ("like", "repost"):
|
||||
# First check for hydrated subject text (added by NotificationBuffer)
|
||||
post_text = g(notification, "_subject_text", "")
|
||||
|
||||
# Check if there's a reasonSubject with embedded post data
|
||||
if not post_text:
|
||||
reason_subject = g(notification, "reasonSubject") or g(notification, "reason_subject")
|
||||
if reason_subject:
|
||||
# Sometimes the subject post is embedded
|
||||
subject_record = g(reason_subject, "record", {})
|
||||
post_text = g(subject_record, "text", "")
|
||||
|
||||
# Check if there's subject post data in other locations
|
||||
if not post_text:
|
||||
subject = g(record, "subject", {})
|
||||
subject_text = g(subject, "text", "")
|
||||
if subject_text:
|
||||
post_text = subject_text
|
||||
|
||||
# Format: action text without username (username is already in column 0)
|
||||
if reason == "like":
|
||||
if post_text:
|
||||
text = _("has added to favorites: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has added to favorites")
|
||||
elif reason == "repost":
|
||||
if post_text:
|
||||
text = _("has reposted: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has reposted")
|
||||
elif reason == "follow":
|
||||
text = _("has followed you.")
|
||||
elif reason == "mention":
|
||||
if post_text:
|
||||
text = _("has mentioned you: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has mentioned you")
|
||||
elif reason == "reply":
|
||||
if post_text:
|
||||
text = _("has replied: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has replied")
|
||||
elif reason == "quote":
|
||||
if post_text:
|
||||
text = _("has quoted your post: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has quoted your post")
|
||||
elif reason == "starterpack-joined":
|
||||
text = _("has joined your starter pack.")
|
||||
else:
|
||||
text = reason
|
||||
|
||||
# Date
|
||||
indexed_at = g(notification, "indexedAt", "") or g(notification, "indexed_at", "")
|
||||
ts_str = ""
|
||||
if indexed_at:
|
||||
try:
|
||||
ts = arrow.get(indexed_at)
|
||||
if relative_times:
|
||||
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
ts_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
ts_str = str(indexed_at)[:16].replace("T", " ")
|
||||
|
||||
return [user_str, text, ts_str]
|
||||
|
||||
|
||||
def compose_user(user, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky user profile for list display.
|
||||
Format matches Mastodon: single string with all info.
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
def resolve_profile(obj):
|
||||
if g(obj, "handle") or g(obj, "did"):
|
||||
return obj
|
||||
for key in ("subject", "actor", "profile", "user"):
|
||||
nested = g(obj, key)
|
||||
if nested and (g(nested, "handle") or g(nested, "did")):
|
||||
return nested
|
||||
return obj
|
||||
|
||||
profile = resolve_profile(user)
|
||||
handle = g(profile, "handle", "unknown")
|
||||
display_name = g(profile, "displayName") or g(profile, "display_name") or handle
|
||||
followers = g(profile, "followersCount") or g(profile, "followers_count") or 0
|
||||
following = g(profile, "followsCount") or g(profile, "follows_count") or 0
|
||||
posts = g(profile, "postsCount") or g(profile, "posts_count") or 0
|
||||
created_at = g(profile, "createdAt") or g(profile, "created_at")
|
||||
|
||||
ts = ""
|
||||
if created_at:
|
||||
try:
|
||||
original_date = arrow.get(created_at)
|
||||
if relative_times:
|
||||
ts = original_date.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
offset = db.get("utc_offset", 0) if isinstance(db, dict) else 0
|
||||
ts = original_date.shift(hours=offset).format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
ts = ""
|
||||
|
||||
# Format like Mastodon: "Name (@handle). X followers, Y following, Z posts. Joined date"
|
||||
# Use the exact same translatable string as Mastodon (sessions/mastodon/compose.py)
|
||||
if not ts:
|
||||
ts = _("unknown")
|
||||
return [_("%s (@%s). %s followers, %s following, %s posts. Joined %s") % (display_name, handle, followers, following, posts, ts)]
|
||||
|
||||
|
||||
def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky chat conversation for list display.
|
||||
|
||||
Args:
|
||||
convo: Conversation dict or ATProto model
|
||||
db: Session database dict
|
||||
settings: Session settings
|
||||
relative_times: If True, use relative time formatting
|
||||
show_screen_names: If True, show only @handle
|
||||
safe: If True, handle exceptions gracefully
|
||||
|
||||
Returns:
|
||||
List of strings: [Participants, Last Message, Date]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
members = g(convo, "members", [])
|
||||
self_did = db.get("user_id") if isinstance(db, dict) else None
|
||||
|
||||
# Build a local DID→name map from conversation members for sender resolution
|
||||
member_names = {}
|
||||
for m in members:
|
||||
did = g(m, "did", None)
|
||||
if did:
|
||||
name = g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
|
||||
member_names[did] = name
|
||||
|
||||
# Get other participants (exclude self)
|
||||
others = []
|
||||
for m in members:
|
||||
did = g(m, "did", None)
|
||||
if self_did and did == self_did:
|
||||
continue
|
||||
label = member_names.get(did, "unknown") if did else g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
|
||||
others.append(label)
|
||||
|
||||
if not others:
|
||||
others = [member_names.get(g(m, "did"), "unknown") if g(m, "did") else "unknown" for m in members]
|
||||
|
||||
participants = ", ".join(others)
|
||||
|
||||
# Last message
|
||||
last_msg_obj = g(convo, "lastMessage") or g(convo, "last_message")
|
||||
last_text = ""
|
||||
last_sender = ""
|
||||
|
||||
if last_msg_obj:
|
||||
last_text = g(last_msg_obj, "text", "")
|
||||
sender = g(last_msg_obj, "sender", None)
|
||||
if sender:
|
||||
last_sender = g(sender, "display_name") or g(sender, "displayName") or g(sender, "handle")
|
||||
if not last_sender:
|
||||
# Resolve DID via local member map
|
||||
sdid = g(sender, "did")
|
||||
if sdid:
|
||||
last_sender = member_names.get(sdid, "")
|
||||
if not last_sender:
|
||||
last_sender = sdid or ""
|
||||
|
||||
# Date
|
||||
date_str = ""
|
||||
if last_msg_obj:
|
||||
sent_at = g(last_msg_obj, "sentAt") or g(last_msg_obj, "sent_at")
|
||||
if sent_at:
|
||||
try:
|
||||
ts = arrow.get(sent_at)
|
||||
if relative_times:
|
||||
date_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
date_str = str(sent_at)[:16]
|
||||
|
||||
if last_sender and last_text:
|
||||
last_text = _("Last message from {user}: {text}").format(user=last_sender, text=last_text)
|
||||
elif last_text:
|
||||
last_text = _("Last message: {text}").format(text=last_text)
|
||||
|
||||
return [participants, last_text, date_str]
|
||||
|
||||
|
||||
def compose_chat_message(msg, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose an individual chat message for display.
|
||||
|
||||
Args:
|
||||
msg: Chat message dict or ATProto model
|
||||
db: Session database dict
|
||||
settings: Session settings
|
||||
relative_times: If True, use relative time formatting
|
||||
show_screen_names: If True, show only @handle
|
||||
safe: If True, handle exceptions gracefully
|
||||
|
||||
Returns:
|
||||
List of strings: [Sender, Text, Date]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
sender = g(msg, "sender", {})
|
||||
sender_did = g(sender, "did")
|
||||
handle = g(sender, "display_name") or g(sender, "displayName") or g(sender, "handle")
|
||||
if not handle and sender_did and isinstance(db, dict):
|
||||
# Look up DID in member maps stored by ChatBuffer
|
||||
for key, val in db.items():
|
||||
if key.endswith("_members") and isinstance(val, dict) and sender_did in val:
|
||||
handle = val[sender_did]
|
||||
break
|
||||
if not handle:
|
||||
handle = sender_did or "unknown"
|
||||
|
||||
text = g(msg, "text", "")
|
||||
|
||||
# Date
|
||||
sent_at = g(msg, "sentAt") or g(msg, "sent_at")
|
||||
date_str = ""
|
||||
if sent_at:
|
||||
try:
|
||||
ts = arrow.get(sent_at)
|
||||
if relative_times:
|
||||
date_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
date_str = str(sent_at)[:16]
|
||||
|
||||
return [handle, text, date_str]
|
||||
@@ -0,0 +1,901 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import wx
|
||||
|
||||
from pubsub import pub
|
||||
|
||||
from sessions import base
|
||||
from sessions import session_exceptions as Exceptions
|
||||
import output
|
||||
import application
|
||||
import languageHandler
|
||||
|
||||
log = logging.getLogger("sessions.blueskiSession")
|
||||
|
||||
|
||||
class Language:
|
||||
"""Simple language object with code and name attributes, mimicking Mastodon.py format."""
|
||||
def __init__(self, code: str, name: str):
|
||||
self.code = code
|
||||
self.name = name
|
||||
|
||||
def __repr__(self):
|
||||
return f"Language({self.code}, {self.name})"
|
||||
|
||||
|
||||
def get_supported_languages():
|
||||
"""Returns the list of supported languages with translated names."""
|
||||
return [
|
||||
Language("", _("Not set")),
|
||||
Language("en", _("English")),
|
||||
Language("es", _("Spanish")),
|
||||
Language("fr", _("French")),
|
||||
Language("de", _("German")),
|
||||
Language("it", _("Italian")),
|
||||
Language("pt", _("Portuguese")),
|
||||
Language("ja", _("Japanese")),
|
||||
Language("ko", _("Korean")),
|
||||
Language("zh", _("Chinese")),
|
||||
Language("ru", _("Russian")),
|
||||
Language("ar", _("Arabic")),
|
||||
Language("hi", _("Hindi")),
|
||||
Language("nl", _("Dutch")),
|
||||
Language("pl", _("Polish")),
|
||||
Language("tr", _("Turkish")),
|
||||
Language("uk", _("Ukrainian")),
|
||||
Language("ca", _("Catalan")),
|
||||
Language("eu", _("Basque")),
|
||||
Language("gl", _("Galician")),
|
||||
]
|
||||
|
||||
# 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
|
||||
self.poller = None
|
||||
self.supported_languages = get_supported_languages()
|
||||
self.default_language = languageHandler.curLang[:2]
|
||||
# Subscribe to pub/sub events from the poller
|
||||
pub.subscribe(self.on_notification, "blueski.notification_received")
|
||||
|
||||
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 (self.settings and self.settings.get("atprotosocial", {}).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 as e:
|
||||
log.exception("Bluesky login failed")
|
||||
self.logged = False
|
||||
raise e
|
||||
|
||||
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 False
|
||||
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,
|
||||
}
|
||||
|
||||
# Facets (Links and Mentions)
|
||||
try:
|
||||
facets = self._get_facets(text, api)
|
||||
if facets:
|
||||
record["facets"] = facets
|
||||
except:
|
||||
pass
|
||||
|
||||
# Labels (CW)
|
||||
if cw_text:
|
||||
record["labels"] = {
|
||||
"$type": "com.atproto.label.defs#selfLabels",
|
||||
"values": [{"val": "warn"}]
|
||||
}
|
||||
|
||||
# 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
|
||||
reply_cid = kwargs.get("reply_to_cid")
|
||||
parent_ref = None
|
||||
if reply_uri and reply_cid:
|
||||
parent_ref = {"uri": reply_uri, "cid": reply_cid}
|
||||
if not parent_ref:
|
||||
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")
|
||||
|
||||
return uri
|
||||
except Exception:
|
||||
log.exception("Error sending Bluesky post")
|
||||
output.speak(_("An error occurred while posting to Bluesky."), True)
|
||||
return None
|
||||
|
||||
def _get_facets(self, text, api):
|
||||
facets = []
|
||||
# Mentions
|
||||
for m in re.finditer(r'@([a-zA-Z0-9.-]+)', text):
|
||||
handle = m.group(1)
|
||||
try:
|
||||
# We should probably cache this identity lookup
|
||||
res = api.com.atproto.identity.resolve_handle({'handle': handle})
|
||||
did = res.did
|
||||
facets.append({
|
||||
'index': {
|
||||
'byteStart': len(text[:m.start()].encode('utf-8')),
|
||||
'byteEnd': len(text[:m.end()].encode('utf-8'))
|
||||
},
|
||||
'features': [{'$type': 'app.bsky.richtext.facet#mention', 'did': did}]
|
||||
})
|
||||
except:
|
||||
continue
|
||||
# Links
|
||||
for m in re.finditer(r'(https?://[^\s]+)', text):
|
||||
url = m.group(1)
|
||||
facets.append({
|
||||
'index': {
|
||||
'byteStart': len(text[:m.start()].encode('utf-8')),
|
||||
'byteEnd': len(text[:m.end()].encode('utf-8'))
|
||||
},
|
||||
'features': [{'$type': 'app.bsky.richtext.facet#link', 'uri': url}]
|
||||
})
|
||||
return facets
|
||||
|
||||
def delete_post(self, uri: str) -> bool:
|
||||
"""Delete a post by its AT URI."""
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
# at://did:plc:xxx/app.bsky.feed.post/rkey
|
||||
parts = uri.split("/")
|
||||
rkey = parts[-1]
|
||||
api.com.atproto.repo.delete_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": rkey
|
||||
})
|
||||
return True
|
||||
except:
|
||||
log.exception("Error deleting Bluesky post")
|
||||
return False
|
||||
|
||||
def block_user(self, did: str) -> bool:
|
||||
"""Block a user by their DID."""
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.block",
|
||||
"record": {
|
||||
"$type": "app.bsky.graph.block",
|
||||
"subject": did,
|
||||
"createdAt": api.get_current_time_iso()
|
||||
}
|
||||
})
|
||||
return True
|
||||
except:
|
||||
log.exception("Error blocking Bluesky user")
|
||||
return False
|
||||
|
||||
def unblock_user(self, block_uri: str) -> bool:
|
||||
"""Unblock a user by the URI of the block record."""
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
parts = block_uri.split("/")
|
||||
rkey = parts[-1]
|
||||
api.com.atproto.repo.delete_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.block",
|
||||
"rkey": rkey
|
||||
})
|
||||
return True
|
||||
except:
|
||||
log.exception("Error unblocking Bluesky user")
|
||||
return False
|
||||
|
||||
def get_profile(self, actor: str) -> Any:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
return api.app.bsky.actor.get_profile({"actor": actor})
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky profile for %s", actor)
|
||||
return None
|
||||
|
||||
def get_profiles(self, actors: list[str]) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
if not actors:
|
||||
return {"items": []}
|
||||
# API limit is 25 actors per request, batch if needed
|
||||
all_profiles = []
|
||||
batch_size = 25
|
||||
for i in range(0, len(actors), batch_size):
|
||||
batch = actors[i:i + batch_size]
|
||||
try:
|
||||
res = api.app.bsky.actor.get_profiles({"actors": batch})
|
||||
profiles = getattr(res, "profiles", []) or []
|
||||
all_profiles.extend(profiles)
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky profiles batch")
|
||||
return {"items": all_profiles}
|
||||
|
||||
def get_post_likes(self, uri: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
params = {"uri": uri, "limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
res = api.app.bsky.feed.get_likes(params)
|
||||
return {"items": getattr(res, "likes", []) or [], "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky likes for %s", uri)
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def get_post_reposts(self, uri: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
params = {"uri": uri, "limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
# SDK uses get_reposted_by (camel or snake)
|
||||
feed = api.app.bsky.feed
|
||||
if hasattr(feed, "get_reposted_by"):
|
||||
res = feed.get_reposted_by(params)
|
||||
else:
|
||||
res = feed.get_repostedBy(params)
|
||||
return {"items": getattr(res, "reposted_by", None) or getattr(res, "repostedBy", None) or getattr(res, "reposted_by", []) or [], "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky reposts for %s", uri)
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def follow_user(self, did: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.follow",
|
||||
"record": {
|
||||
"$type": "app.bsky.graph.follow",
|
||||
"subject": did,
|
||||
"createdAt": api.get_current_time_iso()
|
||||
}
|
||||
})
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error following Bluesky user")
|
||||
return False
|
||||
|
||||
def unfollow_user(self, follow_uri: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
parts = follow_uri.split("/")
|
||||
rkey = parts[-1]
|
||||
api.com.atproto.repo.delete_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.follow",
|
||||
"rkey": rkey
|
||||
})
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error unfollowing Bluesky user")
|
||||
return False
|
||||
|
||||
def mute_user(self, did: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
graph = api.app.bsky.graph
|
||||
if hasattr(graph, "mute_actor"):
|
||||
graph.mute_actor({"actor": did})
|
||||
elif hasattr(graph, "muteActor"):
|
||||
graph.muteActor({"actor": did})
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error muting Bluesky user")
|
||||
return False
|
||||
|
||||
def unmute_user(self, did: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
graph = api.app.bsky.graph
|
||||
if hasattr(graph, "unmute_actor"):
|
||||
graph.unmute_actor({"actor": did})
|
||||
elif hasattr(graph, "unmuteActor"):
|
||||
graph.unmuteActor({"actor": did})
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error unmuting Bluesky user")
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
def like(self, post_uri: str, post_cid: str | None = None) -> str | None:
|
||||
"""Create a like for a given post."""
|
||||
if not self.logged:
|
||||
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
|
||||
try:
|
||||
api = self._ensure_client()
|
||||
|
||||
# Resolve strong ref if needed
|
||||
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])
|
||||
except: posts_res = None
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
if posts:
|
||||
p = posts[0]
|
||||
return {"uri": getattr(p, "uri", uri), "cid": getattr(p, "cid", None)}
|
||||
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.like",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.like",
|
||||
"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 like")
|
||||
return None
|
||||
|
||||
def get_followers(self, actor: str | None = None, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
actor = actor or api.me.did
|
||||
res = api.app.bsky.graph.get_followers({"actor": actor, "limit": limit, "cursor": cursor})
|
||||
return {"items": res.followers, "cursor": res.cursor}
|
||||
|
||||
def get_follows(self, actor: str | None = None, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
actor = actor or api.me.did
|
||||
res = api.app.bsky.graph.get_follows({"actor": actor, "limit": limit, "cursor": cursor})
|
||||
return {"items": res.follows, "cursor": res.cursor}
|
||||
|
||||
def get_blocks(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
res = api.app.bsky.graph.get_blocks({"limit": limit, "cursor": cursor})
|
||||
return {"items": res.blocks, "cursor": res.cursor}
|
||||
|
||||
def list_convos(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
# Chat API requires using the chat proxy
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
params = {"limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
try:
|
||||
res = dm.list_convos(params)
|
||||
return {"items": res.convos, "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error listing conversations")
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def get_convo(self, convo_id: str):
|
||||
"""Fetch a single conversation by ID, returning the convo object or None."""
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
try:
|
||||
res = dm.get_convo({"convoId": convo_id})
|
||||
return res.convo
|
||||
except Exception:
|
||||
log.exception("Error fetching conversation %s", convo_id)
|
||||
return None
|
||||
|
||||
def get_convo_messages(self, convo_id: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
params = {"convoId": convo_id, "limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
try:
|
||||
res = dm.get_messages(params)
|
||||
return {"items": res.messages, "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error getting conversation messages")
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def send_chat_message(self, convo_id: str, text: str) -> Any:
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
try:
|
||||
return dm.send_message({
|
||||
"convoId": convo_id,
|
||||
"message": {
|
||||
"text": text
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
log.exception("Error sending chat message")
|
||||
raise
|
||||
|
||||
def get_or_create_convo(self, members: list[str]) -> dict[str, Any] | None:
|
||||
"""Get or create a conversation with the given members (DIDs)."""
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
try:
|
||||
res = dm.get_convo_for_members({"members": members})
|
||||
return res.convo
|
||||
except Exception:
|
||||
log.exception("Error getting/creating conversation")
|
||||
return None
|
||||
|
||||
# Streaming/Polling methods
|
||||
|
||||
def start_streaming(self):
|
||||
"""Start the background poller for notifications."""
|
||||
if not self.logged:
|
||||
log.debug("Cannot start Bluesky poller: not logged in.")
|
||||
return
|
||||
|
||||
if self.poller is not None and self.poller.is_alive():
|
||||
log.debug("Bluesky poller already running for %s", self.get_name())
|
||||
return
|
||||
|
||||
try:
|
||||
from sessions.blueski.streaming import BlueskyPoller
|
||||
poll_interval = 60
|
||||
try:
|
||||
poll_interval = self.settings["general"].get("update_period", 60)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.poller = BlueskyPoller(
|
||||
session=self,
|
||||
session_name=self.get_name(),
|
||||
poll_interval=poll_interval
|
||||
)
|
||||
self.poller.start()
|
||||
log.info("Started Bluesky poller for session %s", self.get_name())
|
||||
except Exception:
|
||||
log.exception("Failed to start Bluesky poller")
|
||||
|
||||
def stop_streaming(self):
|
||||
"""Stop the background poller."""
|
||||
if self.poller is not None:
|
||||
self.poller.stop()
|
||||
self.poller = None
|
||||
log.info("Stopped Bluesky poller for session %s", self.get_name())
|
||||
|
||||
def on_notification(self, notification, session_name):
|
||||
"""Handle notification received from the poller via pub/sub."""
|
||||
# Discard if notification is for a different session
|
||||
if self.get_name() != session_name:
|
||||
return
|
||||
|
||||
# Add notification to the notifications buffer
|
||||
try:
|
||||
num = self.order_buffer("notifications", [notification])
|
||||
if num > 0:
|
||||
pub.sendMessage(
|
||||
"blueski.new_item",
|
||||
session_name=self.get_name(),
|
||||
item=notification,
|
||||
_buffers=["notifications"]
|
||||
)
|
||||
except Exception:
|
||||
log.exception("Error processing Bluesky notification")
|
||||
|
||||
def order_buffer(self, buffer_name, items):
|
||||
"""Add items to the specified buffer's database.
|
||||
|
||||
Returns the number of new items added.
|
||||
"""
|
||||
if buffer_name not in self.db:
|
||||
self.db[buffer_name] = []
|
||||
|
||||
# Get existing URIs to avoid duplicates
|
||||
existing_uris = set()
|
||||
for item in self.db[buffer_name]:
|
||||
uri = None
|
||||
if isinstance(item, dict):
|
||||
uri = item.get("uri")
|
||||
else:
|
||||
uri = getattr(item, "uri", None)
|
||||
if uri:
|
||||
existing_uris.add(uri)
|
||||
|
||||
# Add new items
|
||||
new_count = 0
|
||||
for item in items:
|
||||
uri = None
|
||||
if isinstance(item, dict):
|
||||
uri = item.get("uri")
|
||||
else:
|
||||
uri = getattr(item, "uri", None)
|
||||
|
||||
if uri and uri in existing_uris:
|
||||
continue
|
||||
|
||||
if uri:
|
||||
existing_uris.add(uri)
|
||||
|
||||
# Insert at beginning (newest first)
|
||||
self.db[buffer_name].insert(0, item)
|
||||
new_count += 1
|
||||
|
||||
return new_count
|
||||
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Bluesky polling-based update system for TWBlue.
|
||||
|
||||
Since Bluesky's Firehose requires complex CAR/CBOR decoding and filtering
|
||||
of millions of events, we use a polling approach instead of true streaming.
|
||||
This matches the existing start_stream() pattern used by buffers.
|
||||
|
||||
Events are published via pub/sub to maintain consistency with Mastodon's
|
||||
streaming implementation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pubsub import pub
|
||||
|
||||
log = logging.getLogger("sessions.blueski.streaming")
|
||||
|
||||
|
||||
class BlueskyPoller:
|
||||
"""
|
||||
Polling-based update system for Bluesky.
|
||||
|
||||
Periodically checks for new notifications and publishes them via pub/sub.
|
||||
This provides a similar interface to Mastodon's StreamListener but uses
|
||||
polling instead of WebSocket streaming.
|
||||
"""
|
||||
|
||||
def __init__(self, session, session_name, poll_interval=60):
|
||||
"""
|
||||
Initialize the poller.
|
||||
|
||||
Args:
|
||||
session: The Bluesky session instance
|
||||
session_name: Unique identifier for this session (for pub/sub routing)
|
||||
poll_interval: Seconds between API polls (default 60, min 30)
|
||||
"""
|
||||
self.session = session
|
||||
self.session_name = session_name
|
||||
self.poll_interval = max(30, poll_interval) # Minimum 30 seconds to respect rate limits
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = None
|
||||
self._last_notification_cursor = None
|
||||
self._last_seen_notification_uri = None
|
||||
|
||||
def start(self):
|
||||
"""Start the polling thread."""
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
log.warning(f"Bluesky poller for {self.session_name} is already running.")
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._poll_loop,
|
||||
name=f"BlueskyPoller-{self.session_name}",
|
||||
daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
log.info(f"Bluesky poller started for {self.session_name} (interval: {self.poll_interval}s)")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the polling thread."""
|
||||
if self._thread is None:
|
||||
return
|
||||
|
||||
self._stop_event.set()
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
log.info(f"Bluesky poller stopped for {self.session_name}")
|
||||
|
||||
def is_alive(self):
|
||||
"""Check if the polling thread is running."""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main polling loop running in background thread."""
|
||||
log.debug(f"Polling loop started for {self.session_name}")
|
||||
|
||||
# Initial delay to let the app fully initialize
|
||||
time.sleep(5)
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self._check_notifications()
|
||||
except Exception as e:
|
||||
log.exception(f"Error in Bluesky polling loop for {self.session_name}: {e}")
|
||||
|
||||
# Wait for next poll interval, checking stop event periodically
|
||||
for _ in range(self.poll_interval):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
log.debug(f"Polling loop ended for {self.session_name}")
|
||||
|
||||
def _check_notifications(self):
|
||||
"""Check for new notifications and publish events."""
|
||||
if not self.session.logged:
|
||||
return
|
||||
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
|
||||
# Fetch recent notifications
|
||||
res = api.app.bsky.notification.list_notifications({"limit": 20})
|
||||
notifications = getattr(res, "notifications", [])
|
||||
|
||||
if not notifications:
|
||||
return
|
||||
|
||||
# Track which notifications are new
|
||||
new_notifications = []
|
||||
newest_uri = None
|
||||
|
||||
for notif in notifications:
|
||||
uri = getattr(notif, "uri", None)
|
||||
if not uri:
|
||||
continue
|
||||
|
||||
# First time running - just record the newest and don't flood
|
||||
if self._last_seen_notification_uri is None:
|
||||
newest_uri = uri
|
||||
break
|
||||
|
||||
# Check if we've seen this notification before
|
||||
if uri == self._last_seen_notification_uri:
|
||||
break
|
||||
|
||||
new_notifications.append(notif)
|
||||
if newest_uri is None:
|
||||
newest_uri = uri
|
||||
|
||||
# Update last seen
|
||||
if newest_uri:
|
||||
self._last_seen_notification_uri = newest_uri
|
||||
|
||||
# Publish new notifications (in reverse order so oldest first)
|
||||
for notif in reversed(new_notifications):
|
||||
self._publish_notification(notif)
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f"Error checking notifications for {self.session_name}: {e}")
|
||||
|
||||
def _publish_notification(self, notification):
|
||||
"""Publish a notification event via pub/sub."""
|
||||
try:
|
||||
reason = getattr(notification, "reason", "unknown")
|
||||
log.debug(f"Publishing Bluesky notification: {reason} for {self.session_name}")
|
||||
|
||||
pub.sendMessage(
|
||||
"blueski.notification_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
|
||||
# Also publish specific events for certain notification types
|
||||
if reason == "mention":
|
||||
pub.sendMessage(
|
||||
"blueski.mention_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
elif reason == "reply":
|
||||
pub.sendMessage(
|
||||
"blueski.reply_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
elif reason == "follow":
|
||||
pub.sendMessage(
|
||||
"blueski.follow_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error publishing notification event: {e}")
|
||||
|
||||
|
||||
def create_poller(session, session_name, poll_interval=60):
|
||||
"""
|
||||
Factory function to create a BlueskyPoller instance.
|
||||
|
||||
Args:
|
||||
session: The Bluesky session instance
|
||||
session_name: Unique identifier for this session
|
||||
poll_interval: Seconds between polls (default 60)
|
||||
|
||||
Returns:
|
||||
BlueskyPoller instance
|
||||
"""
|
||||
return BlueskyPoller(session, session_name, poll_interval)
|
||||
@@ -0,0 +1,307 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import arrow
|
||||
import languageHandler
|
||||
from string import Template
|
||||
from sessions.blueski import utils
|
||||
|
||||
|
||||
post_variables = [
|
||||
"date",
|
||||
"display_name",
|
||||
"screen_name",
|
||||
"reply_to",
|
||||
"source",
|
||||
"lang",
|
||||
"safe_text",
|
||||
"text",
|
||||
"image_descriptions",
|
||||
"visibility",
|
||||
"pinned",
|
||||
]
|
||||
person_variables = [
|
||||
"display_name",
|
||||
"screen_name",
|
||||
"description",
|
||||
"followers",
|
||||
"following",
|
||||
"favorites",
|
||||
"posts",
|
||||
"created_at",
|
||||
]
|
||||
notification_variables = ["display_name", "screen_name", "text", "date"]
|
||||
|
||||
|
||||
def _g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
def _extract_labels(obj):
|
||||
labels = _g(obj, "labels", None)
|
||||
if labels is None:
|
||||
return []
|
||||
if isinstance(labels, dict):
|
||||
return labels.get("values", []) or []
|
||||
if isinstance(labels, list):
|
||||
return labels
|
||||
return []
|
||||
|
||||
|
||||
def _extract_cw_text(post, record):
|
||||
labels = _extract_labels(post) + _extract_labels(record)
|
||||
for label in labels:
|
||||
val = _g(label, "val", "")
|
||||
if val == "warn":
|
||||
return _("Sensitive Content")
|
||||
if isinstance(val, str) and val.startswith("warn:"):
|
||||
return val.split("warn:", 1)[-1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_image_descriptions(post, record):
|
||||
def collect_images(embed):
|
||||
if not embed:
|
||||
return []
|
||||
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
|
||||
if "recordWithMedia" in etype:
|
||||
media = _g(embed, "media")
|
||||
mtype = _g(media, "$type") or _g(media, "py_type") or ""
|
||||
if "images" in mtype:
|
||||
return list(_g(media, "images", []) or [])
|
||||
return []
|
||||
if "images" in etype:
|
||||
return list(_g(embed, "images", []) or [])
|
||||
return []
|
||||
|
||||
images = []
|
||||
images.extend(collect_images(_g(post, "embed")))
|
||||
if not images:
|
||||
images.extend(collect_images(_g(record, "embed")))
|
||||
|
||||
descriptions = []
|
||||
for idx, img in enumerate(images, start=1):
|
||||
alt = _g(img, "alt", "") or ""
|
||||
if alt:
|
||||
descriptions.append(_("Media description {index}: {alt}").format(index=idx, alt=alt))
|
||||
return "\n".join(descriptions)
|
||||
|
||||
|
||||
def process_date(field, relative_times=True, offset_hours=0):
|
||||
original_date = arrow.get(field)
|
||||
if relative_times:
|
||||
return original_date.humanize(locale=languageHandler.curLang[:2])
|
||||
return original_date.shift(hours=offset_hours).format(_("dddd, MMMM D, YYYY H:m:s"), locale=languageHandler.curLang[:2])
|
||||
|
||||
|
||||
def _extract_link_info(post, record):
|
||||
"""Extract link information from post embeds and facets."""
|
||||
embed = _g(post, "embed")
|
||||
if not embed:
|
||||
return None
|
||||
|
||||
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
|
||||
|
||||
# Direct external embed
|
||||
if "external" in etype.lower():
|
||||
ext = _g(embed, "external", {})
|
||||
title = _g(ext, "title", "")
|
||||
if title:
|
||||
return title
|
||||
|
||||
# RecordWithMedia with external
|
||||
if "recordWithMedia" in etype:
|
||||
media = _g(embed, "media", {})
|
||||
mtype = _g(media, "$type") or _g(media, "py_type") or ""
|
||||
if "external" in mtype.lower():
|
||||
ext = _g(media, "external", {})
|
||||
title = _g(ext, "title", "")
|
||||
if title:
|
||||
return title
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def render_post(post, template, settings, relative_times=False, offset_hours=0):
|
||||
actual_post = _g(post, "post", post)
|
||||
record = _g(actual_post, "record") or _g(post, "record") or {}
|
||||
author = _g(actual_post, "author") or _g(post, "author") or {}
|
||||
|
||||
reason = _g(post, "reason")
|
||||
is_repost = False
|
||||
reposter = None
|
||||
if reason:
|
||||
rtype = _g(reason, "$type") or _g(reason, "py_type") or ""
|
||||
if "reasonRepost" in rtype:
|
||||
is_repost = True
|
||||
reposter = _g(reason, "by")
|
||||
|
||||
if is_repost and reposter:
|
||||
display_name = _g(reposter, "displayName") or _g(reposter, "display_name") or _g(reposter, "handle", "")
|
||||
screen_name = _g(reposter, "handle", "")
|
||||
else:
|
||||
display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "")
|
||||
screen_name = _g(author, "handle", "")
|
||||
|
||||
text = _g(record, "text", "") or ""
|
||||
if is_repost:
|
||||
original_handle = _g(author, "handle", "")
|
||||
text = _("Reposted from @{handle}: {text}").format(handle=original_handle, text=text)
|
||||
|
||||
quote_info = utils.extract_quoted_post_info(post)
|
||||
if quote_info:
|
||||
if quote_info["kind"] == "not_found":
|
||||
text += f" [{_('Quoted post not found')}]"
|
||||
elif quote_info["kind"] == "blocked":
|
||||
text += f" [{_('Quoted post blocked')}]"
|
||||
elif quote_info["kind"] == "feed":
|
||||
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
|
||||
else:
|
||||
q_handle = quote_info.get("handle", "unknown")
|
||||
q_text = quote_info.get("text", "")
|
||||
if q_text:
|
||||
text += " " + _("Quoting @{handle}: {text}").format(handle=q_handle, text=q_text)
|
||||
else:
|
||||
text += " " + _("Quoting @{handle}").format(handle=q_handle)
|
||||
|
||||
# Add link indicator for external embeds
|
||||
link_title = _extract_link_info(actual_post, record)
|
||||
if link_title:
|
||||
text += f" [{_('Link')}: {link_title}]"
|
||||
|
||||
reply_to_handle = utils.extract_reply_to_handle(post)
|
||||
reply_to = ""
|
||||
if reply_to_handle:
|
||||
reply_to = _("Replying to @{handle}. ").format(handle=reply_to_handle)
|
||||
|
||||
cw_text = _extract_cw_text(actual_post, record)
|
||||
safe_text = text
|
||||
if cw_text:
|
||||
# Include link info in safe_text even with content warning
|
||||
if link_title:
|
||||
safe_text = _("Content warning: {cw}").format(cw=cw_text) + f" [{_('Link')}: {link_title}]"
|
||||
else:
|
||||
safe_text = _("Content warning: {cw}").format(cw=cw_text)
|
||||
|
||||
# Backward compatibility: older user templates may not include $reply_to.
|
||||
# In that case, prepend the reply marker directly so users still get context.
|
||||
if reply_to and "$reply_to" not in template:
|
||||
text = reply_to + text
|
||||
safe_text = reply_to + safe_text
|
||||
reply_to = ""
|
||||
|
||||
created_at = _g(record, "createdAt") or _g(record, "created_at")
|
||||
indexed_at = _g(actual_post, "indexedAt") or _g(actual_post, "indexed_at")
|
||||
date_field = created_at or indexed_at
|
||||
date = process_date(date_field, relative_times, offset_hours) if date_field else ""
|
||||
|
||||
langs = _g(record, "langs") or _g(record, "languages") or []
|
||||
lang = langs[0] if isinstance(langs, list) and langs else ""
|
||||
|
||||
image_descriptions = _extract_image_descriptions(actual_post, record)
|
||||
|
||||
available_data = dict(
|
||||
date=date,
|
||||
display_name=display_name,
|
||||
screen_name=screen_name,
|
||||
reply_to=reply_to,
|
||||
source="Bluesky",
|
||||
lang=lang,
|
||||
safe_text=safe_text,
|
||||
text=text,
|
||||
image_descriptions=image_descriptions,
|
||||
visibility=_("Public"),
|
||||
pinned="",
|
||||
)
|
||||
return Template(_(template)).safe_substitute(**available_data)
|
||||
|
||||
|
||||
def render_user(user, template, settings, relative_times=True, offset_hours=0):
|
||||
# Resolve nested profile structure (subject, actor, profile, user)
|
||||
def resolve_profile(obj):
|
||||
if _g(obj, "handle") or _g(obj, "did"):
|
||||
return obj
|
||||
for key in ("subject", "actor", "profile", "user"):
|
||||
nested = _g(obj, key)
|
||||
if nested and (_g(nested, "handle") or _g(nested, "did")):
|
||||
return nested
|
||||
return obj
|
||||
|
||||
profile = resolve_profile(user)
|
||||
display_name = _g(profile, "displayName") or _g(profile, "display_name") or _g(profile, "handle", "")
|
||||
screen_name = _g(profile, "handle", "")
|
||||
description = _g(profile, "description", "") or ""
|
||||
followers = _g(profile, "followersCount") or _g(profile, "followers_count") or 0
|
||||
following = _g(profile, "followsCount") or _g(profile, "follows_count") or 0
|
||||
posts = _g(profile, "postsCount") or _g(profile, "posts_count") or 0
|
||||
created_at = _g(profile, "createdAt") or _g(profile, "created_at")
|
||||
created = ""
|
||||
if created_at:
|
||||
created = process_date(created_at, relative_times, offset_hours)
|
||||
|
||||
available_data = dict(
|
||||
display_name=display_name,
|
||||
screen_name=screen_name,
|
||||
description=description,
|
||||
followers=followers,
|
||||
following=following,
|
||||
favorites="",
|
||||
posts=posts,
|
||||
created_at=created,
|
||||
)
|
||||
return Template(_(template)).safe_substitute(**available_data)
|
||||
|
||||
|
||||
def render_notification(notification, template, post_template, settings, relative_times=False, offset_hours=0):
|
||||
author = _g(notification, "author") or {}
|
||||
display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "")
|
||||
screen_name = _g(author, "handle", "")
|
||||
reason = _g(notification, "reason", "unknown")
|
||||
record = _g(notification, "record") or {}
|
||||
|
||||
# Get post text - try multiple locations depending on notification type
|
||||
post_text = _g(record, "text", "") or ""
|
||||
|
||||
# For likes and reposts: try to get the subject post text
|
||||
if not post_text and reason in ("like", "repost"):
|
||||
# First check for hydrated subject text (added by NotificationBuffer)
|
||||
post_text = _g(notification, "_subject_text", "") or ""
|
||||
|
||||
# Check if there's a reasonSubject with embedded post data
|
||||
if not post_text:
|
||||
reason_subject = _g(notification, "reasonSubject") or _g(notification, "reason_subject")
|
||||
if reason_subject:
|
||||
subject_record = _g(reason_subject, "record", {})
|
||||
post_text = _g(subject_record, "text", "") or ""
|
||||
|
||||
# Check subject in record
|
||||
if not post_text:
|
||||
subject = _g(record, "subject", {})
|
||||
post_text = _g(subject, "text", "") or ""
|
||||
|
||||
# Format: action text without username (username is already in display_name for template)
|
||||
if reason == "like":
|
||||
text = _("has added to favorites: {status}").format(status=post_text) if post_text else _("has added to favorites")
|
||||
elif reason == "repost":
|
||||
text = _("has reposted: {status}").format(status=post_text) if post_text else _("has reposted")
|
||||
elif reason == "follow":
|
||||
text = _("has followed you.")
|
||||
elif reason == "mention":
|
||||
text = _("has mentioned you: {status}").format(status=post_text) if post_text else _("has mentioned you")
|
||||
elif reason == "reply":
|
||||
text = _("has replied: {status}").format(status=post_text) if post_text else _("has replied")
|
||||
elif reason == "quote":
|
||||
text = _("has quoted your post: {status}").format(status=post_text) if post_text else _("has quoted your post")
|
||||
else:
|
||||
text = reason
|
||||
|
||||
indexed_at = _g(notification, "indexedAt") or _g(notification, "indexed_at")
|
||||
date = process_date(indexed_at, relative_times, offset_hours) if indexed_at else ""
|
||||
|
||||
available_data = dict(
|
||||
display_name=display_name,
|
||||
screen_name=screen_name,
|
||||
text=text,
|
||||
date=date,
|
||||
)
|
||||
return Template(_(template)).safe_substitute(**available_data)
|
||||
@@ -0,0 +1,444 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Utility functions for Bluesky session.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger("sessions.blueski.utils")
|
||||
|
||||
url_re = re.compile(r'https?://[^\s<>\[\]()"\',]+[^\s<>\[\]()"\',.:;!?]')
|
||||
|
||||
|
||||
def g(obj, key, default=None):
|
||||
"""Helper to get attribute from dict or object."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
def is_audio_or_video(post):
|
||||
"""
|
||||
Check if post contains audio or video content.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object (FeedViewPost or PostView)
|
||||
|
||||
Returns:
|
||||
bool: True if post has audio/video media
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if not embed:
|
||||
return False
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
# Check for video embed
|
||||
if "video" in etype.lower():
|
||||
return True
|
||||
|
||||
# Check for external link that might be video (YouTube, etc.)
|
||||
if "external" in etype.lower():
|
||||
ext = g(embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
|
||||
for host in video_hosts:
|
||||
if host in uri.lower():
|
||||
return True
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordwithmedia" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "video" in mtype.lower():
|
||||
return True
|
||||
if "external" in mtype.lower():
|
||||
ext = g(media, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
|
||||
for host in video_hosts:
|
||||
if host in uri.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_images_from_embed(embed):
|
||||
"""Extract image URLs from an embed object."""
|
||||
images = []
|
||||
if not embed:
|
||||
return images
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
def extract_images(img_list):
|
||||
result = []
|
||||
for img in (img_list or []):
|
||||
url = None
|
||||
# Try all possible URL field names
|
||||
for key in ["fullsize", "thumb", "url", "uri", "src"]:
|
||||
val = g(img, key)
|
||||
if val and isinstance(val, str) and val.startswith("http"):
|
||||
url = val
|
||||
break
|
||||
# Also check for nested 'image' object
|
||||
if not url:
|
||||
image_obj = g(img, "image", {})
|
||||
if image_obj:
|
||||
for key in ["ref", "$link", "url", "uri"]:
|
||||
val = g(image_obj, key)
|
||||
if val:
|
||||
url = val
|
||||
break
|
||||
if url:
|
||||
result.append({
|
||||
"url": url,
|
||||
"alt": g(img, "alt", "") or ""
|
||||
})
|
||||
return result
|
||||
|
||||
# Direct images embed (app.bsky.embed.images or app.bsky.embed.images#view)
|
||||
if "images" in etype.lower():
|
||||
images.extend(extract_images(g(embed, "images", [])))
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordwithmedia" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "images" in mtype.lower():
|
||||
images.extend(extract_images(g(media, "images", [])))
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def is_image(post):
|
||||
"""
|
||||
Check if post contains image content.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object (FeedViewPost or PostView)
|
||||
|
||||
Returns:
|
||||
bool: True if post has image media
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if not embed:
|
||||
return False
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
# Direct images embed
|
||||
if "images" in etype.lower():
|
||||
images = g(embed, "images", [])
|
||||
if images and len(images) > 0:
|
||||
return True
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordwithmedia" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "images" in mtype.lower():
|
||||
images = g(media, "images", [])
|
||||
if images and len(images) > 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_image_urls(post):
|
||||
"""
|
||||
Get URLs for image attachments from post for OCR.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object
|
||||
|
||||
Returns:
|
||||
list: List of dicts with 'url' and 'alt' keys
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
return _extract_images_from_embed(embed)
|
||||
|
||||
|
||||
def get_media_urls(post):
|
||||
"""
|
||||
Get URLs for media attachments (video/audio) from post.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object
|
||||
|
||||
Returns:
|
||||
list: List of media URLs
|
||||
"""
|
||||
urls = []
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if not embed:
|
||||
return urls
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
def extract_video_urls(video_embed):
|
||||
"""Extract URLs from a video embed object."""
|
||||
result = []
|
||||
# Playlist URL (HLS stream)
|
||||
playlist = g(video_embed, "playlist", None)
|
||||
if playlist:
|
||||
result.append(playlist)
|
||||
# Alternative URL fields
|
||||
for key in ["url", "uri"]:
|
||||
val = g(video_embed, key)
|
||||
if val and val not in result:
|
||||
result.append(val)
|
||||
return result
|
||||
|
||||
# Direct video embed (app.bsky.embed.video#view)
|
||||
if "video" in etype.lower():
|
||||
urls.extend(extract_video_urls(embed))
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordWithMedia" in etype or "record_with_media" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "video" in mtype.lower():
|
||||
urls.extend(extract_video_urls(media))
|
||||
# Also check for external in media
|
||||
if "external" in mtype.lower():
|
||||
ext = g(media, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
# External links (YouTube, etc.)
|
||||
if "external" in etype.lower():
|
||||
ext = g(embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def find_urls(post):
|
||||
"""
|
||||
Find all URLs in post content.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object
|
||||
|
||||
Returns:
|
||||
list: List of URLs found
|
||||
"""
|
||||
urls = []
|
||||
actual_post = g(post, "post", post)
|
||||
record = g(actual_post, "record", {})
|
||||
|
||||
# Check facets for link annotations
|
||||
facets = g(record, "facets", []) or []
|
||||
for facet in facets:
|
||||
features = g(facet, "features", []) or []
|
||||
for feature in features:
|
||||
ftype = g(feature, "$type") or g(feature, "py_type")
|
||||
if ftype and "link" in ftype.lower():
|
||||
uri = g(feature, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
# Check embed for external links
|
||||
embed = g(actual_post, "embed", None)
|
||||
if embed:
|
||||
etype = g(embed, "$type") or g(embed, "py_type")
|
||||
if etype and "external" in etype:
|
||||
ext = g(embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
# Also search plain text for URLs using regex (fallback)
|
||||
text = g(record, "text", "")
|
||||
if text:
|
||||
text_urls = url_re.findall(text)
|
||||
for u in text_urls:
|
||||
if u not in urls:
|
||||
urls.append(u)
|
||||
|
||||
# Include URLs from quoted post, if present.
|
||||
quote_info = extract_quoted_post_info(post)
|
||||
if quote_info and quote_info.get("kind") == "post":
|
||||
for uri in quote_info.get("urls", []):
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def find_item(item, items_list):
|
||||
"""
|
||||
Find item index in list by URI.
|
||||
|
||||
Args:
|
||||
item: Item to find
|
||||
items_list: List to search
|
||||
|
||||
Returns:
|
||||
int or None: Index if found, None otherwise
|
||||
"""
|
||||
item_uri = g(item, "uri") or g(g(item, "post"), "uri")
|
||||
if not item_uri:
|
||||
return None
|
||||
|
||||
for i, existing in enumerate(items_list):
|
||||
existing_uri = g(existing, "uri") or g(g(existing, "post"), "uri")
|
||||
if existing_uri == item_uri:
|
||||
return i
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_quoted_record_from_embed(embed):
|
||||
"""Resolve quoted record payload from a Bluesky embed structure."""
|
||||
if not embed:
|
||||
return None
|
||||
|
||||
etype = (g(embed, "$type") or g(embed, "py_type") or "").lower()
|
||||
|
||||
candidate = None
|
||||
if "recordwithmedia" in etype:
|
||||
record_view = g(embed, "record")
|
||||
candidate = g(record_view, "record") or record_view
|
||||
elif "record" in etype:
|
||||
candidate = g(embed, "record") or embed
|
||||
else:
|
||||
record_view = g(embed, "record")
|
||||
if record_view is not None:
|
||||
candidate = g(record_view, "record") or record_view
|
||||
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
# Unwrap one extra layer if still wrapped in a record-view container.
|
||||
nested = g(candidate, "record")
|
||||
nested_type = (g(nested, "$type") or g(nested, "py_type") or "").lower() if nested else ""
|
||||
if nested and ("view" in nested_type or "record" in nested_type):
|
||||
return nested
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def extract_reply_to_handle(post):
|
||||
"""
|
||||
Best-effort extraction of the replied-to handle for a Bluesky post.
|
||||
|
||||
Returns:
|
||||
str | None: Handle (without @) when available.
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
|
||||
# Fast path: pre-hydrated by buffers/session.
|
||||
cached = g(post, "_reply_to_handle", None) or g(actual_post, "_reply_to_handle", None)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Feed views frequently include hydrated reply context.
|
||||
reply_view = g(post, "reply", None) or g(actual_post, "reply", None)
|
||||
if reply_view:
|
||||
parent = g(reply_view, "parent", None) or g(reply_view, "post", None) or reply_view
|
||||
parent_post = g(parent, "post", None) or parent
|
||||
parent_author = g(parent_post, "author", None) or g(parent, "author", None)
|
||||
handle = g(parent_author, "handle", None)
|
||||
if handle:
|
||||
return handle
|
||||
|
||||
# Some payloads include parent author directly under record.reply.parent.
|
||||
record = g(actual_post, "record", {}) or {}
|
||||
record_reply = g(record, "reply", None)
|
||||
if record_reply:
|
||||
parent = g(record_reply, "parent", None) or record_reply
|
||||
parent_post = g(parent, "post", None) or parent
|
||||
parent_author = g(parent_post, "author", None) or g(parent, "author", None)
|
||||
handle = g(parent_author, "handle", None)
|
||||
if handle:
|
||||
return handle
|
||||
|
||||
# When only record.reply is available, we generally only have strong refs.
|
||||
# No handle can be resolved here without extra API calls.
|
||||
return None
|
||||
|
||||
|
||||
def extract_quoted_post_info(post):
|
||||
"""
|
||||
Extract quoted content metadata from a Bluesky post.
|
||||
|
||||
Returns:
|
||||
dict | None: one of:
|
||||
- {"kind": "not_found"}
|
||||
- {"kind": "blocked"}
|
||||
- {"kind": "feed", "feed_name": "..."}
|
||||
- {"kind": "post", "handle": "...", "text": "...", "urls": ["..."]}
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
record = g(actual_post, "record", {}) or {}
|
||||
embed = g(actual_post, "embed", None) or g(record, "embed", None)
|
||||
quote_rec = _resolve_quoted_record_from_embed(embed)
|
||||
if not quote_rec:
|
||||
return None
|
||||
|
||||
qtype = (g(quote_rec, "$type") or g(quote_rec, "py_type") or "").lower()
|
||||
if "viewnotfound" in qtype:
|
||||
return {"kind": "not_found"}
|
||||
if "viewblocked" in qtype:
|
||||
return {"kind": "blocked"}
|
||||
if "generatorview" in qtype:
|
||||
return {"kind": "feed", "feed_name": g(quote_rec, "displayName", "Feed")}
|
||||
|
||||
q_author = g(quote_rec, "author", {}) or {}
|
||||
q_handle = g(q_author, "handle", "unknown") or "unknown"
|
||||
|
||||
q_value = g(quote_rec, "value") or g(quote_rec, "record") or {}
|
||||
q_text = g(q_value, "text", "") or g(quote_rec, "text", "")
|
||||
if not q_text:
|
||||
nested_value = g(q_value, "value") or {}
|
||||
q_text = g(nested_value, "text", "")
|
||||
|
||||
q_urls = []
|
||||
|
||||
q_facets = g(q_value, "facets", []) or []
|
||||
for facet in q_facets:
|
||||
features = g(facet, "features", []) or []
|
||||
for feature in features:
|
||||
ftype = (g(feature, "$type") or g(feature, "py_type") or "").lower()
|
||||
if "link" in ftype:
|
||||
uri = g(feature, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
q_embed = g(quote_rec, "embed", None) or g(q_value, "embed", None)
|
||||
if q_embed:
|
||||
q_etype = (g(q_embed, "$type") or g(q_embed, "py_type") or "").lower()
|
||||
if "external" in q_etype:
|
||||
ext = g(q_embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
if "recordwithmedia" in q_etype:
|
||||
media = g(q_embed, "media", {})
|
||||
mtype = (g(media, "$type") or g(media, "py_type") or "").lower()
|
||||
if "external" in mtype:
|
||||
ext = g(media, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
for uri in url_re.findall(q_text or ""):
|
||||
if uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
return {
|
||||
"kind": "post",
|
||||
"handle": q_handle,
|
||||
"text": q_text or "",
|
||||
"urls": q_urls,
|
||||
}
|
||||
@@ -510,4 +510,4 @@ class Session(base.baseSession):
|
||||
# Now, add notification to its buffer.
|
||||
num = self.order_buffer("notifications", [notification])
|
||||
if num > 0:
|
||||
pub.sendMessage("mastodon.new_item", session_name=self.get_name(), item=notification, _buffers=["notifications"])
|
||||
pub.sendMessage("mastodon.new_item", session_name=self.get_name(), item=notification, _buffers=["notifications"])
|
||||
|
||||
Reference in New Issue
Block a user