From 9a8b556c13512d84091c0e01fd746bbfcaf68c71 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 22 Feb 2026 10:14:40 +0000 Subject: [PATCH] Fix duplicate AP posts + stable object IDs - Stable object ID per source (Post#123 always gets the same id) instead of deriving from activity UUID - Dedup Update activities (Ghost fires duplicate webhooks) - Use setdefault for object id in delivery handler Co-Authored-By: Claude Opus 4.6 --- events/handlers/ap_delivery_handler.py | 3 ++- services/federation_publish.py | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/events/handlers/ap_delivery_handler.py b/events/handlers/ap_delivery_handler.py index 702b7db..3cbbc8f 100644 --- a/events/handlers/ap_delivery_handler.py +++ b/events/handlers/ap_delivery_handler.py @@ -38,7 +38,8 @@ def _build_activity_json(activity: APActivity, actor: ActorProfile, domain: str) obj.setdefault("type", "Tombstone") else: # Create/Update: full object with attribution - obj["id"] = object_id + # Prefer stable id from object_data (set by try_publish), fall back to activity-derived + obj.setdefault("id", object_id) obj.setdefault("type", activity.object_type) obj.setdefault("attributedTo", actor_url) obj.setdefault("published", activity.published.isoformat() if activity.published else None) diff --git a/services/federation_publish.py b/services/federation_publish.py index 90843f3..965055f 100644 --- a/services/federation_publish.py +++ b/services/federation_publish.py @@ -8,6 +8,7 @@ which creates the APActivity in the same DB transaction. AP delivery from __future__ import annotations import logging +import os from sqlalchemy.ext.asyncio import AsyncSession @@ -48,14 +49,20 @@ async def try_publish( 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 == "Delete": - return # never published, nothing to delete + elif activity_type in ("Delete", "Update"): + return # never published, nothing to delete/update - # Delete must reference the same object id Mastodon received in Create - if activity_type == "Delete" and existing: - object_data["id"] = existing.activity_id + "/object" + # 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(