fix(x): thread mention replies and audit every post outcome

x_reply drafts (the one case X's 2026-02-23 reply policy allows - the
author summoned us) now post as real threaded replies via the carried
mention id; barfly drafts stop sending in_reply_to_tweet_id entirely.
Failed posts log a server-side warning and write an x_post.post_failed
audit row; successes write x_post.posted - at the _post chokepoint so
both approve routes are covered.
This commit is contained in:
Renn F
2026-07-28 23:52:04 +02:00
parent 709759fc9b
commit 98ee4d79e5
2 changed files with 92 additions and 19 deletions
+42 -12
View File
@@ -19,6 +19,7 @@ has actually approved the spotlight, mirroring the
from __future__ import annotations
import contextlib
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast
@@ -32,8 +33,8 @@ from roboco.models.base import TaskStatus
from roboco.services.base import BaseService
from roboco.services.notification_delivery import defer_after_commit
from roboco.services.task import (
X_BARFLY_SOURCE,
X_FEATURE_SOURCE,
X_REPLY_SOURCE,
X_SOURCES,
get_task_service,
)
@@ -201,21 +202,19 @@ class XPostService(BaseService):
tweet_id=None,
detail="No X credentials are configured.",
)
# Pass in_reply_to_tweet_id only when it's actually needed (a
# x_barfly draft) — every other source's post_tweet call stays
# byte-for-byte identical to before this param existed, so a test
# fake carrying the pre-reply-support signature (`post_tweet(self,
# text)`) is unaffected.
post_kwargs: dict[str, str] = {}
if task.source == X_BARFLY_SOURCE:
in_reply_to = (markers.get_barfly_reply_ref(task) or {}).get("tweet_id")
if in_reply_to:
post_kwargs["in_reply_to_tweet_id"] = in_reply_to
result = await client.post_tweet(body, **post_kwargs)
result = await client.post_tweet(body, **self._reply_kwargs(task))
if not result.posted:
logger.warning(
"x post failed for draft %s (source=%s): %s",
task.id,
task.source,
result.detail,
)
self._audit_outcome(task, posted=False, detail=result.detail)
return XPostExecuteResult(
status="post_failed", tweet_id=None, detail=result.detail
)
self._audit_outcome(task, posted=True, detail=result.tweet_id or "")
markers.set_x_posted_tweet_id(task, result.tweet_id or "")
task.status = TaskStatus.COMPLETED
# Commit while still holding the lock so COMPLETED is durable before
@@ -229,6 +228,37 @@ class XPostService(BaseService):
status="posted", tweet_id=result.tweet_id, detail=result.detail
)
@staticmethod
def _reply_kwargs(task: TaskTable) -> dict[str, str]:
"""Thread only the "summoned" case X's reply policy allows (2026-02-23:
programmatic replies 403 unless the target's author @mentioned the
account or it's the account's own thread): a mention reply qualifies
by construction. Barfly drafts deliberately do NOT thread — their
targets never mention the account, so they ship as standalone
link-posts with the conversation URL in the body instead."""
if task.source != X_REPLY_SOURCE:
return {}
in_reply_to = (markers.get_x_mention_ref(task) or {}).get("id")
return {"in_reply_to_tweet_id": str(in_reply_to)} if in_reply_to else {}
def _audit_outcome(self, task: TaskTable, *, posted: bool, detail: str) -> None:
"""Best-effort audit row per post outcome — both approve routes (panel
and Telegram) reach this chokepoint. Success rides `_post`'s own
commit; a failure row rides the caller's route-level commit. A row-add
failure never affects the post result."""
with contextlib.suppress(Exception):
from roboco.db.tables import AuditLogTable
self.session.add(
AuditLogTable(
event_type="x_post.posted" if posted else "x_post.post_failed",
target_type="task",
target_id=task.id,
severity="info" if posted else "warning",
details={"source": task.source, "detail": detail[:300]},
)
)
async def _open_spotlight_video(self, task: TaskTable, posted_body: str) -> None:
"""Mirrors ``ReleaseProposalService._draft_video``: a best-effort side
effect after the post has already succeeded, never allowed to affect
+50 -7
View File
@@ -17,7 +17,13 @@ from uuid import UUID, uuid4
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, BoardProgramCycleTable, ProjectTable, TaskTable
from roboco.db.tables import (
AgentTable,
AuditLogTable,
BoardProgramCycleTable,
ProjectTable,
TaskTable,
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import (
@@ -328,6 +334,20 @@ async def test_approve_post_failed_keeps_task_open(db_session: AsyncSession) ->
assert result.status == "post_failed"
await db_session.refresh(task)
assert task.status == TS.PENDING
rows = (
(
await db_session.execute(
select(AuditLogTable).where(
AuditLogTable.event_type == "x_post.post_failed"
)
)
)
.scalars()
.all()
)
assert len(rows) == 1
assert rows[0].target_id == task.id
assert rows[0].details["source"] == task.source
@pytest.mark.asyncio
@@ -505,10 +525,10 @@ async def test_list_open_posts_includes_feature_spotlight_source(
@pytest.mark.asyncio
async def test_approve_posts_barfly_draft_as_a_reply(db_session: AsyncSession) -> None:
"""An x_barfly draft's carried tweet_id (barfly_reply_ref) threads
through to post_tweet's in_reply_to_tweet_id — the CEO's approve posts
it as an actual reply, not a standalone tweet."""
async def test_approve_posts_barfly_draft_standalone(db_session: AsyncSession) -> None:
"""An x_barfly draft never threads: X's reply policy 403s programmatic
replies into unmentioning conversations, so Barfly ships standalone
link-posts the carried barfly_reply_ref is provenance only."""
task = await _seed_draft(db_session, source=X_BARFLY_SOURCE)
markers.set_barfly_reply_ref(
task,
@@ -529,14 +549,37 @@ async def test_approve_posts_barfly_draft_as_a_reply(db_session: AsyncSession) -
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
assert client.reply_targets == ["555"]
assert client.reply_targets == [None]
@pytest.mark.asyncio
async def test_approve_threads_x_reply_draft(db_session: AsyncSession) -> None:
"""An x_reply draft (the mentions poll — the account was 'summoned' by
the author, the one case X's reply policy allows) posts as a real
threaded reply via the carried mention id."""
task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
markers.set_x_mention_ref(
task,
{"id": "777", "author_id": "42", "text": "hey @roboco what do you think?"},
)
await db_session.flush()
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
assert client.reply_targets == ["777"]
@pytest.mark.asyncio
async def test_approve_plain_x_post_never_passes_a_reply_target(
db_session: AsyncSession,
) -> None:
"""A non-barfly source never threads in_reply_to_tweet_id — proves the
"""A non-reply source never threads in_reply_to_tweet_id — proves the
branch is source-gated, not accidentally always-on."""
task = await _seed_draft(db_session, source=X_POST_SOURCE)
client = _StubClient()