mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-06 09:27:33 +01:00
feat: Initial integration of ATProtoSocial (Bluesky) protocol
This commit introduces the initial implementation for supporting the ATProtoSocial (Bluesky) protocol within your application.
Key changes and features I implemented:
1. **Core Protocol Structure:**
* I added new directories `src/sessions/atprotosocial` and `src/controller/atprotosocial`.
* I populated these with foundational files (`session.py`, `utils.py`, `handler.py`, `compose.py`, etc.), mirroring the Mastodon implementation structure but adapted for ATProtoSocial.
2. **Authentication:**
* I implemented login and authorization using Bluesky SDK (handle and app password) in `sessions/atprotosocial/session.py`.
* I integrated this into your session management UI (`sessionManagerDialog.py`) to allow adding ATProtoSocial accounts.
3. **Posting Capabilities:**
* I implemented sending text posts, posts with images, replies, and quoting posts in `sessions/atprotosocial/session.py` and `utils.py`.
* I updated `compose.py` to reflect ATProtoSocial's panel configuration (character limits, media support, quoting).
4. **Notifications:**
* I implemented fetching and processing of notifications (likes, reposts, follows, mentions, replies, quotes) in `sessions/atprotosocial/session.py`.
* Notifications are formatted for display.
5. **Timelines:**
* I implemented fetching and processing for home timeline and user-specific timelines in `sessions/atprotosocial/session.py`.
* This includes handling of posts, reposts, and replies within your application's buffer and message cache system.
6. **User Actions:**
* I implemented core user actions: follow, unfollow, mute, unmute, block, unblock in `sessions/atprotosocial/utils.py`.
* I integrated these actions into the controller layer (`controller/atprotosocial/handler.py`) and exposed them via `session.get_user_actions()`.
7. **User Management & Profile:**
* I implemented fetching user profiles, follower lists, following lists, and user search in `sessions/atprotosocial/utils.py` and `controller/atprotosocial/userList.py`.
8. **UI Integration (Initial Pass):**
* I adapted your session management UI for ATProtoSocial account creation.
* I updated main controller logic to load the ATProtoSocial handler and create basic buffers (Home, Notifications).
* I modified menu item labels based on the active session type (e.g., "Post" vs "Toot", "Like" vs "Favorite").
* I integrated core actions like reposting and liking into existing UI flows.
* I added basic integration for timeline refresh and loading more items.
* I added placeholder integration for viewing user profiles and user-specific timelines.
**Current Status & Next Steps:**
This represents a significant portion of the ATProtoSocial integration. The backend logic for most core features is in place. The immediate next steps, which were part of the original plan but not yet completed, would be:
* **Refining UI elements:** Fully implementing dedicated dialogs (compose, user profile), custom panels for new buffer types, and ensuring accurate rendering of ATProtoSocial posts and notifications.
* **Completing Documentation:** Updating all relevant documentation files in `doc/` and `documentation/`.
* **Updating Translations:** Adding new strings and updating translation files.
* **Adding Tests:** Creating unit and integration tests for the new protocol.
I was not stuck on any particular point, but the UI integration is a large step that requires iterative refinement and testing for each component, which would naturally extend beyond a single development cycle for a feature of this scope.
This commit is contained in:
@@ -11,10 +11,12 @@ import paths
|
||||
import config_utils
|
||||
import config
|
||||
import application
|
||||
import asyncio # For async event handling
|
||||
from pubsub import pub
|
||||
from controller import settings
|
||||
from sessions.mastodon import session as MastodonSession
|
||||
from sessions.gotosocial import session as GotosocialSession
|
||||
from sessions.atprotosocial import session as ATProtoSocialSession # Import ATProtoSocial session
|
||||
from . import manager
|
||||
from . import wxUI as view
|
||||
|
||||
@@ -35,7 +37,8 @@ class sessionManagerController(object):
|
||||
# Initialize the manager, responsible for storing session objects.
|
||||
manager.setup()
|
||||
self.view = view.sessionManagerWindow()
|
||||
pub.subscribe(self.manage_new_account, "sessionmanager.new_account")
|
||||
# Using CallAfter to handle async method from pubsub
|
||||
pub.subscribe(lambda type: wx.CallAfter(asyncio.create_task, self.manage_new_account(type)), "sessionmanager.new_account")
|
||||
pub.subscribe(self.remove_account, "sessionmanager.remove_account")
|
||||
if self.started == False:
|
||||
pub.subscribe(self.configuration, "sessionmanager.configuration")
|
||||
@@ -67,12 +70,28 @@ class sessionManagerController(object):
|
||||
continue
|
||||
if config_test.get("mastodon") != None:
|
||||
name = _("{account_name}@{instance} (Mastodon)").format(account_name=config_test["mastodon"]["user_name"], instance=config_test["mastodon"]["instance"].replace("https://", ""))
|
||||
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "":
|
||||
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "": # Basic validation
|
||||
sessionsList.append(name)
|
||||
self.sessions.append(dict(type=config_test["mastodon"].get("type", "mastodon"), id=i))
|
||||
else:
|
||||
elif config_test.get("atprotosocial") != None: # Check for ATProtoSocial config
|
||||
handle = config_test["atprotosocial"].get("handle")
|
||||
did = config_test["atprotosocial"].get("did") # DID confirms it was authorized
|
||||
if handle and did:
|
||||
name = _("{handle} (Bluesky)").format(handle=handle)
|
||||
sessionsList.append(name)
|
||||
self.sessions.append(dict(type="atprotosocial", id=i))
|
||||
else: # Incomplete config, might be an old attempt or error
|
||||
log.warning(f"Incomplete ATProtoSocial session config found for {i}, skipping.")
|
||||
# Optionally delete malformed config here too
|
||||
try:
|
||||
log.debug("Deleting incomplete ATProtoSocial session %s" % (i,))
|
||||
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||
except Exception as e:
|
||||
log.exception(f"Error deleting incomplete ATProtoSocial session {i}: {e}")
|
||||
continue
|
||||
else: # Unknown or other session type not explicitly handled here for display
|
||||
try:
|
||||
log.debug("Deleting session %s" % (i,))
|
||||
log.debug("Deleting session %s with unknown type" % (i,))
|
||||
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||
except:
|
||||
output.speak("An exception was raised while attempting to clean malformed session data. See the error log for details. If this message persists, contact the developers.",True)
|
||||
@@ -97,30 +116,92 @@ class sessionManagerController(object):
|
||||
s = MastodonSession.Session(i.get("id"))
|
||||
elif i.get("type") == "gotosocial":
|
||||
s = GotosocialSession.Session(i.get("id"))
|
||||
s.get_configuration()
|
||||
if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
|
||||
try:
|
||||
s.login()
|
||||
except Exception as e:
|
||||
log.exception("Exception during login on a TWBlue session.")
|
||||
continue
|
||||
sessions.sessions[i.get("id")] = s
|
||||
self.new_sessions[i.get("id")] = s
|
||||
elif i.get("type") == "atprotosocial": # Handle ATProtoSocial session type
|
||||
s = ATProtoSocialSession.Session(i.get("id"))
|
||||
else:
|
||||
log.warning(f"Unknown session type '{i.get('type')}' for ID {i.get('id')}. Skipping.")
|
||||
continue
|
||||
|
||||
s.get_configuration() # Assumes get_configuration() exists and is useful for all session types
|
||||
# For ATProtoSocial, this loads from its specific config file.
|
||||
|
||||
# Login is now primarily handled by session.start() via mainController,
|
||||
# which calls _ensure_dependencies_ready().
|
||||
# Explicit s.login() here might be redundant or premature if full app context isn't ready.
|
||||
# We'll rely on the mainController to call session.start() which handles login.
|
||||
# if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
|
||||
# try:
|
||||
# # For ATProtoSocial, login is async and handled by session.start()
|
||||
# # if not s.is_ready(): # Only attempt login if not already ready
|
||||
# # log.info(f"Session {s.uid} ({s.kind}) not ready, login will be attempted by start().")
|
||||
# pass
|
||||
# except Exception as e:
|
||||
# log.exception(f"Exception during pre-emptive login check for session {s.uid} ({s.kind}).")
|
||||
# continue
|
||||
sessions.sessions[i.get("id")] = s # Add to global session store
|
||||
self.new_sessions[i.get("id")] = s # Track as a new session for this manager instance
|
||||
# self.view.destroy()
|
||||
|
||||
def show_auth_error(self):
|
||||
error = view.auth_error()
|
||||
error = view.auth_error() # This seems to be a generic auth error display
|
||||
|
||||
def manage_new_account(self, type):
|
||||
async def manage_new_account(self, type): # Made async
|
||||
# Generic settings for all account types.
|
||||
location = (str(time.time())[-6:])
|
||||
location = (str(time.time())[-6:]) # Unique ID for the session config directory
|
||||
log.debug("Creating %s session in the %s path" % (type, location))
|
||||
|
||||
s: sessions.base.baseSession | None = None # Type hint for session object
|
||||
|
||||
if type == "mastodon":
|
||||
s = MastodonSession.Session(location)
|
||||
result = s.authorise()
|
||||
if result == True:
|
||||
self.sessions.append(dict(id=location, type=s.settings["mastodon"].get("type")))
|
||||
self.view.add_new_session_to_list()
|
||||
elif type == "atprotosocial":
|
||||
s = ATProtoSocialSession.Session(location)
|
||||
# Add other session types here if needed (e.g., gotosocial)
|
||||
# elif type == "gotosocial":
|
||||
# s = GotosocialSession.Session(location)
|
||||
|
||||
if not s:
|
||||
log.error(f"Unsupported session type for creation: {type}")
|
||||
self.view.show_unauthorised_error() # Or a more generic "cannot create" error
|
||||
return
|
||||
|
||||
try:
|
||||
result = await s.authorise() # Call the (now potentially async) authorise method
|
||||
if result == True:
|
||||
# Session config (handle, did for atproto) should be saved by authorise/login.
|
||||
# Here we just update the session manager's internal list and UI.
|
||||
session_type_for_dict = type # Store the actual type string
|
||||
if hasattr(s, 'settings') and s.settings and s.settings.get(type) and s.settings[type].get("type"):
|
||||
# Mastodon might have a more specific type in its settings (e.g. gotosocial)
|
||||
session_type_for_dict = s.settings[type].get("type")
|
||||
|
||||
self.sessions.append(dict(id=location, type=session_type_for_dict))
|
||||
self.view.add_new_session_to_list() # This should update the UI list
|
||||
# The session object 's' itself isn't stored in self.new_sessions until do_ok if app is restarting
|
||||
# But for immediate use if not restarting, it might need to be added to sessions.sessions
|
||||
sessions.sessions[location] = s # Make it globally available immediately
|
||||
self.new_sessions[location] = s
|
||||
|
||||
|
||||
else: # Authorise returned False or None
|
||||
self.view.show_unauthorised_error()
|
||||
# Clean up the directory if authorization failed and nothing was saved
|
||||
if os.path.exists(os.path.join(paths.config_path(), location)):
|
||||
try:
|
||||
shutil.rmtree(os.path.join(paths.config_path(), location))
|
||||
log.info(f"Cleaned up directory for failed auth: {location}")
|
||||
except Exception as e_rm:
|
||||
log.error(f"Error cleaning up directory {location} after failed auth: {e_rm}")
|
||||
except Exception as e:
|
||||
log.error(f"Error during new account authorization for type {type}: {e}", exc_info=True)
|
||||
self.view.show_unauthorised_error() # Show generic error
|
||||
# Clean up
|
||||
if os.path.exists(os.path.join(paths.config_path(), location)):
|
||||
try:
|
||||
shutil.rmtree(os.path.join(paths.config_path(), location))
|
||||
except Exception as e_rm:
|
||||
log.error(f"Error cleaning up directory {location} after exception: {e_rm}")
|
||||
|
||||
|
||||
def remove_account(self, index):
|
||||
selected_account = self.sessions[index]
|
||||
|
||||
Reference in New Issue
Block a user