diff --git a/README.md b/README.md index bd2313e..2dd816c 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,7 @@ Priority notifications (filename starts with `0_`) are processed first. - `feed.py`: Read Bluesky feeds - `thread.py`: Add posts to reply threads atomically - `reply.py`: Reply handling utilities + - `get_record.py`: Retrieve any ATProto record by URI (posts, profiles, follows, etc.) **Control Tools:** - `halt.py`: Signal to halt bot activity diff --git a/register_tools.py b/register_tools.py index 76eb2ba..7903284 100755 --- a/register_tools.py +++ b/register_tools.py @@ -20,6 +20,7 @@ from tools.whitewind import blog_post_create, BlogPostCreateArgs from tools.ack import annotate_ack, AnnotateAckArgs from tools.webpage import fetch_webpage, WebpageArgs from tools.flag_memory_deletion import flag_archival_memory_for_deletion, FlagArchivalMemoryForDeletionArgs +from tools.get_record import get_atproto_record, GetRecordArgs logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -88,6 +89,12 @@ TOOL_CONFIGS = [ "description": "Flag an archival memory for deletion based on its exact text content", "tags": ["memory", "archival", "delete", "cleanup"] }, + { + "func": get_atproto_record, + "args_schema": GetRecordArgs, + "description": "Retrieve any ATProto record by URI or repo/collection/rkey (posts, profiles, follows, likes, etc.)", + "tags": ["atproto", "record", "fetch", "bluesky", "generic"] + }, ] diff --git a/tools/__init__.py b/tools/__init__.py index 52c12fb..3cf88c4 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -5,6 +5,7 @@ from .post import create_new_bluesky_post, PostArgs from .feed import get_bluesky_feed, FeedArgs from .whitewind import blog_post_create, BlogPostCreateArgs from .ack import annotate_ack, AnnotateAckArgs +from .get_record import get_atproto_record, GetRecordArgs __all__ = [ # Functions @@ -13,10 +14,12 @@ __all__ = [ "get_bluesky_feed", "blog_post_create", "annotate_ack", + "get_atproto_record", # Pydantic models "SearchArgs", "PostArgs", "FeedArgs", "BlogPostCreateArgs", "AnnotateAckArgs", + "GetRecordArgs", ] \ No newline at end of file diff --git a/tools/get_record.py b/tools/get_record.py new file mode 100644 index 0000000..0398caa --- /dev/null +++ b/tools/get_record.py @@ -0,0 +1,159 @@ +"""Generic ATProto record retrieval tool.""" +from typing import Optional +from pydantic import BaseModel, Field + + +class GetRecordArgs(BaseModel): + uri: Optional[str] = Field( + None, + description="Full AT URI (e.g., 'at://did:plc:xyz/app.bsky.feed.post/abc123'). If provided, repo/collection/rkey are ignored." + ) + repo: Optional[str] = Field( + None, + description="DID or handle of the repo (e.g., 'did:plc:xyz' or 'user.bsky.social')" + ) + collection: Optional[str] = Field( + None, + description="NSID of the collection (e.g., 'app.bsky.feed.post', 'app.bsky.actor.profile', 'app.bsky.graph.follow')" + ) + rkey: Optional[str] = Field( + None, + description="Record key within the collection" + ) + + +def get_atproto_record( + uri: str = None, + repo: str = None, + collection: str = None, + rkey: str = None +) -> str: + """ + Retrieve any ATProto record by URI or by repo/collection/rkey components. + + This is a generic tool for fetching records from the AT Protocol network. + Common collections include: + - app.bsky.feed.post - Posts + - app.bsky.feed.like - Likes + - app.bsky.feed.repost - Reposts + - app.bsky.actor.profile - Profile records + - app.bsky.graph.follow - Follow records + - app.bsky.graph.block - Block records + - app.bsky.graph.list - List records + - app.bsky.graph.listitem - List item records + - app.bsky.feed.generator - Feed generator records + - app.bsky.labeler.service - Labeler service records + + Args: + uri: Full AT URI (e.g., 'at://did:plc:xyz/app.bsky.feed.post/abc123'). + If provided, repo/collection/rkey are ignored. + repo: DID or handle of the repo (e.g., 'did:plc:xyz' or 'user.bsky.social') + collection: NSID of the collection (e.g., 'app.bsky.feed.post') + rkey: Record key within the collection + + Returns: + YAML-formatted record data including the record value and metadata + """ + import os + import re + import yaml + import requests + + try: + # Parse URI if provided + if uri: + # AT URI format: at://did:plc:xyz/collection/rkey + # or at://handle/collection/rkey + match = re.match(r'^at://([^/]+)/([^/]+)/(.+)$', uri) + if not match: + raise Exception(f"Invalid AT URI format: {uri}. Expected format: at://repo/collection/rkey") + repo = match.group(1) + collection = match.group(2) + rkey = match.group(3) + + # Validate we have all required params + if not repo: + raise Exception("repo is required (either via uri or repo parameter)") + if not collection: + raise Exception("collection is required (either via uri or collection parameter)") + if not rkey: + raise Exception("rkey is required (either via uri or rkey parameter)") + + # Use public API endpoint (no auth required for public records) + # Try the public API first, fall back to authenticated if needed + public_url = "https://public.api.bsky.app/xrpc/com.atproto.repo.getRecord" + params = { + "repo": repo, + "collection": collection, + "rkey": rkey + } + + try: + response = requests.get(public_url, params=params, timeout=15) + + # If public API fails with auth error, try authenticated + if response.status_code == 401 or response.status_code == 403: + raise Exception("Auth required") + + response.raise_for_status() + record_data = response.json() + + except Exception as public_error: + # Fall back to authenticated request + username = os.getenv("BSKY_USERNAME") + password = os.getenv("BSKY_PASSWORD") + pds_host = os.getenv("PDS_URI", "https://bsky.social") + + if not username or not password: + raise Exception(f"Public API failed and no credentials available: {str(public_error)}") + + # Create session + session_url = f"{pds_host}/xrpc/com.atproto.server.createSession" + session_data = { + "identifier": username, + "password": password + } + + session_response = requests.post(session_url, json=session_data, timeout=10) + session_response.raise_for_status() + session = session_response.json() + access_token = session.get("accessJwt") + + if not access_token: + raise Exception("Failed to get access token from session") + + # Fetch record with auth + headers = {"Authorization": f"Bearer {access_token}"} + auth_url = f"{pds_host}/xrpc/com.atproto.repo.getRecord" + + response = requests.get(auth_url, headers=headers, params=params, timeout=15) + response.raise_for_status() + record_data = response.json() + + # Format output + result = { + "record": { + "uri": record_data.get("uri", f"at://{repo}/{collection}/{rkey}"), + "cid": record_data.get("cid", ""), + "collection": collection, + "repo": repo, + "rkey": rkey, + "value": record_data.get("value", {}) + } + } + + return yaml.dump(result, default_flow_style=False, sort_keys=False, allow_unicode=True) + + except requests.exceptions.HTTPError as e: + if e.response is not None: + if e.response.status_code == 404: + raise Exception(f"Record not found: at://{repo}/{collection}/{rkey}") + try: + error_body = e.response.json() + error_msg = error_body.get("message", str(e)) + except: + error_msg = str(e) + raise Exception(f"HTTP error fetching record: {error_msg}") + raise Exception(f"HTTP error fetching record: {str(e)}") + except Exception as e: + raise Exception(f"Error fetching ATProto record: {str(e)}")