From e3ab2fb9d90ac9cc1c5093bde8bcac7e79a821db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E7=99=BD?= <31078449+Nowhitestar@users.noreply.github.com> Date: Fri, 8 May 2026 17:10:19 +0800 Subject: [PATCH] feat(x-thread): add Twitter/X thread provider with browser-flow draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new `thread` media_type and `x-thread` provider that drives x.com/compose/post via OpenCLI Browser Bridge — fills tweets one by one into the modal and stops before "Post all", mirroring x-article's autosave-then-user-confirms contract. Tweets are separated by `---` lines in the manifest body; lint enforces 280 char/tweet and 25 tweets/thread. Co-Authored-By: Claude Opus 4.7 (1M context) --- SKILL.md | 1 + core/manifest.py | 2 +- core/wizard/source_extraction.md | 7 +- examples/thread.md | 9 + examples/thread.yaml | 10 + providers/x_thread/__init__.py | 0 providers/x_thread/internal/__init__.py | 0 providers/x_thread/internal/browser_flow.py | 154 +++++++++++++++ providers/x_thread/provider.py | 162 ++++++++++++++++ providers/x_thread/provider.yaml | 11 ++ providers/x_thread/rules.py | 91 +++++++++ providers/x_thread/tests/__init__.py | 0 providers/x_thread/tests/test_provider.py | 202 ++++++++++++++++++++ scripts/meti.py | 2 +- 14 files changed, 647 insertions(+), 4 deletions(-) create mode 100644 examples/thread.md create mode 100644 examples/thread.yaml create mode 100644 providers/x_thread/__init__.py create mode 100644 providers/x_thread/internal/__init__.py create mode 100644 providers/x_thread/internal/browser_flow.py create mode 100644 providers/x_thread/provider.py create mode 100644 providers/x_thread/provider.yaml create mode 100644 providers/x_thread/rules.py create mode 100644 providers/x_thread/tests/__init__.py create mode 100644 providers/x_thread/tests/test_provider.py diff --git a/SKILL.md b/SKILL.md index 9dfe692..8b961ae 100644 --- a/SKILL.md +++ b/SKILL.md @@ -80,6 +80,7 @@ ask in the active conversation. | `xiaohongshu` | image-post (video planned) | dry-run, draft (local) | Uses xiaohongshu skill's `draft.sh` | | `wechat-image` | image-post | dry-run, draft (browser-flow guide) | UI calibration TODO; guide-only path | | `x-article` | longform | dry-run, draft (payload + TODO) | No connector yet; manual paste step | +| `x-thread` | thread | dry-run, draft (browser-flow, fills modal) | Stops before "Post all"; user reviews + ships | | `substack` | longform | dry-run, draft (payload + TODO) | No connector yet; manual paste step | ## Safety rules diff --git a/core/manifest.py b/core/manifest.py index 6cc235f..88127f6 100644 --- a/core/manifest.py +++ b/core/manifest.py @@ -16,7 +16,7 @@ from core.errors import ManifestError -VALID_TYPES = {"image-post", "longform", "video-post"} +VALID_TYPES = {"image-post", "longform", "thread", "video-post"} VALID_MODES = {"dry-run", "draft", "publish"} SCHEMA_VERSION = "0.2" diff --git a/core/wizard/source_extraction.md b/core/wizard/source_extraction.md index 58c98c5..12c6a1e 100644 --- a/core/wizard/source_extraction.md +++ b/core/wizard/source_extraction.md @@ -7,9 +7,10 @@ You are guiding the user to convert source content into a structured draft. Produce an internal draft holding these fields (in your conversation memory, not on disk yet): -- `type`: one of `image-post` / `longform` / `video-post` +- `type`: one of `image-post` / `longform` / `thread` / `video-post` - `title`: short title -- `body`: full content (Markdown for longform; caption text for image-post) +- `body`: full content (Markdown for longform; caption text for image-post; + for `thread`: tweets separated by a line containing only `---`) - `summary`: optional one-line synopsis - `cover`: optional path to a cover image - `images`: list of image paths (image-post only) @@ -23,6 +24,8 @@ Produce an internal draft holding these fields (in your conversation memory, not 2. **Determine `type`** by what's there: - Multiple images + short caption → `image-post` - Long markdown article → `longform` + - Several short text segments (each ≤280 chars), or content already + separated by lines of `---` with the user mentioning X/Twitter → `thread` - Video file → `video-post` - Ambiguous → ask one short question 3. **Extract candidate fields** silently. Do not fabricate; if a field is unknown, leave it None. diff --git a/examples/thread.md b/examples/thread.md new file mode 100644 index 0000000..5d2e34f --- /dev/null +++ b/examples/thread.md @@ -0,0 +1,9 @@ +Thread 的写法很简单:每条推文之间用一行 `---` 分隔。空白行和段首尾空格会被自动清理。 + +--- + +每条 ≤ 280 字(非 Premium 上限)。整个 thread ≤ 25 条。`meti validate` 会按这两条规则逐条 lint,超长会以 `TWEET_TOO_LONG` / `THREAD_TOO_LONG` 报错。 + +--- + +执行流程:浏览器流会打开 `x.com/compose/post`,逐条填进去,**停在「Post all」按钮前**——你自己看一遍再发。X 没有 thread 草稿概念,停在 modal 前是最近似的草稿。 diff --git a/examples/thread.yaml b/examples/thread.yaml new file mode 100644 index 0000000..5546780 --- /dev/null +++ b/examples/thread.yaml @@ -0,0 +1,10 @@ +schema_version: "0.2" +type: thread +title: "thread example" +body: ./thread.md +mode: draft +language: zh-CN +targets: + - x-thread +tags: + - example diff --git a/providers/x_thread/__init__.py b/providers/x_thread/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/x_thread/internal/__init__.py b/providers/x_thread/internal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/x_thread/internal/browser_flow.py b/providers/x_thread/internal/browser_flow.py new file mode 100644 index 0000000..0c64024 --- /dev/null +++ b/providers/x_thread/internal/browser_flow.py @@ -0,0 +1,154 @@ +"""X Thread browser flow (OpenCLI-backed). + +Drives the user's real Chrome (via OpenCLI Browser Bridge) to: +1. Open ``x.com/compose/post`` (the thread-capable composer modal) +2. Type tweet #1 into the first composer textarea +3. For each remaining tweet: click the "Add post" / "+" button, then + type into the newly-revealed textarea +4. Stop. Leave the modal open at the user's cursor — the user reviews + the whole thread and clicks **Post all** themselves. + +X has no thread *draft* concept (only single-tweet drafts), so the +"draft" we deliver here is "modal pre-filled, awaiting user confirm" — +analogous to x-article's autosave behavior. + +Selector strategy +----------------- +Selectors are constants at the top. When X drifts, this is the single +patch point. We keep multiple candidates per role; first match wins. + +X's compose modal exposes ``data-testid="tweetTextarea_"`` on each +tweet's textarea where ```` is the 0-based index. The "Add post" +button below the last tweet has ``data-testid="addButton"`` (verified +across both EN and ZH UIs in 2025-2026). If X drifts these names, +update the constants below. +""" + +from __future__ import annotations + +import re +import time +from typing import Any + +# Compose URL. ``/compose/post`` opens the same modal as the home-feed +# composer but from a stable URL we can navigate to directly. +COMPOSE_POST_URL = "https://x.com/compose/post" + +# Per-tweet textarea. is the 0-based tweet index in the thread. +TEXTAREA_TEMPLATE = '[data-testid="tweetTextarea_{n}"]' + +# "Add another tweet" button. Sits under the *last* composer; clicking +# it appends a new tweet textarea and bumps the index. +ADD_BUTTON_CANDIDATES = [ + '[data-testid="addButton"]', + 'button[aria-label="Add post"]', + 'button[aria-label="添加帖子"]', +] + +# After the modal opens, X redirects login-less sessions here. +LOGIN_PATH_FRAGMENTS = ("/i/flow/login", "/login") + +PAGE_LOAD_WAIT_S = 3.0 +POST_CLICK_WAIT_S = 1.5 +TYPE_SETTLE_WAIT_S = 0.8 + + +def _try_click_first_match( + candidates: list[str], tab: str | None = None +) -> tuple[str | None, Exception | None]: + """Try each selector in order; return (clicked_selector, last_error).""" + from core import browser as br + + last_err: Exception | None = None + for sel in candidates: + try: + br.click(sel, tab=tab) + return sel, None + except Exception as e: + last_err = e + return None, last_err + + +def compose_thread(payload: dict[str, Any]) -> dict[str, Any]: + """Pre-fill an X thread in the user's bound Chrome workspace. + + Opens a fresh tab, navigates to the compose modal, fills each tweet + in order, and leaves the modal open at the editor for user review. + Does NOT click "Post all" — the human reviews and ships. + + Returns ``{"draft_url": , "external_id": None}``. X assigns no + pre-publish identifier for threads (unlike Articles), so external_id + stays None and the URL is just the compose URL. + + Raises: + BrowserNotConnectedError / BrowserNotBoundError / BrowserNotInstalledError: + bridge issues — caller falls back / surfaces to user. + RuntimeError: selector failures, login redirect, etc. + """ + from core import browser as br + + tweets = list(payload.get("tweets") or []) + if not tweets: + raise ValueError("payload.tweets is empty — nothing to compose") + + # 1. Open a dedicated tab for the thread compose. Each browser-flow + # provider gets its own tab so concurrent runs don't trample. + tab = br.tab_new(COMPOSE_POST_URL) + time.sleep(PAGE_LOAD_WAIT_S) + + current_url = br.get_url(tab=tab) + if any(frag in current_url for frag in LOGIN_PATH_FRAGMENTS): + raise RuntimeError( + "X redirected to login. Your Chrome's X session is logged out. " + "Log in to X in Chrome, then retry. " + f"Current URL: {current_url}" + ) + + # 2. Type tweet #1 into the first textarea. + first_sel = TEXTAREA_TEMPLATE.format(n=0) + try: + br.type_text(first_sel, tweets[0], tab=tab) + except Exception as e: + raise RuntimeError( + f"x compose/post: first tweet textarea not found ({first_sel!r}). " + f"X may have changed the testid scheme. Update TEXTAREA_TEMPLATE in " + f"{__file__}. Original: {e}" + ) from e + time.sleep(TYPE_SETTLE_WAIT_S) + + # 3. For each subsequent tweet: click "+" then type into the new + # textarea that just appeared. + for idx in range(1, len(tweets)): + clicked_sel, err = _try_click_first_match(ADD_BUTTON_CANDIDATES, tab=tab) + if clicked_sel is None: + raise RuntimeError( + f"x compose/post: 'Add post' button not found before tweet #{idx + 1}. " + f"Tried selectors: {ADD_BUTTON_CANDIDATES}. " + f"Update ADD_BUTTON_CANDIDATES in {__file__}. " + f"Last error: {err}" + ) + time.sleep(POST_CLICK_WAIT_S) + + next_sel = TEXTAREA_TEMPLATE.format(n=idx) + try: + br.type_text(next_sel, tweets[idx], tab=tab) + except Exception as e: + raise RuntimeError( + f"x compose/post: tweet #{idx + 1} textarea not found ({next_sel!r}). " + f"Add button click may not have spawned a new composer. " + f"Original: {e}" + ) from e + time.sleep(TYPE_SETTLE_WAIT_S) + + # 4. Leave the modal open. The user clicks "Post all" themselves. + final_url = br.get_url(tab=tab) + return {"draft_url": final_url, "external_id": None} + + +# Kept as a small surface for tests — the URL pattern check is one of +# the few things we can unit-test without mocking the entire bridge. +_LOGIN_RE = re.compile("|".join(re.escape(f) for f in LOGIN_PATH_FRAGMENTS)) + + +def _is_login_redirect(url: str) -> bool: + return bool(_LOGIN_RE.search(url or "")) diff --git a/providers/x_thread/provider.py b/providers/x_thread/provider.py new file mode 100644 index 0000000..533988d --- /dev/null +++ b/providers/x_thread/provider.py @@ -0,0 +1,162 @@ +"""X Thread provider — drives x.com/compose/post via OpenCLI Browser Bridge. + +Two execute paths (mirrors x-article's contract): + +- **Browser flow**: when the bridge is connected, drives the bound Chrome + workspace to open X's thread composer, fills each tweet in order, and + *stops before clicking "Post all"* — the user reviews and ships from + their own browser. Returns ``mode_actual="draft-platform"``. +- **Stub fallback**: if the bridge isn't connected, writes a + ``TODO-connector.md`` next to the prepared payload and returns + ``mode_actual="stub"``. Multi-target manifests still progress. + +X has no first-class "thread draft" — leaving the modal mid-compose is the +closest analogue, and matches what x-article does with article autosave. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.errors import ProviderExecutionError +from core.provider import ( + CredentialSpec, + ExecutionResult, + HealthStatus, + PreparedPayload, + Provider, + ValidationResult, +) +from providers.x_thread.rules import X_THREAD_RULES, split_thread + + +class XThreadProvider(Provider): + name = "x-thread" + display_name = "X Thread" + media_types = ["thread"] + capabilities = {"draft": True, "publish": False, "schedule": False} + # Browser-flow provider: no API credentials. Auth is via the user's + # logged-in Chrome session (driven via OpenCLI Browser Bridge). + required_credentials: list[CredentialSpec] = [] + platform_rules = X_THREAD_RULES + browser_login_url = "https://x.com/i/flow/login" + + def validate(self, manifest: Any, target: Any) -> ValidationResult: + return ValidationResult(violations=self.platform_rules.lint(manifest, self.name)) + + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload: + pack_dir = run_dir / "packs" / self.name + pack_dir.mkdir(parents=True, exist_ok=True) + tweets = split_thread(manifest.body or "") + payload = { + "tweets": tweets, + "mode": target.mode, + "options": dict(target.options or {}), + } + payload_path = pack_dir / "payload.json" + payload_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + (pack_dir / "thread.md").write_text(manifest.body or "", encoding="utf-8") + return PreparedPayload(pack_dir=pack_dir, payload_path=payload_path) + + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + if mode == "publish": + raise NotImplementedError("x-thread publish path not enabled") + if mode == "dry-run": + return ExecutionResult(status="ok", mode_actual="dry-run", external_id=None) + + from core import browser as br + + pack_dir = run_dir / "packs" / self.name + payload_path = pack_dir / "payload.json" + payload = json.loads(payload_path.read_text(encoding="utf-8")) + + if not br.is_connected(): + self._write_stub(pack_dir, reason="bridge-not-connected") + return ExecutionResult( + status="ok", + mode_actual="stub", + external_id=None, + extras={ + "connector_status": "bridge-not-connected", + "remediation": ( + "Install OpenCLI Chrome extension + open Chrome; " + "see docs/browser-connectors.md" + ), + }, + ) + + from providers.x_thread.internal.browser_flow import compose_thread + + try: + result = compose_thread(payload) + except br.BrowserNotConnectedError as exc: + self._write_stub(pack_dir, reason="bridge-not-connected") + raise ProviderExecutionError( + target=self.name, + step="browser_bridge", + upstream=exc, + retryable=True, + ) from exc + except br.BrowserNotInstalledError as exc: + self._write_stub(pack_dir, reason="opencli-not-installed") + raise ProviderExecutionError( + target=self.name, + step="browser_bridge", + upstream=exc, + retryable=False, + ) from exc + except Exception as exc: + raise ProviderExecutionError( + target=self.name, + step="browser_compose", + upstream=exc, + retryable=True, + ) from exc + + return ExecutionResult( + status="ok", + mode_actual="draft-platform", + external_id=result.get("external_id"), + draft_url=result.get("draft_url"), + extras={ + "connector_status": "browser-ok", + "tweet_count": len(payload.get("tweets") or []), + }, + ) + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + from core import browser as br + + return HealthStatus.ok if br.is_connected() else HealthStatus.failed + + @staticmethod + def _write_stub(pack_dir: Path, *, reason: str) -> None: + (pack_dir / "TODO-connector.md").write_text( + f"# x-thread browser connector skipped (reason: {reason})\n\n" + "Tweets are ready at `payload.json` (field `tweets[]`) and the\n" + "raw separator-delimited source is at `thread.md`.\n\n" + "**Option A (recommended): set up the OpenCLI Browser Bridge**\n\n" + "1. Install Node.js 21+: `brew install node` (macOS)\n" + "2. Install the Chrome extension:\n" + " https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk\n" + "3. Make sure you're logged in to X in Chrome\n" + "4. Open Chrome to a regular tab, then run: `meti browser bind`\n" + "5. Retry: `meti resume `\n\n" + "Setup details: docs/browser-connectors.md\n\n" + "**Option B: post the thread manually**\n\n" + "1. Open https://x.com/compose/post in a logged-in browser\n" + "2. Paste tweet #1 from `payload.tweets[0]`\n" + "3. Click the `+` (Add post) button under the composer to add\n" + " each subsequent tweet\n" + "4. Repeat for each entry in `payload.tweets[]`\n" + "5. Review the whole thread, then click **Post all** yourself\n", + encoding="utf-8", + ) diff --git a/providers/x_thread/provider.yaml b/providers/x_thread/provider.yaml new file mode 100644 index 0000000..b394067 --- /dev/null +++ b/providers/x_thread/provider.yaml @@ -0,0 +1,11 @@ +name: x-thread +display_name: X Thread +media_types: + - thread +capabilities: + draft: true + publish: false + schedule: false +required_credentials: [] +entry: provider:XThreadProvider +schema_version: 1 diff --git a/providers/x_thread/rules.py b/providers/x_thread/rules.py new file mode 100644 index 0000000..f94cbf1 --- /dev/null +++ b/providers/x_thread/rules.py @@ -0,0 +1,91 @@ +"""Platform rules for X (Twitter) threads. + +Thread body convention: tweets are separated by a line containing only +``---`` (CommonMark hr). Leading/trailing whitespace per tweet is stripped; +empty segments are dropped. The same parser is used by ``provider.prepare`` +and the lint extra below so they can't drift. + +Hard caps used here: +- per-tweet length: 280 chars (free / non-Premium baseline; meti targets + the strictest tier so threads still post for everyone) +- thread length: 25 tweets (X's compose UI caps thread length around here; + longer threads are usually a sign the content should be a longform article) +- minimum thread length: 1 tweet (zero is a manifest error caught upstream, + but we surface a clearer message) +""" + +from __future__ import annotations + +import re +from typing import Any + +from core.rules import PlatformRules, Severity, Violation + +TWEET_MAX = 280 +THREAD_MAX = 25 +THREAD_MIN = 1 + +# Splits on a line that is exactly `---` (optionally surrounded by +# whitespace). DOTALL not needed — we operate on the body text directly. +_SEPARATOR_RE = re.compile(r"(?m)^\s*---\s*$") + + +def split_thread(body: str) -> list[str]: + """Split a thread body into tweets. + + Convention: tweets are separated by a line containing only ``---``. + Whitespace around each tweet is stripped; empty segments are filtered + out so trailing separators don't introduce blank tweets. + """ + if not body: + return [] + parts = _SEPARATOR_RE.split(body) + return [p.strip() for p in parts if p and p.strip()] + + +def _thread_lint(manifest: Any, target_name: str) -> list[Violation]: + body = getattr(manifest, "body", "") or "" + tweets = split_thread(body) + + violations: list[Violation] = [] + + if len(tweets) < THREAD_MIN: + violations.append( + Violation( + code="THREAD_EMPTY", + message="thread body must contain at least one tweet (separate with `---`)", + target=target_name, + field_path="body", + ) + ) + if len(tweets) > THREAD_MAX: + violations.append( + Violation( + code="THREAD_TOO_LONG", + message=f"thread has {len(tweets)} tweets; max {THREAD_MAX}", + target=target_name, + field_path="body", + ) + ) + + for idx, tweet in enumerate(tweets): + if len(tweet) > TWEET_MAX: + violations.append( + Violation( + code="TWEET_TOO_LONG", + message=( + f"tweet #{idx + 1} length {len(tweet)} exceeds {TWEET_MAX} " + "(non-Premium cap)" + ), + severity=Severity.error, + target=target_name, + field_path=f"body[{idx}]", + ) + ) + + return violations + + +X_THREAD_RULES = PlatformRules( + extra_lints=[_thread_lint], +) diff --git a/providers/x_thread/tests/__init__.py b/providers/x_thread/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/x_thread/tests/test_provider.py b/providers/x_thread/tests/test_provider.py new file mode 100644 index 0000000..4a8c72c --- /dev/null +++ b/providers/x_thread/tests/test_provider.py @@ -0,0 +1,202 @@ +import json + +import pytest + +from core.manifest import Manifest, Target +from providers.x_thread.internal.browser_flow import _is_login_redirect +from providers.x_thread.provider import XThreadProvider +from providers.x_thread.rules import TWEET_MAX, split_thread + + +@pytest.fixture +def thread(): + body = "First tweet.\n\n---\n\nSecond tweet.\n\n---\n\nThird tweet." + return Manifest( + schema_version="0.2", + type="thread", + title="A Thread", + body=body, + mode="dry-run", + targets=[Target(name="x-thread")], + ) + + +# ---- split_thread --------------------------------------------------------- + + +def test_split_thread_basic(): + body = "a\n---\nb\n---\nc" + assert split_thread(body) == ["a", "b", "c"] + + +def test_split_thread_strips_whitespace_and_blank_lines(): + body = " one \n\n---\n\n two \n\n---\n\n " + assert split_thread(body) == ["one", "two"] + + +def test_split_thread_separator_must_be_alone_on_line(): + # `---` inline (e.g. "em — em") must NOT split. + body = "tweet with --- inside\n---\nnext tweet" + assert split_thread(body) == ["tweet with --- inside", "next tweet"] + + +def test_split_thread_empty_body(): + assert split_thread("") == [] + assert split_thread(" \n \n") == [] + + +# ---- validate ------------------------------------------------------------- + + +def test_validate_passes(thread): + p = XThreadProvider() + res = p.validate(thread, thread.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) + + +def test_validate_empty_thread_flagged(): + p = XThreadProvider() + m = Manifest( + schema_version="0.2", + type="thread", + title="t", + body="", + mode="dry-run", + targets=[Target(name="x-thread")], + ) + res = p.validate(m, m.targets[0]) + assert any(v.code == "THREAD_EMPTY" for v in res.violations) + + +def test_validate_tweet_too_long(): + p = XThreadProvider() + m = Manifest( + schema_version="0.2", + type="thread", + title="t", + body="x" * (TWEET_MAX + 1), + mode="dry-run", + targets=[Target(name="x-thread")], + ) + res = p.validate(m, m.targets[0]) + assert any(v.code == "TWEET_TOO_LONG" for v in res.violations) + + +def test_validate_thread_too_long(): + p = XThreadProvider() + body = "\n---\n".join([f"tweet {i}" for i in range(30)]) + m = Manifest( + schema_version="0.2", + type="thread", + title="t", + body=body, + mode="dry-run", + targets=[Target(name="x-thread")], + ) + res = p.validate(m, m.targets[0]) + assert any(v.code == "THREAD_TOO_LONG" for v in res.violations) + + +# ---- prepare -------------------------------------------------------------- + + +def test_prepare_writes_payload_and_thread_md(thread, tmp_path): + run_dir = tmp_path / "run" + run_dir.mkdir() + p = XThreadProvider() + out = p.prepare(thread, thread.targets[0], run_dir) + payload = json.loads(out.payload_path.read_text()) + assert payload["tweets"] == ["First tweet.", "Second tweet.", "Third tweet."] + assert (out.pack_dir / "thread.md").read_text() == thread.body + + +# ---- execute -------------------------------------------------------------- + + +def test_execute_dry_run(thread, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "x-thread").mkdir(parents=True) + p = XThreadProvider() + p.prepare(thread, thread.targets[0], run_dir) + res = p.execute(run_dir, thread.targets[0], mode="dry-run", credentials={}) + assert res.mode_actual == "dry-run" + + +def test_execute_draft_returns_stub_when_bridge_disconnected(thread, tmp_path): + from unittest.mock import patch + + run_dir = tmp_path / "run" + (run_dir / "packs" / "x-thread").mkdir(parents=True) + p = XThreadProvider() + p.prepare(thread, thread.targets[0], run_dir) + + with patch("core.browser.is_connected", return_value=False): + res = p.execute(run_dir, thread.targets[0], mode="draft", credentials={}) + + assert res.mode_actual == "stub" + assert res.extras["connector_status"] == "bridge-not-connected" + todo = (run_dir / "packs" / "x-thread" / "TODO-connector.md").read_text() + assert "OpenCLI" in todo + assert "Add post" in todo + + +def test_execute_draft_invokes_browser_flow_when_bridge_connected(thread, tmp_path): + from unittest.mock import patch + + run_dir = tmp_path / "run" + (run_dir / "packs" / "x-thread").mkdir(parents=True) + p = XThreadProvider() + p.prepare(thread, thread.targets[0], run_dir) + + with ( + patch("core.browser.is_connected", return_value=True), + patch( + "providers.x_thread.internal.browser_flow.compose_thread", + return_value={ + "draft_url": "https://x.com/compose/post", + "external_id": None, + }, + ) as mock_compose, + ): + res = p.execute(run_dir, thread.targets[0], mode="draft", credentials={}) + + mock_compose.assert_called_once() + assert res.status == "ok" + assert res.mode_actual == "draft-platform" + assert res.external_id is None + assert res.draft_url == "https://x.com/compose/post" + assert res.extras["tweet_count"] == 3 + + +def test_execute_publish_refused(thread, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "x-thread").mkdir(parents=True) + p = XThreadProvider() + p.prepare(thread, thread.targets[0], run_dir) + with pytest.raises(NotImplementedError, match="publish"): + p.execute(run_dir, thread.targets[0], mode="publish", credentials={}) + + +def test_health_check_reflects_bridge_connectivity(): + from unittest.mock import patch + + p = XThreadProvider() + with patch("core.browser.is_connected", return_value=False): + assert p.health_check({}).value == "failed" + with patch("core.browser.is_connected", return_value=True): + assert p.health_check({}).value == "ok" + + +def test_browser_login_url_is_set(): + p = XThreadProvider() + assert p.browser_login_url == "https://x.com/i/flow/login" + + +# ---- internal helpers ----------------------------------------------------- + + +def test_login_redirect_detection(): + assert _is_login_redirect("https://x.com/i/flow/login?redirect=...") is True + assert _is_login_redirect("https://x.com/login") is True + assert _is_login_redirect("https://x.com/compose/post") is False + assert _is_login_redirect("") is False diff --git a/scripts/meti.py b/scripts/meti.py index eaf374f..49efa16 100755 --- a/scripts/meti.py +++ b/scripts/meti.py @@ -66,7 +66,7 @@ def _build_parser() -> argparse.ArgumentParser: ) sub_wizard = sub.add_parser("wizard", help="Conversational manifest wizard") - sub_wizard.add_argument("--type", choices=["image-post", "longform", "video-post"]) + sub_wizard.add_argument("--type", choices=["image-post", "longform", "thread", "video-post"]) sub_wizard.add_argument("--targets", default=None, help="Comma-separated target names") sub_wizard.add_argument( "--dump-context",