mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-06 09:27:33 +01:00
Terminando integración
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(ls:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,16 +44,26 @@ class Handler:
|
|||||||
start=True,
|
start=True,
|
||||||
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session)
|
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session)
|
||||||
)
|
)
|
||||||
# Following-only timeline (reverse-chronological)
|
# Home (Following-only timeline - reverse-chronological)
|
||||||
pub.sendMessage(
|
pub.sendMessage(
|
||||||
"createBuffer",
|
"createBuffer",
|
||||||
buffer_type="following_timeline",
|
buffer_type="following_timeline",
|
||||||
session_type="blueski",
|
session_type="blueski",
|
||||||
buffer_title=_("Following (Chronological)"),
|
buffer_title=_("Home"),
|
||||||
parent_tab=root_position,
|
parent_tab=root_position,
|
||||||
start=False,
|
start=False,
|
||||||
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session)
|
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session)
|
||||||
)
|
)
|
||||||
|
# Mentions (replies, mentions, quotes)
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="MentionsBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Mentions"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="mentions", session=session)
|
||||||
|
)
|
||||||
# Notifications
|
# Notifications
|
||||||
pub.sendMessage(
|
pub.sendMessage(
|
||||||
"createBuffer",
|
"createBuffer",
|
||||||
@@ -64,6 +74,16 @@ class Handler:
|
|||||||
start=False,
|
start=False,
|
||||||
kwargs=dict(parent=controller.view.nb, name="notifications", session=session)
|
kwargs=dict(parent=controller.view.nb, name="notifications", session=session)
|
||||||
)
|
)
|
||||||
|
# Sent posts
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="SentBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Sent"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="sent", session=session)
|
||||||
|
)
|
||||||
# Likes
|
# Likes
|
||||||
pub.sendMessage(
|
pub.sendMessage(
|
||||||
"createBuffer",
|
"createBuffer",
|
||||||
@@ -84,12 +104,12 @@ class Handler:
|
|||||||
start=False,
|
start=False,
|
||||||
kwargs=dict(parent=controller.view.nb, name="followers", session=session)
|
kwargs=dict(parent=controller.view.nb, name="followers", session=session)
|
||||||
)
|
)
|
||||||
# Following (Users)
|
# Followings (Users you follow)
|
||||||
pub.sendMessage(
|
pub.sendMessage(
|
||||||
"createBuffer",
|
"createBuffer",
|
||||||
buffer_type="FollowingBuffer",
|
buffer_type="FollowingBuffer",
|
||||||
session_type="blueski",
|
session_type="blueski",
|
||||||
buffer_title=_("Following (Users)"),
|
buffer_title=_("Followings"),
|
||||||
parent_tab=root_position,
|
parent_tab=root_position,
|
||||||
start=False,
|
start=False,
|
||||||
kwargs=dict(parent=controller.view.nb, name="following", session=session)
|
kwargs=dict(parent=controller.view.nb, name="following", session=session)
|
||||||
@@ -115,6 +135,12 @@ class Handler:
|
|||||||
kwargs=dict(parent=controller.view.nb, name="direct_messages", session=session)
|
kwargs=dict(parent=controller.view.nb, name="direct_messages", session=session)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Start the background poller for real-time-like updates
|
||||||
|
try:
|
||||||
|
session.start_streaming()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to start Bluesky streaming for session %s", name)
|
||||||
|
|
||||||
def start_buffer(self, controller, buffer):
|
def start_buffer(self, controller, buffer):
|
||||||
"""Start a newly created Bluesky buffer."""
|
"""Start a newly created Bluesky buffer."""
|
||||||
try:
|
try:
|
||||||
@@ -358,3 +384,54 @@ class Handler:
|
|||||||
else:
|
else:
|
||||||
import output
|
import output
|
||||||
output.speak(_("Failed to delete post."))
|
output.speak(_("Failed to delete post."))
|
||||||
|
|
||||||
|
def search(self, controller, session):
|
||||||
|
"""Open search dialog and create search buffer for results."""
|
||||||
|
dlg = wx.TextEntryDialog(
|
||||||
|
controller.view,
|
||||||
|
_("Enter search term:"),
|
||||||
|
_("Search Bluesky")
|
||||||
|
)
|
||||||
|
if dlg.ShowModal() != wx.ID_OK:
|
||||||
|
dlg.Destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
query = dlg.GetValue().strip()
|
||||||
|
dlg.Destroy()
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create unique buffer name for this search
|
||||||
|
buffer_name = f"search_{query[:20]}"
|
||||||
|
account_name = session.get_name()
|
||||||
|
|
||||||
|
# Check if buffer already exists
|
||||||
|
existing = controller.search_buffer(buffer_name, account_name)
|
||||||
|
if existing:
|
||||||
|
# Navigate to existing buffer
|
||||||
|
index = controller.view.search(buffer_name, account_name)
|
||||||
|
if index is not None:
|
||||||
|
controller.view.change_buffer(index)
|
||||||
|
# Refresh search
|
||||||
|
existing.search_query = query
|
||||||
|
existing.start_stream(mandatory=True, play_sound=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create new search buffer
|
||||||
|
title = _("Search: {query}").format(query=query)
|
||||||
|
from pubsub import pub
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="SearchBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=controller.view.search(account_name, account_name),
|
||||||
|
start=True,
|
||||||
|
kwargs=dict(
|
||||||
|
parent=controller.view.nb,
|
||||||
|
name=buffer_name,
|
||||||
|
session=session,
|
||||||
|
query=query
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from .timeline import HomeTimeline, FollowingTimeline, NotificationBuffer, Conversation
|
from .timeline import (
|
||||||
|
HomeTimeline,
|
||||||
|
FollowingTimeline,
|
||||||
|
NotificationBuffer,
|
||||||
|
Conversation,
|
||||||
|
LikesBuffer,
|
||||||
|
MentionsBuffer,
|
||||||
|
SentBuffer,
|
||||||
|
SearchBuffer,
|
||||||
|
)
|
||||||
from .user import FollowersBuffer, FollowingBuffer, BlocksBuffer
|
from .user import FollowersBuffer, FollowingBuffer, BlocksBuffer
|
||||||
from .chat import ConversationListBuffer, ChatBuffer as ChatMessageBuffer
|
from .chat import ConversationListBuffer, ChatBuffer as ChatMessageBuffer
|
||||||
|
|||||||
@@ -123,20 +123,84 @@ class BaseBuffer(base.Buffer):
|
|||||||
output.speak(_("Reposted."))
|
output.speak(_("Reposted."))
|
||||||
|
|
||||||
def on_like(self, evt):
|
def on_like(self, evt):
|
||||||
self.toggle_favorite(confirm=True)
|
self.toggle_favorite(confirm=False)
|
||||||
|
|
||||||
def toggle_favorite(self, confirm=False, *args, **kwargs):
|
def toggle_favorite(self, confirm=False, *args, **kwargs):
|
||||||
item = self.get_item()
|
item = self.get_item()
|
||||||
if not item: return
|
if not item:
|
||||||
uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None)
|
output.speak(_("No item to like."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
uri = g(item, "uri")
|
||||||
|
if not uri:
|
||||||
|
post = g(item, "post") or g(item, "record")
|
||||||
|
uri = g(post, "uri") if post else None
|
||||||
|
|
||||||
|
if not uri:
|
||||||
|
output.speak(_("Could not find post identifier."), True)
|
||||||
|
return
|
||||||
|
|
||||||
if confirm:
|
if confirm:
|
||||||
if wx.MessageBox(_("Like this post?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) != wx.YES:
|
if wx.MessageBox(_("Like this post?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) != wx.YES:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.session.like(uri)
|
# Check if already liked
|
||||||
|
viewer = g(item, "viewer")
|
||||||
|
already_liked = g(viewer, "like") if viewer else None
|
||||||
|
|
||||||
|
if already_liked:
|
||||||
|
output.speak(_("Already liked."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Perform the like
|
||||||
|
like_uri = self.session.like(uri)
|
||||||
|
if not like_uri:
|
||||||
|
output.speak(_("Failed to like post."), True)
|
||||||
|
return
|
||||||
|
|
||||||
output.speak(_("Liked."))
|
output.speak(_("Liked."))
|
||||||
|
|
||||||
|
# Update the viewer state in the item
|
||||||
|
if isinstance(item, dict):
|
||||||
|
if "viewer" not in item:
|
||||||
|
item["viewer"] = {}
|
||||||
|
item["viewer"]["like"] = like_uri
|
||||||
|
else:
|
||||||
|
# For SDK models, create or update viewer
|
||||||
|
if not hasattr(item, "viewer") or item.viewer is None:
|
||||||
|
# Create a simple object to hold the like state
|
||||||
|
class Viewer:
|
||||||
|
def __init__(self):
|
||||||
|
self.like = None
|
||||||
|
item.viewer = Viewer()
|
||||||
|
item.viewer.like = like_uri
|
||||||
|
|
||||||
|
# Refresh the displayed item in the list
|
||||||
|
try:
|
||||||
|
index = self.buffer.list.get_selected()
|
||||||
|
if index > -1:
|
||||||
|
# Recompose and update the list item
|
||||||
|
safe = True
|
||||||
|
relative_times = self.session.settings["general"].get("relative_times", False)
|
||||||
|
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
|
||||||
|
post_data = self.compose_function(item, self.session.db, self.session.settings,
|
||||||
|
relative_times=relative_times,
|
||||||
|
show_screen_names=show_screen_names,
|
||||||
|
safe=safe)
|
||||||
|
|
||||||
|
# Update the item in place (only 3 columns: Author, Post, Date)
|
||||||
|
self.buffer.list.list.SetItem(index, 0, post_data[0]) # Author
|
||||||
|
self.buffer.list.list.SetItem(index, 1, post_data[1]) # Text (with ♥ indicator)
|
||||||
|
self.buffer.list.list.SetItem(index, 2, post_data[2]) # Date
|
||||||
|
# Note: compose_post returns 4 items but list only has 3 columns
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error refreshing list item after like")
|
||||||
|
|
||||||
def add_to_favorites(self, *args, **kwargs):
|
def add_to_favorites(self, *args, **kwargs):
|
||||||
self.toggle_favorite(confirm=False)
|
self.toggle_favorite(confirm=False)
|
||||||
|
|
||||||
@@ -172,8 +236,9 @@ class BaseBuffer(base.Buffer):
|
|||||||
if text:
|
if text:
|
||||||
try:
|
try:
|
||||||
api = self.session._ensure_client()
|
api = self.session._ensure_client()
|
||||||
|
dm_client = api.with_bsky_chat_proxy()
|
||||||
# Get or create conversation
|
# Get or create conversation
|
||||||
res = api.chat.bsky.convo.get_convo_for_members({"members": [did]})
|
res = dm_client.chat.bsky.convo.get_convo_for_members({"members": [did]})
|
||||||
convo_id = res.convo.id
|
convo_id = res.convo.id
|
||||||
self.session.send_chat_message(convo_id, text)
|
self.session.send_chat_message(convo_id, text)
|
||||||
output.speak(_("Message sent."), True)
|
output.speak(_("Message sent."), True)
|
||||||
@@ -186,6 +251,46 @@ class BaseBuffer(base.Buffer):
|
|||||||
# If showing, we'll just open the chat buffer for now as it's more structured
|
# If showing, we'll just open the chat buffer for now as it's more structured
|
||||||
self.view_chat_with_user(did, handle)
|
self.view_chat_with_user(did, handle)
|
||||||
|
|
||||||
|
def url(self, *args, **kwargs):
|
||||||
|
item = self.get_item()
|
||||||
|
if not item: return
|
||||||
|
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
uri = g(item, "uri")
|
||||||
|
author = g(item, "author") or g(g(item, "post"), "author")
|
||||||
|
handle = g(author, "handle")
|
||||||
|
|
||||||
|
if uri and handle:
|
||||||
|
# URI format: at://did:plc:xxx/app.bsky.feed.post/rkey
|
||||||
|
if "app.bsky.feed.post" in uri:
|
||||||
|
rkey = uri.split("/")[-1]
|
||||||
|
url = f"https://bsky.app/profile/{handle}/post/{rkey}"
|
||||||
|
webbrowser.open(url)
|
||||||
|
return
|
||||||
|
elif "app.bsky.feed.like" in uri:
|
||||||
|
# It's a like notification, try to get the subject
|
||||||
|
subject = g(item, "subject")
|
||||||
|
subject_uri = g(subject, "uri") if subject else None
|
||||||
|
if subject_uri:
|
||||||
|
rkey = subject_uri.split("/")[-1]
|
||||||
|
# We might not have the handle of the post author here easily if it's not in the notification
|
||||||
|
# But let's try...
|
||||||
|
# Actually, notification items usually have enough info or we can't deep direct link easily without fetching.
|
||||||
|
# For now, let's just open the profile of the liker
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to profile
|
||||||
|
if handle:
|
||||||
|
url = f"https://bsky.app/profile/{handle}"
|
||||||
|
webbrowser.open(url)
|
||||||
|
return
|
||||||
|
|
||||||
def user_actions(self, *args, **kwargs):
|
def user_actions(self, *args, **kwargs):
|
||||||
pub.sendMessage("execute-action", action="follow")
|
pub.sendMessage("execute-action", action="follow")
|
||||||
|
|
||||||
|
|||||||
@@ -100,8 +100,11 @@ class FollowingTimeline(BaseBuffer):
|
|||||||
|
|
||||||
class NotificationBuffer(BaseBuffer):
|
class NotificationBuffer(BaseBuffer):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
# Override compose_func before calling super().__init__
|
||||||
|
kwargs["compose_func"] = "compose_notification"
|
||||||
super(NotificationBuffer, self).__init__(*args, **kwargs)
|
super(NotificationBuffer, self).__init__(*args, **kwargs)
|
||||||
self.type = "notifications"
|
self.type = "notifications"
|
||||||
|
self.sound = "notification_received.ogg"
|
||||||
|
|
||||||
def create_buffer(self, parent, name):
|
def create_buffer(self, parent, name):
|
||||||
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
|
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
|
||||||
@@ -109,20 +112,32 @@ class NotificationBuffer(BaseBuffer):
|
|||||||
|
|
||||||
def start_stream(self, mandatory=False, play_sound=True):
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
count = 50
|
count = 50
|
||||||
|
try:
|
||||||
|
count = self.session.settings["general"].get("max_posts_per_call", 50)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
api = self.session._ensure_client()
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = api.app.bsky.notification.list_notifications({"limit": count})
|
res = api.app.bsky.notification.list_notifications({"limit": count})
|
||||||
notifs = getattr(res, "notifications", [])
|
notifications = getattr(res, "notifications", [])
|
||||||
items = []
|
if not notifications:
|
||||||
# Notifications are not FeedViewPost. They have different structure.
|
|
||||||
# self.compose_function expects FeedViewPost-like structure (post, author, etc).
|
|
||||||
# We need to map them or have a different compose function.
|
|
||||||
# For now, let's skip items to avoid crash
|
|
||||||
# Or attempt to map.
|
|
||||||
except:
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# Process notifications using the notification compose function
|
||||||
|
return self.process_items(list(notifications), play_sound)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error fetching Bluesky notifications")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def add_new_item(self, notification):
|
||||||
|
"""Add a single new notification from streaming/polling."""
|
||||||
|
return self.process_items([notification], play_sound=True)
|
||||||
|
|
||||||
class Conversation(BaseBuffer):
|
class Conversation(BaseBuffer):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(Conversation, self).__init__(*args, **kwargs)
|
super(Conversation, self).__init__(*args, **kwargs)
|
||||||
@@ -207,3 +222,154 @@ class LikesBuffer(BaseBuffer):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
return self.process_items(list(items), play_sound)
|
return self.process_items(list(items), play_sound)
|
||||||
|
|
||||||
|
|
||||||
|
class MentionsBuffer(BaseBuffer):
|
||||||
|
"""Buffer for mentions and replies to the current user."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# Use notification compose function since mentions come from notifications
|
||||||
|
kwargs["compose_func"] = "compose_notification"
|
||||||
|
super(MentionsBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "mentions"
|
||||||
|
self.sound = "mention_received.ogg"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
count = 50
|
||||||
|
try:
|
||||||
|
count = self.session.settings["general"].get("max_posts_per_call", 50)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.notification.list_notifications({"limit": count})
|
||||||
|
notifications = getattr(res, "notifications", [])
|
||||||
|
if not notifications:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Filter only mentions and replies
|
||||||
|
mentions = [
|
||||||
|
n for n in notifications
|
||||||
|
if getattr(n, "reason", "") in ("mention", "reply", "quote")
|
||||||
|
]
|
||||||
|
|
||||||
|
if not mentions:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return self.process_items(mentions, play_sound)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error fetching Bluesky mentions")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def add_new_item(self, notification):
|
||||||
|
"""Add a single new mention from streaming/polling."""
|
||||||
|
reason = getattr(notification, "reason", "")
|
||||||
|
if reason in ("mention", "reply", "quote"):
|
||||||
|
return self.process_items([notification], play_sound=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class SentBuffer(BaseBuffer):
|
||||||
|
"""Buffer for posts sent by the current user."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(SentBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "sent"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
count = 50
|
||||||
|
try:
|
||||||
|
count = self.session.settings["general"].get("max_posts_per_call", 50)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api or not api.me:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get author's own posts (excluding replies)
|
||||||
|
res = api.app.bsky.feed.get_author_feed({
|
||||||
|
"actor": api.me.did,
|
||||||
|
"limit": count,
|
||||||
|
"filter": "posts_no_replies"
|
||||||
|
})
|
||||||
|
items = getattr(res, "feed", [])
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return self.process_items(list(items), play_sound)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error fetching sent posts")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class SearchBuffer(BaseBuffer):
|
||||||
|
"""Buffer for search results (posts)."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.search_query = kwargs.pop("query", "")
|
||||||
|
super(SearchBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "search"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
if not self.search_query:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
count = 50
|
||||||
|
try:
|
||||||
|
count = self.session.settings["general"].get("max_posts_per_call", 50)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Search posts
|
||||||
|
res = api.app.bsky.feed.search_posts({
|
||||||
|
"q": self.search_query,
|
||||||
|
"limit": count
|
||||||
|
})
|
||||||
|
posts = getattr(res, "posts", [])
|
||||||
|
|
||||||
|
if not posts:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Clear existing results for new search
|
||||||
|
self.session.db[self.name] = []
|
||||||
|
self.buffer.list.clear()
|
||||||
|
|
||||||
|
return self.process_items(list(posts), play_sound)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error searching Bluesky posts")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def remove_buffer(self, force=False):
|
||||||
|
"""Search buffers can always be removed."""
|
||||||
|
try:
|
||||||
|
self.session.db.pop(self.name, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ class Controller(object):
|
|||||||
pub.subscribe(self.mastodon_new_conversation, "mastodon.conversation_received")
|
pub.subscribe(self.mastodon_new_conversation, "mastodon.conversation_received")
|
||||||
pub.subscribe(self.mastodon_error_post, "mastodon.error_post")
|
pub.subscribe(self.mastodon_error_post, "mastodon.error_post")
|
||||||
|
|
||||||
|
# Bluesky specific events.
|
||||||
|
pub.subscribe(self.blueski_new_item, "blueski.new_item")
|
||||||
|
|
||||||
# connect application events to GUI
|
# connect application events to GUI
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.CLOSE_EVENT, self.exit_)
|
widgetUtils.connect_event(self.view, widgetUtils.CLOSE_EVENT, self.exit_)
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.show_hide, menuitem=self.view.show_hide)
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.show_hide, menuitem=self.view.show_hide)
|
||||||
@@ -388,6 +391,12 @@ class Controller(object):
|
|||||||
"notifications": BlueskiTimelines.NotificationBuffer,
|
"notifications": BlueskiTimelines.NotificationBuffer,
|
||||||
"conversation": BlueskiTimelines.Conversation,
|
"conversation": BlueskiTimelines.Conversation,
|
||||||
"likes": BlueskiTimelines.LikesBuffer,
|
"likes": BlueskiTimelines.LikesBuffer,
|
||||||
|
"MentionsBuffer": BlueskiTimelines.MentionsBuffer,
|
||||||
|
"mentions": BlueskiTimelines.MentionsBuffer,
|
||||||
|
"SentBuffer": BlueskiTimelines.SentBuffer,
|
||||||
|
"sent": BlueskiTimelines.SentBuffer,
|
||||||
|
"SearchBuffer": BlueskiTimelines.SearchBuffer,
|
||||||
|
"search": BlueskiTimelines.SearchBuffer,
|
||||||
"UserBuffer": BlueskiUsers.UserBuffer,
|
"UserBuffer": BlueskiUsers.UserBuffer,
|
||||||
"FollowersBuffer": BlueskiUsers.FollowersBuffer,
|
"FollowersBuffer": BlueskiUsers.FollowersBuffer,
|
||||||
"FollowingBuffer": BlueskiUsers.FollowingBuffer,
|
"FollowingBuffer": BlueskiUsers.FollowingBuffer,
|
||||||
@@ -902,29 +911,13 @@ class Controller(object):
|
|||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
if hasattr(buffer, "add_to_favorites"): # Generic buffer method
|
if hasattr(buffer, "add_to_favorites"): # Generic buffer method
|
||||||
return buffer.add_to_favorites()
|
return buffer.add_to_favorites()
|
||||||
|
elif hasattr(buffer, "toggle_favorite"):
|
||||||
|
return buffer.toggle_favorite()
|
||||||
elif buffer.session and buffer.session.KIND == "blueski":
|
elif buffer.session and buffer.session.KIND == "blueski":
|
||||||
item_uri = buffer.get_selected_item_id()
|
# Fallback if buffer doesn't have the method but session is blueski (e.g. ChatBuffer)
|
||||||
if not item_uri:
|
# Chat messages can't be liked yet in this implementation, or handled by specific buffer
|
||||||
output.speak(_("No item selected to like."), True)
|
output.speak(_("This item cannot be liked."), True)
|
||||||
return
|
return
|
||||||
social_handler = self.get_handler(buffer.session.KIND)
|
|
||||||
async def _like():
|
|
||||||
result = await social_handler.like_item(buffer.session, item_uri)
|
|
||||||
wx.CallAfter(output.speak, result["message"], True) # Ensure UI updates on main thread
|
|
||||||
if result.get("status") == "success" and result.get("like_uri"):
|
|
||||||
if hasattr(buffer, "store_item_viewer_state"):
|
|
||||||
# Ensure store_item_viewer_state is called on main thread if it modifies UI/shared data
|
|
||||||
wx.CallAfter(buffer.store_item_viewer_state, item_uri, "like_uri", result["like_uri"])
|
|
||||||
# Also update the item in message_cache to reflect the like
|
|
||||||
if buffer.session and hasattr(buffer.session, "message_cache") and item_uri in buffer.session.message_cache:
|
|
||||||
cached_post = buffer.session.message_cache[item_uri]
|
|
||||||
if isinstance(cached_post, dict) and isinstance(cached_post.get("viewer"), dict):
|
|
||||||
cached_post["viewer"]["like"] = result["like_uri"]
|
|
||||||
elif hasattr(cached_post, "viewer") and cached_post.viewer: # SDK model
|
|
||||||
cached_post.viewer.like = result["like_uri"]
|
|
||||||
# No need to call buffer.update_item here unless it re-renders from scratch
|
|
||||||
# The visual feedback might come from a list refresh or specific item update later
|
|
||||||
asyncio.create_task(_like()) # wx.CallAfter for the task itself if _like might interact with UI before await
|
|
||||||
|
|
||||||
|
|
||||||
def remove_from_favourites(self, *args, **kwargs):
|
def remove_from_favourites(self, *args, **kwargs):
|
||||||
@@ -1475,7 +1468,12 @@ class Controller(object):
|
|||||||
output.speak(_(u"Updating buffer..."), True)
|
output.speak(_(u"Updating buffer..."), True)
|
||||||
session = bf.session
|
session = bf.session
|
||||||
|
|
||||||
async def do_update():
|
output.speak(_(u"Updating buffer..."), True)
|
||||||
|
session = bf.session
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def do_update_sync():
|
||||||
new_ids = []
|
new_ids = []
|
||||||
try:
|
try:
|
||||||
if session.KIND == "blueski":
|
if session.KIND == "blueski":
|
||||||
@@ -1483,26 +1481,34 @@ class Controller(object):
|
|||||||
count = bf.start_stream(mandatory=True)
|
count = bf.start_stream(mandatory=True)
|
||||||
if count: new_ids = [str(x) for x in range(count)]
|
if count: new_ids = [str(x) for x in range(count)]
|
||||||
else:
|
else:
|
||||||
output.speak(_(u"This buffer type cannot be updated."), True)
|
wx.CallAfter(output.speak, _(u"This buffer type cannot be updated."), True)
|
||||||
return
|
return
|
||||||
else: # Generic fallback for other sessions
|
else: # Generic fallback for other sessions
|
||||||
|
# If they are async, this might be tricky in a thread without a loop
|
||||||
|
# But most old sessions in TWBlue are sync (using threads)
|
||||||
if hasattr(bf, "start_stream"):
|
if hasattr(bf, "start_stream"):
|
||||||
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
|
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
|
||||||
if count: new_ids = [str(x) for x in range(count)]
|
if count: new_ids = [str(x) for x in range(count)]
|
||||||
else:
|
else:
|
||||||
output.speak(_(u"Unable to update this buffer."), True)
|
wx.CallAfter(output.speak, _(u"Unable to update this buffer."), True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Generic feedback
|
# Generic feedback
|
||||||
if bf.type in ["home_timeline", "user_timeline"]:
|
if bf.type in ["home_timeline", "user_timeline", "notifications", "mentions"]:
|
||||||
output.speak(_("{0} posts retrieved").format(len(new_ids)), True)
|
wx.CallAfter(output.speak, _("{0} new items.").format(len(new_ids)), True)
|
||||||
elif bf.type == "notifications":
|
|
||||||
output.speak(_("Notifications updated."), True)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Error updating buffer %s", bf.name)
|
log.exception("Error updating buffer %s", bf.name)
|
||||||
output.speak(_("An error occurred while updating the buffer."), True)
|
wx.CallAfter(output.speak, _("An error occurred while updating the buffer."), True)
|
||||||
|
|
||||||
wx.CallAfter(asyncio.create_task, do_update())
|
if session.KIND == "blueski":
|
||||||
|
threading.Thread(target=do_update_sync).start()
|
||||||
|
else:
|
||||||
|
# Original async logic for others if needed, but likely they are sync too.
|
||||||
|
# Assuming TWBlue architecture is mostly thread-based for legacy sessions.
|
||||||
|
# If we have an async loop running, we could use it for async-capable sessions.
|
||||||
|
# For safety, let's use the thread approach generally if we are not sure about the loop state.
|
||||||
|
threading.Thread(target=do_update_sync).start()
|
||||||
|
|
||||||
|
|
||||||
def get_more_items(self, *args, **kwargs):
|
def get_more_items(self, *args, **kwargs):
|
||||||
@@ -1637,6 +1643,33 @@ class Controller(object):
|
|||||||
# if "direct_messages" not in buffer.session.settings["other_buffers"]["muted_buffers"]:
|
# if "direct_messages" not in buffer.session.settings["other_buffers"]["muted_buffers"]:
|
||||||
# self.notify(buffer.session, sound_to_play)
|
# self.notify(buffer.session, sound_to_play)
|
||||||
|
|
||||||
|
def blueski_new_item(self, item, session_name, _buffers):
|
||||||
|
"""Handle new items from Bluesky polling."""
|
||||||
|
sound_to_play = None
|
||||||
|
for buff in _buffers:
|
||||||
|
buffer = self.search_buffer(buff, session_name)
|
||||||
|
if buffer is None or buffer.session.get_name() != session_name:
|
||||||
|
continue
|
||||||
|
if hasattr(buffer, "add_new_item"):
|
||||||
|
buffer.add_new_item(item)
|
||||||
|
# Determine sound to play
|
||||||
|
if buff == "notifications":
|
||||||
|
sound_to_play = "notification_received.ogg"
|
||||||
|
elif buff == "home_timeline":
|
||||||
|
sound_to_play = "tweet_received.ogg"
|
||||||
|
elif "timeline" in buff:
|
||||||
|
sound_to_play = "tweet_timeline.ogg"
|
||||||
|
else:
|
||||||
|
sound_to_play = None
|
||||||
|
# Play sound if buffer is not muted
|
||||||
|
if sound_to_play is not None:
|
||||||
|
try:
|
||||||
|
muted = buffer.session.settings["other_buffers"].get("muted_buffers", [])
|
||||||
|
if buff not in muted:
|
||||||
|
self.notify(buffer.session, sound_to_play)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def mastodon_error_post(self, name, reply_to, visibility, posts, language):
|
def mastodon_error_post(self, name, reply_to, visibility, posts, language):
|
||||||
home = self.search_buffer("home_timeline", name)
|
home = self.search_buffer("home_timeline", name)
|
||||||
if home != None:
|
if home != None:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def character_count(post_text, post_cw, character_limit=500):
|
|||||||
# We will use text for counting character limit only.
|
# We will use text for counting character limit only.
|
||||||
full_text = post_text+post_cw
|
full_text = post_text+post_cw
|
||||||
# find remote users as Mastodon doesn't count the domain in char limit.
|
# find remote users as Mastodon doesn't count the domain in char limit.
|
||||||
users = re.findall("@[\w\.-]+@[\w\.-]+", full_text)
|
users = re.findall(r"@[\w\.-]+@[\w\.-]+", full_text)
|
||||||
for user in users:
|
for user in users:
|
||||||
domain = user.split("@")[-1]
|
domain = user.split("@")[-1]
|
||||||
full_text = full_text.replace("@"+domain, "")
|
full_text = full_text.replace("@"+domain, "")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class EditTemplate(object):
|
|||||||
self.template: str = template
|
self.template: str = template
|
||||||
|
|
||||||
def validate_template(self, template: str) -> bool:
|
def validate_template(self, template: str) -> bool:
|
||||||
used_variables: List[str] = re.findall("\$\w+", template)
|
used_variables: List[str] = re.findall(r"\$\w+", template)
|
||||||
validated: bool = True
|
validated: bool = True
|
||||||
for var in used_variables:
|
for var in used_variables:
|
||||||
if var[1:] not in self.variables:
|
if var[1:] not in self.variables:
|
||||||
|
|||||||
@@ -1,275 +1,43 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import annotations
|
"""
|
||||||
|
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 logging
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
from datetime import datetime
|
|
||||||
import arrow
|
import arrow
|
||||||
import languageHandler
|
import languageHandler
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
log = logging.getLogger("sessions.blueski.compose")
|
||||||
from sessions.blueski.session import Session as BlueskiSession
|
|
||||||
from atproto.xrpc_client import models # For type hinting ATProto models
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# For SUPPORTED_LANG_CHOICES in composeDialog.py
|
|
||||||
SUPPORTED_LANG_CHOICES_COMPOSE = {
|
|
||||||
_("English"): "en", _("Spanish"): "es", _("French"): "fr", _("German"): "de",
|
|
||||||
_("Japanese"): "ja", _("Portuguese"): "pt", _("Russian"): "ru", _("Chinese"): "zh",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BlueskiCompose:
|
|
||||||
MAX_CHARS = 300
|
|
||||||
MAX_MEDIA_ATTACHMENTS = 4
|
|
||||||
MAX_LANGUAGES = 3
|
|
||||||
MAX_IMAGE_SIZE_BYTES = 1_000_000
|
|
||||||
|
|
||||||
def __init__(self, session: BlueskiSession) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.supported_media_types: list[str] = ["image/jpeg", "image/png"]
|
|
||||||
self.max_image_size_bytes: int = self.MAX_IMAGE_SIZE_BYTES
|
|
||||||
|
|
||||||
def get_panel_configuration(self) -> dict[str, Any]:
|
|
||||||
"""Returns configuration for the compose panel specific to Blueski."""
|
|
||||||
return {
|
|
||||||
"max_chars": self.MAX_CHARS,
|
|
||||||
"max_media_attachments": self.MAX_MEDIA_ATTACHMENTS,
|
|
||||||
"supports_content_warning": True,
|
|
||||||
"supports_scheduled_posts": False,
|
|
||||||
"supported_media_types": self.supported_media_types,
|
|
||||||
"max_media_size_bytes": self.max_image_size_bytes,
|
|
||||||
"supports_alternative_text": True,
|
|
||||||
"sensitive_reasons_options": self.session.get_sensitive_reason_options(),
|
|
||||||
"supports_language_selection": True,
|
|
||||||
"max_languages": self.MAX_LANGUAGES,
|
|
||||||
"supports_quoting": True,
|
|
||||||
"supports_polls": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_quote_text(self, message_id: str, url: str) -> str | None:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
async def get_reply_text(self, message_id: str, author_handle: str) -> str | None:
|
|
||||||
if not author_handle.startswith("@"):
|
|
||||||
return f"@{author_handle} "
|
|
||||||
return f"{author_handle} "
|
|
||||||
|
|
||||||
def get_text_formatting_rules(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"markdown_enabled": False,
|
|
||||||
"custom_emojis_enabled": False,
|
|
||||||
"max_length": self.MAX_CHARS,
|
|
||||||
"line_break_char": "\n",
|
|
||||||
"link_format": "Full URL (e.g., https://example.com)",
|
|
||||||
"mention_format": "@handle.bsky.social",
|
|
||||||
"tag_format": "#tag (becomes a facet link)",
|
|
||||||
}
|
|
||||||
|
|
||||||
def is_media_type_supported(self, mime_type: str) -> bool:
|
|
||||||
return mime_type.lower() in self.supported_media_types
|
|
||||||
|
|
||||||
def get_max_schedule_date(self) -> str | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_poll_configuration(self) -> dict[str, Any] | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def compose_post_for_display(self, post_data: dict[str, Any], session_settings: dict[str, Any] | None = None) -> str:
|
|
||||||
"""
|
|
||||||
Composes a string representation of a Bluesky post for display in UI timelines.
|
|
||||||
"""
|
|
||||||
if not post_data or not isinstance(post_data, dict):
|
|
||||||
return _("Invalid post data.")
|
|
||||||
|
|
||||||
author_info = post_data.get("author", {})
|
|
||||||
record = post_data.get("record", {})
|
|
||||||
embed_data = post_data.get("embed")
|
|
||||||
viewer_state = post_data.get("viewer", {})
|
|
||||||
|
|
||||||
display_name = author_info.get("displayName", "") or author_info.get("handle", _("Unknown User"))
|
|
||||||
handle = author_info.get("handle", _("unknown.handle"))
|
|
||||||
|
|
||||||
post_text = getattr(record, 'text', '') if not isinstance(record, dict) else record.get('text', '')
|
|
||||||
|
|
||||||
reason = post_data.get("reason")
|
|
||||||
if reason:
|
|
||||||
rtype = getattr(reason, "$type", "") if not isinstance(reason, dict) else reason.get("$type", "")
|
|
||||||
if not rtype and not isinstance(reason, dict):
|
|
||||||
rtype = getattr(reason, "py_type", "")
|
|
||||||
if rtype and "reasonRepost" in rtype:
|
|
||||||
by = getattr(reason, "by", None) if not isinstance(reason, dict) else reason.get("by")
|
|
||||||
by_handle = getattr(by, "handle", "") if by and not isinstance(by, dict) else (by.get("handle", "") if by else "")
|
|
||||||
reason_line = _("Reposted by @{handle}").format(handle=by_handle) if by_handle else _("Reposted")
|
|
||||||
post_text = f"{reason_line}\n{post_text}" if post_text else reason_line
|
|
||||||
|
|
||||||
created_at_str = getattr(record, 'createdAt', '') if not isinstance(record, dict) else record.get('createdAt', '')
|
|
||||||
timestamp_str = ""
|
|
||||||
if created_at_str:
|
|
||||||
try:
|
|
||||||
ts = arrow.get(created_at_str)
|
|
||||||
timestamp_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Could not parse timestamp {created_at_str}: {e}")
|
|
||||||
timestamp_str = created_at_str
|
|
||||||
|
|
||||||
header = f"{display_name} (@{handle}) - {timestamp_str}"
|
|
||||||
|
|
||||||
labels = post_data.get("labels", [])
|
|
||||||
spoiler_text = None
|
|
||||||
is_sensitive_post = False
|
|
||||||
if labels:
|
|
||||||
for label_obj in labels:
|
|
||||||
label_val = getattr(label_obj, 'val', '') if not isinstance(label_obj, dict) else label_obj.get('val', '')
|
|
||||||
if label_val == "!warn":
|
|
||||||
is_sensitive_post = True
|
|
||||||
elif label_val in ["porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
|
|
||||||
is_sensitive_post = True
|
|
||||||
if not spoiler_text: spoiler_text = _("Sensitive Content: {label}").format(label=label_val)
|
|
||||||
elif label_val.startswith("warn:") and len(label_val) > 5:
|
|
||||||
spoiler_text = label_val.split("warn:", 1)[-1].strip()
|
|
||||||
is_sensitive_post = True
|
|
||||||
|
|
||||||
post_text_display = post_text
|
|
||||||
if spoiler_text:
|
|
||||||
post_text_display = f"CW: {spoiler_text}\n\n{post_text}"
|
|
||||||
elif is_sensitive_post and not spoiler_text:
|
|
||||||
post_text_display = f"CW: {_('Sensitive Content')}\n\n{post_text}"
|
|
||||||
|
|
||||||
embed_display = ""
|
|
||||||
if embed_data:
|
|
||||||
embed_type = getattr(embed_data, '$type', '')
|
|
||||||
if not embed_type and isinstance(embed_data, dict): embed_type = embed_data.get('$type', '')
|
|
||||||
|
|
||||||
if embed_type in ['app.bsky.embed.images#view', 'app.bsky.embed.images']:
|
|
||||||
images = getattr(embed_data, 'images', []) if hasattr(embed_data, 'images') else embed_data.get('images', [])
|
|
||||||
if images:
|
|
||||||
img_count = len(images)
|
|
||||||
alt_texts_present = any(getattr(img, 'alt', '') for img in images if hasattr(img, 'alt')) or \
|
|
||||||
any(img_dict.get('alt', '') for img_dict in images if isinstance(img_dict, dict))
|
|
||||||
embed_display += f"\n[{img_count} Image"
|
|
||||||
if img_count > 1: embed_display += "s"
|
|
||||||
if alt_texts_present: embed_display += _(" (Alt text available)")
|
|
||||||
embed_display += "]"
|
|
||||||
|
|
||||||
elif embed_type in ['app.bsky.embed.record#view', 'app.bsky.embed.record', 'app.bsky.embed.recordWithMedia#view', 'app.bsky.embed.recordWithMedia']:
|
|
||||||
record_embed_data = getattr(embed_data, 'record', None) if hasattr(embed_data, 'record') else embed_data.get('record', None)
|
|
||||||
if record_embed_data and isinstance(record_embed_data, dict):
|
|
||||||
record_embed_data = record_embed_data.get("record") or record_embed_data
|
|
||||||
record_embed_type = getattr(record_embed_data, '$type', '')
|
|
||||||
if not record_embed_type and isinstance(record_embed_data, dict): record_embed_type = record_embed_data.get('$type', '')
|
|
||||||
|
|
||||||
if record_embed_type == 'app.bsky.embed.record#viewNotFound':
|
|
||||||
embed_display += f"\n[{_('Quoted post not found or unavailable')}]"
|
|
||||||
elif record_embed_type == 'app.bsky.embed.record#viewBlocked':
|
|
||||||
embed_display += f"\n[{_('Content from the quoted account is blocked')}]"
|
|
||||||
elif record_embed_data and (isinstance(record_embed_data, dict) or hasattr(record_embed_data, 'author')):
|
|
||||||
quote_author_info = getattr(record_embed_data, 'author', record_embed_data.get('author'))
|
|
||||||
quote_value = getattr(record_embed_data, 'value', record_embed_data.get('value'))
|
|
||||||
|
|
||||||
if quote_author_info and quote_value:
|
|
||||||
quote_author_handle = getattr(quote_author_info, 'handle', 'unknown')
|
|
||||||
quote_text_content = getattr(quote_value, 'text', '') if not isinstance(quote_value, dict) else quote_value.get('text', '')
|
|
||||||
quote_text_snippet = (quote_text_content[:75] + "...") if quote_text_content else _("post content")
|
|
||||||
embed_display += f"\n[ {_('Quote by')} @{quote_author_handle}: \"{quote_text_snippet}\" ]"
|
|
||||||
else:
|
|
||||||
embed_display += f"\n[{_('Quoted Post')}]"
|
|
||||||
|
|
||||||
elif embed_type in ['app.bsky.embed.external#view', 'app.bsky.embed.external']:
|
|
||||||
external_data = getattr(embed_data, 'external', None) if hasattr(embed_data, 'external') else embed_data.get('external', None)
|
|
||||||
if external_data:
|
|
||||||
ext_uri = getattr(external_data, 'uri', _('External Link'))
|
|
||||||
ext_title = getattr(external_data, 'title', '') or ext_uri
|
|
||||||
embed_display += f"\n[{_('Link')}: {ext_title}]"
|
|
||||||
|
|
||||||
reply_context_str = ""
|
|
||||||
actual_record = post_data.get("record", {})
|
|
||||||
reply_ref = getattr(actual_record, 'reply', None) if not isinstance(actual_record, dict) else actual_record.get('reply')
|
|
||||||
|
|
||||||
if reply_ref:
|
|
||||||
reply_context_str = f"[{_('In reply to a post')}] "
|
|
||||||
|
|
||||||
counts_str_parts = []
|
|
||||||
reply_count = post_data.get("replyCount", 0)
|
|
||||||
repost_count = post_data.get("repostCount", 0)
|
|
||||||
like_count = post_data.get("likeCount", 0)
|
|
||||||
|
|
||||||
if reply_count > 0: counts_str_parts.append(f"{_('Replies')}: {reply_count}")
|
|
||||||
if repost_count > 0: counts_str_parts.append(f"{_('Reposts')}: {repost_count}")
|
|
||||||
if like_count > 0: counts_str_parts.append(f"{_('Likes')}: {like_count}")
|
|
||||||
|
|
||||||
viewer_liked_uri = viewer_state.get("like") if isinstance(viewer_state, dict) else getattr(viewer_state, 'like', None)
|
|
||||||
viewer_reposted_uri = viewer_state.get("repost") if isinstance(viewer_state, dict) else getattr(viewer_state, 'repost', None)
|
|
||||||
|
|
||||||
if viewer_liked_uri: counts_str_parts.append(f"({_('Liked by you')})")
|
|
||||||
if viewer_reposted_uri: counts_str_parts.append(f"({_('Reposted by you')})")
|
|
||||||
|
|
||||||
counts_line = ""
|
|
||||||
if counts_str_parts:
|
|
||||||
counts_line = "\n" + " | ".join(counts_str_parts)
|
|
||||||
|
|
||||||
full_display = f"{header}\n{reply_context_str}{post_text_display}{embed_display}{counts_line}"
|
|
||||||
return full_display.strip()
|
|
||||||
|
|
||||||
def compose_notification_for_display(self, notif_data: dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
Composes a string representation of a Bluesky notification for display.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
notif_data: A dictionary representing the notification,
|
|
||||||
typically from BlueskiSession._handle_*_notification methods
|
|
||||||
which create an approve.notifications.Notification object and then
|
|
||||||
convert it to dict or pass relevant parts.
|
|
||||||
Expected keys: 'title', 'body', 'author_name', 'timestamp_dt', 'kind'.
|
|
||||||
The 'title' usually already contains the core action.
|
|
||||||
Returns:
|
|
||||||
A formatted string for display.
|
|
||||||
"""
|
|
||||||
if not notif_data or not isinstance(notif_data, dict):
|
|
||||||
return _("Invalid notification data.")
|
|
||||||
|
|
||||||
title = notif_data.get('title', _("Notification"))
|
|
||||||
body = notif_data.get('body', '')
|
|
||||||
author_name = notif_data.get('author_name') # Author of the action (e.g. who liked)
|
|
||||||
timestamp_dt = notif_data.get('timestamp_dt') # datetime object
|
|
||||||
|
|
||||||
timestamp_str = ""
|
|
||||||
if timestamp_dt and isinstance(timestamp_dt, datetime):
|
|
||||||
try:
|
|
||||||
timestamp_str = timestamp_dt.strftime("%I:%M %p - %b %d, %Y")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Could not format notification timestamp {timestamp_dt}: {e}")
|
|
||||||
timestamp_str = str(timestamp_dt)
|
|
||||||
|
|
||||||
display_parts = []
|
|
||||||
if timestamp_str:
|
|
||||||
display_parts.append(f"[{timestamp_str}]")
|
|
||||||
|
|
||||||
# Title already contains good info like "UserX liked your post"
|
|
||||||
display_parts.append(title)
|
|
||||||
|
|
||||||
if body: # Body might be text of a reply/mention/quote
|
|
||||||
# Truncate body if too long for a list display
|
|
||||||
body_snippet = (body[:100] + "...") if len(body) > 103 else body
|
|
||||||
display_parts.append(f"\"{body_snippet}\"")
|
|
||||||
|
|
||||||
return " ".join(display_parts).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def compose_post(post, db, settings, relative_times, show_screen_names=False, safe=True):
|
def compose_post(post, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||||
"""
|
"""
|
||||||
Compose a Bluesky post into a list of strings [User, Text, Date, Source].
|
Compose a Bluesky post into a list of strings for display.
|
||||||
post: dict or ATProto model object.
|
|
||||||
|
Args:
|
||||||
|
post: dict or ATProto model object (FeedViewPost or PostView)
|
||||||
|
db: Session database dict
|
||||||
|
settings: Session settings
|
||||||
|
relative_times: If True, use relative time formatting
|
||||||
|
show_screen_names: If True, show only @handle instead of display name
|
||||||
|
safe: If True, handle exceptions gracefully
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of strings: [User, Text, Date, Source]
|
||||||
"""
|
"""
|
||||||
# Extract data using getattr for models or .get for dicts
|
|
||||||
def g(obj, key, default=None):
|
def g(obj, key, default=None):
|
||||||
|
"""Helper to get attribute from dict or object."""
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return obj.get(key, default)
|
return obj.get(key, default)
|
||||||
return getattr(obj, key, default)
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
# Resolve Post View or Feed View structure
|
# Resolve Post View or Feed View structure
|
||||||
# Feed items often have .post field. Direct post objects don't.
|
# Feed items have .post field, direct post objects don't
|
||||||
actual_post = g(post, "post", post)
|
actual_post = g(post, "post", post)
|
||||||
|
|
||||||
record = g(actual_post, "record", {})
|
record = g(actual_post, "record", {})
|
||||||
@@ -282,7 +50,6 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
|
|||||||
if show_screen_names:
|
if show_screen_names:
|
||||||
user_str = f"@{handle}"
|
user_str = f"@{handle}"
|
||||||
else:
|
else:
|
||||||
# "Display Name (@handle)"
|
|
||||||
if handle and display_name != handle:
|
if handle and display_name != handle:
|
||||||
user_str = f"{display_name} (@{handle})"
|
user_str = f"{display_name} (@{handle})"
|
||||||
else:
|
else:
|
||||||
@@ -291,7 +58,7 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
|
|||||||
# Text
|
# Text
|
||||||
text = g(record, "text", "")
|
text = g(record, "text", "")
|
||||||
|
|
||||||
# Repost reason (so users know why they see an unfamiliar post)
|
# Repost reason
|
||||||
reason = g(post, "reason", None)
|
reason = g(post, "reason", None)
|
||||||
if reason:
|
if reason:
|
||||||
rtype = g(reason, "$type") or g(reason, "py_type")
|
rtype = g(reason, "$type") or g(reason, "py_type")
|
||||||
@@ -304,52 +71,49 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
|
|||||||
# Labels / Content Warning
|
# Labels / Content Warning
|
||||||
labels = g(actual_post, "labels", [])
|
labels = g(actual_post, "labels", [])
|
||||||
cw_text = ""
|
cw_text = ""
|
||||||
is_sensitive = False
|
|
||||||
|
|
||||||
for label in labels:
|
for label in labels:
|
||||||
val = g(label, "val", "")
|
val = g(label, "val", "")
|
||||||
if val in ["!warn", "porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
|
if val in ["!warn", "porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
|
||||||
is_sensitive = True
|
if not cw_text:
|
||||||
if not cw_text: cw_text = _("Sensitive Content")
|
cw_text = _("Sensitive Content")
|
||||||
elif val.startswith("warn:"):
|
elif val.startswith("warn:"):
|
||||||
is_sensitive = True
|
|
||||||
cw_text = val.split("warn:", 1)[-1].strip()
|
cw_text = val.split("warn:", 1)[-1].strip()
|
||||||
|
|
||||||
if cw_text:
|
if cw_text:
|
||||||
text = f"CW: {cw_text}\n\n{text}"
|
text = f"CW: {cw_text}\n\n{text}"
|
||||||
|
|
||||||
# Embeds (Images, Quotes)
|
# Embeds (Images, Quotes, Links)
|
||||||
embed = g(actual_post, "embed", None)
|
embed = g(actual_post, "embed", None)
|
||||||
if embed:
|
if embed:
|
||||||
etype = g(embed, "$type") or g(embed, "py_type")
|
etype = g(embed, "$type") or g(embed, "py_type")
|
||||||
|
|
||||||
|
# Images
|
||||||
if etype and ("images" in etype):
|
if etype and ("images" in etype):
|
||||||
images = g(embed, "images", [])
|
images = g(embed, "images", [])
|
||||||
if images:
|
if images:
|
||||||
text += f"\n[{len(images)} {_('Images')}]"
|
text += f"\n[{len(images)} {_('Images')}]"
|
||||||
|
|
||||||
# Handle Record (Quote) or RecordWithMedia (Quote + Media)
|
# Quote posts
|
||||||
quote_rec = None
|
quote_rec = None
|
||||||
if etype and ("recordWithMedia" in etype):
|
if etype and ("recordWithMedia" in etype):
|
||||||
# Extract the nested record
|
|
||||||
rec_embed = g(embed, "record", {})
|
rec_embed = g(embed, "record", {})
|
||||||
if rec_embed:
|
if rec_embed:
|
||||||
quote_rec = g(rec_embed, "record", None) or rec_embed
|
quote_rec = g(rec_embed, "record", None) or rec_embed
|
||||||
# Also check for media in the wrapper
|
# Media in wrapper
|
||||||
media = g(embed, "media", {})
|
media = g(embed, "media", {})
|
||||||
mtype = g(media, "$type") or g(media, "py_type")
|
mtype = g(media, "$type") or g(media, "py_type")
|
||||||
if mtype and "images" in mtype:
|
if mtype and "images" in mtype:
|
||||||
images = g(media, "images", [])
|
images = g(media, "images", [])
|
||||||
if images: text += f"\n[{len(images)} {_('Images')}]"
|
if images:
|
||||||
|
text += f"\n[{len(images)} {_('Images')}]"
|
||||||
|
|
||||||
elif etype and ("record" in etype):
|
elif etype and ("record" in etype):
|
||||||
# Direct quote
|
|
||||||
quote_rec = g(embed, "record", {})
|
quote_rec = g(embed, "record", {})
|
||||||
if isinstance(quote_rec, dict):
|
if isinstance(quote_rec, dict):
|
||||||
quote_rec = quote_rec.get("record") or quote_rec
|
quote_rec = quote_rec.get("record") or quote_rec
|
||||||
|
|
||||||
if quote_rec:
|
if quote_rec:
|
||||||
# It is likely a ViewRecord
|
|
||||||
# Check type (ViewRecord, ViewNotFound, ViewBlocked, etc)
|
|
||||||
qtype = g(quote_rec, "$type") or g(quote_rec, "py_type")
|
qtype = g(quote_rec, "$type") or g(quote_rec, "py_type")
|
||||||
|
|
||||||
if qtype and "viewNotFound" in qtype:
|
if qtype and "viewNotFound" in qtype:
|
||||||
@@ -357,14 +121,11 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
|
|||||||
elif qtype and "viewBlocked" in qtype:
|
elif qtype and "viewBlocked" in qtype:
|
||||||
text += f"\n[{_('Quoted post blocked')}]"
|
text += f"\n[{_('Quoted post blocked')}]"
|
||||||
elif qtype and "generatorView" in qtype:
|
elif qtype and "generatorView" in qtype:
|
||||||
# Feed generator
|
|
||||||
gen = g(quote_rec, "displayName", "Feed")
|
gen = g(quote_rec, "displayName", "Feed")
|
||||||
text += f"\n[{_('Quoting Feed')}: {gen}]"
|
text += f"\n[{_('Quoting Feed')}: {gen}]"
|
||||||
else:
|
else:
|
||||||
# Assume ViewRecord
|
|
||||||
q_author = g(quote_rec, "author", {})
|
q_author = g(quote_rec, "author", {})
|
||||||
q_handle = g(q_author, "handle", "unknown")
|
q_handle = g(q_author, "handle", "unknown")
|
||||||
|
|
||||||
q_val = g(quote_rec, "value", {})
|
q_val = g(quote_rec, "value", {})
|
||||||
q_text = g(q_val, "text", "")
|
q_text = g(q_val, "text", "")
|
||||||
|
|
||||||
@@ -375,17 +136,14 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
|
|||||||
|
|
||||||
elif etype and ("external" in etype):
|
elif etype and ("external" in etype):
|
||||||
ext = g(embed, "external", {})
|
ext = g(embed, "external", {})
|
||||||
uri = g(ext, "uri", "")
|
|
||||||
title = g(ext, "title", "")
|
title = g(ext, "title", "")
|
||||||
text += f"\n[{_('Link')}: {title}]"
|
text += f"\n[{_('Link')}: {title}]"
|
||||||
|
|
||||||
# Date
|
# Date
|
||||||
indexed_at = g(actual_post, "indexed_at", "")
|
indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "")
|
||||||
ts_str = ""
|
ts_str = ""
|
||||||
if indexed_at:
|
if indexed_at:
|
||||||
try:
|
try:
|
||||||
# Try arrow parsing
|
|
||||||
import arrow
|
|
||||||
ts = arrow.get(indexed_at)
|
ts = arrow.get(indexed_at)
|
||||||
if relative_times:
|
if relative_times:
|
||||||
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
|
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||||
@@ -394,19 +152,111 @@ def compose_post(post, db, settings, relative_times, show_screen_names=False, sa
|
|||||||
except Exception:
|
except Exception:
|
||||||
ts_str = str(indexed_at)[:16].replace("T", " ")
|
ts_str = str(indexed_at)[:16].replace("T", " ")
|
||||||
|
|
||||||
# Source (not always available in Bsky view, often just client)
|
# Source / Client
|
||||||
# We'll leave it empty or mock it if needed
|
|
||||||
source = "Bluesky"
|
source = "Bluesky"
|
||||||
|
|
||||||
|
# Viewer state (liked, reposted, etc.)
|
||||||
|
viewer_indicators = []
|
||||||
|
viewer = g(actual_post, "viewer") or g(post, "viewer")
|
||||||
|
if viewer:
|
||||||
|
if g(viewer, "like"):
|
||||||
|
viewer_indicators.append("♥") # Liked
|
||||||
|
if g(viewer, "repost"):
|
||||||
|
viewer_indicators.append("🔁") # Reposted
|
||||||
|
|
||||||
|
# Add viewer indicators to the source column or create a prefix for text
|
||||||
|
if viewer_indicators:
|
||||||
|
indicator_str = " ".join(viewer_indicators)
|
||||||
|
# Add to beginning of text for visibility
|
||||||
|
text = f"{indicator_str} {text}"
|
||||||
|
|
||||||
return [user_str, text, ts_str, source]
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notification: ATProto notification object
|
||||||
|
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: [User, Action/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:
|
||||||
|
user_str = f"{display_name} (@{handle})"
|
||||||
|
|
||||||
|
# Notification reason/type
|
||||||
|
reason = g(notification, "reason", "unknown")
|
||||||
|
|
||||||
|
# Map reason to user-readable text
|
||||||
|
reason_text_map = {
|
||||||
|
"like": _("liked your post"),
|
||||||
|
"repost": _("reposted your post"),
|
||||||
|
"follow": _("followed you"),
|
||||||
|
"mention": _("mentioned you"),
|
||||||
|
"reply": _("replied to you"),
|
||||||
|
"quote": _("quoted your post"),
|
||||||
|
"starterpack-joined": _("joined your starter pack"),
|
||||||
|
}
|
||||||
|
|
||||||
|
action_text = reason_text_map.get(reason, reason)
|
||||||
|
|
||||||
|
# For mentions/replies/quotes, include snippet of the text
|
||||||
|
record = g(notification, "record", {})
|
||||||
|
post_text = g(record, "text", "")
|
||||||
|
if post_text and reason in ["mention", "reply", "quote"]:
|
||||||
|
snippet = post_text[:100] + "..." if len(post_text) > 100 else post_text
|
||||||
|
action_text = f"{action_text}: {snippet}"
|
||||||
|
|
||||||
|
# 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, action_text, ts_str]
|
||||||
|
|
||||||
|
|
||||||
def compose_user(user, db, settings, relative_times, show_screen_names=False, safe=True):
|
def compose_user(user, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||||
"""
|
"""
|
||||||
Compose a Bluesky user for list display.
|
Compose a Bluesky user profile for list display.
|
||||||
Returns: [User summary string]
|
|
||||||
|
Args:
|
||||||
|
user: User profile 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: [User summary]
|
||||||
"""
|
"""
|
||||||
# Extract data using getattr for models or .get for dicts
|
|
||||||
def g(obj, key, default=None):
|
def g(obj, key, default=None):
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return obj.get(key, default)
|
return obj.get(key, default)
|
||||||
@@ -422,7 +272,6 @@ def compose_user(user, db, settings, relative_times, show_screen_names=False, sa
|
|||||||
ts = ""
|
ts = ""
|
||||||
if created_at:
|
if created_at:
|
||||||
try:
|
try:
|
||||||
import arrow
|
|
||||||
original_date = arrow.get(created_at)
|
original_date = arrow.get(created_at)
|
||||||
if relative_times:
|
if relative_times:
|
||||||
ts = original_date.humanize(locale=languageHandler.curLang[:2])
|
ts = original_date.humanize(locale=languageHandler.curLang[:2])
|
||||||
@@ -442,10 +291,21 @@ def compose_user(user, db, settings, relative_times, show_screen_names=False, sa
|
|||||||
|
|
||||||
return [" ".join(parts).strip()]
|
return [" ".join(parts).strip()]
|
||||||
|
|
||||||
|
|
||||||
def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True):
|
def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||||
"""
|
"""
|
||||||
Compose a Bluesky chat conversation for list display.
|
Compose a Bluesky chat conversation for list display.
|
||||||
Returns: [Participants, Last Message, Date]
|
|
||||||
|
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):
|
def g(obj, key, default=None):
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
@@ -454,6 +314,8 @@ def compose_convo(convo, db, settings, relative_times, show_screen_names=False,
|
|||||||
|
|
||||||
members = g(convo, "members", [])
|
members = g(convo, "members", [])
|
||||||
self_did = db.get("user_id") if isinstance(db, dict) else None
|
self_did = db.get("user_id") if isinstance(db, dict) else None
|
||||||
|
|
||||||
|
# Get other participants (exclude self)
|
||||||
others = []
|
others = []
|
||||||
for m in members:
|
for m in members:
|
||||||
did = g(m, "did", None)
|
did = g(m, "did", None)
|
||||||
@@ -461,34 +323,35 @@ def compose_convo(convo, db, settings, relative_times, show_screen_names=False,
|
|||||||
continue
|
continue
|
||||||
label = g(m, "displayName") or g(m, "display_name") or g(m, "handle", "unknown")
|
label = g(m, "displayName") or g(m, "display_name") or g(m, "handle", "unknown")
|
||||||
others.append(label)
|
others.append(label)
|
||||||
|
|
||||||
if not others:
|
if not others:
|
||||||
others = [g(m, "displayName") or g(m, "display_name") or g(m, "handle", "unknown") for m in members]
|
others = [g(m, "displayName") or g(m, "display_name") or g(m, "handle", "unknown") for m in members]
|
||||||
|
|
||||||
participants = ", ".join(others)
|
participants = ", ".join(others)
|
||||||
|
|
||||||
|
# Last message
|
||||||
last_msg_obj = g(convo, "lastMessage") or g(convo, "last_message")
|
last_msg_obj = g(convo, "lastMessage") or g(convo, "last_message")
|
||||||
last_text = ""
|
last_text = ""
|
||||||
last_sender = ""
|
last_sender = ""
|
||||||
|
|
||||||
if last_msg_obj:
|
if last_msg_obj:
|
||||||
last_text = g(last_msg_obj, "text", "")
|
last_text = g(last_msg_obj, "text", "")
|
||||||
sender = g(last_msg_obj, "sender", None)
|
sender = g(last_msg_obj, "sender", None)
|
||||||
if sender:
|
if sender:
|
||||||
last_sender = g(sender, "displayName") or g(sender, "display_name") or g(sender, "handle", "")
|
last_sender = g(sender, "displayName") or g(sender, "display_name") or g(sender, "handle", "")
|
||||||
|
|
||||||
# Date (using lastMessage.sentAt)
|
# Date
|
||||||
date_str = ""
|
date_str = ""
|
||||||
sent_at = None
|
|
||||||
if last_msg_obj:
|
if last_msg_obj:
|
||||||
sent_at = g(last_msg_obj, "sentAt") or g(last_msg_obj, "sent_at")
|
sent_at = g(last_msg_obj, "sentAt") or g(last_msg_obj, "sent_at")
|
||||||
|
|
||||||
if sent_at:
|
if sent_at:
|
||||||
try:
|
try:
|
||||||
import arrow
|
|
||||||
ts = arrow.get(sent_at)
|
ts = arrow.get(sent_at)
|
||||||
if relative_times:
|
if relative_times:
|
||||||
date_str = ts.humanize(locale=languageHandler.curLang[:2])
|
date_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||||
else:
|
else:
|
||||||
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||||
except:
|
except Exception:
|
||||||
date_str = str(sent_at)[:16]
|
date_str = str(sent_at)[:16]
|
||||||
|
|
||||||
if last_sender and last_text:
|
if last_sender and last_text:
|
||||||
@@ -498,10 +361,21 @@ def compose_convo(convo, db, settings, relative_times, show_screen_names=False,
|
|||||||
|
|
||||||
return [participants, last_text, date_str]
|
return [participants, last_text, date_str]
|
||||||
|
|
||||||
|
|
||||||
def compose_chat_message(msg, db, settings, relative_times, show_screen_names=False, safe=True):
|
def compose_chat_message(msg, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||||
"""
|
"""
|
||||||
Compose an individual chat message for display in a thread.
|
Compose an individual chat message for display.
|
||||||
Returns: [Sender, Text, Date]
|
|
||||||
|
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):
|
def g(obj, key, default=None):
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
@@ -513,17 +387,17 @@ def compose_chat_message(msg, db, settings, relative_times, show_screen_names=Fa
|
|||||||
|
|
||||||
text = g(msg, "text", "")
|
text = g(msg, "text", "")
|
||||||
|
|
||||||
|
# Date
|
||||||
sent_at = g(msg, "sentAt") or g(msg, "sent_at")
|
sent_at = g(msg, "sentAt") or g(msg, "sent_at")
|
||||||
date_str = ""
|
date_str = ""
|
||||||
if sent_at:
|
if sent_at:
|
||||||
try:
|
try:
|
||||||
import arrow
|
|
||||||
ts = arrow.get(sent_at)
|
ts = arrow.get(sent_at)
|
||||||
if relative_times:
|
if relative_times:
|
||||||
date_str = ts.humanize(locale=languageHandler.curLang[:2])
|
date_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||||
else:
|
else:
|
||||||
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||||
except:
|
except Exception:
|
||||||
date_str = str(sent_at)[:16]
|
date_str = str(sent_at)[:16]
|
||||||
|
|
||||||
return [handle, text, date_str]
|
return [handle, text, date_str]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from typing import Any
|
|||||||
|
|
||||||
import wx
|
import wx
|
||||||
|
|
||||||
|
from pubsub import pub
|
||||||
|
|
||||||
from sessions import base
|
from sessions import base
|
||||||
from sessions import session_exceptions as Exceptions
|
from sessions import session_exceptions as Exceptions
|
||||||
import output
|
import output
|
||||||
@@ -36,6 +38,9 @@ class Session(base.baseSession):
|
|||||||
self.type = "blueski"
|
self.type = "blueski"
|
||||||
self.char_limit = 300
|
self.char_limit = 300
|
||||||
self.api = None
|
self.api = None
|
||||||
|
self.poller = None
|
||||||
|
# Subscribe to pub/sub events from the poller
|
||||||
|
pub.subscribe(self.on_notification, "blueski.notification_received")
|
||||||
|
|
||||||
def _ensure_settings_namespace(self) -> None:
|
def _ensure_settings_namespace(self) -> None:
|
||||||
"""Migrate legacy atprotosocial settings to blueski namespace."""
|
"""Migrate legacy atprotosocial settings to blueski namespace."""
|
||||||
@@ -648,19 +653,119 @@ class Session(base.baseSession):
|
|||||||
|
|
||||||
def list_convos(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
def list_convos(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||||
api = self._ensure_client()
|
api = self._ensure_client()
|
||||||
res = api.chat.bsky.convo.list_convos({"limit": limit, "cursor": cursor})
|
# Chat API requires using the chat proxy
|
||||||
|
dm_client = api.with_bsky_chat_proxy()
|
||||||
|
res = dm_client.chat.bsky.convo.list_convos({"limit": limit, "cursor": cursor})
|
||||||
return {"items": res.convos, "cursor": res.cursor}
|
return {"items": res.convos, "cursor": res.cursor}
|
||||||
|
|
||||||
def get_convo_messages(self, convo_id: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
def get_convo_messages(self, convo_id: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||||
api = self._ensure_client()
|
api = self._ensure_client()
|
||||||
res = api.chat.bsky.convo.get_messages({"convoId": convo_id, "limit": limit, "cursor": cursor})
|
dm_client = api.with_bsky_chat_proxy()
|
||||||
|
res = dm_client.chat.bsky.convo.get_messages({"convoId": convo_id, "limit": limit, "cursor": cursor})
|
||||||
return {"items": res.messages, "cursor": res.cursor}
|
return {"items": res.messages, "cursor": res.cursor}
|
||||||
|
|
||||||
def send_chat_message(self, convo_id: str, text: str) -> Any:
|
def send_chat_message(self, convo_id: str, text: str) -> Any:
|
||||||
api = self._ensure_client()
|
api = self._ensure_client()
|
||||||
return api.chat.bsky.convo.send_message({
|
dm_client = api.with_bsky_chat_proxy()
|
||||||
|
return dm_client.chat.bsky.convo.send_message({
|
||||||
"convoId": convo_id,
|
"convoId": convo_id,
|
||||||
"message": {
|
"message": {
|
||||||
"text": text
|
"text": text
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -1,209 +1,196 @@
|
|||||||
from __future__ import annotations
|
# -*- 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 asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
import threading
|
||||||
|
import time
|
||||||
|
from pubsub import pub
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
log = logging.getLogger("sessions.blueski.streaming")
|
||||||
fromapprove.sessions.blueski.session import Session as BlueskiSession
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Blueski (Bluesky) uses a Firehose model for streaming.
|
|
||||||
# This typically involves connecting to a WebSocket endpoint and receiving events.
|
|
||||||
# The atproto SDK provides tools for this.
|
|
||||||
|
|
||||||
|
|
||||||
class BlueskiStreaming:
|
class BlueskyPoller:
|
||||||
def __init__(self, session: BlueskiSession, stream_type: str, params: dict[str, Any] | None = None) -> None:
|
"""
|
||||||
|
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 = session
|
||||||
self.stream_type = stream_type # e.g., 'user', 'public', 'hashtag' - will need mapping to Firehose concepts
|
self.session_name = session_name
|
||||||
self.params = params or {}
|
self.poll_interval = max(30, poll_interval) # Minimum 30 seconds to respect rate limits
|
||||||
self._handler: Callable[[dict[str, Any]], Coroutine[Any, Any, None]] | None = None
|
|
||||||
self._connection_task: asyncio.Task[None] | None = None
|
|
||||||
self._should_stop = False
|
|
||||||
# self._client = None # This would be an instance of atproto.firehose.FirehoseSubscribeReposClient or similar
|
|
||||||
|
|
||||||
# TODO: Map stream_type and params to ATProto Firehose subscription needs.
|
self._stop_event = threading.Event()
|
||||||
# For example, 'user' might mean subscribing to mentions, replies, follows for the logged-in user.
|
self._thread = None
|
||||||
# This would likely involve filtering the general repo firehose for relevant events,
|
self._last_notification_cursor = None
|
||||||
# or using a more specific subscription if available for user-level events.
|
self._last_seen_notification_uri = None
|
||||||
|
|
||||||
async def _connect(self) -> None:
|
def start(self):
|
||||||
"""Internal method to connect to the Blueski Firehose."""
|
"""Start the polling thread."""
|
||||||
# from atproto import AsyncClient
|
if self._thread is not None and self._thread.is_alive():
|
||||||
# from atproto.firehose import FirehoseSubscribeReposClient, parse_subscribe_repos_message
|
log.warning(f"Bluesky poller for {self.session_name} is already running.")
|
||||||
# from atproto.xrpc_client.models import get_or_create, ids, models
|
|
||||||
|
|
||||||
logger.info(f"Blueski streaming: Connecting to Firehose for user {self.session.user_id}, stream type {self.stream_type}")
|
|
||||||
self._should_stop = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# TODO: Replace with actual atproto SDK usage
|
|
||||||
# client = self.session.util.get_client() # Get authenticated client from session utils
|
|
||||||
# if not client or not client.me: # Check if client is authenticated
|
|
||||||
# logger.error("Blueski client not authenticated. Cannot start Firehose.")
|
|
||||||
# return
|
|
||||||
|
|
||||||
# self._firehose_client = FirehoseSubscribeReposClient(params=None, base_uri=self.session.api_base_url) # Adjust base_uri if needed
|
|
||||||
|
|
||||||
# async def on_message_handler(message: models.ComAtprotoSyncSubscribeRepos.Message) -> None:
|
|
||||||
# if self._should_stop:
|
|
||||||
# await self._firehose_client.stop() # Ensure client stops if flag is set
|
|
||||||
# return
|
|
||||||
|
|
||||||
# # This is a simplified example. Real implementation needs to:
|
|
||||||
# # 1. Determine the type of message (commit, handle, info, migrate, tombstone)
|
|
||||||
# # 2. For commits, unpack operations to find posts, likes, reposts, follows, etc.
|
|
||||||
# # 3. Filter these events to be relevant to the user (e.g., mentions, replies to user, new posts from followed users)
|
|
||||||
# # 4. Format the data into a structure that self._handle_event expects.
|
|
||||||
# # This filtering can be complex.
|
|
||||||
|
|
||||||
# # Example: if it's a commit and contains a new post that mentions the user
|
|
||||||
# # if isinstance(message, models.ComAtprotoSyncSubscribeRepos.Commit):
|
|
||||||
# # # This part is highly complex due to CAR CIBOR decoding
|
|
||||||
# # # Operations need to be extracted from the commit block
|
|
||||||
# # # For each op, check if it's a create, and if the record is a post
|
|
||||||
# # # Then, check if the post's text or facets mention the current user.
|
|
||||||
# # # This is a placeholder for that logic.
|
|
||||||
# # logger.debug(f"Firehose commit from {message.repo} at {message.time}")
|
|
||||||
# # # Example of processing ops (pseudo-code, actual decoding is more involved):
|
|
||||||
# # # ops = message.ops
|
|
||||||
# # # for op in ops:
|
|
||||||
# # # if op.action == 'create' and op.path.endswith('/app.bsky.feed.post/...'):
|
|
||||||
# # # record_data = ... # decode op.cid from message.blocks
|
|
||||||
# # # if self.session.util.is_mention_of_me(record_data):
|
|
||||||
# # # event_data = self.session.util.format_post_event(record_data)
|
|
||||||
# # # await self._handle_event("mention", event_data)
|
|
||||||
|
|
||||||
# # For now, we'll just log that a message was received
|
|
||||||
# logger.debug(f"Blueski Firehose message received: {message.__class__.__name__}")
|
|
||||||
|
|
||||||
|
|
||||||
# await self._firehose_client.start(on_message_handler)
|
|
||||||
|
|
||||||
# Placeholder loop to simulate receiving events
|
|
||||||
while not self._should_stop:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
# In a real implementation, this loop wouldn't exist; it'd be driven by the SDK's event handler.
|
|
||||||
# To simulate an event:
|
|
||||||
# if self._handler:
|
|
||||||
# mock_event = {"type": "placeholder_event", "data": {"text": "Hello from mock stream"}}
|
|
||||||
# await self._handler(mock_event) # Call the registered handler
|
|
||||||
|
|
||||||
logger.info(f"Blueski streaming: Placeholder loop for {self.session.user_id} stopped.")
|
|
||||||
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.info(f"Blueski streaming task for user {self.session.user_id} was cancelled.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Blueski streaming error for user {self.session.user_id}: {e}", exc_info=True)
|
|
||||||
# Optional: implement retry logic here or in the start_streaming method
|
|
||||||
if not self._should_stop:
|
|
||||||
await asyncio.sleep(30) # Wait before trying to reconnect (if auto-reconnect is desired)
|
|
||||||
if not self._should_stop: # Check again before restarting
|
|
||||||
self._connection_task = asyncio.create_task(self._connect())
|
|
||||||
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# if self._firehose_client:
|
|
||||||
# await self._firehose_client.stop()
|
|
||||||
logger.info(f"Blueski streaming connection closed for user {self.session.user_id}.")
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_event(self, event_type: str, data: dict[str, Any]) -> None:
|
|
||||||
"""
|
|
||||||
Internal method to process an event from the stream and pass it to the session's handler.
|
|
||||||
"""
|
|
||||||
if self._handler:
|
|
||||||
try:
|
|
||||||
# The data should be transformed into a common format expected by session.handle_streaming_event
|
|
||||||
# This is where Blueski-specific event data is mapped to Approve's internal event structure.
|
|
||||||
# For example, an Blueski 'mention' event needs to be structured similarly to
|
|
||||||
# how a Mastodon 'mention' event would be.
|
|
||||||
await self.session.handle_streaming_event(event_type, data)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error handling Blueski streaming event type {event_type}: {e}", exc_info=True)
|
|
||||||
else:
|
|
||||||
logger.warning(f"Blueski streaming: No handler registered for session {self.session.user_id}, event: {event_type}")
|
|
||||||
|
|
||||||
|
|
||||||
def start_streaming(self, handler: Callable[[dict[str, Any]], Coroutine[Any, Any, None]]) -> None:
|
|
||||||
"""Starts the streaming connection."""
|
|
||||||
if self._connection_task and not self._connection_task.done():
|
|
||||||
logger.warning(f"Blueski streaming already active for user {self.session.user_id}.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self._handler = handler # This handler is what session.py's handle_streaming_event calls
|
self._stop_event.clear()
|
||||||
self._should_stop = False
|
self._thread = threading.Thread(
|
||||||
logger.info(f"Blueski streaming: Starting for user {self.session.user_id}, type: {self.stream_type}")
|
target=self._poll_loop,
|
||||||
self._connection_task = asyncio.create_task(self._connect())
|
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
|
||||||
|
|
||||||
async def stop_streaming(self) -> None:
|
self._stop_event.set()
|
||||||
"""Stops the streaming connection."""
|
self._thread.join(timeout=5)
|
||||||
logger.info(f"Blueski streaming: Stopping for user {self.session.user_id}")
|
self._thread = None
|
||||||
self._should_stop = True
|
log.info(f"Bluesky poller stopped for {self.session_name}")
|
||||||
# if self._firehose_client: # Assuming the SDK has a stop method
|
|
||||||
# await self._firehose_client.stop()
|
|
||||||
|
|
||||||
if self._connection_task:
|
def is_alive(self):
|
||||||
if not self._connection_task.done():
|
"""Check if the polling thread is running."""
|
||||||
self._connection_task.cancel()
|
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:
|
try:
|
||||||
await self._connection_task
|
self._check_notifications()
|
||||||
except asyncio.CancelledError:
|
except Exception as e:
|
||||||
logger.info(f"Blueski streaming task successfully cancelled for {self.session.user_id}.")
|
log.exception(f"Error in Bluesky polling loop for {self.session_name}: {e}")
|
||||||
self._connection_task = None
|
|
||||||
self._handler = None
|
|
||||||
logger.info(f"Blueski streaming stopped for user {self.session.user_id}.")
|
|
||||||
|
|
||||||
def is_alive(self) -> bool:
|
# Wait for next poll interval, checking stop event periodically
|
||||||
"""Checks if the streaming connection is currently active."""
|
for _ in range(self.poll_interval):
|
||||||
# return self._connection_task is not None and not self._connection_task.done() and self._firehose_client and self._firehose_client.is_connected
|
if self._stop_event.is_set():
|
||||||
return self._connection_task is not None and not self._connection_task.done() # Simplified check
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
def get_stream_type(self) -> str:
|
log.debug(f"Polling loop ended for {self.session_name}")
|
||||||
return self.stream_type
|
|
||||||
|
|
||||||
def get_params(self) -> dict[str, Any]:
|
def _check_notifications(self):
|
||||||
return self.params
|
"""Check for new notifications and publish events."""
|
||||||
|
if not self.session.logged:
|
||||||
|
return
|
||||||
|
|
||||||
# TODO: Add methods specific to Blueski streaming if necessary,
|
try:
|
||||||
# e.g., methods to modify subscription details on the fly if the API supports it.
|
api = self.session._ensure_client()
|
||||||
# For Bluesky Firehose, this might not be applicable as you usually connect and filter client-side.
|
if not api:
|
||||||
# However, if there were different Firehose endpoints (e.g., one for public posts, one for user-specific events),
|
return
|
||||||
# this class might manage multiple connections or re-establish with new parameters.
|
|
||||||
|
|
||||||
# Example of how events might be processed (highly simplified):
|
# Fetch recent notifications
|
||||||
# This would be called by the on_message_handler in _connect
|
res = api.app.bsky.notification.list_notifications({"limit": 20})
|
||||||
# async def _process_firehose_message(self, message: models.ComAtprotoSyncSubscribeRepos.Message):
|
notifications = getattr(res, "notifications", [])
|
||||||
# if isinstance(message, models.ComAtprotoSyncSubscribeRepos.Commit):
|
|
||||||
# # Decode CAR file in message.blocks to get ops
|
if not notifications:
|
||||||
# # For each op (create, update, delete of a record):
|
return
|
||||||
# # record = get_record_from_blocks(message.blocks, op.cid)
|
|
||||||
# # if op.path.startswith("app.bsky.feed.post"): # It's a post
|
# Track which notifications are new
|
||||||
# # # Check if it's a new post, a reply, a quote, etc.
|
new_notifications = []
|
||||||
# # # Check for mentions of the current user
|
newest_uri = None
|
||||||
# # # Example:
|
|
||||||
# # if self.session.util.is_mention_of_me(record):
|
for notif in notifications:
|
||||||
# # formatted_event = self.session.util.format_post_as_notification(record, "mention")
|
uri = getattr(notif, "uri", None)
|
||||||
# # await self._handle_event("mention", formatted_event)
|
if not uri:
|
||||||
# # elif op.path.startswith("app.bsky.graph.follow"):
|
continue
|
||||||
# # # Check if it's a follow of the current user
|
|
||||||
# # if record.subject == self.session.util.get_my_did(): # Assuming get_my_did() exists
|
# First time running - just record the newest and don't flood
|
||||||
# # formatted_event = self.session.util.format_follow_as_notification(record)
|
if self._last_seen_notification_uri is None:
|
||||||
# # await self._handle_event("follow", formatted_event)
|
newest_uri = uri
|
||||||
# # # Handle likes (app.bsky.feed.like), reposts (app.bsky.feed.repost), etc.
|
break
|
||||||
# pass
|
|
||||||
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Handle):
|
# Check if we've seen this notification before
|
||||||
# # Handle DID to handle mapping updates if necessary
|
if uri == self._last_seen_notification_uri:
|
||||||
# logger.debug(f"Handle update: {message.handle} now points to {message.did} at {message.time}")
|
break
|
||||||
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Migrate):
|
|
||||||
# logger.info(f"Repo migration: {message.did} migrating from {message.migrateTo} at {message.time}")
|
new_notifications.append(notif)
|
||||||
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Tombstone):
|
if newest_uri is None:
|
||||||
# logger.info(f"Repo tombstone: {message.did} at {message.time}")
|
newest_uri = uri
|
||||||
# elif isinstance(message, models.ComAtprotoSyncSubscribeRepos.Info):
|
|
||||||
# logger.info(f"Firehose info: {message.name} - {message.message}")
|
# Update last seen
|
||||||
# else:
|
if newest_uri:
|
||||||
# logger.debug(f"Unknown Firehose message type: {message.__class__.__name__}")
|
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)
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
fromapprove.translation import translate as _
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
fromapprove.sessions.blueski.session import Session as BlueskiSession
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class BlueskiTemplates:
|
|
||||||
def __init__(self, session: BlueskiSession) -> None:
|
|
||||||
self.session = session
|
|
||||||
|
|
||||||
def get_template_data(self, template_name: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Returns data required for rendering a specific template for Blueski.
|
|
||||||
This method would populate template variables based on the template name and context.
|
|
||||||
"""
|
|
||||||
base_data = {
|
|
||||||
"session_kind": self.session.kind,
|
|
||||||
"session_label": self.session.label,
|
|
||||||
"user_id": self.session.user_id,
|
|
||||||
# Add any other common data needed by Blueski templates
|
|
||||||
}
|
|
||||||
if context:
|
|
||||||
base_data.update(context)
|
|
||||||
|
|
||||||
# TODO: Implement specific data fetching for different Blueski templates
|
|
||||||
# Example:
|
|
||||||
# if template_name == "profile_summary.html":
|
|
||||||
# # profile_info = await self.session.util.get_my_profile_info() # Assuming such a method exists
|
|
||||||
# # base_data["profile"] = profile_info
|
|
||||||
# base_data["profile"] = {"display_name": "User", "handle": "user.bsky.social"} # Placeholder
|
|
||||||
# elif template_name == "post_details.html":
|
|
||||||
# # post_id = context.get("post_id")
|
|
||||||
# # post_details = await self.session.util.get_post_by_id(post_id)
|
|
||||||
# # base_data["post"] = post_details
|
|
||||||
# base_data["post"] = {"text": "A sample post", "author_handle": "author.bsky.social"} # Placeholder
|
|
||||||
|
|
||||||
return base_data
|
|
||||||
|
|
||||||
def get_message_card_template(self) -> str:
|
|
||||||
"""Returns the path to the message card template for Blueski."""
|
|
||||||
# This template would define how a single Blueski post (or other message type)
|
|
||||||
# is rendered in a list (e.g., in a timeline or search results).
|
|
||||||
# return "sessions/blueski/cards/message.html" # Example path
|
|
||||||
return "sessions/generic/cards/message_generic.html" # Placeholder, use generic if no specific yet
|
|
||||||
|
|
||||||
def get_notification_template_map(self) -> dict[str, str]:
|
|
||||||
"""
|
|
||||||
Returns a map of Blueski notification types to their respective template paths.
|
|
||||||
"""
|
|
||||||
# TODO: Define templates for different Blueski notification types
|
|
||||||
# (e.g., mention, reply, new follower, like, repost).
|
|
||||||
# The keys should match the notification types used internally by Approve
|
|
||||||
# when processing Blueski events.
|
|
||||||
# Example:
|
|
||||||
# return {
|
|
||||||
# "mention": "sessions/blueski/notifications/mention.html",
|
|
||||||
# "reply": "sessions/blueski/notifications/reply.html",
|
|
||||||
# "follow": "sessions/blueski/notifications/follow.html",
|
|
||||||
# "like": "sessions/blueski/notifications/like.html", # Bluesky uses 'like'
|
|
||||||
# "repost": "sessions/blueski/notifications/repost.html", # Bluesky uses 'repost'
|
|
||||||
# # ... other notification types
|
|
||||||
# }
|
|
||||||
# Using generic templates as placeholders:
|
|
||||||
return {
|
|
||||||
"mention": "sessions/generic/notifications/mention.html",
|
|
||||||
"reply": "sessions/generic/notifications/reply.html",
|
|
||||||
"follow": "sessions/generic/notifications/follow.html",
|
|
||||||
"like": "sessions/generic/notifications/favourite.html", # Map to favourite if generic expects that
|
|
||||||
"repost": "sessions/generic/notifications/reblog.html", # Map to reblog if generic expects that
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_settings_template(self) -> str | None:
|
|
||||||
"""Returns the path to the settings template for Blueski, if any."""
|
|
||||||
# This template would be used to render Blueski-specific settings in the UI.
|
|
||||||
# return "sessions/blueski/settings.html"
|
|
||||||
return "sessions/generic/settings_auth_password.html" # If using simple handle/password auth
|
|
||||||
|
|
||||||
def get_user_action_templates(self) -> dict[str, str] | None:
|
|
||||||
"""
|
|
||||||
Returns a map of user action identifiers to their template paths for Blueski.
|
|
||||||
User actions are typically buttons or forms displayed on a user's profile.
|
|
||||||
"""
|
|
||||||
# TODO: Define templates for Blueski user actions
|
|
||||||
# Example:
|
|
||||||
# return {
|
|
||||||
# "view_profile_on_bsky": "sessions/blueski/actions/view_profile_button.html",
|
|
||||||
# "send_direct_message": "sessions/blueski/actions/send_dm_form.html", # If DMs are supported
|
|
||||||
# }
|
|
||||||
return None # Placeholder
|
|
||||||
|
|
||||||
def get_user_list_action_templates(self) -> dict[str, str] | None:
|
|
||||||
"""
|
|
||||||
Returns a map of user list action identifiers to their template paths for Blueski.
|
|
||||||
These actions might appear on lists of users (e.g., followers, following).
|
|
||||||
"""
|
|
||||||
# TODO: Define templates for Blueski user list actions
|
|
||||||
# Example:
|
|
||||||
# return {
|
|
||||||
# "follow_all_visible": "sessions/blueski/list_actions/follow_all_button.html",
|
|
||||||
# }
|
|
||||||
return None # Placeholder
|
|
||||||
|
|
||||||
# Add any other template-related helper methods specific to Blueski.
|
|
||||||
# For example, methods to get templates for specific types of content (images, polls)
|
|
||||||
# if they need special rendering.
|
|
||||||
|
|
||||||
def get_template_for_message_type(self, message_type: str) -> str | None:
|
|
||||||
"""
|
|
||||||
Returns a specific template path for a given message type (e.g., post, reply, quote).
|
|
||||||
This can be useful if different types of messages need distinct rendering beyond the standard card.
|
|
||||||
"""
|
|
||||||
# TODO: Define specific templates if Blueski messages have varied structures
|
|
||||||
# that require different display logic.
|
|
||||||
# if message_type == "quote_post":
|
|
||||||
# return "sessions/blueski/cards/quote_post.html"
|
|
||||||
return None # Default to standard message card if not specified
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user