All cross-service events now flow through ap_activities with a unified EventProcessor. Internal events use visibility="internal"; federation activities use visibility="public" and get delivered by a wildcard handler. - Add processing columns to APActivity (process_state, actor_uri, etc.) - New emit_activity() / register_activity_handler() API - EventProcessor polls ap_activities instead of domain_events - Rewrite all handlers to accept APActivity - Migrate all 7 emit_event call sites to emit_activity - publish_activity() sets process_state=pending directly (no emit_event bridge) - Migration to drop domain_events table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
83 lines
2.7 KiB
Python
83 lines
2.7 KiB
Python
"""Inline federation publication — called at write time, not via async handler.
|
|
|
|
The originating service calls try_publish() directly, which creates the
|
|
APActivity (with process_state='pending') in the same DB transaction.
|
|
The EventProcessor picks it up and the delivery wildcard handler POSTs
|
|
to follower inboxes.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from shared.services.registry import services
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
async def try_publish(
|
|
session: AsyncSession,
|
|
*,
|
|
user_id: int | None,
|
|
activity_type: str,
|
|
object_type: str,
|
|
object_data: dict,
|
|
source_type: str,
|
|
source_id: int,
|
|
) -> None:
|
|
"""Publish an AP activity if federation is available and user has a profile.
|
|
|
|
Safe to call from any app — returns silently if federation isn't wired
|
|
or the user has no actor profile.
|
|
"""
|
|
if not services.has("federation"):
|
|
return
|
|
|
|
if not user_id:
|
|
return
|
|
|
|
actor = await services.federation.get_actor_by_user_id(session, user_id)
|
|
if not actor:
|
|
return
|
|
|
|
# Dedup: don't re-Create if already published, don't re-Delete if already deleted
|
|
existing = await services.federation.get_activity_for_source(
|
|
session, source_type, source_id,
|
|
)
|
|
if existing:
|
|
if activity_type == "Create" and existing.activity_type != "Delete":
|
|
return # already published (allow re-Create after Delete/unpublish)
|
|
if activity_type == "Update" and existing.activity_type == "Update":
|
|
return # already updated (Ghost fires duplicate webhooks)
|
|
if activity_type == "Delete" and existing.activity_type == "Delete":
|
|
return # already deleted
|
|
elif activity_type in ("Delete", "Update"):
|
|
return # never published, nothing to delete/update
|
|
|
|
# Stable object ID: same source always gets the same object id so
|
|
# Mastodon treats Create/Update/Delete as the same post.
|
|
domain = os.getenv("AP_DOMAIN", "rose-ash.com")
|
|
object_data["id"] = (
|
|
f"https://{domain}/users/{actor.preferred_username}"
|
|
f"/objects/{source_type.lower()}/{source_id}"
|
|
)
|
|
|
|
try:
|
|
await services.federation.publish_activity(
|
|
session,
|
|
actor_user_id=user_id,
|
|
activity_type=activity_type,
|
|
object_type=object_type,
|
|
object_data=object_data,
|
|
source_type=source_type,
|
|
source_id=source_id,
|
|
)
|
|
log.info(
|
|
"Published %s/%s for %s#%d by user %d",
|
|
activity_type, object_type, source_type, source_id, user_id,
|
|
)
|
|
except Exception:
|
|
log.exception("Failed to publish activity for %s#%d", source_type, source_id)
|