This commit is contained in:
Jesús Pavón Abián
2026-01-10 19:46:53 +01:00
55 changed files with 1504 additions and 407 deletions

342
CLAUDE.md Normal file
View File

@@ -0,0 +1,342 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
TWBlue is an accessible desktop Mastodon client for Windows, built with Python 3.10 and wxPython. It provides two specialized interfaces optimized for screen reader users to interact with Mastodon instances. The application emphasizes accessibility-first design with keyboard navigation, audio feedback, and screen reader integration.
## Development Commands
### Running from Source
```bash
cd src
python main.py
```
For development from source, VLC dependencies are loaded from `../windows-dependencies/{arch}/` where arch is x86 or x64.
### Installing Dependencies
```bash
# Install all Python dependencies
pip install -r requirements.txt
# Initialize git submodules for Windows dependencies
git submodule init
git submodule update
```
### Building
```bash
# Build binary distribution (from src/ directory)
python setup.py build
# Output will be in src/dist/
```
### Testing
```bash
# Run tests using pytest
pytest
# Tests are located in src/test/
```
### Generating Documentation
```bash
cd doc
python documentation_importer.py
python generator.py
# Copy generated language folders to src/documentation/
# Copy license.txt to src/documentation/
```
### Translation Management
```bash
# Extract translation strings (from doc/ directory)
pybabel extract -o twblue.pot --msgid-bugs-address "manuel@manuelcortez.net" --copyright-holder "MCV software" --input-dirs ../src
# Note: Translations managed via Weblate at https://weblate.mcvsoftware.com
```
## Architecture Overview
TWBlue follows an MVC architecture with distinct separation between data access (Sessions), business logic (Controllers), and presentation (wxUI).
### Core Components
#### 1. Session Layer (`src/sessions/`)
Sessions represent authenticated connections to Mastodon instances. They manage API interactions, OAuth2 authentication, and persistent data storage.
- **Base Session** (`sessions/base.py`): Abstract base class with configuration management, SQLiteDict persistence, and decorators for login/configuration checks
- **Mastodon Session** (`sessions/mastodon/session.py`): Implements Mastodon.py API wrapper, OAuth2 flow, and account credential management
- **Streaming** (`sessions/mastodon/streaming.py`): Real-time event listener that publishes to pub/sub system
Key patterns:
- Sessions use `@_require_login` and `@_require_configuration` decorators
- Configuration files stored as INI format via configobj in `config/{session_id}/session.conf`
- Persistent data (caches, user lists) stored in `config/{session_id}/cache.db` using SQLiteDict
- Each session has its own sound system instance
#### 2. Buffer System (`src/controller/buffers/`)
Buffers are the primary data structures for displaying social media content (timelines, mentions, notifications, conversations, etc.).
**Base Buffer** (`controller/buffers/base/base.py`):
- Links buffer UI (wxPanel) with session (API access) and compose functions (data rendering)
- Handles keyboard events (F5/F6 for volume, Delete for item removal, Return for URLs)
- Manages periodic updates via `start_stream()` function
- Each buffer has a `compose_function` that formats API data for display
**Mastodon Buffers** (`controller/buffers/mastodon/`):
- `base.py`: Mastodon-specific base buffer with timeline pagination
- `users.py`: Home timeline, mentions buffer
- `community.py`: Local/federated timelines
- `notifications.py`: System notifications
- `conversations.py`: Direct message threads
- `search.py`: Search results
Buffer lifecycle:
1. Created by mainController when session initializes
2. Added to view (wx.Treebook)
3. Periodically updated via `start_stream()` or real-time via pub/sub events
4. Destroyed when session ends or buffer removed
#### 3. Controller Layer (`src/controller/`)
Controllers orchestrate application logic and coordinate between sessions, buffers, and UI.
**Main Controller** (`controller/mainController.py`):
- Manages all active buffers and sessions
- Binds keyboard shortcuts to actions
- Handles pub/sub event subscriptions
- Periodically calls `start_stream()` on visible buffers
- Provides buffer search methods: `search_buffer()`, `get_current_buffer()`, `get_best_buffer()`
**Specialized Controllers**:
- `settings.py`: Settings dialog management
- `userAlias.py` / `userList.py`: User management features
- `mastodon/handler.py`: Mastodon-specific operations (filters, etc.)
#### 4. GUI Layer (`src/wxUI/`)
wxPython-based interface with menu-driven navigation and list controls.
- **Main Frame** (`wxUI/view.py`): Primary window with wx.Treebook for buffers, menu system, system tray integration
- **Buffer Panels** (`wxUI/buffers/`): Panel implementations for each buffer type
- **Dialogs** (`wxUI/dialogs/`): Post composition, settings, user profiles, filters
#### 5. Pub/Sub Event System
Decoupled communication using PyPubSub 4.0.3.
Key events:
- `mastodon.status_received`: New post received via streaming
- `mastodon.status_updated`: Post edited
- `mastodon.notification_received`: New notification
- `mastodon.conversation_received`: New DM
Event flow:
1. Streaming listener receives API event
2. Publishes to topic via `pub.sendMessage()`
3. mainController subscribes to topics and routes to appropriate buffer
4. Buffer updates its display
#### 6. Session Manager (`src/sessionmanager/`)
Manages session lifecycle (creation, configuration, activation, deletion).
- `sessionManager.py`: UI for managing multiple accounts
- `manager.py`: Persists session list to global config
- Handles OAuth2 authorization flow for new accounts
- Loads saved sessions on startup
#### 7. Configuration System (`src/config.py`, `src/config_utils.py`)
Hierarchical configuration with defaults and user overrides.
- Global config: `config/app-configuration.conf` (defaults in `src/app-configuration.defaults`)
- Session configs: `config/{session_id}/session.conf` (defaults in `src/mastodon.defaults`)
- Keymaps in `src/keymaps/`
- Sound packs in `src/sounds/`
**Path Management** (`src/paths.py`):
- Portable mode: Config/logs in application directory
- Installed mode: Config/logs in AppData
- Detects installation by presence of `Uninstall.exe`
#### 8. Accessibility Features
Built for screen reader users from the ground up.
- `accessible_output2`: Multi-screen reader support (NVDA, JAWS, SAPI, etc.)
- `sound_lib`: Accessible audio playback with spatial audio
- `platform_utils`: OS-specific accessibility hooks
- `output.py`: Unified interface for speech output
- `sound.py`: Sound system with volume control and sound pack management
#### 9. Keyboard Handling (`src/keyboard_handler/`)
Cross-platform keyboard input with global hotkey support.
- `wx_handler.py`: wxPython integration
- `global_handler.py`: System-wide hotkeys
- Platform implementations: `windows.py`, `osx.py`, `linux.py`
- `keystrokeEditor/`: UI for customizing shortcuts
### Application Initialization Flow
From `src/main.py`:
1. Setup logging to temp directory, then move to permanent location
2. Initialize language handler
3. Load global configuration
4. Setup sound system
5. Setup accessibility output
6. Initialize session manager
7. Load saved sessions or prompt for account creation
8. Create main controller
9. Start main event loop
### Data Flow Patterns
#### Real-time Update Flow
```
Mastodon Streaming API
→ sessions/mastodon/streaming.py (StreamListener)
→ pub.sendMessage("mastodon.status_received", ...)
→ controller/mainController.py (subscriber)
→ buffer.add_new_item()
→ compose_function(item)
→ wxUI update
```
#### User Action Flow
```
Keyboard input
→ wx event handler
→ buffer.get_event()
→ buffer action method (e.g., open_status())
→ session.api_call()
→ UI update or pub/sub event
```
#### Periodic Update Flow
```
RepeatingTimer (every N seconds)
→ mainController calls buffer.start_stream()
→ session.get_timeline_data()
→ buffer.put_items_on_list()
→ compose_function for each item
→ wxUI list control update
```
## Key Design Patterns and Conventions
### Compose Functions
Buffers use compose functions to render API objects as user-readable strings. Located in `sessions/mastodon/compose.py`:
```python
compose_function(item, db, relative_times, show_screen_names=False, session=None)
# Returns a string representation of the item for display
```
### Session Decorators
Sessions use decorators to enforce prerequisites:
```python
@baseSession._require_login
def post_status(self, text):
# Only executes if self.logged == True
pass
@baseSession._require_configuration
def get_timeline(self):
# Only executes if self.settings != None
pass
```
### Buffer Naming Convention
Buffers have both a `name` (internal identifier) and `account` (associated username):
- `name`: e.g., "home_timeline", "mentions", "notifications"
- `account`: e.g., "user@mastodon.social"
- Buffers are uniquely identified by (name, account) tuple
### Configuration Hierarchy
1. Default values in `src/*.defaults` files
2. User overrides in `config/*.conf` files
3. Runtime modifications via settings dialogs
4. Written back to user config files on change
## Important Caveats
### Platform-Specific Code
- VLC paths must be set via environment variables when running from source (see `main.py`)
- Windows-specific: pywin32, win-inet-pton, winpaths dependencies
- Accessibility output works best on Windows with NVDA/JAWS
### Threading and Event Handling
- API calls often wrapped in `call_threaded()` to avoid blocking UI
- Streaming runs in background thread and publishes to main thread via pub/sub
- wx events must be handled on main thread
### Session Lifecycle
- Sessions must be logged in before buffer creation
- Buffers maintain references to sessions via `self.session`
- Destroying a session should destroy all associated buffers
- Session settings auto-save on write via `settings.write()`
### Buffer Visibility
- Buffers have `invisible` flag for internal/system buffers
- Main controller distinguishes between visible buffers (shown in tree) and invisible buffers (used for data access)
- Empty buffers serve as account placeholders in tree structure
### Logging and Debugging
- Logs written to temp directory on startup, then moved to permanent location
- Binary builds redirect stdout/stderr to `logs/` directory
- Source builds use console output
- Use `logging.getLogger("module.name")` pattern throughout
## Build System Details
### cx_Freeze Configuration (`src/setup.py`)
- Target: Win32GUI (suppresses console window)
- Includes: keymaps, locales, sounds, documentation, icon, config defaults
- Architecture-specific: Loads x86 or x64 dependencies from windows-dependencies submodule
- Special handling for enchant dictionaries, VLC plugins, VC++ redistributables
### NSIS Installer (`scripts/twblue.nsi`)
- Expects binary distribution in `scripts/twblue64/`
- Creates Start Menu shortcuts, Desktop shortcut (optional)
- Registers uninstaller
- Checks for running instances before install/uninstall
### CI/CD (`.github/workflows/release.yml`)
- Triggers on version tags (v20*)
- Builds on Windows-latest with Python 3.10
- Creates both installer (EXE) and portable (ZIP) distributions
- Uploads to GitHub releases
## Mastodon API Integration
### Authentication
OAuth2 flow implemented in `sessions/mastodon/session.py`:
1. Create application credentials for instance
2. Request OAuth authorization URL
3. User authorizes in browser
4. Exchange code for access token
5. Store credentials in session config
### API Client
Uses Mastodon.py 2.1.4 library:
- Instance created with base URL and access token
- Methods: `status_post()`, `timeline()`, `account()`, etc.
- Rate limiting handled by library
- Supports multiple instances simultaneously
### Streaming API
Real-time updates via `sessions/mastodon/streaming.py`:
- Inherits from `Mastodon.StreamListener`
- Connects to user, public, or hashtag streams
- Runs in background thread
- Events published to main thread via pub/sub
## Localization
TWBlue supports 23 languages:
- Translation files in `src/locales/{lang}/LC_MESSAGES/twblue.mo`
- Uses gettext with `_()` function throughout codebase
- Language selection in settings, stored in global config
- Babel for extraction and compilation
- Weblate for translation management

104
debug_config_save.py Normal file
View File

@@ -0,0 +1,104 @@
import sys
import os
import shutil
# Add src to path
sys.path.insert(0, os.path.join(os.getcwd(), 'src'))
import config_utils
from configobj import ConfigObj
import logging
# Setup simple logging
logging.basicConfig(level=logging.DEBUG)
def test_config_save():
print("Beginning Config Save Test")
# 1. Setup paths
config_dir = os.path.join(os.getcwd(), 'test_config_dir')
if os.path.exists(config_dir):
shutil.rmtree(config_dir)
os.mkdir(config_dir)
session_id = "test_session"
session_dir = os.path.join(config_dir, session_id)
os.mkdir(session_dir)
config_path = os.path.join(session_dir, "session.conf")
# We use the ACTUAL atproto.defaults from src
spec_path = os.path.join(os.getcwd(), 'src', 'atproto.defaults')
print(f"Config Path: {config_path}")
print(f"Spec Path: {spec_path}")
if not os.path.exists(spec_path):
print("ERROR: Spec file not found at", spec_path)
return
# 2. Simulate Load & Create
print("\n--- Loading Config (create empty) ---")
try:
# Mimic session.get_configuration
config = config_utils.load_config(config_path, spec_path)
except Exception as e:
print("Error loading config:", e)
return
# 3. Modify Values
print("\n--- Modifying Values ---")
# Check if section exists, if not, create it
if 'atproto' not in config:
print("Section 'atproto' missing (expected for new file). Using defaults from spec?")
# ConfigObj with spec should automatically have sections if create_empty=True?
# Actually config_utils.load_config sets create_empty=True
# Let's inspect what we have
print("Current Config Keys:", config.keys())
# If section is missing (it might be if file was empty and defaults didn't force creation yet?), force create
if 'atproto' not in config:
print("Creating 'atproto' section manually (simulating what might happen if defaults don't auto-create structure)")
config['atproto'] = {}
config['atproto']['handle'] = "test_user.bsky.social"
config['atproto']['session_string'] = "fake_session_string_12345"
print(f"Set handle: {config['atproto']['handle']}")
print(f"Set session_string: {config['atproto']['session_string']}")
# 4. Write
print("\n--- Writing Config ---")
config.write()
print("Write called.")
# 5. Read Back from Disk (Raw)
print("\n--- Reading Back (Raw Text) ---")
if os.path.exists(config_path):
with open(config_path, 'r') as f:
content = f.read()
print("File Content:")
print(content)
if "session_string = fake_session_string_12345" in content:
print("SUCCESS: Session string found in file.")
else:
print("FAILURE: Session string NOT found in file.")
else:
print("FAILURE: File does not exist.")
# 6. Read Back (using config_utils again)
print("\n--- Reading Back (config_utils) ---")
config2 = config_utils.load_config(config_path, spec_path)
val = config2['atproto']['session_string']
print(f"Read session_string: {val}")
if val == "fake_session_string_12345":
print("SUCCESS: Read back correct value.")
else:
print("FAILURE: Read back mismatched value.")
if __name__ == "__main__":
test_config_save()

View File

@@ -30,10 +30,10 @@ Global settings and session settings
TWBlue has two different configuration dialogs: the global configuration dialog, which affects how TWBlue works for all sessions, and the session configuration dialog, which only affects how the current session works. You will find specific information about the session settings dialog for Twitter and Mastodon in its corresponding chapter in this guide.
ATProtoSocial / Bluesky Specific Terms
Blueski / Bluesky Specific Terms
++++++++++++++++++++++++++++++++++++++
When using the ATProtoSocial (Bluesky) integration, you might encounter these terms:
When using the Blueski (Bluesky) integration, you might encounter these terms:
* **Handle**: Your user-facing address on Bluesky (e.g., ``@username.bsky.social`` or a custom domain like ``@yourname.com``). This is what you use to log in with an App Password in TWBlue. Handles can be changed, but your DID remains the same.
* **App Password**: A specific password you generate within your Bluesky account settings (usually under Settings -> Advanced -> App passwords) for use with third-party applications like TWBlue. This is more secure than using your main account password, as each App Password can be revoked individually.

View File

