Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion core/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
7 changes: 5 additions & 2 deletions core/wizard/source_extraction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions examples/thread.md
Original file line number Diff line number Diff line change
@@ -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 前是最近似的草稿。
10 changes: 10 additions & 0 deletions examples/thread.yaml
Original file line number Diff line number Diff line change
@@ -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
Empty file added providers/x_thread/__init__.py
Empty file.
Empty file.
154 changes: 154 additions & 0 deletions providers/x_thread/internal/browser_flow.py
Original file line number Diff line number Diff line change
@@ -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_<n>"`` on each
tweet's textarea where ``<n>`` 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. <n> 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": <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 ""))
162 changes: 162 additions & 0 deletions providers/x_thread/provider.py
Original file line number Diff line number Diff line change
@@ -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 <this-run-dir>`\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",
)
11 changes: 11 additions & 0 deletions providers/x_thread/provider.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading