From 4eef9374f0f8208c2a238d962081d667731f02a0 Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Sat, 24 May 2025 15:43:16 -0400 Subject: [PATCH] initial commit --- .gitignore | 4 ++ blastodon/__init__.py | 1 + blastodon/__main__.py | 7 +++ blastodon/auth/__init__.py | 0 blastodon/auth/cli.py | 81 ++++++++++++++++++++++++++++++ blastodon/client/__init__.py | 3 ++ blastodon/client/client.py | 39 ++++++++++++++ blastodon/client/created_post.py | 8 +++ blastodon/client/retreived_post.py | 23 +++++++++ pyproject.toml | 22 ++++++++ 10 files changed, 188 insertions(+) create mode 100644 .gitignore create mode 100644 blastodon/__init__.py create mode 100644 blastodon/__main__.py create mode 100644 blastodon/auth/__init__.py create mode 100644 blastodon/auth/cli.py create mode 100644 blastodon/client/__init__.py create mode 100644 blastodon/client/client.py create mode 100644 blastodon/client/created_post.py create mode 100644 blastodon/client/retreived_post.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e44143b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.secret +**/__pycache__ +**/*.egg-info +build/ diff --git a/blastodon/__init__.py b/blastodon/__init__.py new file mode 100644 index 0000000..a033d4e --- /dev/null +++ b/blastodon/__init__.py @@ -0,0 +1 @@ +from blastodon.auth import cli \ No newline at end of file diff --git a/blastodon/__main__.py b/blastodon/__main__.py new file mode 100644 index 0000000..072c822 --- /dev/null +++ b/blastodon/__main__.py @@ -0,0 +1,7 @@ +from blastodon.client import Client + +client = Client.init_cli() + +post_text = input(' enter a status to post: ') + +client.send_text_post(post_text) \ No newline at end of file diff --git a/blastodon/auth/__init__.py b/blastodon/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blastodon/auth/cli.py b/blastodon/auth/cli.py new file mode 100644 index 0000000..a0b3705 --- /dev/null +++ b/blastodon/auth/cli.py @@ -0,0 +1,81 @@ +from pathlib import Path +from sqlite3 import Connection as SQLite3 +from getpass import getpass + +from mastodon import Mastodon +from atproto import Client as BskyClient + + +def auth_mastodon() -> Mastodon: + metadata_file = Path.cwd() / 'mastodon_client.secret' + scopes = [ + "read", "write", # "follow", "push" + ] + # For first run of app instance + if not metadata_file.exists(): + instance = input(' enter your mastodon instance: https://') + api_base_url = f'https://{instance}' + Mastodon.create_app('blastodon', + api_base_url=instance, + scopes=scopes, + to_file=metadata_file) + login_file = _mastodon_find_single_user_login() + if login_file is None: + username = input(' enter your mastodon username: ') + login_file = Path.cwd() / f'mastodon.{username}.secret' + # for first run for user + if login_file.exists(): + return Mastodon(client_id=metadata_file, access_token=login_file) + + mastodon = Mastodon(client_id=metadata_file) + print('Visit', mastodon.auth_request_url(scopes=scopes), 'to log in') + oauth_token = input(' Paste the OAuth token shown to you on login: ') + mastodon.log_in(code=oauth_token, to_file=login_file, scopes=scopes) + return mastodon + + +def auth_bsky() -> BskyClient: + session_path = _bsky_find_single_user_login() + username = None + if session_path is None: + username = input(' enter your bsky username: @') + session_path = Path.cwd() / f'bsky_{username}_session.secret' + client = BskyClient() + if session_path.exists(): + with open(session_path) as file: + client.login(session_string=file.read()) + return client + client.login( + # username is definitely not None here because if session is not None that means + # the file exists and the function has already returned with session restoration + login=username, + password=getpass(' password: ') + ) + with open(session_path, mode='w') as file: + file.write(client.export_session_string()) + return client + + +def _mastodon_find_single_user_login() -> Path | None: + found_file = None + for file in Path.cwd().iterdir(): + if file.name.startswith('login.') and file.name.endswith('.secret'): + print('found secret file', file.name) + if found_file is None: + found_file = file + else: + # More than one login found, skip to prompt user for name + return None + return found_file + + +def _bsky_find_single_user_login() -> Path | None: + found_file = None + for file in Path.cwd().iterdir(): + if file.name.startswith('bsky-') and file.name.endswith('-jwt.secret'): + if found_file is None: + found_file = file + else: + # More than one login found, skip to prompt user for name + return None + return found_file diff --git a/blastodon/client/__init__.py b/blastodon/client/__init__.py new file mode 100644 index 0000000..75184cf --- /dev/null +++ b/blastodon/client/__init__.py @@ -0,0 +1,3 @@ +from blastodon.client.retreived_post import RetreivedPost +from blastodon.client.created_post import CreatedPost +from blastodon.client.client import Client \ No newline at end of file diff --git a/blastodon/client/client.py b/blastodon/client/client.py new file mode 100644 index 0000000..b3fb7fb --- /dev/null +++ b/blastodon/client/client.py @@ -0,0 +1,39 @@ +from typing import Self + +from atproto import Client as BskyClient +from mastodon import Mastodon, MastodonError + +from blastodon import auth +from blastodon.client import RetreivedPost, CreatedPost + +class Client: + """A wrapper around both Mastodon and Bsky clients""" + mastodon: Mastodon + bsky: BskyClient + + def __init__(self, mastodon: Mastodon, bsky: BskyClient): + self.mastodon = mastodon + self.bsky = bsky + + @classmethod + def init_cli(cls) -> Self: + return cls( + mastodon=auth.cli.auth_mastodon(), + bsky=auth.cli.auth_bsky() + ) + + def most_recent_posts(self) -> RetreivedPost: + return RetreivedPost.most_recent(client=self) + + def send_text_post(self, text: str): + bsky_post = self.bsky.send_post(text=text) + try: + mastodon_post = self.mastodon.status_post(status=text) + except MastodonError as err: + print('error posting to mastodon after posting to bsky. Deleting bsky post.') + self.bsky.delete_post(bsky_post.uri) + raise err + + return CreatedPost(bsky=bsky_post, mastodon=mastodon_post) + + \ No newline at end of file diff --git a/blastodon/client/created_post.py b/blastodon/client/created_post.py new file mode 100644 index 0000000..f64be67 --- /dev/null +++ b/blastodon/client/created_post.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass +from atproto_client.models import AppBskyFeedPost +from mastodon.statuses import Status + +@dataclass +class CreatedPost: + bsky: AppBskyFeedPost.CreateRecordResponse + mastodon: Status \ No newline at end of file diff --git a/blastodon/client/retreived_post.py b/blastodon/client/retreived_post.py new file mode 100644 index 0000000..f1719c5 --- /dev/null +++ b/blastodon/client/retreived_post.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Self + +from mastodon.statuses import Status as MastodonStatus +from atproto_client.models import AppBskyFeedDefs + +@dataclass +class RetreivedPost: + mastodon: MastodonStatus + bsky: AppBskyFeedDefs.FeedViewPost + + @classmethod + def most_recent(cls, client: 'Client') -> Self: + logged_in_user = client.mastodon.account_verify_credentials() + mpost = client.mastodon.account_statuses( + id=logged_in_user.id, + exclude_replies=True, + exclude_reblogs=True, + limit=1 + )[0] + logged_in_user = client.bsky.me.did + bpost = client.bsky.get_author_feed(actor=logged_in_user).feed[0] + return cls(mastodon=mpost, bsky=bpost) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e50c41c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[project] +name = "blastodon" +authors = [ + {name = "D. Scott Boggs", email = "scott@techwork.zone"} +] +description = "Bluesky and Mastdon Cross-Poster" +readme = "README.md" +requires-python = ">=3.11" # Self type +license = "AGPL-3.0-only" +dependencies = [ + "atproto", + "mastodon.py", +] +dynamic = ["version"] + + +[project.scripts] +# Put scripts here