@@ -1,12 +1,12 @@
.. _atprotosocial_bluesky:
.. _blueski_bluesky:
**************************************
ATProtoSocial (Bluesky) Integration
Blueski (Bluesky) Integration
**************************************
TWBlue now supports the AT Protocol (ATProto), the decentralized social networking protocol that powers Bluesky. This allows you to interact with your Bluesky account directly within TWBlue.
Adding an ATProtoSocial Account
Adding a Blueski Account
===============================
To connect your Bluesky account to TWBlue, you will need your user **handle** and an **App Password**.
@@ -22,7 +22,7 @@ Once you have your handle and the App Password:
1. Open TWBlue and go to the Session Manager (Application Menu -> Manage accounts).
2. Click on "New account".
3. Select "ATProtoSocial (Bluesky)" from the menu.
3. Select "Blueski (Bluesky)" from the menu.
4. A dialog will prompt you to confirm that you want to authorize your account. Click "Yes".
5. You will then be asked for your Bluesky Handle. Enter your full handle (e.g., ``@username.bsky.social`` or ``username.bsky.social``).
6. Next, you will be asked for the App Password you generated. Enter it carefully.
@@ -31,11 +31,12 @@ Once you have your handle and the App Password:
Key Features
============
Once your ATProtoSocial account is connected, you can use the following features in TWBlue:
Once your Blueski account is connected, you can use the following features in TWBlue:
* **Posting**: Create new posts (often called "skeets") with text, images, and specify language.
* **Timelines**:
* **Home Timeline (Skyline)**: View posts from users you follow.
* **Discover (algorithmic)**: A home feed curated by Bluesky.
* **Following (chronological)**: View posts from users you follow in order.
* **User Timelines**: View posts from specific users.
* **Mentions & Replies**: These will appear in your Notifications.
* **Notifications**: Receive notifications for likes, reposts, follows, mentions, replies, and quotes.
@@ -47,7 +48,7 @@ Once your ATProtoSocial account is connected, you can use the following features
* **User Search**: Search for users by their handle or display name.
* **Content Warnings**: Create posts with content warnings (sensitive content labels).
Basic Concepts for ATProtoSocial
Basic Concepts for Blueski
==================================
* **DID (Decentralized Identifier)**: A unique, permanent identifier for users and data on the AT Protocol. It doesn't change even if your handle does. You generally won't need to interact with DIDs directly in TWBlue, as handles are used more commonly.

View File

@@ -16,7 +16,7 @@ This is the user guide for the latest available version of TWBlue. The purpose o
system_requirements
installation
basic_concepts
atprotosocial
blueski
usage
global_settings
credits

63
example_atproto.py Normal file
View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""Simple example of using Blueski session programmatically.
This is a minimal example showing how to use the Blueski session.
For full testing with wx dialogs, use test_atproto_session.py instead.
"""
import sys
import os
# Add src to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from sessions.blueski import session
import logging
# Setup basic logging
logging.basicConfig(level=logging.INFO)
def main():
print("Blueski Session Simple Example")
print("=" * 50)
# Create session
print("\n1. Creating session...")
s = session.Session(session_id="example_blueski")
# Try to get configuration (will create folder if needed)
print("2. Loading configuration...")
s.get_configuration()
# Try to login (will fail if no stored credentials)
print("3. Attempting login...")
try:
s.login()
print(f" ✓ Logged in as: {s.get_name()}")
print(f" User DID: {s.db.get('user_id', 'unknown')}")
except Exception as e:
print(f" ✗ Login failed: {e}")
print("\n To authorize a new session:")
print(" - Run test_atproto_session.py for GUI-based auth")
print(" - Or manually call s.authorise() after importing wx")
return
# Show session info
print("\n4. Session information:")
print(f" Logged: {s.logged}")
print(f" Handle: {s.settings['blueski']['handle']}")
print(f" Service: {s.settings['blueski'].get('service_url', '')}")
print(f" Has session_string: {bool(s.settings['blueski']['session_string'])}")
# Test logout
print("\n5. Testing logout...")
s.logout()
print(f" Logged: {s.logged}")
print(f" Session string cleared: {not s.settings['blueski']['session_string']}")
print("\n" + "=" * 50)
print("Example complete!")
if __name__ == "__main__":
main()

View File

@@ -1,45 +1,45 @@
accessible_output2 @ git+https://github.com/accessibleapps/accessible_output2@57bda997d98e87dd78aa049e7021cf777871619b
arrow==1.3.0
attrs==25.3.0
arrow==1.4.0
attrs==25.4.0
backports.functools-lru-cache==2.0.0
blurhash==1.1.4
certifi==2025.4.26
blurhash==1.1.5
certifi==2026.1.4
chardet==5.2.0
charset-normalizer==3.4.2
charset-normalizer==3.4.4
colorama==0.4.6
configobj==5.0.9
coverage==7.8.0
cx-Freeze==8.3.0
coverage==7.13.1
cx-Freeze==8.5.3
cx-Logging==3.2.1
decorator==5.2.1
demoji==1.1.0
deepl==1.22.0
deepl==1.27.0
future==1.0.0
idna==3.10
importlib-metadata==8.7.0
iniconfig==2.1.0
idna==3.11
importlib-metadata==8.7.1
iniconfig==2.3.0
libloader @ git+https://github.com/accessibleapps/libloader@bc94811c095b2e57a036acd88660be9a33260267
libretranslatepy==2.1.4
lief==0.15.1
Markdown==3.8
Mastodon.py==2.0.1
numpy==2.2.3
oauthlib==3.2.2
Markdown==3.10
Mastodon.py==2.1.4
numpy==2.4.0
oauthlib==3.3.1
packaging==25.0
pillow==11.2.1
pillow==12.1.0
platform_utils @ git+https://github.com/accessibleapps/platform_utils@e0d79f7b399c4ea677a633d2dde9202350d62c38
pluggy==1.6.0
psutil==7.0.0
pyenchant==3.2.2
psutil==7.2.1
pyenchant==3.3.0
pypiwin32==223
Pypubsub==4.0.3
Pypubsub==4.0.7
PySocks==1.7.1
pytest==8.3.5
pytest==9.0.2
python-dateutil==2.9.0.post0
python-magic-bin==0.4.14
python-vlc==3.0.21203
pywin32==310
requests==2.32.3
pywin32==311
requests==2.32.5
requests-oauthlib==2.0.0
requests-toolbelt==1.0.0
rfc3986==2.0.0
@@ -49,11 +49,11 @@ sniffio==1.3.1
sound_lib @ git+https://github.com/accessibleapps/sound_lib@a439f0943fb95ee7b6ba24f51a686f47c4ad66b2
sqlitedict==2.1.0
twitter-text-parser==3.0.0
types-python-dateutil==2.9.0.20250516
urllib3==2.4.0
types-python-dateutil==2.9.0.20251115
urllib3==2.6.3
win-inet-pton==1.1.0
winpaths==0.2
wxPython==4.2.3
wxPython==4.2.4
youtube-dl==2021.12.17
zipp==3.21.0
zipp==3.23.0
atproto>=0.0.45

View File

@@ -1,4 +1,4 @@
[atprotosocial]
[blueski]
handle = string(default="")
app_password = string(default="")
did = string(default="")

View File

@@ -24,13 +24,13 @@ class Handler:
from pubsub import pub
pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=True)
root_position = controller.view.search(name, name)
# Home timeline only for now
# Discover/home timeline
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="home_timeline",
session_type="atprotosocial",
buffer_title=_("Home"),
session_type="blueski",
buffer_title=_("Discover"),
parent_tab=root_position,
start=True,
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session)
@@ -39,8 +39,8 @@ class Handler:
pub.sendMessage(
"createBuffer",
buffer_type="following_timeline",
session_type="atprotosocial",
buffer_title=_("Following"),
session_type="blueski",
buffer_title=_("Following (Chronological)"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session)
@@ -71,7 +71,7 @@ class Handler:
current_mode = None
ask_default = True if current_mode in (None, "ask") else False
from wxUI.dialogs.atprotosocial.configuration import AccountSettingsDialog
from wxUI.dialogs.blueski.configuration import AccountSettingsDialog
dlg = AccountSettingsDialog(controller.view, ask_before_boost=ask_default)
resp = dlg.ShowModal()
if resp == wx.ID_OK:

View File

@@ -8,34 +8,34 @@ from typing import Any
logger = logging.getLogger(__name__)
# This file would typically contain functions to generate complex message bodies or
# interactive components for ATProtoSocial, similar to how it might be done for Mastodon.
# Since ATProtoSocial's interactive features (beyond basic posts) are still evolving
# interactive components for Blueski, similar to how it might be done for Mastodon.
# Since Blueski's interactive features (beyond basic posts) are still evolving
# or client-dependent (like polls), this might be less complex initially.
# Example: If ATProtoSocial develops a standard for "cards" or interactive messages,
# Example: If Blueski develops a standard for "cards" or interactive messages,
# functions to create those would go here. For now, we can imagine placeholders.
def format_welcome_message(session: Any) -> dict[str, Any]:
"""
Generates a welcome message for a new ATProtoSocial session.
Generates a welcome message for a new Blueski session.
This is just a placeholder and example.
"""
# user_profile = session.util.get_own_profile_info() # Assuming this method exists and is async or cached
# handle = user_profile.get("handle", _("your ATProtoSocial account")) if user_profile else _("your ATProtoSocial account")
# handle = user_profile.get("handle", _("your Blueski account")) if user_profile else _("your Blueski account")
# Expect session to expose username via db/settings
handle = (getattr(session, "db", {}).get("user_name")
or getattr(getattr(session, "settings", {}), "get", lambda *_: {})("atprotosocial").get("handle")
or getattr(getattr(session, "settings", {}), "get", lambda *_: {})("blueski").get("handle")
or _("your Bluesky account"))
return {
"text": _("Welcome to Approve for ATProtoSocial! Your account {handle} is connected.").format(handle=handle),
# "blocks": [ # If ATProtoSocial supports a block kit like Slack or Discord
"text": _("Welcome to Approve for Blueski! Your account {handle} is connected.").format(handle=handle),
# "blocks": [ # If Blueski supports a block kit like Slack or Discord
# {
# "type": "section",
# "text": {
# "type": "mrkdwn", # Or ATProtoSocial's equivalent
# "text": _("Welcome to Approve for ATProtoSocial! Your account *{handle}* is connected.").format(handle=handle)
# "type": "mrkdwn", # Or Blueski's equivalent
# "text": _("Welcome to Approve for Blueski! Your account *{handle}* is connected.").format(handle=handle)
# }
# },
# {
@@ -44,7 +44,7 @@ def format_welcome_message(session: Any) -> dict[str, Any]:
# {
# "type": "button",
# "text": {"type": "plain_text", "text": _("Post your first Skeet")},
# "action_id": "atprotosocial_compose_new_post" # Example action ID
# "action_id": "blueski_compose_new_post" # Example action ID
# }
# ]
# }
@@ -65,16 +65,16 @@ def format_error_message(error_description: str, details: str | None = None) ->
# ]
return message
# More functions could be added here as ATProtoSocial's capabilities become clearer
# More functions could be added here as Blueski's capabilities become clearer
# or as specific formatting needs for Approve arise. For example:
# - Formatting a post for display with all its embeds and cards.
# - Generating help messages specific to ATProtoSocial features.
# - Generating help messages specific to Blueski features.
# - Creating interactive messages for polls (if supported via some convention).
# Example of adapting a function that might exist in mastodon_messages:
# def build_post_summary_message(session: ATProtoSocialSession, post_uri: str, post_content: dict) -> dict[str, Any]:
# def build_post_summary_message(session: BlueskiSession, post_uri: str, post_content: dict) -> dict[str, Any]:
# """
# Builds a summary message for an ATProtoSocial post.
# Builds a summary message for an Blueski post.
# """
# author_handle = post_content.get("author", {}).get("handle", "Unknown user")
# text_preview = post_content.get("text", "")[:100] # First 100 chars of text
@@ -88,4 +88,4 @@ def format_error_message(error_description: str, details: str | None = None) ->
# # Potentially with "blocks" for richer formatting if the platform supports it
# }
logger.info("ATProtoSocial messages module loaded (placeholders).")
logger.info("Blueski messages module loaded (placeholders).")

View File

