diff --git a/pyproject.toml b/pyproject.toml index 180400b..0fdff3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.10" dependencies = [ "atproto", "python-dotenv", + "PyYAML", ] [project.scripts] diff --git a/src/void_tools/cli.py b/src/void_tools/cli.py index 61b7ae2..1cb23c0 100644 --- a/src/void_tools/cli.py +++ b/src/void_tools/cli.py @@ -3,9 +3,146 @@ CLI entrypoints for void tools. """ import argparse import sys +import os +import json +import yaml from atproto import models from void_tools.client import get_client +def cmd_sync(args): + client = get_client() + try: + response = client.app.bsky.notification.list_notifications({'limit': args.limit}) + + inbox = [] + mapping = {} + + for i, notif in enumerate(response.notifications): + if args.unread_only and notif.is_read: + continue + + local_id = f"notif_{i}" + item = { + "id": local_id, + "type": notif.reason, + "author": f"@{notif.author.handle}", + } + if hasattr(notif.record, 'text'): + item["text"] = notif.record.text + + inbox.append(item) + mapping[local_id] = notif.uri + + with open("inbox.yaml", "w") as f: + yaml.dump(inbox, f, sort_keys=False, default_flow_style=False) + + with open(".inbox_mapping.json", "w") as f: + json.dump(mapping, f) + + print(f"Synced {len(inbox)} notifications to inbox.yaml") + + # Initialize an empty outbox template if it doesn't exist + if not os.path.exists("outbox.yaml"): + template = { + "config": {"mark_seen": False}, + "actions": [ + { + "action": "reply", + "target_id": "notif_0", + "posts": ["First post of reply thread", "Second post"] + }, + { + "action": "post", + "posts": ["Standalone post"] + } + ] + } + with open("outbox.yaml", "w") as f: + f.write("# Example outbox\\n") + yaml.dump(template, f, sort_keys=False, default_flow_style=False) + + except Exception as e: + print(f"Error syncing notifications: {e}", file=sys.stderr) + +def cmd_dispatch(args): + if not os.path.exists(args.file): + print(f"File {args.file} not found.", file=sys.stderr) + sys.exit(1) + + try: + with open(args.file, "r") as f: + outbox = yaml.safe_load(f) + + mapping = {} + if os.path.exists(".inbox_mapping.json"): + with open(".inbox_mapping.json", "r") as f: + mapping = json.load(f) + + except Exception as e: + print(f"Error reading outbox/mapping: {e}", file=sys.stderr) + sys.exit(1) + + client = get_client() + actions = outbox.get("actions", []) + + for act in actions: + action_type = act.get("action") + posts = act.get("posts", []) + + if not posts: + continue + + if action_type == "post": + print("Dispatching standalone post/thread...") + root_ref = None + parent_ref = None + + for text in posts: + if root_ref is None: + post = client.send_post(text=text) + root_ref = models.ComAtprotoRepoStrongRef.Main(cid=post.cid, uri=post.uri) + parent_ref = root_ref + else: + reply_ref = models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref) + post = client.send_post(text=text, reply_to=reply_ref) + parent_ref = models.ComAtprotoRepoStrongRef.Main(cid=post.cid, uri=post.uri) + print(f" Posted: {post.uri}") + + elif action_type == "reply": + target_id = act.get("target_id") + if target_id not in mapping: + print(f"Error: target_id {target_id} not found in mapping. Skipping.") + continue + + target_uri = mapping[target_id] + print(f"Dispatching reply to {target_uri}...") + + try: + parent_post = client.app.bsky.feed.get_posts({'uris': [target_uri]}).posts[0] + + parent_ref = models.ComAtprotoRepoStrongRef.Main(cid=parent_post.cid, uri=parent_post.uri) + if hasattr(parent_post.record, 'reply') and parent_post.record.reply: + root_ref = parent_post.record.reply.root + else: + root_ref = parent_ref + + for text in posts: + reply_ref = models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref) + post = client.send_post(text=text, reply_to=reply_ref) + parent_ref = models.ComAtprotoRepoStrongRef.Main(cid=post.cid, uri=post.uri) + print(f" Replied: {post.uri}") + + except Exception as e: + print(f"Error replying to {target_uri}: {e}") + + config = outbox.get("config", {}) + if config.get("mark_seen"): + try: + client.app.bsky.notification.update_seen({'seen_at': client.get_current_time_iso()}) + print("Marked notifications as seen.") + except Exception as e: + print(f"Error marking seen: {e}") + def cmd_notifications(args): client = get_client() try: @@ -134,6 +271,12 @@ def main(): notif_parser.add_argument("--limit", type=int, default=10, help="Number of notifications to fetch") notif_parser.add_argument("--mark-seen", action="store_true", help="Mark fetched notifications as seen") + # Sync + sync_parser = subparsers.add_parser("sync", help="Sync unread notifications to inbox.yaml") + sync_parser.add_argument("--limit", type=int, default=20, help="Number of notifications to fetch") + sync_parser.add_argument("--all", dest="unread_only", action="store_false", help="Fetch read notifications as well") + sync_parser.set_defaults(unread_only=True) + # Post post_parser = subparsers.add_parser("post", help="Create a post or reply") post_parser.add_argument("text", help="Text to post") @@ -156,10 +299,18 @@ def main(): update_parser.add_argument("--description", help="New description/bio") update_parser.add_argument("--display-name", help="New display name") + # Dispatch + dispatch_parser = subparsers.add_parser("dispatch", help="Dispatch posts from an outbox YAML file") + dispatch_parser.add_argument("file", nargs="?", default="outbox.yaml", help="Path to outbox YAML file") + args = parser.parse_args() if args.command == "notifs": cmd_notifications(args) + elif args.command == "sync": + cmd_sync(args) + elif args.command == "dispatch": + cmd_dispatch(args) elif args.command == "post": cmd_post(args) elif args.command == "search":