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 from __future__ import annotations
import contextlib
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, cast 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.base import BaseService
from roboco.services.notification_delivery import defer_after_commit from roboco.services.notification_delivery import defer_after_commit
from roboco.services.task import ( from roboco.services.task import (
X_BARFLY_SOURCE,
X_FEATURE_SOURCE, X_FEATURE_SOURCE,
X_REPLY_SOURCE,
X_SOURCES, X_SOURCES,
get_task_service, get_task_service,
) )
@@ -201,21 +202,19 @@ class XPostService(BaseService):
tweet_id=None, tweet_id=None,
detail="No X credentials are configured.", detail="No X credentials are configured.",
) )
# Pass in_reply_to_tweet_id only when it's actually needed (a result = await client.post_tweet(body, **self._reply_kwargs(task))
# 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)
if not result.posted: 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( return XPostExecuteResult(
status="post_failed", tweet_id=None, detail=result.detail 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 "") markers.set_x_posted_tweet_id(task, result.tweet_id or "")
task.status = TaskStatus.COMPLETED task.status = TaskStatus.COMPLETED
# Commit while still holding the lock so COMPLETED is durable before # 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 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: async def _open_spotlight_video(self, task: TaskTable, posted_body: str) -> None:
"""Mirrors ``ReleaseProposalService._draft_video``: a best-effort side """Mirrors ``ReleaseProposalService._draft_video``: a best-effort side
effect after the post has already succeeded, never allowed to affect 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 import pytest
from roboco.config import settings as cfg 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 import identity as _foundation
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.models.base import ( 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" assert result.status == "post_failed"
await db_session.refresh(task) await db_session.refresh(task)
assert task.status == TS.PENDING 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 @pytest.mark.asyncio
@@ -505,10 +525,10 @@ async def test_list_open_posts_includes_feature_spotlight_source(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_approve_posts_barfly_draft_as_a_reply(db_session: AsyncSession) -> None: async def test_approve_posts_barfly_draft_standalone(db_session: AsyncSession) -> None:
"""An x_barfly draft's carried tweet_id (barfly_reply_ref) threads """An x_barfly draft never threads: X's reply policy 403s programmatic
through to post_tweet's in_reply_to_tweet_id — the CEO's approve posts replies into unmentioning conversations, so Barfly ships standalone
it as an actual reply, not a standalone tweet.""" link-posts — the carried barfly_reply_ref is provenance only."""
task = await _seed_draft(db_session, source=X_BARFLY_SOURCE) task = await _seed_draft(db_session, source=X_BARFLY_SOURCE)
markers.set_barfly_reply_ref( markers.set_barfly_reply_ref(
task, 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)) result = await _svc(db_session).approve(_id(task))
assert result is not None assert result is not None
assert result.status == "posted" 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 @pytest.mark.asyncio
async def test_approve_plain_x_post_never_passes_a_reply_target( async def test_approve_plain_x_post_never_passes_a_reply_target(
db_session: AsyncSession, db_session: AsyncSession,
) -> None: ) -> 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.""" branch is source-gated, not accidentally always-on."""
task = await _seed_draft(db_session, source=X_POST_SOURCE) task = await _seed_draft(db_session, source=X_POST_SOURCE)
client = _StubClient() client = _StubClient()