@@ -8,29 +8,29 @@ fromapprove.translation import translate as _
if TYPE_CHECKING:
fromapprove.config import ConfigSectionProxy
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
logger = logging.getLogger(__name__)
# This file is for defining forms and handling for ATProtoSocial-specific settings
# This file is for defining forms and handling for Blueski-specific settings
# that might be more complex than simple key-value pairs handled by Session.get_settings_inputs.
# For ATProtoSocial, initial settings might be simple (handle, app password),
# For Blueski, initial settings might be simple (handle, app password),
# but this structure allows for expansion.
class ATProtoSocialSettingsForm(Form):
class BlueskiSettingsForm(Form):
"""
A settings form for ATProtoSocial sessions.
A settings form for Blueski sessions.
This would mirror the kind of settings found in Session.get_settings_inputs
but using the WTForms-like Form structure for more complex validation or layout.
"""
# Example fields - these should align with what ATProtoSocialSession.get_settings_inputs defines
# and what ATProtoSocialSession.get_configurable_values expects for its config.
# Example fields - these should align with what BlueskiSession.get_settings_inputs defines
# and what BlueskiSession.get_configurable_values expects for its config.
# instance_url = TextField(
# _("Instance URL"),
# default="https://bsky.social", # Default PDS for Bluesky
# description=_("The base URL of your ATProtoSocial PDS instance (e.g., https://bsky.social)."),
# description=_("The base URL of your Blueski PDS instance (e.g., https://bsky.social)."),
# validators=[], # Add validators if needed, e.g., URL validator
# )
handle = TextField(
@@ -43,19 +43,19 @@ class ATProtoSocialSettingsForm(Form):
description=_("Your Bluesky App Password. Generate this in your Bluesky account settings."),
validators=[], # e.g., DataRequired()
)
# Add more fields as needed for ATProtoSocial configuration.
# Add more fields as needed for Blueski configuration.
# For example, if there were specific notification settings, content filters, etc.
submit = SubmitField(_("Save ATProtoSocial Settings"))
submit = SubmitField(_("Save Blueski Settings"))
async def get_settings_form(
user_id: str,
session: ATProtoSocialSession | None = None,
config: ConfigSectionProxy | None = None, # User-specific config for ATProtoSocial
) -> ATProtoSocialSettingsForm:
session: BlueskiSession | None = None,
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
) -> BlueskiSettingsForm:
"""
Creates and pre-populates the ATProtoSocial settings form.
Creates and pre-populates the Blueski settings form.
"""
form_data = {}
if session: # If a session exists, use its current config
@@ -68,31 +68,31 @@ async def get_settings_form(
form_data["handle"] = config.handle.get("")
form_data["app_password"] = ""
form = ATProtoSocialSettingsForm(formdata=None, **form_data) # formdata=None for initial display
form = BlueskiSettingsForm(formdata=None, **form_data) # formdata=None for initial display
return form
async def process_settings_form(
form: ATProtoSocialSettingsForm,
form: BlueskiSettingsForm,
user_id: str,
session: ATProtoSocialSession | None = None, # Pass if update should affect live session
config: ConfigSectionProxy | None = None, # User-specific config for ATProtoSocial
session: BlueskiSession | None = None, # Pass if update should affect live session
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
) -> bool:
"""
Processes the submitted ATProtoSocial settings form and updates configuration.
Processes the submitted Blueski settings form and updates configuration.
Returns True if successful, False otherwise.
"""
if not form.validate(): # Assuming form has a validate method
logger.warning(f"ATProtoSocial settings form validation failed for user {user_id}: {form.errors}")
logger.warning(f"Blueski settings form validation failed for user {user_id}: {form.errors}")
return False
if not config and session: # Try to get config via session if not directly provided
# This depends on how ConfigSectionProxy is obtained.
# config = approve.config.config.sessions.atprotosocial[user_id] # Example path
# config = approve.config.config.sessions.blueski[user_id] # Example path
pass # Needs actual way to get config proxy
if not config:
logger.error(f"Cannot process ATProtoSocial settings for user {user_id}: no config proxy available.")
logger.error(f"Cannot process Blueski settings for user {user_id}: no config proxy available.")
return False
try:
@@ -101,11 +101,11 @@ async def process_settings_form(
await config.handle.set(form.handle.data)
await config.app_password.set(form.app_password.data) # Ensure this is stored securely
logger.info(f"ATProtoSocial settings updated for user {user_id}.")
logger.info(f"Blueski settings updated for user {user_id}.")
# If there's an active session, it might need to be reconfigured or restarted
if session:
logger.info(f"Requesting ATProtoSocial session re-initialization for user {user_id} due to settings change.")
logger.info(f"Requesting Blueski session re-initialization for user {user_id} due to settings change.")
# await session.stop() # Stop it
# # Update session instance with new values directly or rely on it re-reading config
# session.api_base_url = form.instance_url.data
@@ -118,11 +118,11 @@ async def process_settings_form(
return True
except Exception as e:
logger.error(f"Error saving ATProtoSocial settings for user {user_id}: {e}", exc_info=True)
logger.error(f"Error saving Blueski settings for user {user_id}: {e}", exc_info=True)
return False
# Any additional ATProtoSocial-specific settings views or handlers would go here.
# For instance, if ATProtoSocial had features like "Relays" or "Feed Generators"
# Any additional Blueski-specific settings views or handlers would go here.
# For instance, if Blueski had features like "Relays" or "Feed Generators"
# that needed UI configuration within Approve, those forms and handlers could be defined here.
logger.info("ATProtoSocial settings module loaded (placeholders).")
logger.info("Blueski settings module loaded (placeholders).")

View File

@@ -7,29 +7,29 @@ from typing import TYPE_CHECKING, Any
fromapprove.translation import translate as _
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
logger = logging.getLogger(__name__)
# This file would handle the logic for a template editor specific to ATProtoSocial.
# This file would handle the logic for a template editor specific to Blueski.
# A template editor allows users to customize how certain information or messages
# from ATProtoSocial are displayed in Approve.
# from Blueski are displayed in Approve.
# For ATProtoSocial, this might be less relevant initially if its content structure
# For Blueski, this might be less relevant initially if its content structure
# is simpler than Mastodon's, or if user-customizable templates are not a primary feature.
# However, having the structure allows for future expansion.
# Example: Customizing the format of a "new follower" notification, or how a "skeet" is displayed.
class ATProtoSocialTemplateEditor:
def __init__(self, session: ATProtoSocialSession) -> None:
class BlueskiTemplateEditor:
def __init__(self, session: BlueskiSession) -> None:
self.session = session
# self.user_id = session.user_id
# self.config_prefix = f"sessions.atprotosocial.{self.user_id}.templates." # Example config path
# self.config_prefix = f"sessions.blueski.{self.user_id}.templates." # Example config path
def get_editable_templates(self) -> list[dict[str, Any]]:
"""
Returns a list of templates that the user can edit for ATProtoSocial.
Returns a list of templates that the user can edit for Blueski.
Each entry should describe the template, its purpose, and current value.
"""
# This would typically fetch template definitions from a default set
@@ -40,8 +40,8 @@ class ATProtoSocialTemplateEditor:
# {
# "id": "new_follower_notification", # Unique ID for this template
# "name": _("New Follower Notification Format"),
# "description": _("Customize how new follower notifications from ATProtoSocial are displayed."),
# "default_template": "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on ATProtoSocial!",
# "description": _("Customize how new follower notifications from Blueski are displayed."),
# "default_template": "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on Blueski!",
# "current_template": self._get_template_content("new_follower_notification"),
# "variables": [ # Available variables for this template
# {"name": "actor.displayName", "description": _("Display name of the new follower")},
@@ -50,10 +50,10 @@ class ATProtoSocialTemplateEditor:
# ],
# "category": "notifications", # For grouping in UI
# },
# # Add more editable templates for ATProtoSocial here
# # Add more editable templates for Blueski here
# ]
# return templates
return [] # Placeholder - no editable templates defined yet for ATProtoSocial
return [] # Placeholder - no editable templates defined yet for Blueski
def _get_template_content(self, template_id: str) -> str:
"""
@@ -70,7 +70,7 @@ class ATProtoSocialTemplateEditor:
"""
# This could be hardcoded or loaded from a defaults file.
# if template_id == "new_follower_notification":
# return "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on ATProtoSocial!"
# return "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on Blueski!"
# # ... other default templates
return "" # Placeholder
@@ -81,10 +81,10 @@ class ATProtoSocialTemplateEditor:
# config_key = self.config_prefix + template_id
# try:
# await approve.config.config.set_value(config_key, content) # Example config access
# logger.info(f"ATProtoSocial template '{template_id}' saved for user {self.user_id}.")
# logger.info(f"Blueski template '{template_id}' saved for user {self.user_id}.")
# return True
# except Exception as e:
# logger.error(f"Error saving ATProtoSocial template '{template_id}' for user {self.user_id}: {e}")
# logger.error(f"Error saving Blueski template '{template_id}' for user {self.user_id}: {e}")
# return False
return False # Placeholder
@@ -104,9 +104,9 @@ class ATProtoSocialTemplateEditor:
# # return preview
# return f"Preview for '{template_id}': {content_to_render}" # Basic placeholder
# except Exception as e:
# logger.error(f"Error generating preview for ATProtoSocial template '{template_id}': {e}")
# logger.error(f"Error generating preview for Blueski template '{template_id}': {e}")
# return _("Error generating preview.")
return _("Template previews not yet implemented for ATProtoSocial.") # Placeholder
return _("Template previews not yet implemented for Blueski.") # Placeholder
def _get_sample_data_for_template(self, template_id: str) -> dict[str, Any]:
"""
@@ -125,29 +125,29 @@ class ATProtoSocialTemplateEditor:
# Functions to be called by the main controller/handler for template editor actions.
async def get_editor_config(session: ATProtoSocialSession) -> dict[str, Any]:
async def get_editor_config(session: BlueskiSession) -> dict[str, Any]:
"""
Get the configuration needed to display the template editor for ATProtoSocial.
Get the configuration needed to display the template editor for Blueski.
"""
editor = ATProtoSocialTemplateEditor(session)
editor = BlueskiTemplateEditor(session)
return {
"editable_templates": editor.get_editable_templates(),
"help_text": _("Customize ATProtoSocial message formats. Use variables shown for each template."),
"help_text": _("Customize Blueski message formats. Use variables shown for each template."),
}
async def save_template(session: ATProtoSocialSession, template_id: str, content: str) -> bool:
async def save_template(session: BlueskiSession, template_id: str, content: str) -> bool:
"""
Save a modified template for ATProtoSocial.
Save a modified template for Blueski.
"""
editor = ATProtoSocialTemplateEditor(session)
editor = BlueskiTemplateEditor(session)
return await editor.save_template_content(template_id, content)
async def get_template_preview_html(session: ATProtoSocialSession, template_id: str, content: str) -> str:
async def get_template_preview_html(session: BlueskiSession, template_id: str, content: str) -> str:
"""
Get an HTML preview for a template with given content.
"""
editor = ATProtoSocialTemplateEditor(session)
editor = BlueskiTemplateEditor(session)
return editor.get_template_preview(template_id, custom_content=content)
logger.info("ATProtoSocial template editor module loaded (placeholders).")
logger.info("Blueski template editor module loaded (placeholders).")

View File

@@ -7,32 +7,32 @@ fromapprove.translation import translate as _
# fromapprove.controller.mastodon import userActions as mastodon_user_actions # If adapting
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
logger = logging.getLogger(__name__)
# This file defines user-specific actions that can be performed on ATProtoSocial entities,
# This file defines user-specific actions that can be performed on Blueski entities,
# typically represented as buttons or links in the UI, often on user profiles or posts.
# For ATProtoSocial, actions might include:
# - Viewing a user's profile on Bluesky/ATProtoSocial instance.
# For Blueski, actions might include:
# - Viewing a user's profile on Bluesky/Blueski instance.
# - Following/Unfollowing a user.
# - Muting/Blocking a user.
# - Reporting a user.
# - Fetching a user's latest posts.
# These actions are often presented in a context menu or as direct buttons.
# The `get_user_actions` method in the ATProtoSocialSession class would define these.
# The `get_user_actions` method in the BlueskiSession class would define these.
# This file would contain the implementation or further handling logic if needed,
# or if actions are too complex for simple lambda/method calls in the session class.
# Example structure for defining an action:
# (This might be more detailed if actions require forms or multi-step processes)
# def view_profile_action(session: ATProtoSocialSession, user_id: str) -> dict[str, Any]:
# def view_profile_action(session: BlueskiSession, user_id: str) -> dict[str, Any]:
# """
# Generates data for a "View Profile on ATProtoSocial" action.
# user_id here would be the ATProtoSocial DID or handle.
# Generates data for a "View Profile on Blueski" action.
# user_id here would be the Blueski DID or handle.
# """
# # profile_url = f"https://bsky.app/profile/{user_id}" # Example, construct from handle or DID
# # This might involve resolving DID to handle or vice-versa if only one is known.
@@ -40,20 +40,20 @@ logger = logging.getLogger(__name__)
# # profile_url = f"https://bsky.app/profile/{handle}"
# return {
# "id": "atprotosocial_view_profile",
# "id": "blueski_view_profile",
# "label": _("View Profile on Bluesky"),
# "icon": "external-link-alt", # FontAwesome icon name
# "action_type": "link", # "link", "modal", "api_call"
# "url": profile_url, # For "link" type
# # "api_endpoint": "/api/atprotosocial/user_action", # For "api_call"
# # "api_endpoint": "/api/blueski/user_action", # For "api_call"
# # "payload": {"action": "view_profile", "target_user_id": user_id},
# "confirmation_required": False,
# }
# async def follow_user_action_handler(session: ATProtoSocialSession, target_user_id: str) -> dict[str, Any]:
# async def follow_user_action_handler(session: BlueskiSession, target_user_id: str) -> dict[str, Any]:
# """
# Handles the 'follow_user' action for ATProtoSocial.
# Handles the 'follow_user' action for Blueski.
# target_user_id should be the DID of the user to follow.
# """
# # success = await session.util.follow_user(target_user_id)
@@ -65,11 +65,11 @@ logger = logging.getLogger(__name__)
# The list of available actions is typically defined in the Session class,
# e.g., ATProtoSocialSession.get_user_actions(). That method would return a list
# e.g., BlueskiSession.get_user_actions(). That method would return a list
# of dictionaries, and this file might provide handlers for more complex actions
# if they aren't simple API calls defined directly in the session's util.
# For now, this file can be a placeholder if most actions are simple enough
# to be handled directly by the session.util methods or basic handler routes.
logger.info("ATProtoSocial userActions module loaded (placeholders).")
logger.info("Blueski userActions module loaded (placeholders).")

View File

@@ -7,39 +7,39 @@ fromapprove.translation import translate as _
# fromapprove.controller.mastodon import userList as mastodon_user_list # If adapting
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted
# Define a type for what a user entry in a list might look like for ATProtoSocial
ATProtoSocialUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."}
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
# Define a type for what a user entry in a list might look like for Blueski
BlueskiUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."}
logger = logging.getLogger(__name__)
# This file is responsible for fetching and managing lists of users from ATProtoSocial.
# This file is responsible for fetching and managing lists of users from Blueski.
# Examples include:
# - Followers of a user
# - Users a user is following
# - Users who liked or reposted a post
# - Users in a specific list or feed (if ATProtoSocial supports user lists like Twitter/Mastodon)
# - Users in a specific list or feed (if Blueski supports user lists like Twitter/Mastodon)
# - Search results for users
# The structure will likely involve:
# - A base class or functions for paginating through user lists from the ATProtoSocial API.
# - A base class or functions for paginating through user lists from the Blueski API.
# - Specific functions for each type of user list.
# - Formatting ATProtoSocial user data into a consistent structure for UI display.
# - Formatting Blueski user data into a consistent structure for UI display.
async def fetch_followers(
session: ATProtoSocialSession,
session: BlueskiSession,
user_id: str, # DID of the user whose followers to fetch
limit: int = 20,
cursor: str | None = None
) -> AsyncGenerator[ATProtoSocialUserListItem, None]:
) -> AsyncGenerator[BlueskiUserListItem, None]:
"""
Asynchronously fetches a list of followers for a given ATProtoSocial user.
Asynchronously fetches a list of followers for a given Blueski user.
user_id is the DID of the target user.
Yields user data dictionaries.
"""
# client = await session.util._get_client() # Get authenticated client
# if not client:
# logger.warning(f"ATProtoSocial client not available for fetching followers of {user_id}.")
# logger.warning(f"Blueski client not available for fetching followers of {user_id}.")
# return
# current_cursor = cursor
@@ -80,7 +80,7 @@ async def fetch_followers(
"""
if not session.is_ready():
logger.warning(f"Cannot fetch followers for {user_id}: ATProtoSocial session not ready.")
logger.warning(f"Cannot fetch followers for {user_id}: Blueski session not ready.")
# yield {} # Stop iteration if not ready
return
@@ -94,22 +94,22 @@ async def fetch_followers(
logger.info(f"No followers data returned for user {user_id}.")
except Exception as e:
logger.error(f"Error in fetch_followers for ATProtoSocial user {user_id}: {e}", exc_info=True)
logger.error(f"Error in fetch_followers for Blueski user {user_id}: {e}", exc_info=True)
# Depending on desired error handling, could raise or yield an error marker
async def fetch_following(
session: ATProtoSocialSession,
session: BlueskiSession,
user_id: str, # DID of the user whose followed accounts to fetch
limit: int = 20,
cursor: str | None = None
) -> AsyncGenerator[ATProtoSocialUserListItem, None]:
) -> AsyncGenerator[BlueskiUserListItem, None]:
"""
Asynchronously fetches a list of users followed by a given ATProtoSocial user.
Asynchronously fetches a list of users followed by a given Blueski user.
Yields user data dictionaries.
"""
if not session.is_ready():
logger.warning(f"Cannot fetch following for {user_id}: ATProtoSocial session not ready.")
logger.warning(f"Cannot fetch following for {user_id}: Blueski session not ready.")
return
try:
@@ -122,21 +122,21 @@ async def fetch_following(
logger.info(f"No following data returned for user {user_id}.")
except Exception as e:
logger.error(f"Error in fetch_following for ATProtoSocial user {user_id}: {e}", exc_info=True)
logger.error(f"Error in fetch_following for Blueski user {user_id}: {e}", exc_info=True)
async def search_users(
session: ATProtoSocialSession,
session: BlueskiSession,
query: str,
limit: int = 20,
cursor: str | None = None
) -> AsyncGenerator[ATProtoSocialUserListItem, None]:
) -> AsyncGenerator[BlueskiUserListItem, None]:
"""
Searches for users on ATProtoSocial based on a query string.
Searches for users on Blueski based on a query string.
Yields user data dictionaries.
"""
if not session.is_ready():
logger.warning(f"Cannot search users for '{query}': ATProtoSocial session not ready.")
logger.warning(f"Cannot search users for '{query}': Blueski session not ready.")
return
try:
@@ -149,25 +149,25 @@ async def search_users(
logger.info(f"No users found for search term '{query}'.")
except Exception as e:
logger.error(f"Error in search_users for ATProtoSocial query '{query}': {e}", exc_info=True)
logger.error(f"Error in search_users for Blueski query '{query}': {e}", exc_info=True)
# This function is designed to be called by an API endpoint that returns JSON
async def get_user_list_paginated(
session: ATProtoSocialSession,
session: BlueskiSession,
list_type: str, # "followers", "following", "search"
identifier: str, # User DID for followers/following, or search query for search
limit: int = 20,
cursor: str | None = None
) -> tuple[list[ATProtoSocialUserListItem], str | None]:
) -> tuple[list[BlueskiUserListItem], str | None]:
"""
Fetches a paginated list of users (followers, following, or search results)
and returns the list and the next cursor.
"""
users_list: list[ATProtoSocialUserListItem] = []
users_list: list[BlueskiUserListItem] = []
next_cursor: str | None = None
if not session.is_ready():
logger.warning(f"Cannot fetch user list '{list_type}': ATProtoSocial session not ready.")
logger.warning(f"Cannot fetch user list '{list_type}': Blueski session not ready.")
return [], None
try:
@@ -192,13 +192,13 @@ async def get_user_list_paginated(
return users_list, next_cursor
async def get_user_profile_details(session: ATProtoSocialSession, user_ident: str) -> ATProtoSocialUserListItem | None:
async def get_user_profile_details(session: BlueskiSession, user_ident: str) -> BlueskiUserListItem | None:
"""
Fetches detailed profile information for a user by DID or handle.
Returns a dictionary of formatted profile data, or None if not found/error.
"""
if not session.is_ready():
logger.warning(f"Cannot get profile for {user_ident}: ATProtoSocial session not ready.")
logger.warning(f"Cannot get profile for {user_ident}: Blueski session not ready.")
return None
try:
@@ -222,4 +222,4 @@ async def get_user_profile_details(session: ATProtoSocialSession, user_ident: st
# The UI part of Approve that displays user lists would call these functions.
# Each function needs to handle pagination as provided by the ATProto API (usually cursor-based).
logger.info("ATProtoSocial userList module loaded (placeholders).")
logger.info("Blueski userList module loaded (placeholders).")

View File

@@ -280,6 +280,12 @@ class BaseBuffer(base.Buffer):
return
menu = menus.base()
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
# Enable/disable edit based on whether the post belongs to the user
item = self.get_item()
if item and item.account.id == self.session.db["user_id"] and item.reblog == None:
widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit)
else:
menu.edit.Enable(False)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
if self.can_share() == True:
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
@@ -501,6 +507,49 @@ class BaseBuffer(base.Buffer):
log.exception("")
self.session.db[self.name] = items
def edit_status(self, event=None, item=None, *args, **kwargs):
if item == None:
item = self.get_item()
# Check if the post belongs to the current user
if item.account.id != self.session.db["user_id"] or item.reblog != None:
output.speak(_("You can only edit your own posts."))
return
# Check if post has a poll with votes - warn user before proceeding
if hasattr(item, 'poll') and item.poll is not None:
votes_count = item.poll.votes_count if hasattr(item.poll, 'votes_count') else 0
if votes_count > 0:
# Show confirmation dialog
warning_title = _("Warning: Poll with votes")
warning_message = _("This post contains a poll with {votes} votes.\n\n"
"According to Mastodon's API, editing this post will reset ALL votes to zero, "
"even if you don't modify the poll itself.\n\n"
"Do you want to continue editing?").format(votes=votes_count)
dialog = wx.MessageDialog(self.buffer, warning_message, warning_title,
wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING)
result = dialog.ShowModal()
dialog.Destroy()
if result != wx.ID_YES:
output.speak(_("Edit cancelled"))
return
# Log item info for debugging
log.debug("Editing status: id={}, has_media_attachments={}, media_count={}".format(
item.id,
hasattr(item, 'media_attachments'),
len(item.media_attachments) if hasattr(item, 'media_attachments') else 0
))
# Create edit dialog with existing post data
title = _("Edit post")
caption = _("Edit your post here")
post = messages.editPost(session=self.session, item=item, title=title, caption=caption)
response = post.message.ShowModal()
if response == wx.ID_OK:
post_data = post.get_data()
# Call edit_post method in session
# Note: visibility and language cannot be changed when editing per Mastodon API
call_threaded(self.session.edit_post, post_id=post.post_id, posts=post_data)
if hasattr(post.message, "destroy"):
post.message.destroy()
def user_details(self):
item = self.get_item()
pass

View File

@@ -161,6 +161,13 @@ class NotificationsBuffer(BaseBuffer):
menu = menus.notification(notification.type)
if self.is_post():
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
# Enable/disable edit based on whether the post belongs to the user
if hasattr(menu, 'edit'):
status = self.get_post()
if status and status.account.id == self.session.db["user_id"] and status.reblog == None:
widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit)
else:
menu.edit.Enable(False)
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
if self.can_share() == True:
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)

View File

@@ -25,7 +25,7 @@ from mysc import localization
from mysc.thread_utils import call_threaded
from mysc.repeating_timer import RepeatingTimer
from controller.mastodon import handler as MastodonHandler
from controller.atprotosocial import handler as ATProtoSocialHandler # Added import
from controller.blueski import handler as BlueskiHandler # Added import
from . import settings, userAlias
log = logging.getLogger("mainController")
@@ -99,8 +99,8 @@ class Controller(object):
try:
if type == "mastodon":
return MastodonHandler.Handler()
if type == "atprotosocial":
return ATProtoSocialHandler.Handler()
if type == "blueski":
return BlueskiHandler.Handler()
except Exception:
log.exception("Error creating handler for type %s", type)
return None
@@ -207,8 +207,8 @@ class Controller(object):
if handler is None:
if type == "mastodon":
handler = MastodonHandler.Handler()
elif type == "atprotosocial":
handler = ATProtoSocialHandler.Handler()
elif type == "blueski":
handler = BlueskiHandler.Handler()
self.handlers[type] = handler
return handler
@@ -250,9 +250,9 @@ class Controller(object):
for i in sessions.sessions:
log.debug("Working on session %s" % (i,))
if sessions.sessions[i].is_logged == False:
# Try auto-login for ATProtoSocial sessions if credentials exist
# Try auto-login for Blueski sessions if credentials exist
try:
if getattr(sessions.sessions[i], "type", None) == "atprotosocial":
if getattr(sessions.sessions[i], "type", None) == "blueski":
sessions.sessions[i].login()
except Exception:
log.exception("Auto-login attempt failed for session %s", i)
@@ -260,7 +260,7 @@ class Controller(object):
self.create_ignored_session_buffer(sessions.sessions[i])
continue
# Supported session types
valid_session_types = ["mastodon", "atprotosocial"]
valid_session_types = ["mastodon", "blueski"]
if sessions.sessions[i].type in valid_session_types:
try:
handler = self.get_handler(type=sessions.sessions[i].type)
@@ -323,15 +323,15 @@ class Controller(object):
log.debug("Creating buffer of type {0} with parent_tab of {2} arguments {1}".format(buffer_type, kwargs, parent_tab))
if kwargs.get("parent") == None:
kwargs["parent"] = self.view.nb
if not hasattr(buffers, session_type) and session_type != "atprotosocial": # Allow atprotosocial to be handled separately
if not hasattr(buffers, session_type) and session_type != "blueski": # Allow blueski to be handled separately
raise AttributeError("Session type %s does not exist yet." % (session_type))
try:
buffer_panel_class = None
if session_type == "atprotosocial":
from wxUI.buffers.atprotosocial import panels as ATProtoSocialPanels # Import new panels
if session_type == "blueski":
from wxUI.buffers.blueski import panels as BlueskiPanels # Import new panels
if buffer_type == "home_timeline":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialHomeTimelinePanel
buffer_panel_class = BlueskiPanels.BlueskiHomeTimelinePanel
# kwargs for HomeTimelinePanel: parent, name, session
# 'name' is buffer_title, 'parent' is self.view.nb
# 'session' needs to be fetched based on user_id in kwargs
@@ -343,38 +343,38 @@ class Controller(object):
if "name" not in kwargs: kwargs["name"] = buffer_title
elif buffer_type == "user_timeline":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialUserTimelinePanel
buffer_panel_class = BlueskiPanels.BlueskiUserTimelinePanel
# kwargs for UserTimelinePanel: parent, name, session, target_user_did, target_user_handle
if "user_id" in kwargs and "session" not in kwargs:
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
kwargs.pop("user_id", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
# target_user_did and target_user_handle must be in kwargs from atprotosocial.Handler
# target_user_did and target_user_handle must be in kwargs from blueski.Handler
elif buffer_type == "notifications":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel
buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel
if "user_id" in kwargs and "session" not in kwargs:
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
kwargs.pop("user_id", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
# target_user_did and target_user_handle must be in kwargs from atprotosocial.Handler
# target_user_did and target_user_handle must be in kwargs from blueski.Handler
elif buffer_type == "notifications":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel
buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel
if "user_id" in kwargs and "session" not in kwargs:
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
kwargs.pop("user_id", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
elif buffer_type == "user_list_followers" or buffer_type == "user_list_following":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialUserListPanel
buffer_panel_class = BlueskiPanels.BlueskiUserListPanel
elif buffer_type == "following_timeline":
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialFollowingTimelinePanel
buffer_panel_class = BlueskiPanels.BlueskiFollowingTimelinePanel
# Clean stray keys that this panel doesn't accept
kwargs.pop("user_id", None)
kwargs.pop("list_type", None)
if "name" not in kwargs: kwargs["name"] = buffer_title
else:
log.warning(f"Unsupported ATProtoSocial buffer type: {buffer_type}. Falling back to generic.")
log.warning(f"Unsupported Blueski buffer type: {buffer_type}. Falling back to generic.")
# Fallback to trying to find it in generic buffers or error
available_buffers = getattr(buffers, "base", None) # Or some generic panel module
if available_buffers and hasattr(available_buffers, buffer_type):
@@ -382,7 +382,7 @@ class Controller(object):
elif available_buffers and hasattr(available_buffers, "TimelinePanel"): # Example generic
buffer_panel_class = getattr(available_buffers, "TimelinePanel")
else:
raise AttributeError(f"ATProtoSocial buffer type {buffer_type} not found in atprotosocial.panels or base panels.")
raise AttributeError(f"Blueski buffer type {buffer_type} not found in blueski.panels or base panels.")
else: # Existing logic for other session types
available_buffers = getattr(buffers, session_type)
if not hasattr(available_buffers, buffer_type):
@@ -549,6 +549,15 @@ class Controller(object):
buffer = self.search_buffer(buffer.name, buffer.account)
buffer.destroy_status()
def edit_post(self, *args, **kwargs):
""" Edits a post in the current buffer.
Users can only edit their own posts."""
buffer = self.view.get_current_buffer()
if hasattr(buffer, "account"):
buffer = self.search_buffer(buffer.name, buffer.account)
if hasattr(buffer, "edit_status"):
buffer.edit_status()
def exit(self, *args, **kwargs):
if config.app["app-settings"]["ask_at_exit"] == True:
answer = commonMessageDialogs.exit_dialog(self.view)
@@ -598,7 +607,7 @@ class Controller(object):
session = buffer.session
# Compose for Bluesky (ATProto): dialog with attachments/CW/language
if getattr(session, "type", "") == "atprotosocial":
if getattr(session, "type", "") == "blueski":
# In invisible interface, prefer a quick, minimal compose to avoid complex UI
if self.showing == False:
# Parent=None so it shows even if main window is hidden
@@ -620,7 +629,7 @@ class Controller(object):
else:
dlg.Destroy()
return
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
dlg = ATPostDialog()
if dlg.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = dlg.get_payload()
@@ -712,7 +721,7 @@ class Controller(object):
return
session = buffer.session
if getattr(session, "type", "") == "atprotosocial":
if getattr(session, "type", "") == "blueski":
if self.showing == False:
dlg = wx.TextEntryDialog(None, _("Write your reply:"), _("Reply"))
if dlg.ShowModal() == wx.ID_OK:
@@ -732,7 +741,7 @@ class Controller(object):
else:
dlg.Destroy()
return
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
dlg = ATPostDialog(caption=_("Reply"))
if dlg.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = dlg.get_payload()
@@ -774,7 +783,7 @@ class Controller(object):
session = getattr(buffer, "session", None)
if not session:
return
if getattr(session, "type", "") == "atprotosocial":
if getattr(session, "type", "") == "blueski":
item_uri = None
if hasattr(buffer, "get_selected_item_id"):
item_uri = buffer.get_selected_item_id()
@@ -819,7 +828,7 @@ class Controller(object):
dlg.Destroy()
return
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
dlg = ATPostDialog(caption=_("Quote post"))
if dlg.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = dlg.get_payload()
@@ -865,7 +874,7 @@ class Controller(object):
buffer = self.get_current_buffer()
if hasattr(buffer, "add_to_favorites"): # Generic buffer method
return buffer.add_to_favorites()
elif buffer.session and buffer.session.KIND == "atprotosocial":
elif buffer.session and buffer.session.KIND == "blueski":
item_uri = buffer.get_selected_item_id()
if not item_uri:
output.speak(_("No item selected to like."), True)
@@ -894,7 +903,7 @@ class Controller(object):
buffer = self.get_current_buffer()
if hasattr(buffer, "remove_from_favorites"): # Generic buffer method
return buffer.remove_from_favorites()
elif buffer.session and buffer.session.KIND == "atprotosocial":
elif buffer.session and buffer.session.KIND == "blueski":
item_uri = buffer.get_selected_item_id()
if not item_uri:
output.speak(_("No item selected to unlike."), True)
@@ -1423,9 +1432,9 @@ class Controller(object):
def update_buffers(self):
for i in self.buffers[:]:
if i.session != None and i.session.is_logged == True:
# For ATProtoSocial, initial load is in session.start() or manual.
# For Blueski, initial load is in session.start() or manual.
# Periodic updates would need a separate timer or manual refresh via update_buffer.
if i.session.KIND != "atprotosocial":
if i.session.KIND != "blueski":
try:
i.start_stream(mandatory=True) # This is likely for streaming connections or timed polling within buffer
except Exception as err:
@@ -1444,7 +1453,7 @@ class Controller(object):
async def do_update():
new_ids = []
try:
if session.KIND == "atprotosocial":
if session.KIND == "blueski":
if bf.name == f"{session.label} Home": # Assuming buffer name indicates type
# Its panel's load_initial_posts calls session.fetch_home_timeline
if hasattr(bf, "load_initial_posts"): # Generic for timeline panels
@@ -1462,7 +1471,7 @@ class Controller(object):
await bf.load_initial_users(limit=config.app["app-settings"].get("items_per_request", 30))
new_ids = [u.get("did") for u in getattr(bf, "user_list_data", []) if isinstance(u,dict)]
else:
if hasattr(bf, "start_stream"): # Fallback for non-ATProtoSocial panels or unhandled types
if hasattr(bf, "start_stream"): # Fallback for non-Blueski panels or unhandled types
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
if count is not None: new_ids = [str(x) for x in range(count)] # Dummy IDs for count
else:
@@ -1506,14 +1515,14 @@ class Controller(object):
# e.g., bf.pagination_cursor or bf.older_items_cursor
# This cursor should be set by the result of previous fetch_..._timeline(new_only=False) calls.
# For ATProtoSocial, session methods like fetch_home_timeline store their own cursor (e.g., session.home_timeline_cursor)
# For Blueski, session methods like fetch_home_timeline store their own cursor (e.g., session.home_timeline_cursor)
# The panel (bf) itself should manage its own cursor for "load more"
current_cursor = None
can_load_more_natively = False
if session.KIND == "atprotosocial":
if hasattr(bf, "load_more_posts"): # For ATProtoSocialUserTimelinePanel & ATProtoSocialHomeTimelinePanel
if session.KIND == "blueski":
if hasattr(bf, "load_more_posts"): # For BlueskiUserTimelinePanel & BlueskiHomeTimelinePanel
can_load_more_natively = True
if hasattr(bf, "load_more_posts"):
can_load_more_natively = True
@@ -1530,7 +1539,7 @@ class Controller(object):
else:
output.speak(_(u"This buffer does not support loading more items in this way."), True)
return
else: # For other non-ATProtoSocial session types
else: # For other non-Blueski session types
if hasattr(bf, "get_more_items"):
return bf.get_more_items()
else:
@@ -1541,7 +1550,7 @@ class Controller(object):
async def do_load_more():
try:
if session.KIND == "atprotosocial":
if session.KIND == "blueski":
if hasattr(bf, "load_more_posts"):
await bf.load_more_posts(limit=config.app["app-settings"].get("items_per_request", 20))
elif hasattr(bf, "load_more_users"):
@@ -1664,7 +1673,7 @@ class Controller(object):
if handler and hasattr(handler, 'user_details'):
# The handler's user_details method is responsible for extracting context
# (e.g., selected user) from the buffer and displaying the profile.
# For ATProtoSocial, handler.user_details calls the ShowUserProfileDialog.
# For Blueski, handler.user_details calls the ShowUserProfileDialog.
# It's an async method, so needs to be called appropriately.
async def _show_details():
await handler.user_details(buffer)

View File

@@ -48,7 +48,7 @@ class Handler(object):
addAlias=_("Add a&lias"),
addToList=None,
removeFromList=None,
details=_("Show user profile"),
details=_("S&how user profile"),
favs=None,
# In buffer Menu.
community_timeline =_("Create c&ommunity timeline"),

View File

@@ -2,6 +2,7 @@
import os
import re
import wx
import logging
import widgetUtils
import config
import output
@@ -14,6 +15,8 @@ from wxUI.dialogs.mastodon import postDialogs
from extra.autocompletionUsers import completion
from . import userList
log = logging.getLogger("controller.mastodon.messages")
def character_count(post_text, post_cw, character_limit=500):
# We will use text for counting character limit only.
full_text = post_text+post_cw
@@ -262,6 +265,108 @@ class post(messages.basicMessage):
visibility_setting = visibility_settings.index(setting)
self.message.visibility.SetSelection(setting)
class editPost(post):
def __init__(self, session, item, title, caption, *args, **kwargs):
""" Initialize edit dialog with existing post data.
Note: Per Mastodon API, visibility and language cannot be changed when editing.
These fields will be displayed but disabled in the UI.
"""
# Extract text from post
if item.reblog != None:
item = item.reblog
text = item.content
# Remove HTML tags from content
import re
text = re.sub('<[^<]+?>', '', text)
# Initialize parent class
super(editPost, self).__init__(session, title, caption, text=text, *args, **kwargs)
# Store the post ID for editing
self.post_id = item.id
# Set visibility (read-only, cannot be changed)
visibility_settings = dict(public=0, unlisted=1, private=2, direct=3)
self.message.visibility.SetSelection(visibility_settings.get(item.visibility, 0))
self.message.visibility.Enable(False) # Disable as it cannot be edited
# Set language (read-only, cannot be changed)
if item.language:
self.set_language(item.language)
self.message.language.Enable(False) # Disable as it cannot be edited
# Set sensitive content and spoiler
if item.sensitive:
self.message.sensitive.SetValue(True)
if item.spoiler_text:
self.message.spoiler.ChangeValue(item.spoiler_text)
self.message.on_sensitivity_changed()
# Load existing poll (if any)
# Note: You cannot have both media and a poll, so check poll first
if hasattr(item, 'poll') and item.poll is not None:
log.debug("Loading existing poll for post {}".format(self.post_id))
poll = item.poll
# Extract poll options (just the text, not the votes)
poll_options = [option.title for option in poll.options]
# Calculate expires_in based on current time and expires_at
# For editing, we need to provide a new expiration time
# Since we can't get the original expires_in, use a default or let user configure
# For now, use 1 day (86400 seconds) as default
expires_in = 86400
if hasattr(poll, 'expires_at') and poll.expires_at and not poll.expired:
# Calculate remaining time if poll hasn't expired
from dateutil import parser as date_parser
import datetime
try:
expires_at = poll.expires_at
if isinstance(expires_at, str):
expires_at = date_parser.parse(expires_at)
now = datetime.datetime.now(datetime.timezone.utc)
remaining = (expires_at - now).total_seconds()
if remaining > 0:
expires_in = int(remaining)
except Exception as e:
log.warning("Could not calculate poll expiration: {}".format(e))
poll_info = {
"type": "poll",
"file": "",
"description": _("Poll with {} options").format(len(poll_options)),
"options": poll_options,
"expires_in": expires_in,
"multiple": poll.multiple if hasattr(poll, 'multiple') else False,
"hide_totals": poll.voters_count == 0 if hasattr(poll, 'voters_count') else False
}
self.attachments.append(poll_info)
self.message.add_item(item=[poll_info["file"], poll_info["type"], poll_info["description"]])
log.debug("Loaded poll with {} options. WARNING: Editing will reset all votes!".format(len(poll_options)))
# Load existing media attachments (only if no poll)
elif hasattr(item, 'media_attachments'):
log.debug("Loading existing media attachments for post {}".format(self.post_id))
log.debug("Item has media_attachments attribute, count: {}".format(len(item.media_attachments)))
if len(item.media_attachments) > 0:
for media in item.media_attachments:
log.debug("Processing media: id={}, type={}, url={}".format(media.id, media.type, media.url))
media_info = {
"id": media.id, # Keep the existing media ID
"type": media.type,
"file": media.url, # URL of existing media
"description": media.description or ""
}
# Include focus point if available
if hasattr(media, 'meta') and media.meta and 'focus' in media.meta:
focus = media.meta['focus']
media_info["focus"] = (focus.get('x'), focus.get('y'))
log.debug("Added focus point: {}".format(media_info["focus"]))
self.attachments.append(media_info)
# Display in the attachment list
display_name = media.url.split('/')[-1]
log.debug("Adding item to UI: name={}, type={}, desc={}".format(display_name, media.type, media.description or ""))
self.message.add_item(item=[display_name, media.type, media.description or ""])
log.debug("Total attachments loaded: {}".format(len(self.attachments)))
else:
log.debug("media_attachments list is empty")
else:
log.debug("Item has no poll or media attachments")
# Update text processor to reflect the loaded content
self.text_processor()
class viewPost(post):
def __init__(self, session, post, offset_hours=0, date="", item_url=""):
self.session = session

View File

@@ -13,8 +13,8 @@ class autocompletionManageDialog(widgetUtils.BaseDialog):
self.users = widgets.list(panel, _(u"Username"), _(u"Name"), style=wx.LC_REPORT)
sizer.Add(label, 0, wx.ALL, 5)
sizer.Add(self.users.list, 0, wx.ALL, 5)
self.add = wx.Button(panel, -1, _(u"Add user"))
self.remove = wx.Button(panel, -1, _(u"Remove user"))
self.add = wx.Button(panel, -1, _(u"&Add user"))
self.remove = wx.Button(panel, -1, _(u"&Remove user"))
optionsBox = wx.BoxSizer(wx.HORIZONTAL)
optionsBox.Add(self.add, 0, wx.ALL, 5)
optionsBox.Add(self.remove, 0, wx.ALL, 5)

View File

@@ -23,6 +23,7 @@ url = string(default="control+win+b")
go_home = string(default="control+win+home")
go_end = string(default="control+win+end")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

View File

@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="control+win+shift+p")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

View File

@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="alt+win+delete")
edit_post = string(default="")
clear_buffer = string(default="alt+win+shift+delete")
repeat_item = string(default="alt+win+space")
copy_to_clipboard = string(default="alt+win+shift+c")

View File

@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="alt+win+delete")
edit_post = string(default="")
clear_buffer = string(default="alt+win+shift+delete")
repeat_item = string(default="control+alt+win+space")
copy_to_clipboard = string(default="alt+win+shift+c")

View File

@@ -34,6 +34,7 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

Binary file not shown.

View File

@@ -1,22 +1,23 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) 2019 ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, 2019.
# zvonimir stanecic <zvonimirek222@yandex.com>, 2023.
# zvonimir stanecic <zvonimirek222@yandex.com>, 2023, 2025.
msgid ""
msgstr ""
"Project-Id-Version: Tw Blue 0.80\n"
"Report-Msgid-Bugs-To: manuel@manuelcortez.net\n"
"POT-Creation-Date: 2025-04-13 01:18+0000\n"
"PO-Revision-Date: 2023-04-21 07:45+0000\n"
"PO-Revision-Date: 2025-08-10 16:08+0000\n"
"Last-Translator: zvonimir stanecic <zvonimirek222@yandex.com>\n"
"Language-Team: Polish <https://weblate.mcvsoftware.com/projects/twblue/"
"twblue/pl/>\n"
"Language: pl\n"
"Language-Team: Polish "
"<https://weblate.mcvsoftware.com/projects/twblue/twblue/pl/>\n"
"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && "
"(n%100<10 || n%100>=20) ? 1 : 2;\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
"|| n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 5.10.4\n"
"Generated-By: Babel 2.17.0\n"
#: languageHandler.py:61
@@ -101,7 +102,7 @@ msgstr "Domyślne dla użytkownika"
#: main.py:105
#, fuzzy
msgid "https://twblue.mcvsoftware.com/donate"
msgstr "https://twblue.es/donate"
msgstr "https://twblue.mcvsoftware.com/donate"
#: main.py:118
#, python-brace-format
@@ -246,9 +247,8 @@ msgid "Following for {}"
msgstr "Śledzący użytkownika {}"
#: controller/messages.py:18
#, fuzzy
msgid "Translated"
msgstr "&Przetłumacz"
msgstr "Przetłumaczono"
#: controller/settings.py:60
msgid "System default"
@@ -540,9 +540,8 @@ msgid "There are no more items in this buffer."
msgstr "W tym buforze nie ma więcej elementów."
#: controller/mastodon/handler.py:30 wxUI/dialogs/mastodon/updateProfile.py:35
#, fuzzy
msgid "Update Profile"
msgstr "&Edytuj profil"
msgstr "Zaktualizuj profil"
#: controller/mastodon/handler.py:31 wxUI/dialogs/mastodon/search.py:10
#: wxUI/view.py:19
@@ -615,13 +614,12 @@ msgid "Add a&lias"
msgstr "Dodaj a&lias"
#: controller/mastodon/handler.py:51
#, fuzzy
msgid "Show user profile"
msgstr "&Pokaż profil użytkownika"
msgstr "Pokaż profil użytkownika"
#: controller/mastodon/handler.py:54
msgid "Create c&ommunity timeline"
msgstr ""
msgstr "Stwórz &oś czasu społeczności"
#: controller/mastodon/handler.py:55 wxUI/view.py:57
msgid "Create a &filter"
@@ -647,10 +645,9 @@ msgstr "Wyszukiwanie {}"
#: controller/mastodon/handler.py:111
msgid "Communities"
msgstr ""
msgstr "Społeczności"
#: controller/mastodon/handler.py:114
#, fuzzy
msgid "federated"
msgstr "federowana"
@@ -4864,4 +4861,3 @@ msgstr "Dodatki"
#~ msgid "DeepL API Key: "
#~ msgstr ""

View File

@@ -17,7 +17,7 @@ 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 sessions.blueski import session as BlueskiSession # Import Blueski session
from . import manager
from . import wxUI as view
@@ -74,21 +74,37 @@ class sessionManagerController(object):
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))
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
elif config_test.get("blueski") != None: # Check for Blueski config
handle = config_test["blueski"].get("handle")
did = config_test["blueski"].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))
self.sessions.append(dict(type="blueski", id=i))
else: # Incomplete config, might be an old attempt or error
log.warning(f"Incomplete ATProtoSocial session config found for {i}, skipping.")
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
# Optionally delete malformed config here too
try:
log.debug("Deleting incomplete ATProtoSocial session %s" % (i,))
log.debug("Deleting incomplete Blueski 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}")
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
continue
elif config_test.get("atprotosocial") != None: # Legacy config namespace
handle = config_test["atprotosocial"].get("handle")
did = config_test["atprotosocial"].get("did")
if handle and did:
name = _("{handle} (Bluesky)").format(handle=handle)
sessionsList.append(name)
self.sessions.append(dict(type="blueski", id=i))
else: # Incomplete config, might be an old attempt or error
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
# Optionally delete malformed config here too
try:
log.debug("Deleting incomplete Blueski session %s" % (i,))
shutil.rmtree(os.path.join(paths.config_path(), i))
except Exception as e:
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
continue
else: # Unknown or other session type not explicitly handled here for display
try:
@@ -117,14 +133,14 @@ class sessionManagerController(object):
s = MastodonSession.Session(i.get("id"))
elif i.get("type") == "gotosocial":
s = GotosocialSession.Session(i.get("id"))
elif i.get("type") == "atprotosocial": # Handle ATProtoSocial session type
s = ATProtoSocialSession.Session(i.get("id"))
elif i.get("type") == "blueski": # Handle Blueski session type
s = BlueskiSession.Session(i.get("id"))
else:
log.warning(f"Unknown session type '{i.get('type')}' for ID {i.get('id')}. Skipping.")
continue
s.get_configuration() # Load per-session configuration
# For ATProtoSocial, this loads from its specific config file.
# For Blueski, this loads from its specific config file.
# Login is now primarily handled by session.start() via mainController,
# which calls _ensure_dependencies_ready().
@@ -132,19 +148,19 @@ class sessionManagerController(object):
# 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()
# # For Blueski, 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
# Try to auto-login for ATProtoSocial so the app starts with buffers ready
# Try to auto-login for Blueski so the app starts with buffers ready
try:
if i.get("type") == "atprotosocial":
if i.get("type") == "blueski":
s.login()
except Exception:
log.exception("Auto-login failed for ATProtoSocial session %s", i.get("id"))
log.exception("Auto-login failed for Blueski session %s", i.get("id"))
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
@@ -162,8 +178,8 @@ class sessionManagerController(object):
if type == "mastodon":
s = MastodonSession.Session(location)
elif type == "atprotosocial":
s = ATProtoSocialSession.Session(location)
elif type == "blueski":
s = BlueskiSession.Session(location)
# Add other session types here if needed (e.g., gotosocial)
# elif type == "gotosocial":
# s = GotosocialSession.Session(location)

View File

@@ -54,8 +54,8 @@ class sessionManagerWindow(wx.Dialog):
mastodon = menu.Append(wx.ID_ANY, _("Mastodon"))
menu.Bind(wx.EVT_MENU, self.on_new_mastodon_account, mastodon)
atprotosocial = menu.Append(wx.ID_ANY, _("ATProtoSocial (Bluesky)"))
menu.Bind(wx.EVT_MENU, self.on_new_atprotosocial_account, atprotosocial)
blueski = menu.Append(wx.ID_ANY, _("Blueski (Bluesky)"))
menu.Bind(wx.EVT_MENU, self.on_new_blueski_account, blueski)
self.PopupMenu(menu, self.new.GetPosition())
@@ -66,12 +66,12 @@ class sessionManagerWindow(wx.Dialog):
if response == wx.ID_YES:
pub.sendMessage("sessionmanager.new_account", type="mastodon")
def on_new_atprotosocial_account(self, *args, **kwargs):
dlg = wx.MessageDialog(self, _("You will be prompted for your ATProtoSocial (Bluesky) data (user handle and App Password) to authorize TWBlue. Would you like to authorize your account now?"), _(u"ATProtoSocial Authorization"), wx.YES_NO)
def on_new_blueski_account(self, *args, **kwargs):
dlg = wx.MessageDialog(self, _("You will be prompted for your Blueski (Bluesky) data (user handle and App Password) to authorize TWBlue. Would you like to authorize your account now?"), _(u"Blueski Authorization"), wx.YES_NO)
response = dlg.ShowModal()
dlg.Destroy()
if response == wx.ID_YES:
pub.sendMessage("sessionmanager.new_account", type="atprotosocial")
pub.sendMessage("sessionmanager.new_account", type="blueski")
def add_new_session_to_list(self):
total = self.list.get_count()

View File

@@ -9,7 +9,7 @@ from approve.translation import translate as _
from approve.util import parse_iso_datetime # For parsing ISO timestamps
if TYPE_CHECKING:
from approve.sessions.atprotosocial.session import Session as ATProtoSocialSession
from approve.sessions.blueski.session import Session as BlueskiSession
from atproto.xrpc_client import models # For type hinting ATProto models
logger = logging.getLogger(__name__)
@@ -21,19 +21,19 @@ SUPPORTED_LANG_CHOICES_COMPOSE = {
}
class ATProtoSocialCompose:
class BlueskiCompose:
MAX_CHARS = 300
MAX_MEDIA_ATTACHMENTS = 4
MAX_LANGUAGES = 3
MAX_IMAGE_SIZE_BYTES = 1_000_000
def __init__(self, session: ATProtoSocialSession) -> None:
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 ATProtoSocial."""
"""Returns configuration for the compose panel specific to Blueski."""
return {
"max_chars": self.MAX_CHARS,
"max_media_attachments": self.MAX_MEDIA_ATTACHMENTS,
@@ -206,7 +206,7 @@ class ATProtoSocialCompose:
Args:
notif_data: A dictionary representing the notification,
typically from ATProtoSocialSession._handle_*_notification methods
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'.

View File

@@ -10,7 +10,7 @@ from sessions import session_exceptions as Exceptions
import output
import application
log = logging.getLogger("sessions.atprotosocialSession")
log = logging.getLogger("sessions.blueskiSession")
# Optional import of atproto. Code handles absence gracefully.
try:
@@ -27,26 +27,45 @@ class Session(base.baseSession):
"""
name = "Bluesky"
KIND = "atprotosocial"
KIND = "blueski"
def __init__(self, *args, **kwargs):
super(Session, self).__init__(*args, **kwargs)
self.config_spec = "atprotosocial.defaults"
self.type = "atprotosocial"
self.config_spec = "blueski.defaults"
self.type = "blueski"
self.char_limit = 300
self.api = None
def _ensure_settings_namespace(self) -> None:
"""Migrate legacy atprotosocial settings to blueski namespace."""
try:
if not self.settings:
return
if self.settings.get("blueski") is None and self.settings.get("atprotosocial") is not None:
self.settings["blueski"] = dict(self.settings["atprotosocial"])
try:
del self.settings["atprotosocial"]
except Exception:
pass
try:
self.settings.write()
except Exception:
pass
except Exception:
log.exception("Failed to migrate legacy Blueski settings")
def get_name(self):
"""Return a human-friendly, stable account name for UI.
Prefer the user's handle if available so accounts are uniquely
identifiable, falling back to a generic network name otherwise.
"""
self._ensure_settings_namespace()
try:
# Prefer runtime DB, then persisted settings, then SDK client
handle = (
self.db.get("user_name")
or (self.settings and self.settings.get("atprotosocial", {}).get("handle"))
or (self.settings and self.settings.get("blueski", {}).get("handle"))
or (getattr(getattr(self, "api", None), "me", None) and self.api.me.handle)
)
if handle:
@@ -65,11 +84,12 @@ class Session(base.baseSession):
return self.api
def login(self, verify_credentials=True):
if self.settings.get("atprotosocial") is None:
self._ensure_settings_namespace()
if self.settings.get("blueski") is None:
raise Exceptions.RequireCredentialsSessionError
handle = self.settings["atprotosocial"].get("handle")
app_password = self.settings["atprotosocial"].get("app_password")
session_string = self.settings["atprotosocial"].get("session_string")
handle = self.settings["blueski"].get("handle")
app_password = self.settings["blueski"].get("app_password")
session_string = self.settings["blueski"].get("session_string")
if not handle or (not app_password and not session_string):
self.logged = False
raise Exceptions.RequireCredentialsSessionError
@@ -100,10 +120,10 @@ class Session(base.baseSession):
self.db["user_name"] = api.me.handle
self.db["user_id"] = api.me.did
# Persist DID in settings for session manager display
self.settings["atprotosocial"]["did"] = api.me.did
self.settings["blueski"]["did"] = api.me.did
# Export session for future reuse
try:
self.settings["atprotosocial"]["session_string"] = api.export_session_string()
self.settings["blueski"]["session_string"] = api.export_session_string()
except Exception:
pass
self.settings.write()
@@ -114,6 +134,7 @@ class Session(base.baseSession):
self.logged = False
def authorise(self):
self._ensure_settings_namespace()
if self.logged:
raise Exceptions.AlreadyAuthorisedError("Already authorised.")
# Ask for handle
@@ -141,8 +162,8 @@ class Session(base.baseSession):
# Create session folder and config, then attempt login
self.create_session_folder()
self.get_configuration()
self.settings["atprotosocial"]["handle"] = handle
self.settings["atprotosocial"]["app_password"] = app_password
self.settings["blueski"]["handle"] = handle
self.settings["blueski"]["app_password"] = app_password
self.settings.write()
try:
self.login()
@@ -159,7 +180,8 @@ class Session(base.baseSession):
def get_message_url(self, message_id, context=None):
# message_id may be full at:// URI or rkey
handle = self.db.get("user_name") or self.settings["atprotosocial"].get("handle", "")
self._ensure_settings_namespace()
handle = self.db.get("user_name") or self.settings["blueski"].get("handle", "")
rkey = message_id
if isinstance(message_id, str) and message_id.startswith("at://"):
parts = message_id.split("/")
@@ -169,6 +191,7 @@ class Session(base.baseSession):
def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs):
if not self.logged:
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
self._ensure_settings_namespace()
try:
api = self._ensure_client()
# Basic text-only post for now. Attachments and CW can be extended later.
@@ -273,8 +296,8 @@ class Session(base.baseSession):
# Accept full web URL and try to resolve via get_post_thread below
return identifier
# Accept bare rkey case by constructing a guess using own handle
handle = self.db.get("user_name") or self.settings["atprotosocial"].get("handle")
did = self.db.get("user_id") or self.settings["atprotosocial"].get("did")
handle = self.db.get("user_name") or self.settings["blueski"].get("handle")
did = self.db.get("user_id") or self.settings["blueski"].get("did")
if handle and did and len(identifier) in (13, 14, 15):
# rkey length is typically ~13 chars base32
return f"at://{did}/app.bsky.feed.post/{identifier}"

View File

@@ -5,17 +5,17 @@ import logging
from typing import TYPE_CHECKING, Any, Callable, Coroutine
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
fromapprove.sessions.blueski.session import Session as BlueskiSession
logger = logging.getLogger(__name__)
# ATProtoSocial (Bluesky) uses a Firehose model for streaming.
# 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 ATProtoSocialStreaming:
def __init__(self, session: ATProtoSocialSession, stream_type: str, params: dict[str, Any] | None = None) -> None:
class BlueskiStreaming:
def __init__(self, session: BlueskiSession, stream_type: str, params: dict[str, Any] | None = None) -> None:
self.session = session
self.stream_type = stream_type # e.g., 'user', 'public', 'hashtag' - will need mapping to Firehose concepts
self.params = params or {}
@@ -30,19 +30,19 @@ class ATProtoSocialStreaming:
# or using a more specific subscription if available for user-level events.
async def _connect(self) -> None:
"""Internal method to connect to the ATProtoSocial Firehose."""
"""Internal method to connect to the Blueski Firehose."""
# from atproto import AsyncClient
# from atproto.firehose import FirehoseSubscribeReposClient, parse_subscribe_repos_message
# from atproto.xrpc_client.models import get_or_create, ids, models
logger.info(f"ATProtoSocial streaming: Connecting to Firehose for user {self.session.user_id}, stream type {self.stream_type}")
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("ATProtoSocial client not authenticated. Cannot start Firehose.")
# 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
@@ -77,7 +77,7 @@ class ATProtoSocialStreaming:
# # # await self._handle_event("mention", event_data)
# # For now, we'll just log that a message was received
# logger.debug(f"ATProtoSocial Firehose message received: {message.__class__.__name__}")
# logger.debug(f"Blueski Firehose message received: {message.__class__.__name__}")
# await self._firehose_client.start(on_message_handler)
@@ -91,13 +91,13 @@ class ATProtoSocialStreaming:
# mock_event = {"type": "placeholder_event", "data": {"text": "Hello from mock stream"}}
# await self._handler(mock_event) # Call the registered handler
logger.info(f"ATProtoSocial streaming: Placeholder loop for {self.session.user_id} stopped.")
logger.info(f"Blueski streaming: Placeholder loop for {self.session.user_id} stopped.")
except asyncio.CancelledError:
logger.info(f"ATProtoSocial streaming task for user {self.session.user_id} was cancelled.")
logger.info(f"Blueski streaming task for user {self.session.user_id} was cancelled.")
except Exception as e:
logger.error(f"ATProtoSocial streaming error for user {self.session.user_id}: {e}", exc_info=True)
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)
@@ -108,7 +108,7 @@ class ATProtoSocialStreaming:
finally:
# if self._firehose_client:
# await self._firehose_client.stop()
logger.info(f"ATProtoSocial streaming connection closed for user {self.session.user_id}.")
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:
@@ -118,31 +118,31 @@ class ATProtoSocialStreaming:
if self._handler:
try:
# The data should be transformed into a common format expected by session.handle_streaming_event
# This is where ATProtoSocial-specific event data is mapped to Approve's internal event structure.
# For example, an ATProtoSocial 'mention' event needs to be structured similarly to
# 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 ATProtoSocial streaming event type {event_type}: {e}", exc_info=True)
logger.error(f"Error handling Blueski streaming event type {event_type}: {e}", exc_info=True)
else:
logger.warning(f"ATProtoSocial streaming: No handler registered for session {self.session.user_id}, event: {event_type}")
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"ATProtoSocial streaming already active for user {self.session.user_id}.")
logger.warning(f"Blueski streaming already active for user {self.session.user_id}.")
return
self._handler = handler # This handler is what session.py's handle_streaming_event calls
self._should_stop = False
logger.info(f"ATProtoSocial streaming: Starting for user {self.session.user_id}, type: {self.stream_type}")
logger.info(f"Blueski streaming: Starting for user {self.session.user_id}, type: {self.stream_type}")
self._connection_task = asyncio.create_task(self._connect())
async def stop_streaming(self) -> None:
"""Stops the streaming connection."""
logger.info(f"ATProtoSocial streaming: Stopping for user {self.session.user_id}")
logger.info(f"Blueski streaming: Stopping for user {self.session.user_id}")
self._should_stop = True
# if self._firehose_client: # Assuming the SDK has a stop method
# await self._firehose_client.stop()
@@ -153,10 +153,10 @@ class ATProtoSocialStreaming:
try:
await self._connection_task
except asyncio.CancelledError:
logger.info(f"ATProtoSocial streaming task successfully cancelled for {self.session.user_id}.")
logger.info(f"Blueski streaming task successfully cancelled for {self.session.user_id}.")
self._connection_task = None
self._handler = None
logger.info(f"ATProtoSocial streaming stopped for user {self.session.user_id}.")
logger.info(f"Blueski streaming stopped for user {self.session.user_id}.")
def is_alive(self) -> bool:
"""Checks if the streaming connection is currently active."""
@@ -169,7 +169,7 @@ class ATProtoSocialStreaming:
def get_params(self) -> dict[str, Any]:
return self.params
# TODO: Add methods specific to ATProtoSocial streaming if necessary,
# TODO: Add methods specific to Blueski streaming if necessary,
# e.g., methods to modify subscription details on the fly if the API supports it.
# For Bluesky Firehose, this might not be applicable as you usually connect and filter client-side.
# However, if there were different Firehose endpoints (e.g., one for public posts, one for user-specific events),

View File

@@ -6,30 +6,30 @@ from typing import TYPE_CHECKING, Any
fromapprove.translation import translate as _
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
fromapprove.sessions.blueski.session import Session as BlueskiSession
logger = logging.getLogger(__name__)
class ATProtoSocialTemplates:
def __init__(self, session: ATProtoSocialSession) -> None:
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 ATProtoSocial.
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 ATProtoSocial templates
# Add any other common data needed by Blueski templates
}
if context:
base_data.update(context)
# TODO: Implement specific data fetching for different ATProtoSocial templates
# 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
@@ -44,27 +44,27 @@ class ATProtoSocialTemplates:
return base_data
def get_message_card_template(self) -> str:
"""Returns the path to the message card template for ATProtoSocial."""
# This template would define how a single ATProtoSocial post (or other message type)
"""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/atprotosocial/cards/message.html" # Example path
# 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 ATProtoSocial notification types to their respective template paths.
Returns a map of Blueski notification types to their respective template paths.
"""
# TODO: Define templates for different ATProtoSocial notification types
# 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 ATProtoSocial events.
# when processing Blueski events.
# Example:
# return {
# "mention": "sessions/atprotosocial/notifications/mention.html",
# "reply": "sessions/atprotosocial/notifications/reply.html",
# "follow": "sessions/atprotosocial/notifications/follow.html",
# "like": "sessions/atprotosocial/notifications/like.html", # Bluesky uses 'like'
# "repost": "sessions/atprotosocial/notifications/repost.html", # Bluesky uses 'repost'
# "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:
@@ -77,37 +77,37 @@ class ATProtoSocialTemplates:
}
def get_settings_template(self) -> str | None:
"""Returns the path to the settings template for ATProtoSocial, if any."""
# This template would be used to render ATProtoSocial-specific settings in the UI.
# return "sessions/atprotosocial/settings.html"
"""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 ATProtoSocial.
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 ATProtoSocial user actions
# TODO: Define templates for Blueski user actions
# Example:
# return {
# "view_profile_on_bsky": "sessions/atprotosocial/actions/view_profile_button.html",
# "send_direct_message": "sessions/atprotosocial/actions/send_dm_form.html", # If DMs are supported
# "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 ATProtoSocial.
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 ATProtoSocial user list actions
# TODO: Define templates for Blueski user list actions
# Example:
# return {
# "follow_all_visible": "sessions/atprotosocial/list_actions/follow_all_button.html",
# "follow_all_visible": "sessions/blueski/list_actions/follow_all_button.html",
# }
return None # Placeholder
# Add any other template-related helper methods specific to ATProtoSocial.
# 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.
@@ -116,8 +116,8 @@ class ATProtoSocialTemplates:
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 ATProtoSocial messages have varied structures
# TODO: Define specific templates if Blueski messages have varied structures
# that require different display logic.
# if message_type == "quote_post":
# return "sessions/atprotosocial/cards/quote_post.html"
# return "sessions/blueski/cards/quote_post.html"
return None # Default to standard message card if not specified

View File

@@ -16,7 +16,7 @@ fromapprove.notifications import NotificationError
if TYPE_CHECKING:
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
fromapprove.sessions.blueski.session import Session as BlueskiSession
# Define common type aliases if needed
ATUserProfile = models.AppBskyActorDefs.ProfileViewDetailed
ATPost = models.AppBskyFeedDefs.PostView
@@ -27,8 +27,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class ATProtoSocialUtils:
def __init__(self, session: ATProtoSocialSession) -> None:
class BlueskiUtils:
def __init__(self, session: BlueskiSession) -> None:
self.session = session
# _own_did and _own_handle are now set by Session.login upon successful authentication
# and directly on the util instance.
@@ -47,7 +47,7 @@ class ATProtoSocialUtils:
self._own_handle = self.session.client.me.handle
return self.session.client
logger.warning("ATProtoSocialUtils: Client not available or not authenticated.")
logger.warning("BlueskiUtils: Client not available or not authenticated.")
# Optionally, try to trigger re-authentication if appropriate,
# but generally, the caller should ensure session is ready.
# For example, by calling session.start() or session.authorise()
@@ -59,7 +59,7 @@ class ATProtoSocialUtils:
"""Retrieves the authenticated user's profile information."""
client = await self._get_client()
if not client or not self.get_own_did(): # Use getter for _own_did
logger.warning("ATProtoSocial client not available or user DID not known.")
logger.warning("Blueski client not available or user DID not known.")
return None
try:
# client.me should be populated after login by the SDK
@@ -78,7 +78,7 @@ class ATProtoSocialUtils:
return response
return None
except AtProtocolError as e:
logger.error(f"Error fetching own ATProtoSocial profile: {e}")
logger.error(f"Error fetching own Blueski profile: {e}")
return None
def get_own_did(self) -> str | None:
@@ -116,13 +116,13 @@ class ATProtoSocialUtils:
**kwargs: Any
) -> str | None: # Returns the ATURI of the new post, or None on failure
"""
Posts a status (skeet) to ATProtoSocial.
Posts a status (skeet) to Blueski.
Handles text, images, replies, quotes, and content warnings (labels).
"""
client = await self._get_client()
if not client:
logger.error("ATProtoSocial client not available for posting.")
raise NotificationError(_("Not connected to ATProtoSocial. Please check your connection settings or log in."))
logger.error("Blueski client not available for posting.")
raise NotificationError(_("Not connected to Blueski. Please check your connection settings or log in."))
if not self.get_own_did():
logger.error("Cannot post status: User DID not available.")
@@ -130,7 +130,7 @@ class ATProtoSocialUtils:
try:
# Prepare core post record
post_record_data = {'text': text, 'created_at': client.get_current_time_iso()} # SDK handles datetime format
post_record_data = {'text': text, 'createdAt': client.get_current_time_iso()} # SDK handles datetime format
if langs:
post_record_data['langs'] = langs
@@ -227,13 +227,13 @@ class ATProtoSocialUtils:
record=final_post_record,
)
)
logger.info(f"Successfully posted to ATProtoSocial. URI: {response.uri}")
logger.info(f"Successfully posted to Blueski. URI: {response.uri}")
return response.uri
except AtProtocolError as e:
logger.error(f"Error posting status to ATProtoSocial: {e.error} - {e.message}", exc_info=True)
logger.error(f"Error posting status to Blueski: {e.error} - {e.message}", exc_info=True)
raise NotificationError(_("Failed to post: {error} - {message}").format(error=e.error or "Error", message=e.message or "Protocol error")) from e
except Exception as e: # Catch any other unexpected errors
logger.error(f"Unexpected error posting status to ATProtoSocial: {e}", exc_info=True)
logger.error(f"Unexpected error posting status to Blueski: {e}", exc_info=True)
raise NotificationError(_("An unexpected error occurred while posting: {error}").format(error=str(e))) from e
@@ -241,7 +241,7 @@ class ATProtoSocialUtils:
"""Deletes a status (post) given its AT URI."""
client = await self._get_client()
if not client:
logger.error("ATProtoSocial client not available for deleting post.")
logger.error("Blueski client not available for deleting post.")
return False
if not self.get_own_did():
logger.error("Cannot delete status: User DID not available.")
@@ -268,10 +268,10 @@ class ATProtoSocialUtils:
rkey=rkey,
)
)
logger.info(f"Successfully deleted post {post_uri} from ATProtoSocial.")
logger.info(f"Successfully deleted post {post_uri} from Blueski.")
return True
except AtProtocolError as e:
logger.error(f"Error deleting post {post_uri} from ATProtoSocial: {e.error} - {e.message}")
logger.error(f"Error deleting post {post_uri} from Blueski: {e.error} - {e.message}")
except Exception as e:
logger.error(f"Unexpected error deleting post {post_uri}: {e}", exc_info=True)
return False
@@ -279,12 +279,12 @@ class ATProtoSocialUtils:
async def upload_media(self, file_path: str, mime_type: str, alt_text: str | None = None) -> dict[str, Any] | None:
"""
Uploads media (image) to ATProtoSocial.
Uploads media (image) to Blueski.
Returns a dictionary containing the SDK's BlobRef object and alt_text, or None on failure.
"""
client = await self._get_client()
if not client:
logger.error("ATProtoSocial client not available for media upload.")
logger.error("Blueski client not available for media upload.")
return None
try:
with open(file_path, "rb") as f:
@@ -303,7 +303,7 @@ class ATProtoSocialUtils:
logger.error(f"Media upload failed for {file_path}, no blob in response.")
return None
except AtProtocolError as e:
logger.error(f"Error uploading media {file_path} to ATProtoSocial: {e.error} - {e.message}", exc_info=True)
logger.error(f"Error uploading media {file_path} to Blueski: {e.error} - {e.message}", exc_info=True)
except Exception as e:
logger.error(f"Unexpected error uploading media {file_path}: {e}", exc_info=True)
return None
@@ -341,7 +341,7 @@ class ATProtoSocialUtils:
models.ComAtprotoRepoCreateRecord.Input(
repo=self.get_own_did(),
collection=ids.AppBskyGraphFollow, # "app.bsky.graph.follow"
record=models.AppBskyGraphFollow.Main(subject=user_did, created_at=client.get_current_time_iso()),
record=models.AppBskyGraphFollow.Main(subject=user_did, createdAt=client.get_current_time_iso()),
)
)
logger.info(f"Successfully followed user {user_did}.")
@@ -596,7 +596,7 @@ class ATProtoSocialUtils:
models.ComAtprotoRepoCreateRecord.Input(
repo=self.get_own_did(),
collection=ids.AppBskyGraphBlock, # "app.bsky.graph.block"
record=models.AppBskyGraphBlock.Main(subject=user_did, created_at=client.get_current_time_iso()),
record=models.AppBskyGraphBlock.Main(subject=user_did, createdAt=client.get_current_time_iso()),
)
)
logger.info(f"Successfully blocked user {user_did}. Block record URI: {response.uri}")
@@ -1098,7 +1098,7 @@ class ATProtoSocialUtils:
"""
client = await self._get_client()
if not client:
logger.error("ATProtoSocial client not available for reporting.")
logger.error("Blueski client not available for reporting.")
return False
try:

View File

@@ -17,6 +17,11 @@ def compose_post(post, db, settings, relative_times, show_screen_names, safe=Tru
text = _("Boosted from @{}: {}").format(post.reblog.account.acct, templates.process_text(post.reblog, safe=safe))
else:
text = templates.process_text(post, safe=safe)
# Handle quoted posts
if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None:
quoted_user = post.quote.quoted_status.account.acct
quoted_text = templates.process_text(post.quote.quoted_status, safe=safe)
text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text)
filtered = utils.evaluate_filters(post=post, current_context="home")
if filtered != None:
text = _("hidden by filter {}").format(filtered)

View File

@@ -248,6 +248,106 @@ class Session(base.baseSession):
pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, lang=language)
return
def edit_post(self, post_id, posts=[]):
""" Convenience function to edit a post. Only the first item in posts list is used as threads cannot be edited.
Note: According to Mastodon API, not all fields can be edited. Visibility, language, and reply context cannot be changed.
Args:
post_id: ID of the status to edit
posts: List with post data. Only first item is used.
Returns:
Updated status object or None on failure
"""
if len(posts) == 0:
log.warning("edit_post called with empty posts list")
return None
obj = posts[0]
text = obj.get("text")
if not text:
log.warning("edit_post called without text content")
return None
media_ids = []
media_attributes = []
try:
poll = None
# Handle poll attachments
if len(obj["attachments"]) == 1 and obj["attachments"][0]["type"] == "poll":
poll = self.api.make_poll(
options=obj["attachments"][0]["options"],
expires_in=obj["attachments"][0]["expires_in"],
multiple=obj["attachments"][0]["multiple"],
hide_totals=obj["attachments"][0]["hide_totals"]
)
log.debug("Editing post with poll (this will reset votes)")
# Handle media attachments
elif len(obj["attachments"]) > 0:
for i in obj["attachments"]:
# If attachment has an 'id', it's an existing media that we keep
if "id" in i:
media_ids.append(i["id"])
# If existing media has metadata to update, use generate_media_edit_attributes
if "description" in i or "focus" in i:
media_attr = self.api.generate_media_edit_attributes(
id=i["id"],
description=i.get("description"),
focus=i.get("focus")
)
media_attributes.append(media_attr)
# Otherwise it's a new file to upload
elif "file" in i:
description = i.get("description", "")
focus = i.get("focus", None)
media = self.api_call(
"media_post",
media_file=i["file"],
description=description,
focus=focus,
synchronous=True
)
media_ids.append(media.id)
log.debug("Uploaded new media with id: {}".format(media.id))
# Prepare parameters for status_update
update_params = {
"id": post_id,
"status": text,
"_sound": "tweet_send.ogg",
"sensitive": obj.get("sensitive", False),
"spoiler_text": obj.get("spoiler_text", None),
}
# Add optional parameters only if provided
if media_ids:
update_params["media_ids"] = media_ids
if media_attributes:
update_params["media_attributes"] = media_attributes
if poll:
update_params["poll"] = poll
# Call status_update API
log.debug("Editing post {} with params: {}".format(post_id, {k: v for k, v in update_params.items() if k not in ["_sound"]}))
item = self.api_call(call_name="status_update", **update_params)
if item:
log.info("Successfully edited post {}".format(post_id))
return item
except MastodonAPIError as e:
log.exception("Mastodon API error updating post {}: {}".format(post_id, str(e)))
output.speak(_("Error editing post: {}").format(str(e)))
pub.sendMessage("mastodon.error_edit", name=self.get_name(), post_id=post_id, error=str(e))
return None
except Exception as e:
log.exception("Unexpected error updating post {}: {}".format(post_id, str(e)))
output.speak(_("Error editing post: {}").format(str(e)))
return None
def get_name(self):
instance = self.settings["mastodon"]["instance"]
instance = instance.replace("https://", "")

View File

@@ -76,6 +76,13 @@ def render_post(post, template, settings, relative_times=False, offset_hours=0):
else:
text = process_text(post, safe=False)
safe_text = process_text(post)
# Handle quoted posts
if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None:
quoted_user = post.quote.quoted_status.account.acct
quoted_text = process_text(post.quote.quoted_status, safe=False)
quoted_safe_text = process_text(post.quote.quoted_status, safe=True)
text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text)
safe_text = safe_text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_safe_text)
filtered = utils.evaluate_filters(post=post, current_context="home")
if filtered != None:
text = _("hidden by filter {}").format(filtered)

View File

@@ -3,23 +3,47 @@ import demoji
from html.parser import HTMLParser
from datetime import datetime, timezone
url_re = re.compile('<a\s*href=[\'|"](.*?)[\'"].*?>')
url_re = re.compile(r'<a\s*href=[\'|"](.*?)[\'"].*?>')
class HTMLFilter(HTMLParser):
# Classes to ignore when parsing HTML
IGNORED_CLASSES = ["quote-inline"]
text = ""
first_paragraph = True
skip_depth = 0 # Track nesting depth of ignored elements
def handle_data(self, data):
self.text += data
# Only add data if we're not inside an ignored element
if self.skip_depth == 0:
self.text += data
def handle_starttag(self, tag, attrs):
if tag == "br":
self.text = self.text+"\n"
elif tag == "p":
if self.first_paragraph:
self.first_paragraph = False
else:
self.text = self.text+"\n\n"
# Check if this tag has a class that should be ignored
attrs_dict = dict(attrs)
tag_class = attrs_dict.get("class", "")
# Check if any ignored class is present in this tag
should_skip = any(ignored_class in tag_class for ignored_class in self.IGNORED_CLASSES)
if should_skip:
self.skip_depth += 1
elif self.skip_depth == 0: # Only process tags if we're not skipping
if tag == "br":
self.text = self.text+"\n"
elif tag == "p":
if self.first_paragraph:
self.first_paragraph = False
else:
self.text = self.text+"\n\n"
else:
# We're inside a skipped element, increment depth for nested tags
self.skip_depth += 1
def handle_endtag(self, tag):
# Decrement skip depth when closing any tag while skipping
if self.skip_depth > 0:
self.skip_depth -= 1
def html_filter(data):
f = HTMLFilter()

View File

@@ -4,7 +4,7 @@ import unittest
from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock
# Assuming paths are set up correctly for test environment to find these
from sessions.atprotosocial.session import Session as ATProtoSocialSession
from sessions.blueski.session import Session as BlueskiSession
from sessions.session_exceptions import SessionLoginError, SessionError
from approve.notifications import NotificationError # Assuming this is the correct import path
from atproto.xrpc_client.models.common import XrpcError
@@ -48,7 +48,7 @@ mock_wx.ICON_QUESTION = 32 # Example
# Mock config objects
# This structure tries to mimic how config is accessed in session.py
# e.g., config.sessions.atprotosocial[user_id].handle
# e.g., config.sessions.blueski[user_id].handle
class MockConfigNode:
def __init__(self, initial_value=None):
self._value = initial_value
@@ -60,9 +60,9 @@ class MockUserSessionConfig:
self.handle = MockConfigNode("")
self.app_password = MockConfigNode("")
self.did = MockConfigNode("")
# Add other config values if session.py uses them for atprotosocial
# Add other config values if session.py uses them for blueski
class MockATProtoSocialConfig:
class MockBlueskiConfig:
def __init__(self):
self._user_configs = {"test_user": MockUserSessionConfig()}
def __getitem__(self, key):
@@ -70,31 +70,31 @@ class MockATProtoSocialConfig:
class MockSessionsConfig:
def __init__(self):
self.atprotosocial = MockATProtoSocialConfig()
self.blueski = MockBlueskiConfig()
mock_config_global = MagicMock()
mock_config_global.sessions = MockSessionsConfig()
class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
class TestBlueskiSession(unittest.IsolatedAsyncioTestCase):
@patch('sessions.atprotosocial.session.wx', mock_wx)
@patch('sessions.atprotosocial.session.config', mock_config_global)
@patch('sessions.blueski.session.wx', mock_wx)
@patch('sessions.blueski.session.config', mock_config_global)
def setUp(self):
self.mock_approval_api = MagicMock()
# Reset mocks for user_config part of global mock_config_global for each test
self.mock_user_config_instance = MockUserSessionConfig()
mock_config_global.sessions.atprotosocial.__getitem__.return_value = self.mock_user_config_instance
mock_config_global.sessions.blueski.__getitem__.return_value = self.mock_user_config_instance
self.session = ATProtoSocialSession(approval_api=self.mock_approval_api, user_id="test_user", channel_id="test_channel")
self.session = BlueskiSession(approval_api=self.mock_approval_api, user_id="test_user", channel_id="test_channel")
self.session.db = {}
self.session.save_db = AsyncMock()
self.session.notify_session_ready = AsyncMock()
self.session.send_text_notification = MagicMock()
# Mock the util property to return a MagicMock for ATProtoSocialUtils
# Mock the util property to return a MagicMock for BlueskiUtils
self.mock_util_instance = AsyncMock() # Make it an AsyncMock if its methods are async
self.mock_util_instance._own_did = None # These are set directly by session.login
self.mock_util_instance._own_handle = None
@@ -104,12 +104,12 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
self.session.util # Call property to ensure _util is set if it's lazy loaded
def test_session_initialization(self):
self.assertIsInstance(self.session, ATProtoSocialSession)
self.assertEqual(self.session.KIND, "atprotosocial")
self.assertIsInstance(self.session, BlueskiSession)
self.assertEqual(self.session.KIND, "blueski")
self.assertIsNone(self.session.client)
self.assertEqual(self.session.user_id, "test_user")
@patch('sessions.atprotosocial.session.AsyncClient')
@patch('sessions.blueski.session.AsyncClient')
async def test_login_successful(self, MockAsyncClient):
mock_client_instance = MockAsyncClient.return_value
# Use actual ATProto models for spec if possible for better type checking in mocks
@@ -142,7 +142,7 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
self.session.notify_session_ready.assert_called_once()
@patch('sessions.atprotosocial.session.AsyncClient')
@patch('sessions.blueski.session.AsyncClient')
async def test_login_failure_xrpc(self, MockAsyncClient):
mock_client_instance = MockAsyncClient.return_value
mock_client_instance.login = AsyncMock(side_effect=XrpcError(error="AuthenticationFailed", message="Invalid credentials"))
@@ -155,8 +155,8 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(self.session.client)
self.session.notify_session_ready.assert_not_called()
@patch('sessions.atprotosocial.session.wx', new=mock_wx)
@patch.object(ATProtoSocialSession, 'login', new_callable=AsyncMock)
@patch('sessions.blueski.session.wx', new=mock_wx)
@patch.object(BlueskiSession, 'login', new_callable=AsyncMock)
async def test_authorise_successful(self, mock_login_method):
mock_login_method.return_value = True
@@ -174,8 +174,8 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
# Further check if wx.MessageBox was called with success
# This requires more complex mocking or inspection of calls to mock_wx.MessageBox
@patch('sessions.atprotosocial.session.wx', new=mock_wx)
@patch.object(ATProtoSocialSession, 'login', new_callable=AsyncMock)
@patch('sessions.blueski.session.wx', new=mock_wx)
@patch.object(BlueskiSession, 'login', new_callable=AsyncMock)
async def test_authorise_login_fails_with_notification_error(self, mock_login_method):
mock_login_method.side_effect = NotificationError("Specific login failure from mock.")
@@ -220,7 +220,7 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
cw_text=None, is_sensitive=False, langs=["en", "es"], tags=None
)
@patch('sessions.atprotosocial.session.os.path.basename', return_value="image.png") # Mock os.path.basename
@patch('sessions.blueski.session.os.path.basename', return_value="image.png") # Mock os.path.basename
async def test_send_post_with_media(self, mock_basename):
self.session.is_ready = MagicMock(return_value=True)
mock_blob_info = {"blob_ref": MagicMock(spec=atp_models.ComAtprotoRepoStrongRef.Blob), "alt_text": "A test image"}
@@ -360,4 +360,3 @@ if 'wx' not in sys.modules: # type: ignore
mock_wx_module.MessageBox = MockWxMessageBox
mock_wx_module.CallAfter = MagicMock()
mock_wx_module.GetApp = MagicMock()
>>>>>>> REPLACE

View File

@@ -11,10 +11,10 @@ from datetime import datetime
from multiplatform_widgets import widgets
log = logging.getLogger("wxUI.buffers.atprotosocial.panels")
log = logging.getLogger("wxUI.buffers.blueski.panels")
class ATProtoSocialHomeTimelinePanel(object):
class BlueskiHomeTimelinePanel(object):
"""Minimal Home timeline buffer for Bluesky.
Exposes a .buffer wx.Panel with a List control and provides
@@ -27,6 +27,7 @@ class ATProtoSocialHomeTimelinePanel(object):
self.account = session.get_name()
self.name = name
self.type = "home_timeline"
self.timeline_algorithm = None
self.invisible = True
self.needs_init = True
self.buffer = _HomePanel(parent, name)
@@ -49,17 +50,16 @@ class ATProtoSocialHomeTimelinePanel(object):
# The atproto SDK expects params, not raw kwargs
try:
from atproto import models as at_models # type: ignore
# Home: algorithmic/default timeline
try:
params = at_models.AppBskyFeedGetTimeline.Params(limit=count)
res = api.app.bsky.feed.get_timeline(params)
except Exception:
# Some SDKs may require explicit algorithm for home; try behavioral
params = at_models.AppBskyFeedGetTimeline.Params(limit=count, algorithm="behavioral")
res = api.app.bsky.feed.get_timeline(params)
params = at_models.AppBskyFeedGetTimeline.Params(
limit=count,
algorithm=self.timeline_algorithm
)
res = api.app.bsky.feed.get_timeline(params)
except Exception:
# Fallback to plain dict params if typed models unavailable
res = api.app.bsky.feed.get_timeline({"limit": count})
payload = {"limit": count}
if self.timeline_algorithm:
payload["algorithm"] = self.timeline_algorithm
res = api.app.bsky.feed.get_timeline(payload)
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
self.items = []
@@ -103,10 +103,17 @@ class ATProtoSocialHomeTimelinePanel(object):
api = self.session._ensure_client()
try:
from atproto import models as at_models # type: ignore
params = at_models.AppBskyFeedGetTimeline.Params(limit=40, cursor=self.cursor)
params = at_models.AppBskyFeedGetTimeline.Params(
limit=40,
cursor=self.cursor,
algorithm=self.timeline_algorithm
)
res = api.app.bsky.feed.get_timeline(params)
except Exception:
res = api.app.bsky.feed.get_timeline({"limit": 40, "cursor": self.cursor})
payload = {"limit": 40, "cursor": self.cursor}
if self.timeline_algorithm:
payload["algorithm"] = self.timeline_algorithm
res = api.app.bsky.feed.get_timeline(payload)
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
new_items = []
@@ -144,7 +151,7 @@ class ATProtoSocialHomeTimelinePanel(object):
log.exception("Failed to load more Bluesky timeline items")
return 0
# Alias to integrate with mainController expectations for ATProto
# Alias to integrate with mainController expectations for Blueski
def load_more_posts(self, *args, **kwargs):
return self.get_more_items()
@@ -281,12 +288,13 @@ class _HomePanel(wx.Panel):
self.SetSizer(sizer)
class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
class BlueskiFollowingTimelinePanel(BlueskiHomeTimelinePanel):
"""Following-only timeline (reverse-chronological)."""
def __init__(self, parent, name: str, session):
super().__init__(parent, name, session)
self.type = "following_timeline"
self.timeline_algorithm = "reverse-chronological"
# Make sure the underlying wx panel also reflects this type
try:
self.buffer.type = "following_timeline"
@@ -302,7 +310,7 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
api = self.session._ensure_client()
# Following timeline via reverse-chronological algorithm on get_timeline
# Use plain dict to avoid typed-model mismatches across SDK versions
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"})
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": self.timeline_algorithm})
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
self.items = []
@@ -343,7 +351,11 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
try:
api = self.session._ensure_client()
# Pagination via reverse-chronological algorithm on get_timeline
res = api.app.bsky.feed.get_timeline({"limit": 40, "cursor": self.cursor, "algorithm": "reverse-chronological"})
res = api.app.bsky.feed.get_timeline({
"limit": 40,
"cursor": self.cursor,
"algorithm": self.timeline_algorithm
})
feed = getattr(res, "feed", [])
self.cursor = getattr(res, "cursor", None)
new_items = []

