mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user