diff --git a/contracts/protocols.py b/contracts/protocols.py index 5f6e507..e320b31 100644 --- a/contracts/protocols.py +++ b/contracts/protocols.py @@ -262,6 +262,10 @@ class FederationService(Protocol): self, session: AsyncSession, acct: str, ) -> RemoteActorDTO | None: ... + async def search_actors( + self, session: AsyncSession, query: str, page: int = 1, limit: int = 20, + ) -> tuple[list[RemoteActorDTO], int]: ... + # -- Following (outbound) ------------------------------------------------- async def send_follow( self, session: AsyncSession, local_username: str, remote_actor_url: str, diff --git a/services/federation_impl.py b/services/federation_impl.py index edd4718..0937908 100644 --- a/services/federation_impl.py +++ b/services/federation_impl.py @@ -541,6 +541,92 @@ class SqlFederationService: return await self._upsert_remote_actor(session, actor_url, data) + async def search_actors( + self, session: AsyncSession, query: str, page: int = 1, limit: int = 20, + ) -> tuple[list[RemoteActorDTO], int]: + from sqlalchemy import or_ + + pattern = f"%{query}%" + offset = (page - 1) * limit + + # WebFinger resolve for @user@domain queries (first page only) + webfinger_result: RemoteActorDTO | None = None + if page == 1 and "@" in query: + webfinger_result = await self.search_remote_actor(session, query) + + # Search cached remote actors + remote_filter = or_( + RemoteActor.preferred_username.ilike(pattern), + RemoteActor.display_name.ilike(pattern), + RemoteActor.domain.ilike(pattern), + ) + remote_total = ( + await session.execute( + select(func.count(RemoteActor.id)).where(remote_filter) + ) + ).scalar() or 0 + + # Search local actor profiles + local_filter = or_( + ActorProfile.preferred_username.ilike(pattern), + ActorProfile.display_name.ilike(pattern), + ) + local_total = ( + await session.execute( + select(func.count(ActorProfile.id)).where(local_filter) + ) + ).scalar() or 0 + + total = remote_total + local_total + + # Fetch remote actors page + remote_rows = ( + await session.execute( + select(RemoteActor) + .where(remote_filter) + .order_by(RemoteActor.preferred_username) + .limit(limit) + .offset(offset) + ) + ).scalars().all() + + results: list[RemoteActorDTO] = [_remote_actor_to_dto(r) for r in remote_rows] + + # Fill remaining slots with local actors + remaining = limit - len(results) + local_offset = max(0, offset - remote_total) + if remaining > 0 and offset + len(results) >= remote_total: + domain = _domain() + local_rows = ( + await session.execute( + select(ActorProfile) + .where(local_filter) + .order_by(ActorProfile.preferred_username) + .limit(remaining) + .offset(local_offset) + ) + ).scalars().all() + for lp in local_rows: + results.append(RemoteActorDTO( + id=0, + actor_url=f"https://{domain}/users/{lp.preferred_username}", + inbox_url=f"https://{domain}/users/{lp.preferred_username}/inbox", + preferred_username=lp.preferred_username, + domain=domain, + display_name=lp.display_name, + summary=lp.summary, + icon_url=None, + )) + + # Prepend WebFinger result (deduped) + if webfinger_result: + existing_urls = {r.actor_url for r in results} + if webfinger_result.actor_url not in existing_urls: + results.insert(0, webfinger_result) + total += 1 + + return results, total + # -- Following (outbound) ------------------------------------------------- async def send_follow( diff --git a/services/stubs.py b/services/stubs.py index 3150041..a46d5ab 100644 --- a/services/stubs.py +++ b/services/stubs.py @@ -246,6 +246,9 @@ class StubFederationService: async def search_remote_actor(self, session, acct): return None + async def search_actors(self, session, query, page=1, limit=20): + return [], 0 + async def send_follow(self, session, local_username, remote_actor_url): raise RuntimeError("FederationService not available")