Compare commits

...

1 Commits

Author SHA1 Message Date
giles
2e48760b38 Fix AP re-publish: use versioned object IDs after Delete
After Delete + re-Create, Mastodon tombstones the old object ID and
ignores new Creates with the same ID. Now appends /v2, /v3 etc. so
remote servers treat re-publishes as fresh posts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:59:48 +00:00
4 changed files with 37 additions and 3 deletions

View File

@@ -216,6 +216,11 @@ class FederationService(Protocol):
self, session: AsyncSession, source_type: str, source_id: int,
) -> APActivityDTO | None: ...
async def count_activities_for_source(
self, session: AsyncSession, source_type: str, source_id: int,
*, activity_type: str,
) -> int: ...
# -- Followers ------------------------------------------------------------
async def get_followers(
self, session: AsyncSession, username: str,

View File

@@ -288,6 +288,20 @@ class SqlFederationService:
).scalars().first()
return _activity_to_dto(a) if a else None
async def count_activities_for_source(
self, session: AsyncSession, source_type: str, source_id: int,
*, activity_type: str,
) -> int:
from sqlalchemy import func
result = await session.execute(
select(func.count()).select_from(APActivity).where(
APActivity.source_type == source_type,
APActivity.source_id == source_id,
APActivity.activity_type == activity_type,
)
)
return result.scalar_one()
# -- Followers ------------------------------------------------------------
async def get_followers(

View File

@@ -56,13 +56,25 @@ async def try_publish(
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.
# Stable object ID within a publish cycle. After Delete + re-Create
# we append a version suffix so remote servers (Mastodon) treat it as
# a brand-new post rather than ignoring the tombstoned ID.
domain = os.getenv("AP_DOMAIN", "rose-ash.com")
object_data["id"] = (
base_object_id = (
f"https://{domain}/users/{actor.preferred_username}"
f"/objects/{source_type.lower()}/{source_id}"
)
if activity_type == "Create" and existing and existing.activity_type == "Delete":
# Count prior Creates to derive a version number
create_count = await services.federation.count_activities_for_source(
session, source_type, source_id, activity_type="Create",
)
object_data["id"] = f"{base_object_id}/v{create_count + 1}"
elif activity_type in ("Update", "Delete") and existing and existing.object_data:
# Use the same object ID as the most recent activity
object_data["id"] = existing.object_data.get("id", base_object_id)
else:
object_data["id"] = base_object_id
try:
await services.federation.publish_activity(

View File

@@ -217,6 +217,9 @@ class StubFederationService:
async def get_activity_for_source(self, session, source_type, source_id):
return None
async def count_activities_for_source(self, session, source_type, source_id, *, activity_type):
return 0
async def get_followers(self, session, username):
return []