View File

@@ -6,9 +6,9 @@ from pubsub import pub
from approve.translation import translate as _
from approve.notifications import NotificationError
# Assuming controller.atprotosocial.userList.get_user_profile_details and session.util._format_profile_data exist
# Assuming controller.blueski.userList.get_user_profile_details and session.util._format_profile_data exist
# For direct call to util:
# from sessions.atprotosocial import utils as ATProtoSocialUtils
# from sessions.blueski import utils as BlueskiUtils
logger = logging.getLogger(__name__)
@@ -272,7 +272,7 @@ class ShowUserProfileDialog(wx.Dialog):
self.SetTitle(f"{_('User Profile')} - {text}")
```python
# Example of how this dialog might be called from atprotosocial.Handler.user_details:
# Example of how this dialog might be called from blueski.Handler.user_details:
# (This is conceptual, actual integration in handler.py will use the dialog)
#
# async def user_details(self, buffer_panel_or_user_ident):

View File

@@ -8,6 +8,8 @@ class base(wx.Menu):
self.Append(self.boost)
self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
self.Append(self.reply)
self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit"))
self.Append(self.edit)
self.fav = wx.MenuItem(self, wx.ID_ANY, _(u"&Add to favorites"))
self.Append(self.fav)
self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites"))
@@ -36,6 +38,8 @@ class notification(wx.Menu):
self.Append(self.boost)
self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
self.Append(self.reply)
self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit"))
self.Append(self.edit)
self.fav = wx.MenuItem(self, wx.ID_ANY, _(u"&Add to favorites"))
self.Append(self.fav)
self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites"))

View File

@@ -51,7 +51,7 @@ class Post(wx.Dialog):
visibility_sizer.Add(self.visibility, 0, 0, 0)
language_sizer = wx.BoxSizer(wx.HORIZONTAL)
post_actions_sizer.Add(language_sizer, 0, wx.RIGHT, 20)
lang_label = wx.StaticText(self, wx.ID_ANY, _("Language"))
lang_label = wx.StaticText(self, wx.ID_ANY, _("&Language"))
language_sizer.Add(lang_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
self.language = wx.ComboBox(self, wx.ID_ANY, choices=languages, style=wx.CB_DROPDOWN | wx.CB_READONLY)
language_sizer.Add(self.language, 0, wx.ALIGN_CENTER_VERTICAL, 0)
@@ -234,9 +234,9 @@ class viewPost(wx.Dialog):
def create_buttons_section(self, panel):
sizer = wx.BoxSizer(wx.HORIZONTAL)
self.mute = wx.Button(panel, wx.ID_ANY, _("Mute conversation"))
self.mute = wx.Button(panel, wx.ID_ANY, _("&Mute conversation"))
self.mute.Enable(False)
self.share = wx.Button(panel, wx.ID_ANY, _("Copy link to clipboard"))
self.share = wx.Button(panel, wx.ID_ANY, _("&Copy link to clipboard"))
self.share.Enable(False)
self.spellcheck = wx.Button(panel, wx.ID_ANY, _("Check &spelling..."))
self.translateButton = wx.Button(panel, wx.ID_ANY, _("&Translate..."))
@@ -295,7 +295,7 @@ class poll(wx.Dialog):
sizer_1 = wx.BoxSizer(wx.VERTICAL)
period_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_1.Add(period_sizer, 1, wx.EXPAND, 0)
label_period = wx.StaticText(self, wx.ID_ANY, _("Participation time"))
label_period = wx.StaticText(self, wx.ID_ANY, _("&Participation time"))
period_sizer.Add(label_period, 0, 0, 0)
self.period = wx.ComboBox(self, wx.ID_ANY, choices=[_("5 minutes"), _("30 minutes"), _("1 hour"), _("6 hours"), _("1 day"), _("2 days"), _("3 days"), _("4 days"), _("5 days"), _("6 days"), _("7 days")], style=wx.CB_DROPDOWN | wx.CB_READONLY | wx.CB_SIMPLE)
self.period.SetFocus()
@@ -305,36 +305,36 @@ class poll(wx.Dialog):
sizer_1.Add(sizer_2, 1, wx.EXPAND, 0)
option1_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option1_sizer, 1, wx.EXPAND, 0)
label_2 = wx.StaticText(self, wx.ID_ANY, _("Option 1"))
label_2 = wx.StaticText(self, wx.ID_ANY, _("Option &1"))
option1_sizer.Add(label_2, 0, 0, 0)
self.option1 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option1.SetMaxLength(25)
option1_sizer.Add(self.option1, 0, 0, 0)
option2_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option2_sizer, 1, wx.EXPAND, 0)
label_3 = wx.StaticText(self, wx.ID_ANY, _("Option 2"))
label_3 = wx.StaticText(self, wx.ID_ANY, _("Option &2"))
option2_sizer.Add(label_3, 0, 0, 0)
self.option2 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option2.SetMaxLength(25)
option2_sizer.Add(self.option2, 0, 0, 0)
option3_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option3_sizer, 1, wx.EXPAND, 0)
label_4 = wx.StaticText(self, wx.ID_ANY, _("Option 3"))
label_4 = wx.StaticText(self, wx.ID_ANY, _("Option &3"))
option3_sizer.Add(label_4, 0, 0, 0)
self.option3 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option3.SetMaxLength(25)
option3_sizer.Add(self.option3, 0, 0, 0)
option4_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option4_sizer, 1, wx.EXPAND, 0)
label_5 = wx.StaticText(self, wx.ID_ANY, _("Option 4"))
label_5 = wx.StaticText(self, wx.ID_ANY, _("Option &4"))
option4_sizer.Add(label_5, 0, 0, 0)
self.option4 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option4.SetMaxLength(25)
option4_sizer.Add(self.option4, 0, 0, 0)
self.multiple = wx.CheckBox(self, wx.ID_ANY, _("Allow multiple choices per user"))
self.multiple = wx.CheckBox(self, wx.ID_ANY, _("&Allow multiple choices per user"))
self.multiple.SetValue(False)
sizer_1.Add(self.multiple, 0, wx.ALL, 5)
self.hide_votes = wx.CheckBox(self, wx.ID_ANY, _("Hide votes count until the poll expires"))
self.hide_votes = wx.CheckBox(self, wx.ID_ANY, _("&Hide votes count until the poll expires"))
self.hide_votes.SetValue(False)
sizer_1.Add(self.hide_votes, 0, wx.ALL, 5)
btn_sizer = wx.StdDialogButtonSizer()

View File

@@ -141,7 +141,7 @@ class ShowUserProfile(wx.Dialog):
mainSizer.Add(privateSizer, 0, wx.ALL | wx.CENTER)
botSizer = wx.BoxSizer(wx.HORIZONTAL)
botLabel = wx.StaticText(self.panel, label=_("&Bot account: "))
botLabel = wx.StaticText(self.panel, label=_("B&ot account: "))
botText = self.createTextCtrl(bullSwitch[user.bot], (30, 30))
botSizer.Add(botLabel, wx.SizerFlags().Center())
botSizer.Add(botText, wx.SizerFlags().Center())
@@ -154,7 +154,7 @@ class ShowUserProfile(wx.Dialog):
discoverSizer.Add(discoverText, wx.SizerFlags().Center())
mainSizer.Add(discoverSizer, 0, wx.ALL | wx.CENTER)
posts = wx.Button(self.panel, label=_("{} p&osts. Click to open posts timeline").format(user.statuses_count))
posts = wx.Button(self.panel, label=_("{} pos&ts. Click to open posts timeline").format(user.statuses_count))
# posts.SetToolTip(_("Click to open {}'s posts").format(user.display_name))
posts.Bind(wx.EVT_BUTTON, self.onPost)
mainSizer.Add(posts, wx.SizerFlags().Center())

View File

@@ -119,7 +119,7 @@ class UpdateProfileDialog(wx.Dialog):
self.locked = wx.CheckBox(panel, label=_("&Private account"))
self.locked.SetValue(locked)
self.bot = wx.CheckBox(panel, label=_("&Bot account"))
self.bot = wx.CheckBox(panel, label=_("B&ot account"))
self.bot.SetValue(bot)
self.discoverable = wx.CheckBox(panel, label=_("&Discoverable account"))
self.discoverable.SetValue(discoverable)

View File

@@ -26,7 +26,7 @@ class EditTemplateDialog(wx.Dialog):
sizer_3.AddButton(self.button_SAVE)
self.button_CANCEL = wx.Button(self, wx.ID_CANCEL)
sizer_3.AddButton(self.button_CANCEL)
self.button_RESTORE = wx.Button(self, wx.ID_ANY, _("Restore template"))
self.button_RESTORE = wx.Button(self, wx.ID_ANY, _("&Restore template"))
self.button_RESTORE.Bind(wx.EVT_BUTTON, self.on_restore)
sizer_3.AddButton(self.button_CANCEL)
sizer_3.Realize()

View File

@@ -22,11 +22,11 @@ class UserListDialog(wx.Dialog):
user_list_sizer.Add(self.user_list, 1, wx.EXPAND | wx.ALL, 10)
main_sizer.Add(user_list_sizer, 1, wx.EXPAND | wx.ALL, 15)
buttons_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.actions_button = wx.Button(panel, wx.ID_ANY, "Actions")
self.actions_button = wx.Button(panel, wx.ID_ANY, "&Actions")
buttons_sizer.Add(self.actions_button, 0, wx.RIGHT, 10)
self.details_button = wx.Button(panel, wx.ID_ANY, _("View profile"))
self.details_button = wx.Button(panel, wx.ID_ANY, _("&View profile"))
buttons_sizer.Add(self.details_button, 0, wx.RIGHT, 10)
close_button = wx.Button(panel, wx.ID_CANCEL, "Close")
close_button = wx.Button(panel, wx.ID_CANCEL, "&Close")
buttons_sizer.Add(close_button, 0)
main_sizer.Add(buttons_sizer, 0, wx.ALIGN_CENTER | wx.BOTTOM, 15)
panel.SetSizer(main_sizer)

View File

@@ -19,7 +19,7 @@ class mainFrame(wx.Frame):
self.menuitem_search = self.menubar_application.Append(wx.ID_ANY, _(u"&Search"))
self.lists = self.menubar_application.Append(wx.ID_ANY, _(u"&Lists manager"))
self.lists.Enable(False)
self.manageAliases = self.menubar_application.Append(wx.ID_ANY, _("Manage user aliases"))
self.manageAliases = self.menubar_application.Append(wx.ID_ANY, _("M&anage user aliases"))
self.keystroke_editor = self.menubar_application.Append(wx.ID_ANY, _(u"&Edit keystrokes"))
self.account_settings = self.menubar_application.Append(wx.ID_ANY, _(u"Account se&ttings"))
self.prefs = self.menubar_application.Append(wx.ID_PREFERENCES, _(u"&Global settings"))
@@ -56,7 +56,7 @@ class mainFrame(wx.Frame):
self.trends = self.menubar_buffer.Append(wx.ID_ANY, _(u"New &trending topics buffer..."))
self.filter = self.menubar_buffer.Append(wx.ID_ANY, _(u"Create a &filter"))
self.manage_filters = self.menubar_buffer.Append(wx.ID_ANY, _(u"&Manage filters"))
self.find = self.menubar_buffer.Append(wx.ID_ANY, _(u"Find a string in the currently focused buffer..."))
self.find = self.menubar_buffer.Append(wx.ID_ANY, _(u"F&ind a string in the currently focused buffer..."))
self.load_previous_items = self.menubar_buffer.Append(wx.ID_ANY, _(u"&Load previous items"))
self.menubar_buffer.AppendSeparator()
self.mute_buffer = self.menubar_buffer.AppendCheckItem(wx.ID_ANY, _(u"&Mute"))
@@ -66,8 +66,8 @@ class mainFrame(wx.Frame):
# audio menu
self.menubar_audio = wx.Menu()
self.seekLeft = self.menubar_audio.Append(wx.ID_ANY, _(u"&Seek back 5 seconds"))
self.seekRight = self.menubar_audio.Append(wx.ID_ANY, _(u"&Seek forward 5 seconds"))
self.seekLeft = self.menubar_audio.Append(wx.ID_ANY, _(u"Seek &back 5 seconds"))
self.seekRight = self.menubar_audio.Append(wx.ID_ANY, _(u"Seek &forward 5 seconds"))
# Help Menu
self.menubar_help = wx.Menu()

186
test_atproto_session.py Normal file
View File

@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
"""Test script for Blueski session functionality.
This script demonstrates how to use the Blueski session implementation.
It can be used to verify that the authentication flow works correctly.
Usage:
python test_atproto_session.py
"""
import sys
import os
# Add src to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
# Import required modules
from sessions.blueski import session
import paths
import logging
# Setup logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def test_authorization():
"""Test the authorization flow."""
print("\n" + "="*60)
print("Blueski Session Authorization Test")
print("="*60)
print("\nThis test will:")
print("1. Create a new Blueski session")
print("2. Walk you through the authorization process")
print("3. Verify the session was created correctly")
print("\nNOTE: You'll need:")
print(" - Your Blueski handle (e.g., user.bsky.social)")
print(" - An app password (NOT your main password)")
print("\n" + "="*60 + "\n")
# Create a test session
test_session_id = "test_blueski_001"
print(f"Creating session with ID: {test_session_id}")
s = session.Session(session_id=test_session_id)
try:
# Try to login first (should fail for new session)
print("\nAttempting to login with existing credentials...")
s.get_configuration()
s.login()
print("✓ Login successful! Session already exists.")
print(f" Session name: {s.get_name()}")
print(f" Logged in: {s.logged}")
except Exception as e:
# Expected for first run
print(f"✗ Login failed (expected): {type(e).__name__}")
print("\nStarting authorization process...")
print("(GUI dialogs will open for handle and password)")
try:
result = s.authorise()
if result:
print("\n✓ Authorization successful!")
print(f" Session name: {s.get_name()}")
print(f" Logged in: {s.logged}")
print(f" User DID: {s.settings['blueski']['did']}")
print(f" Handle: {s.settings['blueski']['handle']}")
# Check that session_string was saved
if s.settings['blueski']['session_string']:
print(" ✓ Session string saved")
else:
print(" ✗ WARNING: Session string not saved!")
# Check that app_password was cleared
if not s.settings['blueski']['app_password']:
print(" ✓ App password cleared (secure)")
else:
print(" ✗ WARNING: App password still in config!")
print(f"\nSession configuration saved to:")
print(f" {os.path.join(paths.config_path(), test_session_id, 'session.conf')}")
return s
else:
print("\n✗ Authorization cancelled or failed")
return None
except Exception as e:
print(f"\n✗ Authorization error: {e}")
import traceback
traceback.print_exc()
return None
def test_logout(s):
"""Test the logout functionality."""
if not s:
print("\nSkipping logout test (no active session)")
return
print("\n" + "="*60)
print("Testing logout functionality")
print("="*60)
print(f"\nCurrent state: logged={s.logged}")
s.logout()
print(f"After logout: logged={s.logged}")
# Check that session_string was cleared
if not s.settings['blueski']['session_string']:
print("✓ Session string cleared")
else:
print("✗ WARNING: Session string not cleared!")
print("\nLogout test complete")
def test_session_restoration():
"""Test restoring a session from saved credentials."""
print("\n" + "="*60)
print("Testing session restoration")
print("="*60)
test_session_id = "test_blueski_001"
print(f"\nCreating new session object with ID: {test_session_id}")
s = session.Session(session_id=test_session_id)
try:
s.get_configuration()
s.login()
print("✓ Session restored successfully!")
print(f" Session name: {s.get_name()}")
print(f" Logged in: {s.logged}")
return s
except Exception as e:
print(f"✗ Failed to restore session: {e}")
import traceback
traceback.print_exc()
return None
def main():
"""Main test function."""
print("\nBlueski Session Test Suite")
print("Ensure you have wxPython installed for GUI dialogs\n")
# Test 1: Authorization
session_obj = test_authorization()
if not session_obj:
print("\nTests aborted (authorization failed)")
return
input("\nPress Enter to test session restoration...")
# Test 2: Logout
test_logout(session_obj)
input("\nPress Enter to test session restoration after logout...")
# Test 3: Try to restore (should fail after logout)
restored_session = test_session_restoration()
if not restored_session:
print("\n✓ Session restoration correctly failed after logout")
else:
print("\n✗ WARNING: Session was restored after logout (unexpected)")
print("\n" + "="*60)
print("All tests complete!")
print("="*60)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\nTest interrupted by user")
except Exception as e:
print(f"\n\nUnexpected error: {e}")
import traceback
traceback.print_exc()

View File

@@ -0,0 +1,40 @@
[atproto]
handle = test_user.bsky.social
app_password = ""
service_url = https://bsky.social
session_string = fake_session_string_12345
access_jwt = ""
refresh_jwt = ""
did = ""
[general]
relative_times = True
max_posts_per_call = 40
reverse_timelines = False
persist_size = 0
load_cache_in_memory = True
show_screen_names = False
buffer_order = home, notifications
[sound]
volume = 1.0
input_device = Default
output_device = Default
session_mute = False
current_soundpack = FreakyBlue
indicate_audio = True
indicate_img = True
[other_buffers]
timelines = ,
[mysc]
spelling_language = ""
[reporting]
braille_reporting = True
speech_reporting = True
[filters]
[user-aliases]