"""Reusable AP inbox handlers for all apps. Extracted from federation/bp/actors/routes.py so that every app's shared AP blueprint can process Follow, Undo, Accept, Create, etc. """ from __future__ import annotations import json import logging import uuid from datetime import datetime, timezone import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from shared.models.federation import ( ActorProfile, APInboxItem, APInteraction, APNotification, APRemotePost, APActivity, RemoteActor, ) from shared.services.registry import services log = logging.getLogger(__name__) AP_CONTENT_TYPE = "application/activity+json" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def fetch_remote_actor(actor_url: str) -> dict | None: """Fetch a remote actor's JSON-LD profile.""" try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( actor_url, headers={"Accept": AP_CONTENT_TYPE}, ) if resp.status_code == 200: return resp.json() except Exception: log.exception("Failed to fetch remote actor: %s", actor_url) return None async def send_accept( actor: ActorProfile, follow_activity: dict, follower_inbox: str, domain: str, ) -> None: """Send an Accept activity back to the follower.""" from shared.utils.http_signatures import sign_request from urllib.parse import urlparse username = actor.preferred_username actor_url = f"https://{domain}/users/{username}" accept_id = f"{actor_url}/activities/{uuid.uuid4()}" accept = { "@context": "https://www.w3.org/ns/activitystreams", "id": accept_id, "type": "Accept", "actor": actor_url, "object": follow_activity, } body_bytes = json.dumps(accept).encode() key_id = f"{actor_url}#main-key" parsed = urlparse(follower_inbox) headers = sign_request( private_key_pem=actor.private_key_pem, key_id=key_id, method="POST", path=parsed.path, host=parsed.netloc, body=body_bytes, ) headers["Content-Type"] = AP_CONTENT_TYPE try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( follower_inbox, content=body_bytes, headers=headers, ) log.info("Accept → %s: %d", follower_inbox, resp.status_code) except Exception: log.exception("Failed to send Accept to %s", follower_inbox) async def backfill_follower( session: AsyncSession, actor: ActorProfile, follower_inbox: str, domain: str, origin_app: str | None = None, ) -> None: """Deliver recent Create activities to a new follower's inbox.""" from shared.events.handlers.ap_delivery_handler import ( _build_activity_json, _deliver_to_inbox, ) filters = [ APActivity.actor_profile_id == actor.id, APActivity.is_local == True, # noqa: E712 APActivity.activity_type == "Create", ] if origin_app is not None: filters.append(APActivity.origin_app == origin_app) activities = ( await session.execute( select(APActivity).where(*filters) .order_by(APActivity.published.desc()) .limit(20) ) ).scalars().all() if not activities: return log.info( "Backfilling %d posts to %s for @%s", len(activities), follower_inbox, actor.preferred_username, ) async with httpx.AsyncClient() as client: for activity in reversed(activities): # oldest first activity_json = _build_activity_json(activity, actor, domain) await _deliver_to_inbox(client, follower_inbox, activity_json, actor, domain) # --------------------------------------------------------------------------- # Inbox activity handlers # --------------------------------------------------------------------------- async def handle_follow( session: AsyncSession, actor_row: ActorProfile, body: dict, from_actor_url: str, domain: str, app_domain: str = "federation", ) -> None: """Process a Follow activity: add follower, send Accept, backfill.""" remote_actor = await fetch_remote_actor(from_actor_url) if not remote_actor: log.warning("Could not fetch remote actor for Follow: %s", from_actor_url) return follower_inbox = remote_actor.get("inbox") if not follower_inbox: log.warning("Remote actor has no inbox: %s", from_actor_url) return remote_username = remote_actor.get("preferredUsername", "") from urllib.parse import urlparse remote_domain = urlparse(from_actor_url).netloc follower_acct = f"{remote_username}@{remote_domain}" if remote_username else from_actor_url pub_key = (remote_actor.get("publicKey") or {}).get("publicKeyPem") await services.federation.add_follower( session, actor_row.preferred_username, follower_acct=follower_acct, follower_inbox=follower_inbox, follower_actor_url=from_actor_url, follower_public_key=pub_key, app_domain=app_domain, ) log.info( "New follower: %s → @%s (app_domain=%s)", follower_acct, actor_row.preferred_username, app_domain, ) # Notification ra = ( await session.execute( select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) ) ).scalar_one_or_none() if not ra: ra_dto = await services.federation.get_or_fetch_remote_actor(session, from_actor_url) if ra_dto: ra = (await session.execute( select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) )).scalar_one_or_none() if ra: notif = APNotification( actor_profile_id=actor_row.id, notification_type="follow", from_remote_actor_id=ra.id, ) session.add(notif) # Send Accept await send_accept(actor_row, body, follower_inbox, domain) # Backfill: deliver recent posts (filtered by origin_app for per-app follows) backfill_origin = app_domain if app_domain != "federation" else None await backfill_follower(session, actor_row, follower_inbox, domain, origin_app=backfill_origin) async def handle_undo( session: AsyncSession, actor_row: ActorProfile, body: dict, from_actor_url: str, app_domain: str = "federation", ) -> None: """Process an Undo activity (typically Undo Follow).""" inner = body.get("object") if not inner: return inner_type = inner.get("type") if isinstance(inner, dict) else None if inner_type == "Follow": from urllib.parse import urlparse remote_domain = urlparse(from_actor_url).netloc remote_actor = await fetch_remote_actor(from_actor_url) remote_username = "" if remote_actor: remote_username = remote_actor.get("preferredUsername", "") follower_acct = f"{remote_username}@{remote_domain}" if remote_username else from_actor_url removed = await services.federation.remove_follower( session, actor_row.preferred_username, follower_acct, app_domain=app_domain, ) if removed: log.info("Unfollowed: %s → @%s (app_domain=%s)", follower_acct, actor_row.preferred_username, app_domain) else: log.debug("Undo Follow: follower not found: %s", follower_acct) else: log.debug("Undo for %s — not handled", inner_type) async def handle_accept( session: AsyncSession, actor_row: ActorProfile, body: dict, from_actor_url: str, ) -> None: """Process Accept activity — update outbound follow state.""" inner = body.get("object") if not inner: return inner_type = inner.get("type") if isinstance(inner, dict) else None if inner_type == "Follow": await services.federation.accept_follow_response( session, actor_row.preferred_username, from_actor_url, ) log.info("Follow accepted by %s for @%s", from_actor_url, actor_row.preferred_username) async def handle_create( session: AsyncSession, actor_row: ActorProfile, body: dict, from_actor_url: str, federation_domain: str, ) -> None: """Process Create(Note/Article) — ingest remote post.""" obj = body.get("object") if not obj or not isinstance(obj, dict): return obj_type = obj.get("type", "") if obj_type not in ("Note", "Article"): log.debug("Create with type %s — skipping", obj_type) return remote = await services.federation.get_or_fetch_remote_actor(session, from_actor_url) if not remote: log.warning("Could not resolve remote actor for Create: %s", from_actor_url) return await services.federation.ingest_remote_post(session, remote.id, body, obj) log.info("Ingested %s from %s", obj_type, from_actor_url) # Mention notification tags = obj.get("tag", []) if isinstance(tags, list): for tag in tags: if not isinstance(tag, dict): continue if tag.get("type") != "Mention": continue href = tag.get("href", "") if f"https://{federation_domain}/users/" in href: mentioned_username = href.rsplit("/", 1)[-1] mentioned = await services.federation.get_actor_by_username( session, mentioned_username, ) if mentioned: rp = (await session.execute( select(APRemotePost).where( APRemotePost.object_id == obj.get("id") ) )).scalar_one_or_none() ra = (await session.execute( select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) )).scalar_one_or_none() notif = APNotification( actor_profile_id=mentioned.id, notification_type="mention", from_remote_actor_id=ra.id if ra else None, target_remote_post_id=rp.id if rp else None, ) session.add(notif) # Reply notification in_reply_to = obj.get("inReplyTo") if in_reply_to and f"https://{federation_domain}/users/" in str(in_reply_to): local_activity = (await session.execute( select(APActivity).where( APActivity.activity_id == in_reply_to, ) )).scalar_one_or_none() if local_activity: ra = (await session.execute( select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) )).scalar_one_or_none() rp = (await session.execute( select(APRemotePost).where( APRemotePost.object_id == obj.get("id") ) )).scalar_one_or_none() notif = APNotification( actor_profile_id=local_activity.actor_profile_id, notification_type="reply", from_remote_actor_id=ra.id if ra else None, target_remote_post_id=rp.id if rp else None, ) session.add(notif) async def handle_update( session: AsyncSession, actor_row: ActorProfile, body: dict, from_actor_url: str, ) -> None: """Process Update — re-ingest remote post.""" obj = body.get("object") if not obj or not isinstance(obj, dict): return obj_type = obj.get("type", "") if obj_type in ("Note", "Article"): remote = await services.federation.get_or_fetch_remote_actor(session, from_actor_url) if remote: await services.federation.ingest_remote_post(session, remote.id, body, obj) log.info("Updated %s from %s", obj_type, from_actor_url) async def handle_delete( session: AsyncSession, actor_row: ActorProfile, body: dict, from_actor_url: str, ) -> None: """Process Delete — remove remote post.""" obj = body.get("object") if isinstance(obj, str): object_id = obj elif isinstance(obj, dict): object_id = obj.get("id", "") else: return if object_id: await services.federation.delete_remote_post(session, object_id) log.info("Deleted remote post %s from %s", object_id, from_actor_url) async def handle_like( session: AsyncSession, actor_row: ActorProfile, body: dict, from_actor_url: str, ) -> None: """Process incoming Like — record interaction + notify.""" object_id = body.get("object", "") if isinstance(object_id, dict): object_id = object_id.get("id", "") if not object_id: return remote = await services.federation.get_or_fetch_remote_actor(session, from_actor_url) if not remote: return ra = (await session.execute( select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) )).scalar_one_or_none() target = (await session.execute( select(APActivity).where(APActivity.activity_id == object_id) )).scalar_one_or_none() if not target: log.info("Like from %s for %s (target not found locally)", from_actor_url, object_id) return interaction = APInteraction( remote_actor_id=ra.id if ra else None, post_type="local", post_id=target.id, interaction_type="like", activity_id=body.get("id"), ) session.add(interaction) notif = APNotification( actor_profile_id=target.actor_profile_id, notification_type="like", from_remote_actor_id=ra.id if ra else None, target_activity_id=target.id, ) session.add(notif) log.info("Like from %s on activity %s", from_actor_url, object_id) async def handle_announce( session: AsyncSession, actor_row: ActorProfile, body: dict, from_actor_url: str, ) -> None: """Process incoming Announce (boost) — record interaction + notify.""" object_id = body.get("object", "") if isinstance(object_id, dict): object_id = object_id.get("id", "") if not object_id: return remote = await services.federation.get_or_fetch_remote_actor(session, from_actor_url) if not remote: return ra = (await session.execute( select(RemoteActor).where(RemoteActor.actor_url == from_actor_url) )).scalar_one_or_none() target = (await session.execute( select(APActivity).where(APActivity.activity_id == object_id) )).scalar_one_or_none() if not target: log.info("Announce from %s for %s (target not found locally)", from_actor_url, object_id) return interaction = APInteraction( remote_actor_id=ra.id if ra else None, post_type="local", post_id=target.id, interaction_type="boost", activity_id=body.get("id"), ) session.add(interaction) notif = APNotification( actor_profile_id=target.actor_profile_id, notification_type="boost", from_remote_actor_id=ra.id if ra else None, target_activity_id=target.id, ) session.add(notif) log.info("Announce from %s on activity %s", from_actor_url, object_id) async def dispatch_inbox_activity( session: AsyncSession, actor_row: ActorProfile, body: dict, from_actor_url: str, domain: str, app_domain: str = "federation", ) -> None: """Route an inbox activity to the correct handler.""" activity_type = body.get("type", "") if activity_type == "Follow": await handle_follow(session, actor_row, body, from_actor_url, domain, app_domain=app_domain) elif activity_type == "Undo": await handle_undo(session, actor_row, body, from_actor_url, app_domain=app_domain) elif activity_type == "Accept": await handle_accept(session, actor_row, body, from_actor_url) elif activity_type == "Create": await handle_create(session, actor_row, body, from_actor_url, domain) elif activity_type == "Update": await handle_update(session, actor_row, body, from_actor_url) elif activity_type == "Delete": await handle_delete(session, actor_row, body, from_actor_url) elif activity_type == "Like": await handle_like(session, actor_row, body, from_actor_url) elif activity_type == "Announce": await handle_announce(session, actor_row, body, from_actor_url)