diff --git a/examples/README.md b/examples/README.md index 39249ed1a2b4edc7e10e4b738b0a899b868a0d3f..95961a26eebd82f87e826882189082fb1ae9a069 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,2 +1,118 @@ -To run examples, use the following command: -`python <example.py>` \ No newline at end of file +# PyFed Mastodon Integration Demo + +This guide demonstrates how to use PyFed to interact with Mastodon using the ActivityPub protocol. The example shows how to send a message (Note) to a Mastodon user using federation. + +## Prerequisites + +1. Python 3.9 or higher +2. Virtual environment (recommended) +3. ngrok installed (for local testing) +4. A Mastodon account to send messages to + +## Installation + +1. Clone the repository and set up the environment: + +```bash +# Create and activate virtual environment +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt +``` + +2. Set up ngrok for local testing: +```bash +# Start ngrok on port 8880 +ngrok http 8880 +``` +Save the ngrok URL (e.g., "12f8-197-211-61-33.ngrok-free.app") for configuration. + +## Configuration + +1. Create a `config.py` file in the examples directory with your settings: + +```python +CONFIG = { + "domain": "12f8-197-211-61-33.ngrok-free.app", # Your ngrok URL (without https://) + "user": "testuser", + "keys_path": "example_keys", + "port": 8880 +} +``` + +2. Create the keys directory: +```bash +mkdir example_keys +``` + +3. Start the key server: +```bash +python examples/key_server.py +``` +The key server will handle key management and HTTP signatures automatically. + +## Running the Example + +1. Ensure both ngrok and the key server are running + +2. Run the example: +```bash +python examples/send_to_mastodon.py +``` + +3. Check the logs for the federation process: + - WebFinger lookup + - Actor discovery + - Message delivery + - HTTP signatures + +## Troubleshooting + +Common issues and solutions: + +1. **Connection Issues** + - Verify ngrok is running and URL is correct + - Check if key server is running on port 8880 + - Ensure ngrok URL is properly set in config + +2. **WebFinger Lookup Fails** + - Verify the Mastodon username is correct + - Check if the instance is online + - Ensure proper network connectivity + +3. **Delivery Errors** + - Verify your ngrok URL is current (they expire) + - Check key server logs for signature issues + - Ensure Mastodon instance is reachable + +## Understanding the Code + +The example demonstrates several key PyFed features: + +1. **Key Management** + - Automatic key generation and management via key server + - Handles HTTP signatures for federation + +2. **Federation** + - WebFinger protocol for user discovery + - ActivityPub protocol for message delivery + +3. **Activity Creation** + - Creates ActivityPub Note objects + - Wraps them in Create activities + +## Next Steps + +1. Try sending different types of activities +2. Implement inbox handling for responses +3. Add error handling and retries +4. Explore other ActivityPub interactions + +## Support + +For issues and questions: +1. Check the PyFed documentation +2. Open an issue on GitHub +3. Join our community discussions \ No newline at end of file diff --git a/src/pyfed/federation/__init__.py b/src/pyfed/federation/__init__.py index 7770cfdadd2a832700722298a1c75e52ed719950..a298d4341900269b23a1f4dbdc324b135a76b629 100644 --- a/src/pyfed/federation/__init__.py +++ b/src/pyfed/federation/__init__.py @@ -6,10 +6,12 @@ from .delivery import ActivityDelivery from .fetch import ResourceFetcher from .resolver import ActivityPubResolver from .discovery import InstanceDiscovery +from .webfinger import WebFingerClient __all__ = [ 'ActivityDelivery', 'ResourceFetcher', - 'ActivityPubResolver', - 'InstanceDiscovery' + 'ActivityPubResolver', + 'InstanceDiscovery', + 'WebFingerClient' ] \ No newline at end of file diff --git a/src/pyfed/federation/resolver.py b/src/pyfed/federation/resolver.py index 6dda1ddcee695c5e191e21c2b52c71ec1e734a11..be2bd63d474ce1fa451caf13062eec81c2e477f5 100644 --- a/src/pyfed/federation/resolver.py +++ b/src/pyfed/federation/resolver.py @@ -10,7 +10,7 @@ from urllib.parse import urlparse from ..utils.exceptions import ResolverError from ..utils.logging import get_logger from ..cache.actor_cache import ActorCache -from ..protocols.webfinger import WebFingerClient +from .webfinger import WebFingerClient logger = get_logger(__name__) diff --git a/src/pyfed/models/__init__.py b/src/pyfed/models/__init__.py index 365021f853072f44e6df0cba3f20b54c06d716e4..205ffe9e0ada3099adaebac266262a75706944a4 100644 --- a/src/pyfed/models/__init__.py +++ b/src/pyfed/models/__init__.py @@ -13,7 +13,7 @@ from .collections import ( ) from .activities import ( APCreate, APUpdate, APDelete, - APFollow, APUndo, APLike, APAnnounce, APActivity + APFollow, APUndo, APLike, APAnnounce, APActivity, APAccept, APRemove, APBlock, APReject ) from .links import APLink, APMention @@ -23,5 +23,5 @@ __all__ = [ 'APActor', 'APPerson', 'APGroup', 'APOrganization', 'APApplication', 'APService', 'APEvent', 'APPlace', 'APProfile', 'APRelationship', 'APTombstone', 'APArticle', 'APAudio', 'APDocument', 'APImage', 'APNote', 'APPage', 'APVideo', 'APCollection', 'APOrderedCollection', 'APCollectionPage', 'APOrderedCollectionPage', - 'APCreate', 'APUpdate', 'APDelete', 'APFollow', 'APUndo', 'APLike', 'APAnnounce', 'APMention' + 'APCreate', 'APUpdate', 'APDelete', 'APFollow', 'APUndo', 'APLike', 'APAnnounce', 'APMention', 'APAccept', 'APRemove', 'APBlock', 'APReject' ] diff --git a/src/pyfed/models/activities.py b/src/pyfed/models/activities.py index 50646e21d31c1f7381f51e747b8c92e8ce2a9d26..25e609408a04a0b5624a25b2670476535f70a8b2 100644 --- a/src/pyfed/models/activities.py +++ b/src/pyfed/models/activities.py @@ -181,3 +181,76 @@ class APAnnounce(APActivity): ..., description="The object being announced" ) + +class APAccept(APActivity): + """ + Represents an Accept activity in ActivityPub. + + Indicates that the actor accepts the object. + + Attributes: + type (Literal["Accept"]): Always set to "Accept". + object ([Union[str, HttpUrl, APObject, Dict[str, Any]]]): The object of the activity. + + Specification: https://www.w3.org/TR/activitystreams-vocabulary/#accept + """ + type: Literal["Accept"] = Field(default="Accept", description="Indicates that this object represents an accept activity") + object: Union[str, HttpUrl, APObject, Dict[str, Any]] = Field( + ..., + description="The object being accepted" + ) + +class APRemove(APActivity): + """ + Represents a Remove activity in ActivityPub. + + Indicates that the actor is removing the object. + + Attributes: + type (Literal["Remove"]): Always set to "Remove". + object ([Union[str, HttpUrl, APObject, Dict[str, Any]]]): The object of the activity. + + Specification: https://www.w3.org/TR/activitystreams-vocabulary/#remove + """ + type: Literal["Remove"] = Field(default="Remove", description="Indicates that this object represents a remove activity") + object: Union[str, HttpUrl, APObject, Dict[str, Any]] = Field( + ..., + description="The object being removed" + ) + +class APBlock(APActivity): + """ + Represents a Block activity in ActivityPub. + + Indicates that the actor is blocking the object. + + Attributes: + type (Literal["Block"]): Always set to "Block". + object ([Union[str, HttpUrl, APObject, Dict[str, Any]]]): The object of the activity. + + Specification: https://www.w3.org/TR/activitystreams-vocabulary/#block + """ + type: Literal["Block"] = Field(default="Block", description="Indicates that this object represents a block activity") + object: Union[str, HttpUrl, APObject] = Field( + ..., + description="The object being blocked" + ) + +class APReject(APActivity): + """ + Represents a Reject activity in ActivityPub. + + Indicates that the actor rejects the object. + This is typically used to reject incoming activities such as Follow requests. + + Attributes: + type (Literal["Reject"]): Always set to "Reject". + object ([Union[str, HttpUrl, APObject, Dict[str, Any]]]): The object of the activity. + + Specification: https://www.w3.org/TR/activitystreams-vocabulary/#reject + """ + type: Literal["Reject"] = Field(default="Reject", description="Indicates that this object represents a reject activity") + object: Union[str, HttpUrl, APObject, Dict[str, Any]] = Field( + ..., + description="The object being rejected" + ) diff --git a/src/pyfed/models/collections.py b/src/pyfed/models/collections.py index 64a9759b43864b3e8f0eb802a28ab3ca1a335a84..aae976127ea66be7e973564b41cfbfe5f2f10a8d 100644 --- a/src/pyfed/models/collections.py +++ b/src/pyfed/models/collections.py @@ -9,7 +9,7 @@ https://www.w3.org/TR/activitystreams-core/#collections from __future__ import annotations from typing import Optional, List, Union, Literal, Dict, Any -from pydantic import Field, HttpUrl +from pydantic import Field, BaseModel, HttpUrl from pyfed.models.objects import APObject from pyfed.utils.logging import get_logger @@ -101,6 +101,26 @@ class APOrderedCollectionPage(APCollectionPage): description="The index of the first item" ) +class APFollowersCollection(APOrderedCollection): + """Collection of followers.""" + type: Literal["OrderedCollection"] = Field(default="OrderedCollection", description="Indicates an OrderedCollection") + name: Literal["Followers"] = Field(default="Followers") + +class APFollowingCollection(APOrderedCollection): + """Collection of following.""" + type: Literal["OrderedCollection"] = Field(default="OrderedCollection", description="Indicates an OrderedCollection") + name: Literal["Following"] = Field(default="Following") + +class APLikedCollection(APOrderedCollection): + """Collection of liked objects.""" + type: Literal["OrderedCollection"] = Field(default="OrderedCollection", description="Indicates an OrderedCollection") + name: Literal["Liked"] = Field(default="Liked") + +class APSharedCollection(APOrderedCollection): + """Collection of shared objects.""" + type: Literal["OrderedCollection"] = Field(default="OrderedCollection", description="Indicates an OrderedCollection") + name: Literal["Shared"] = Field(default="Shared") + async def fetch_collection(url: str) -> Union[APCollection, APOrderedCollection]: """ Fetch a collection from a given URL. diff --git a/src/pyfed/protocols/__init__.py b/src/pyfed/protocols/__init__.py index b6f4736c422d2d586f76fc4e47c24a9a93a4d05c..b0d1be4fe2125376c7e14597c6d14bf42f3244d1 100644 --- a/src/pyfed/protocols/__init__.py +++ b/src/pyfed/protocols/__init__.py @@ -1,6 +1,27 @@ -# from .c2s import C2SConfig, ClientToServerProtocol -# from .s2s import ServerToServerProtocol +""" +Protocol implementations for PyFed. +""" +from .base import ( + BaseProtocol, BaseProtocolConfig, ProtocolVersion, + ProtocolError, ValidationError, SecurityError, TimeoutError +) +from .c2s import C2SConfig, ClientToServerProtocol +from .s2s import ServerToServerProtocol -# __all__ = [ -# 'C2SConfig', 'ClientToServerProtocol', 'ServerToServerProtocol' -# ] +__all__ = [ + # Base + 'BaseProtocol', + 'BaseProtocolConfig', + 'ProtocolVersion', + 'ProtocolError', + 'ValidationError', + 'SecurityError', + 'TimeoutError', + + # Client-to-Server + 'C2SConfig', + 'ClientToServerProtocol', + + # Server-to-Server + 'ServerToServerProtocol', +] diff --git a/src/pyfed/protocols/webfinger.py b/src/pyfed/protocols/webfinger.py deleted file mode 100644 index 7dcd164d0380f76603388bb53fa7491dfd0b7a56..0000000000000000000000000000000000000000 --- a/src/pyfed/protocols/webfinger.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -WebFinger protocol implementation. -""" - -from typing import Dict, Any, Optional -import aiohttp -from urllib.parse import quote -import logging - -logger = logging.getLogger(__name__) - -class WebFingerClient: - """WebFinger client implementation.""" - - def __init__(self, verify_ssl: bool = True): - self.verify_ssl = verify_ssl - self.session = None - - async def initialize(self) -> None: - """Initialize client.""" - ssl = None if self.verify_ssl else False - self.session = aiohttp.ClientSession( - headers={"Accept": "application/jrd+json, application/json"}, - connector=aiohttp.TCPConnector(ssl=ssl) - ) - - async def finger(self, account: str) -> Optional[Dict[str, Any]]: - """ - Perform WebFinger lookup. - - Args: - account: Account to look up (e.g., user@domain.com) - - Returns: - WebFinger response if found - """ - try: - if not '@' in account: - return None - - # Ensure acct: prefix - if not account.startswith('acct:'): - account = f"acct:{account}" - - domain = account.split('@')[-1] - url = f"https://{domain}/.well-known/webfinger?resource={quote(account)}" - - response = await self.session.get(url) - async with response as resp: - if resp.status != 200: - return None - return await resp.json() - - except Exception as e: - logger.error(f"WebFinger lookup failed for {account}: {e}") - return None - - async def get_actor_url(self, account: str) -> Optional[str]: - """ - Get actor URL from WebFinger response. - - Args: - account: Account to look up - - Returns: - Actor URL if found - """ - try: - data = await self.finger(account) - if not data: - return None - - for link in data.get('links', []): - if (link.get('rel') == 'self' and - link.get('type') == 'application/activity+json'): - return link.get('href') - return None - - except Exception as e: - logger.error(f"Failed to get actor URL for {account}: {e}") - return None - - async def get_inbox_url(self, account: str) -> Optional[str]: - """ - Get inbox URL for account. - - Args: - account: Account to look up - - Returns: - Inbox URL if found - """ - try: - actor_url = await self.get_actor_url(account) - logger.debug(f"actor_url: {actor_url}") - if not actor_url: - return None - - response = await self.session.get(actor_url) - async with response as resp: - if resp.status != 200: - return None - data = await resp.json() - return data.get('inbox') - - except Exception as e: - logger.error(f"Failed to get inbox URL for {account}: {e}") - return None - - async def close(self) -> None: - """Clean up resources.""" - if self.session: - await self.session.close() \ No newline at end of file diff --git a/src/pyfed/requirements.txt b/src/pyfed/requirements.txt index 0519ecba6ea913e21689ec692e81e9e4973fbf73..b8ed079bfed22762bcfbcc43515920d1dbeca305 100644 --- a/src/pyfed/requirements.txt +++ b/src/pyfed/requirements.txt @@ -1 +1,51 @@ - \ No newline at end of file +aiofiles==24.1.0 +aiohappyeyeballs==2.4.3 +aiohttp==3.10.10 +aioredis==2.0.1 +aiosignal==1.3.1 +aiosqlite==0.20.0 +annotated-types==0.7.0 +anyio==4.6.2.post1 +async-timeout==5.0.0 +asyncpg==0.30.0 +attrs==24.2.0 +backoff==2.2.1 +beautifulsoup4==4.12.3 +bs4==0.0.2 +cachetools==5.5.0 +cffi==1.17.1 +cryptography==43.0.3 +dnspython==2.7.0 +factory_boy==3.3.1 +Faker==30.8.0 +fastapi==0.115.4 +frozenlist==1.4.1 +greenlet==3.1.1 +idna==3.10 +iniconfig==2.0.0 +Markdown==3.7 +motor==3.6.0 +multidict==6.1.0 +packaging==24.1 +pluggy==1.5.0 +prometheus_client==0.21.0 +propcache==0.2.0 +pycparser==2.22 +pydantic==2.9.2 +pydantic_core==2.23.4 +PyJWT==2.9.0 +pymongo==4.9.2 +pytest==8.3.3 +pytest-aiohttp==1.0.5 +pytest-asyncio==0.24.0 +python-dateutil==2.9.0.post0 +PyYAML==6.0.2 +redis==5.2.0 +six==1.16.0 +sniffio==1.3.1 +soupsieve==2.6 +SQLAlchemy==2.0.36 +starlette==0.41.2 +typing_extensions==4.12.2 +yarl==1.15.2 +certifi \ No newline at end of file