2026-02-01 10:42:05 +01:00
|
|
|
# -*- 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.
|
|
|
|
|
"""
|
2025-05-26 14:11:01 +00:00
|
|
|
|
|
|
|
|
import logging
|
2026-02-01 10:42:05 +01:00
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
from pubsub import pub
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
log = logging.getLogger("sessions.blueski.streaming")
|
2025-05-26 14:11:01 +00:00
|
|
|
|
|
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
class BlueskyPoller:
|
|
|
|
|
"""
|
|
|
|
|
Polling-based update system for Bluesky.
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
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.
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
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)
|
|
|
|
|
"""
|
2025-05-26 14:11:01 +00:00
|
|
|
self.session = session
|
2026-02-01 10:42:05 +01:00
|
|
|
self.session_name = session_name
|
|
|
|
|
self.poll_interval = max(30, poll_interval) # Minimum 30 seconds to respect rate limits
|
|
|
|
|
|
|
|
|
|
self._stop_event = threading.Event()
|
|
|
|
|
self._thread = None
|
|
|
|
|
self._last_notification_cursor = None
|
|
|
|
|
self._last_seen_notification_uri = None
|
|
|
|
|
|
|
|
|
|
def start(self):
|
|
|
|
|
"""Start the polling thread."""
|
|
|
|
|
if self._thread is not None and self._thread.is_alive():
|
|
|
|
|
log.warning(f"Bluesky poller for {self.session_name} is already running.")
|
|
|
|
|
return
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
self._stop_event.clear()
|
|
|
|
|
self._thread = threading.Thread(
|
|
|
|
|
target=self._poll_loop,
|
|
|
|
|
name=f"BlueskyPoller-{self.session_name}",
|
|
|
|
|
daemon=True
|
|
|
|
|
)
|
|
|
|
|
self._thread.start()
|
|
|
|
|
log.info(f"Bluesky poller started for {self.session_name} (interval: {self.poll_interval}s)")
|
|
|
|
|
|
|
|
|
|
def stop(self):
|
|
|
|
|
"""Stop the polling thread."""
|
|
|
|
|
if self._thread is None:
|
|
|
|
|
return
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
self._stop_event.set()
|
|
|
|
|
self._thread.join(timeout=5)
|
|
|
|
|
self._thread = None
|
|
|
|
|
log.info(f"Bluesky poller stopped for {self.session_name}")
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
def is_alive(self):
|
|
|
|
|
"""Check if the polling thread is running."""
|
|
|
|
|
return self._thread is not None and self._thread.is_alive()
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
def _poll_loop(self):
|
|
|
|
|
"""Main polling loop running in background thread."""
|
|
|
|
|
log.debug(f"Polling loop started for {self.session_name}")
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
# Initial delay to let the app fully initialize
|
|
|
|
|
time.sleep(5)
|
|
|
|
|
|
|
|
|
|
while not self._stop_event.is_set():
|
2025-05-26 14:11:01 +00:00
|
|
|
try:
|
2026-02-01 10:42:05 +01:00
|
|
|
self._check_notifications()
|
2025-05-26 14:11:01 +00:00
|
|
|
except Exception as e:
|
2026-02-01 10:42:05 +01:00
|
|
|
log.exception(f"Error in Bluesky polling loop for {self.session_name}: {e}")
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
# Wait for next poll interval, checking stop event periodically
|
|
|
|
|
for _ in range(self.poll_interval):
|
|
|
|
|
if self._stop_event.is_set():
|
|
|
|
|
break
|
|
|
|
|
time.sleep(1)
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
log.debug(f"Polling loop ended for {self.session_name}")
|
|
|
|
|
|
|
|
|
|
def _check_notifications(self):
|
|
|
|
|
"""Check for new notifications and publish events."""
|
|
|
|
|
if not self.session.logged:
|
2025-05-26 14:11:01 +00:00
|
|
|
return
|
|
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
try:
|
|
|
|
|
api = self.session._ensure_client()
|
|
|
|
|
if not api:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Fetch recent notifications
|
|
|
|
|
res = api.app.bsky.notification.list_notifications({"limit": 20})
|
|
|
|
|
notifications = getattr(res, "notifications", [])
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
if not notifications:
|
|
|
|
|
return
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
# Track which notifications are new
|
|
|
|
|
new_notifications = []
|
|
|
|
|
newest_uri = None
|
2025-05-26 14:11:01 +00:00
|
|
|
|
2026-02-01 10:42:05 +01:00
|
|
|
for notif in notifications:
|
|
|
|
|
uri = getattr(notif, "uri", None)
|
|
|
|
|
if not uri:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# First time running - just record the newest and don't flood
|
|
|
|
|
if self._last_seen_notification_uri is None:
|
|
|
|
|
newest_uri = uri
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
# Check if we've seen this notification before
|
|
|
|
|
if uri == self._last_seen_notification_uri:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
new_notifications.append(notif)
|
|
|
|
|
if newest_uri is None:
|
|
|
|
|
newest_uri = uri
|
|
|
|
|
|
|
|
|
|
# Update last seen
|
|
|
|
|
if newest_uri:
|
|
|
|
|
self._last_seen_notification_uri = newest_uri
|
|
|
|
|
|
|
|
|
|
# Publish new notifications (in reverse order so oldest first)
|
|
|
|
|
for notif in reversed(new_notifications):
|
|
|
|
|
self._publish_notification(notif)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log.debug(f"Error checking notifications for {self.session_name}: {e}")
|
|
|
|
|
|
|
|
|
|
def _publish_notification(self, notification):
|
|
|
|
|
"""Publish a notification event via pub/sub."""
|
|
|
|
|
try:
|
|
|
|
|
reason = getattr(notification, "reason", "unknown")
|
|
|
|
|
log.debug(f"Publishing Bluesky notification: {reason} for {self.session_name}")
|
|
|
|
|
|
|
|
|
|
pub.sendMessage(
|
|
|
|
|
"blueski.notification_received",
|
|
|
|
|
notification=notification,
|
|
|
|
|
session_name=self.session_name
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Also publish specific events for certain notification types
|
|
|
|
|
if reason == "mention":
|
|
|
|
|
pub.sendMessage(
|
|
|
|
|
"blueski.mention_received",
|
|
|
|
|
notification=notification,
|
|
|
|
|
session_name=self.session_name
|
|
|
|
|
)
|
|
|
|
|
elif reason == "reply":
|
|
|
|
|
pub.sendMessage(
|
|
|
|
|
"blueski.reply_received",
|
|
|
|
|
notification=notification,
|
|
|
|
|
session_name=self.session_name
|
|
|
|
|
)
|
|
|
|
|
elif reason == "follow":
|
|
|
|
|
pub.sendMessage(
|
|
|
|
|
"blueski.follow_received",
|
|
|
|
|
notification=notification,
|
|
|
|
|
session_name=self.session_name
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log.exception(f"Error publishing notification event: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_poller(session, session_name, poll_interval=60):
|
|
|
|
|
"""
|
|
|
|
|
Factory function to create a BlueskyPoller instance.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
session: The Bluesky session instance
|
|
|
|
|
session_name: Unique identifier for this session
|
|
|
|
|
poll_interval: Seconds between polls (default 60)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
BlueskyPoller instance
|
|
|
|
|
"""
|
|
|
|
|
return BlueskyPoller(session, session_name, poll_interval)
|