Add full fediverse social service
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Social blueprint with timeline, compose, search, follow/unfollow, like/boost interactions, and notifications. Inbox handler extended for Create/Update/Delete/Accept/Like/Announce with notification creation. HTMX-powered infinite scroll and interaction buttons. Nav updated with Timeline, Public, Search, and Notifications links. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -301,8 +301,18 @@ def register(url_prefix="/users"):
|
||||
await _handle_follow(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Undo":
|
||||
await _handle_undo(actor_row, body, from_actor_url)
|
||||
elif activity_type in ("Like", "Announce"):
|
||||
log.info("Received %s from %s (noted)", activity_type, from_actor_url)
|
||||
elif activity_type == "Accept":
|
||||
await _handle_accept(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Create":
|
||||
await _handle_create(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Update":
|
||||
await _handle_update(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Delete":
|
||||
await _handle_delete(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Like":
|
||||
await _handle_like(actor_row, body, from_actor_url)
|
||||
elif activity_type == "Announce":
|
||||
await _handle_announce(actor_row, body, from_actor_url)
|
||||
|
||||
# Mark as processed
|
||||
item.state = "processed"
|
||||
@@ -351,6 +361,29 @@ def register(url_prefix="/users"):
|
||||
follower_acct, actor_row.preferred_username,
|
||||
)
|
||||
|
||||
# Notification
|
||||
from shared.models.federation import APNotification, RemoteActor
|
||||
ra = (
|
||||
await g.s.execute(
|
||||
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not ra:
|
||||
# Store this remote actor
|
||||
ra_dto = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
|
||||
if ra_dto:
|
||||
ra = (await g.s.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,
|
||||
)
|
||||
g.s.add(notif)
|
||||
|
||||
# Send Accept
|
||||
await _send_accept(actor_row, body, follower_inbox)
|
||||
|
||||
@@ -388,6 +421,246 @@ def register(url_prefix="/users"):
|
||||
else:
|
||||
log.debug("Undo for %s — not handled", inner_type)
|
||||
|
||||
async def _handle_accept(
|
||||
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(
|
||||
g.s, 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(
|
||||
actor_row: ActorProfile,
|
||||
body: dict,
|
||||
from_actor_url: 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
|
||||
|
||||
# Get or create remote actor
|
||||
remote = await services.federation.get_or_fetch_remote_actor(g.s, 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(g.s, remote.id, body, obj)
|
||||
log.info("Ingested %s from %s", obj_type, from_actor_url)
|
||||
|
||||
# Create notification if mentions a local actor
|
||||
from shared.models.federation import APNotification, APRemotePost, RemoteActor
|
||||
tags = obj.get("tag", [])
|
||||
if isinstance(tags, list):
|
||||
domain = _domain()
|
||||
for tag in tags:
|
||||
if not isinstance(tag, dict):
|
||||
continue
|
||||
if tag.get("type") != "Mention":
|
||||
continue
|
||||
href = tag.get("href", "")
|
||||
if f"https://{domain}/users/" in href:
|
||||
mentioned_username = href.rsplit("/", 1)[-1]
|
||||
mentioned = await services.federation.get_actor_by_username(
|
||||
g.s, mentioned_username,
|
||||
)
|
||||
if mentioned:
|
||||
# Find the remote post we just created
|
||||
rp = (await g.s.execute(
|
||||
select(APRemotePost).where(
|
||||
APRemotePost.object_id == obj.get("id")
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
ra = (await g.s.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,
|
||||
)
|
||||
g.s.add(notif)
|
||||
|
||||
# Also check if it's a reply to one of our posts
|
||||
in_reply_to = obj.get("inReplyTo")
|
||||
if in_reply_to and f"https://{domain}/users/" in str(in_reply_to):
|
||||
# It's a reply to one of our local posts
|
||||
from shared.models.federation import APActivity
|
||||
local_activity = (await g.s.execute(
|
||||
select(APActivity).where(
|
||||
APActivity.activity_id == in_reply_to,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if local_activity:
|
||||
ra = (await g.s.execute(
|
||||
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
||||
)).scalar_one_or_none()
|
||||
rp = (await g.s.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,
|
||||
)
|
||||
g.s.add(notif)
|
||||
|
||||
async def _handle_update(
|
||||
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(g.s, from_actor_url)
|
||||
if remote:
|
||||
await services.federation.ingest_remote_post(g.s, remote.id, body, obj)
|
||||
log.info("Updated %s from %s", obj_type, from_actor_url)
|
||||
|
||||
async def _handle_delete(
|
||||
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(g.s, object_id)
|
||||
log.info("Deleted remote post %s from %s", object_id, from_actor_url)
|
||||
|
||||
async def _handle_like(
|
||||
actor_row: ActorProfile,
|
||||
body: dict,
|
||||
from_actor_url: str,
|
||||
) -> None:
|
||||
"""Process incoming Like — record interaction + notify."""
|
||||
from shared.models.federation import APInteraction, APNotification, RemoteActor
|
||||
|
||||
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(g.s, from_actor_url)
|
||||
if not remote:
|
||||
return
|
||||
|
||||
ra = (await g.s.execute(
|
||||
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# Find the local activity this Like targets
|
||||
from shared.models.federation import APActivity
|
||||
target = (await g.s.execute(
|
||||
select(APActivity).where(APActivity.activity_id == object_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if not target:
|
||||
# Try matching object_data.id
|
||||
log.info("Like from %s for %s (target not found locally)", from_actor_url, object_id)
|
||||
return
|
||||
|
||||
# Record interaction
|
||||
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"),
|
||||
)
|
||||
g.s.add(interaction)
|
||||
|
||||
# Notification
|
||||
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,
|
||||
)
|
||||
g.s.add(notif)
|
||||
log.info("Like from %s on activity %s", from_actor_url, object_id)
|
||||
|
||||
async def _handle_announce(
|
||||
actor_row: ActorProfile,
|
||||
body: dict,
|
||||
from_actor_url: str,
|
||||
) -> None:
|
||||
"""Process incoming Announce (boost) — record interaction + notify."""
|
||||
from shared.models.federation import APInteraction, APNotification, RemoteActor
|
||||
|
||||
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(g.s, from_actor_url)
|
||||
if not remote:
|
||||
return
|
||||
|
||||
ra = (await g.s.execute(
|
||||
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
from shared.models.federation import APActivity
|
||||
target = (await g.s.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"),
|
||||
)
|
||||
g.s.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,
|
||||
)
|
||||
g.s.add(notif)
|
||||
log.info("Announce from %s on activity %s", from_actor_url, object_id)
|
||||
|
||||
@bp.get("/<username>/followers")
|
||||
async def followers(username: str):
|
||||
actor = await services.federation.get_actor_by_username(g.s, username)
|
||||
@@ -431,6 +704,7 @@ def register(url_prefix="/users"):
|
||||
|
||||
domain = _domain()
|
||||
collection_id = f"https://{domain}/users/{username}/following"
|
||||
following_list, total = await services.federation.get_following(g.s, username)
|
||||
page_param = request.args.get("page")
|
||||
|
||||
if not page_param:
|
||||
@@ -439,7 +713,7 @@ def register(url_prefix="/users"):
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollection",
|
||||
"id": collection_id,
|
||||
"totalItems": 0,
|
||||
"totalItems": total,
|
||||
"first": f"{collection_id}?page=1",
|
||||
}),
|
||||
content_type=AP_CONTENT_TYPE,
|
||||
@@ -451,8 +725,8 @@ def register(url_prefix="/users"):
|
||||
"type": "OrderedCollectionPage",
|
||||
"id": f"{collection_id}?page=1",
|
||||
"partOf": collection_id,
|
||||
"totalItems": 0,
|
||||
"orderedItems": [],
|
||||
"totalItems": total,
|
||||
"orderedItems": [f.actor_url for f in following_list],
|
||||
}),
|
||||
content_type=AP_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user