social-autoposter 1.6.21 → 1.6.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/dist/index.js
CHANGED
|
@@ -242,7 +242,12 @@ async function postApproved(batchId, plan) {
|
|
|
242
242
|
return { attempted: 0, exit_code: 0, summary: "nothing approved" };
|
|
243
243
|
const approvedBatch = `${batchId}_approved`;
|
|
244
244
|
writePlan(approvedBatch, { ...plan, candidates: approved });
|
|
245
|
-
|
|
245
|
+
// SAPS_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
|
|
246
|
+
// never get the active-campaign suffix (e.g. " written with ai") appended.
|
|
247
|
+
// twitter_browser.py's reply handler reads this env (inherited through
|
|
248
|
+
// twitter_post_plan.py's subprocess). The cron pipeline doesn't set it, so the
|
|
249
|
+
// A/B disclosure experiment keeps running on autopilot/cron and on Reddit.
|
|
250
|
+
const res = await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], { timeoutMs: 900_000, env: { SAPS_SKIP_CAMPAIGN_SUFFIX: "1" } });
|
|
246
251
|
let summary = res.stdout.trim();
|
|
247
252
|
try {
|
|
248
253
|
const lines = res.stdout.trim().split("\n");
|
|
Binary file
|
|
@@ -261,17 +261,62 @@ def ensure_chrome() -> dict:
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
def _port_owner_pids() -> list[int]:
|
|
265
|
+
"""PIDs LISTENing on our debug PORT, via lsof. Lets stop_chrome reap a Chrome
|
|
266
|
+
that another launcher (e.g. setup_twitter_auth.py's connect_x) started without
|
|
267
|
+
writing PID_FILE, instead of stranding an un-reapable orphan that makes
|
|
268
|
+
bh_start keep reporting 'already_running'. Returns [] if lsof is unavailable."""
|
|
269
|
+
try:
|
|
270
|
+
out = subprocess.run(
|
|
271
|
+
["lsof", "-ti", f"tcp:{PORT}", "-sTCP:LISTEN"],
|
|
272
|
+
capture_output=True, text=True, timeout=5,
|
|
273
|
+
)
|
|
274
|
+
except (OSError, subprocess.SubprocessError):
|
|
275
|
+
return []
|
|
276
|
+
pids = []
|
|
277
|
+
for tok in (out.stdout or "").split():
|
|
278
|
+
try:
|
|
279
|
+
pids.append(int(tok))
|
|
280
|
+
except ValueError:
|
|
281
|
+
pass
|
|
282
|
+
return pids
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _terminate(pid: int, grace: float = 5.0) -> None:
|
|
286
|
+
"""SIGTERM, then SIGKILL only if still alive after `grace` seconds. The wait
|
|
287
|
+
gives Chrome time to flush its in-memory cookie store to the on-disk profile,
|
|
288
|
+
so a stop->start restart preserves the X session instead of coming back
|
|
289
|
+
logged out (the failure the setup agent hit after a hard kill)."""
|
|
290
|
+
try:
|
|
291
|
+
os.kill(pid, 15)
|
|
292
|
+
except OSError:
|
|
293
|
+
return
|
|
294
|
+
deadline = time.time() + grace
|
|
295
|
+
while time.time() < deadline:
|
|
296
|
+
if not _pid_alive(pid):
|
|
297
|
+
return
|
|
298
|
+
time.sleep(0.2)
|
|
299
|
+
try:
|
|
300
|
+
os.kill(pid, 9)
|
|
301
|
+
except OSError:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
|
|
264
305
|
def stop_chrome() -> dict:
|
|
265
|
-
"""
|
|
306
|
+
"""Gracefully stop the managed Chrome — the tracked process AND any orphan
|
|
307
|
+
still LISTENing on the debug port — so connect_x-launched Chromes can't
|
|
308
|
+
strand the port. SIGTERM-with-grace lets Chrome persist cookies to disk."""
|
|
309
|
+
targets: list[int] = []
|
|
266
310
|
pid = _read_pid()
|
|
267
311
|
if pid and _pid_alive(pid):
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
312
|
+
targets.append(pid)
|
|
313
|
+
for owner in _port_owner_pids():
|
|
314
|
+
if owner not in targets and _pid_alive(owner):
|
|
315
|
+
targets.append(owner)
|
|
316
|
+
|
|
317
|
+
for t in targets:
|
|
318
|
+
_terminate(t)
|
|
319
|
+
|
|
275
320
|
try:
|
|
276
321
|
PID_FILE.unlink()
|
|
277
322
|
except FileNotFoundError:
|
|
@@ -281,7 +326,7 @@ def stop_chrome() -> dict:
|
|
|
281
326
|
os.unlink(stale)
|
|
282
327
|
except FileNotFoundError:
|
|
283
328
|
pass
|
|
284
|
-
return {"status": "stopped", "
|
|
329
|
+
return {"status": "stopped", "reaped": targets, "tracked_pid": pid}
|
|
285
330
|
|
|
286
331
|
|
|
287
332
|
# --- browser-harness exec wrapper ---
|
|
@@ -375,12 +420,16 @@ def bh_run(script: str, timeout: int = EXEC_TIMEOUT_SEC) -> str:
|
|
|
375
420
|
def bh_status() -> str:
|
|
376
421
|
"""Report whether the managed Chrome is alive and where it lives."""
|
|
377
422
|
pid = _read_pid()
|
|
423
|
+
owners = _port_owner_pids()
|
|
378
424
|
return json.dumps(
|
|
379
425
|
{
|
|
380
426
|
"cdp_url": CDP_URL,
|
|
381
427
|
"cdp_alive": _cdp_alive(),
|
|
382
428
|
"chrome_pid": pid,
|
|
383
|
-
"chrome_alive": pid is not None and _pid_alive(pid),
|
|
429
|
+
"chrome_alive": (pid is not None and _pid_alive(pid)) or _cdp_alive(),
|
|
430
|
+
# Untracked Chromes (e.g. launched by connect_x) show up here even
|
|
431
|
+
# when chrome_pid is null — that's the orphan that bh_stop now reaps.
|
|
432
|
+
"port_owner_pids": owners,
|
|
384
433
|
"profile_dir": str(PROFILE_DIR),
|
|
385
434
|
"log_file": str(LOG_FILE),
|
|
386
435
|
"harness_bin": BROWSER_HARNESS_BIN,
|
|
@@ -400,10 +449,26 @@ def bh_start() -> str:
|
|
|
400
449
|
|
|
401
450
|
@mcp.tool()
|
|
402
451
|
def bh_stop() -> str:
|
|
403
|
-
"""Kill the managed Chrome instance. Cookies/profile data persist on disk.
|
|
452
|
+
"""Kill the managed Chrome instance. Cookies/profile data persist on disk.
|
|
453
|
+
|
|
454
|
+
Reaps both the tracked process and any orphan still holding the debug port,
|
|
455
|
+
using a graceful SIGTERM-with-grace so cookies flush to disk first."""
|
|
404
456
|
return json.dumps(stop_chrome(), indent=2)
|
|
405
457
|
|
|
406
458
|
|
|
459
|
+
@mcp.tool()
|
|
460
|
+
def bh_restart() -> str:
|
|
461
|
+
"""Flush + restart the managed Chrome in one step. Gracefully stops it (so
|
|
462
|
+
Chrome persists the cookie store to disk and any port-orphan is reaped), then
|
|
463
|
+
starts a fresh instance that loads the just-flushed session from disk. Use
|
|
464
|
+
this instead of killing Chrome by hand — a hard kill drops the in-memory X
|
|
465
|
+
session before it is written, which is what forces a re-login."""
|
|
466
|
+
stopped = stop_chrome()
|
|
467
|
+
time.sleep(0.5)
|
|
468
|
+
started = ensure_chrome()
|
|
469
|
+
return json.dumps({"status": "restarted", "stopped": stopped, "started": started}, indent=2)
|
|
470
|
+
|
|
471
|
+
|
|
407
472
|
@mcp.tool()
|
|
408
473
|
def bh_seed_cookies(source: str = "chrome:Default", domains: str | None = None) -> str:
|
|
409
474
|
"""Import cookies from a local browser profile into the managed Chrome.
|
package/package.json
CHANGED
|
@@ -63,12 +63,27 @@ except ImportError:
|
|
|
63
63
|
)
|
|
64
64
|
sys.exit(0)
|
|
65
65
|
|
|
66
|
+
# Optional server-side session-cookie store (best-effort). Lets connect_x persist
|
|
67
|
+
# the validated X cookies so restore_twitter_session.py can auto-re-inject them
|
|
68
|
+
# after any logout. Guarded so a missing dep or offline API never breaks setup.
|
|
69
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
70
|
+
try:
|
|
71
|
+
from http_api import api_post # noqa: E402
|
|
72
|
+
from twitter_account import resolve_handle # noqa: E402
|
|
73
|
+
except Exception:
|
|
74
|
+
api_post = None
|
|
75
|
+
resolve_handle = None
|
|
76
|
+
|
|
66
77
|
# --- Config -----------------------------------------------------------------
|
|
67
78
|
|
|
68
79
|
# Same managed Chrome the twitter-harness pipeline uses (skill/lib/twitter-backend.sh).
|
|
69
80
|
CDP = os.environ.get("SAPS_TWITTER_CDP_URL", os.environ.get("TWITTER_CDP_URL", "http://127.0.0.1:9555")).rstrip("/")
|
|
70
81
|
PORT = int(CDP.rsplit(":", 1)[-1]) if CDP.rsplit(":", 1)[-1].isdigit() else 9555
|
|
71
82
|
PROFILE_DIR = Path.home() / ".claude" / "browser-profiles" / "browser-harness"
|
|
83
|
+
# Same PID file server.py (the twitter-harness MCP) writes, so a Chrome launched
|
|
84
|
+
# here is tracked and reapable by bh_stop instead of becoming an orphan that
|
|
85
|
+
# strands the debug port.
|
|
86
|
+
PID_FILE = Path.home() / ".claude" / "browser-profiles" / "browser-harness.chrome.pid"
|
|
72
87
|
|
|
73
88
|
# Browsers ai_browser_profile.cookies can read from, in auto-detect priority.
|
|
74
89
|
AUTO_SOURCES = ["chrome:Default", "arc:Default", "brave:Default", "edge:Default"]
|
|
@@ -163,7 +178,11 @@ def _launch_chrome() -> bool:
|
|
|
163
178
|
cmd += ["--window-position=80,80", "--window-size=1100,900"]
|
|
164
179
|
cmd.append("about:blank")
|
|
165
180
|
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
|
166
|
-
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
181
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
182
|
+
try:
|
|
183
|
+
PID_FILE.write_text(str(proc.pid))
|
|
184
|
+
except OSError:
|
|
185
|
+
pass
|
|
167
186
|
for _ in range(15):
|
|
168
187
|
if _cdp_alive():
|
|
169
188
|
return True
|
|
@@ -258,6 +277,50 @@ def _has_session_quick() -> bool:
|
|
|
258
277
|
pass
|
|
259
278
|
|
|
260
279
|
|
|
280
|
+
def _save_session_to_store() -> None:
|
|
281
|
+
"""Best-effort: persist the live x.com cookies to the server-side session
|
|
282
|
+
store (social_accounts.session_cookies, via POST /api/v1/twitter/session-cookies)
|
|
283
|
+
so restore_twitter_session.py can auto-re-inject them on the next preflight
|
|
284
|
+
after ANY logout — hard kill, crash, or AppMaker VM reseed. Non-fatal: the
|
|
285
|
+
local session is already valid; this only enables future auto-recovery.
|
|
286
|
+
Without it the restore rail has nothing to read and every logout needs a
|
|
287
|
+
manual connect_x."""
|
|
288
|
+
if api_post is None or resolve_handle is None:
|
|
289
|
+
return
|
|
290
|
+
try:
|
|
291
|
+
handle = resolve_handle()
|
|
292
|
+
except Exception:
|
|
293
|
+
handle = None
|
|
294
|
+
if not handle:
|
|
295
|
+
return
|
|
296
|
+
try:
|
|
297
|
+
ws, send = _attach()
|
|
298
|
+
except Exception:
|
|
299
|
+
return
|
|
300
|
+
try:
|
|
301
|
+
send("Network.enable")
|
|
302
|
+
r = send("Network.getAllCookies")
|
|
303
|
+
cks = r.get("result", {}).get("cookies", []) or []
|
|
304
|
+
wanted = tuple(d.strip() for d in DOMAINS.split(",") if d.strip())
|
|
305
|
+
cookies = [c for c in cks if any(w in (c.get("domain") or "") for w in wanted)]
|
|
306
|
+
if not cookies:
|
|
307
|
+
return
|
|
308
|
+
api_post("/api/v1/twitter/session-cookies", {"handle": handle, "cookies": cookies})
|
|
309
|
+
print(f"setup_twitter_auth: saved {len(cookies)} session cookies for @{handle} "
|
|
310
|
+
"(auto-restore enabled)", file=sys.stderr)
|
|
311
|
+
# api_post raises SystemExit (BaseException, NOT Exception) on a 4xx/5xx —
|
|
312
|
+
# e.g. "no social_accounts row" on a persistent machine that never registered
|
|
313
|
+
# this handle. The save is best-effort and must never abort connect_x, so
|
|
314
|
+
# catch SystemExit too.
|
|
315
|
+
except (Exception, SystemExit) as e:
|
|
316
|
+
print(f"setup_twitter_auth: session-store save skipped ({e})", file=sys.stderr)
|
|
317
|
+
finally:
|
|
318
|
+
try:
|
|
319
|
+
ws.close()
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
|
|
261
324
|
def _show_window_and_open_login() -> bool:
|
|
262
325
|
"""Make the managed Chrome window VISIBLE + focused and land it on the X login
|
|
263
326
|
page, so the user can sign in by hand (the manual-login fallback).
|
|
@@ -387,6 +450,7 @@ def cmd_connect(args) -> dict:
|
|
|
387
450
|
# 1. Already logged in? Nothing to import.
|
|
388
451
|
try:
|
|
389
452
|
if _is_session_valid():
|
|
453
|
+
_save_session_to_store()
|
|
390
454
|
return {
|
|
391
455
|
"ok": True,
|
|
392
456
|
"connected": True,
|
|
@@ -410,6 +474,7 @@ def cmd_connect(args) -> dict:
|
|
|
410
474
|
# 3. Re-validate after this source.
|
|
411
475
|
try:
|
|
412
476
|
if _is_session_valid():
|
|
477
|
+
_save_session_to_store()
|
|
413
478
|
return {
|
|
414
479
|
"ok": True,
|
|
415
480
|
"connected": True,
|
|
@@ -2072,7 +2072,14 @@ def main():
|
|
|
2072
2072
|
file=sys.stderr,
|
|
2073
2073
|
)
|
|
2074
2074
|
sys.exit(1)
|
|
2075
|
-
|
|
2075
|
+
# SAPS_SKIP_CAMPAIGN_SUFFIX=1 opts this reply out of active-campaign
|
|
2076
|
+
# suffixes (e.g. " written with ai"). Set ONLY by the MCP draft_cycle
|
|
2077
|
+
# post path (mcp/src/index.ts::postApproved) so manual/reviewed posts
|
|
2078
|
+
# land clean; the cron pipeline never sets it, so the A/B experiment
|
|
2079
|
+
# keeps running there and on Reddit. Reuses the existing apply_campaigns
|
|
2080
|
+
# plumbing (same flag the self-reply path uses below).
|
|
2081
|
+
_skip_camp = os.environ.get("SAPS_SKIP_CAMPAIGN_SUFFIX", "").strip().lower() in ("1", "true", "yes")
|
|
2082
|
+
result = reply_to_tweet(sys.argv[2], sys.argv[3], apply_campaigns=not _skip_camp)
|
|
2076
2083
|
print(json.dumps(result, indent=2))
|
|
2077
2084
|
|
|
2078
2085
|
elif cmd == "like":
|