social-autoposter 1.6.40 → 1.6.41
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/config.example.json +5 -19
- package/mcp/dist/index.js +32 -1
- package/mcp/dist/setup.js +5 -1
- package/mcp/dist/version.json +1 -0
- package/package.json +1 -1
- package/scripts/log_post.py +28 -5
- package/scripts/setup_twitter_auth.py +84 -2
- package/scripts/twitter_post_plan.py +9 -6
- package/skill/run-twitter-cycle.sh +33 -1
package/config.example.json
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
"login_method": "browser"
|
|
9
9
|
},
|
|
10
10
|
"twitter": {
|
|
11
|
-
"handle": "
|
|
12
|
-
"login_method": "browser"
|
|
11
|
+
"handle": "",
|
|
12
|
+
"login_method": "browser",
|
|
13
|
+
"_handle_note": "Leave empty. connect_x auto-detects and writes your real @handle here on first connect. A non-empty placeholder would poison our_account (attribution / own-reply dedup)."
|
|
13
14
|
},
|
|
14
15
|
"linkedin": {
|
|
15
16
|
"name": "Your Name",
|
|
@@ -26,23 +27,8 @@
|
|
|
26
27
|
|
|
27
28
|
"content_angle": "Describe your unique experience/perspective that gives you authentic angles for comments. Example: 'Building a macOS desktop AI agent. Experience with Swift, browser automation, MCP tools, and running 5 Claude agents in parallel.'",
|
|
28
29
|
|
|
29
|
-
"projects": [
|
|
30
|
-
|
|
31
|
-
"name": "My Main Project",
|
|
32
|
-
"description": "One-line description of what it does",
|
|
33
|
-
"website": "https://example.com",
|
|
34
|
-
"github": "",
|
|
35
|
-
"topics": ["desktop automation", "AI agents", "browser control"],
|
|
36
|
-
"_comment": "Topics are keywords that trigger mentioning this project in replies. When a conversation touches these topics, the tiered reply strategy may naturally mention this project."
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
"name": "My Open Source Tool",
|
|
40
|
-
"description": "What the tool does",
|
|
41
|
-
"website": "",
|
|
42
|
-
"github": "https://github.com/you/your-repo",
|
|
43
|
-
"topics": ["macOS automation", "accessibility APIs"]
|
|
44
|
-
}
|
|
45
|
-
],
|
|
30
|
+
"projects": [],
|
|
31
|
+
"_projects_help": "Projects are created via the social-autoposter MCP `setup` tool, not by hand. setup collects name, website, description, icp, voice, and search_topics, writes them here, and seeds search_topics into the DB (project_search_topics) that the draft cycle reads. Leave this empty on a fresh install: a hand-added project with no seeded search_topics is skipped by the cycle (and used to crash it to an empty result).",
|
|
46
32
|
|
|
47
33
|
"exclusions": {
|
|
48
34
|
"authors": ["spambot123", "annoying_user"],
|
package/mcp/dist/index.js
CHANGED
|
@@ -151,6 +151,15 @@ function blockedReasonMessage(reason) {
|
|
|
151
151
|
case "rate_limit_5h":
|
|
152
152
|
return (`The drafting step hit an Anthropic usage limit (${reason}), so no replies were drafted. ` +
|
|
153
153
|
"Wait for the limit to reset, then run draft_cycle again.");
|
|
154
|
+
case "no_search_topics":
|
|
155
|
+
return ("This project has no search topics yet, so there was nothing to scan. Topics live in the " +
|
|
156
|
+
"DB (project_search_topics) and are seeded from your project's `search_topics` during setup. " +
|
|
157
|
+
"Re-run the `setup` tool for this project with a `search_topics` list (comma-separated keywords/" +
|
|
158
|
+
"phrases your buyers tweet about); setup seeds them automatically, then run draft_cycle again.");
|
|
159
|
+
case "topics_api_unreachable":
|
|
160
|
+
return ("Couldn't reach the search-topics service to load this project's topics, so the cycle stopped " +
|
|
161
|
+
"before scanning. This is usually a transient backend/network issue. Try draft_cycle again in a " +
|
|
162
|
+
"moment; if it persists, check connectivity to the autoposter backend.");
|
|
154
163
|
case "credit_balance":
|
|
155
164
|
return ("The drafting step failed because the Anthropic account is out of credits. " +
|
|
156
165
|
"Add credits, then run draft_cycle again.");
|
|
@@ -482,15 +491,37 @@ server.registerTool("setup", {
|
|
|
482
491
|
// named project, then report whether it's now ready or still missing fields.
|
|
483
492
|
try {
|
|
484
493
|
const result = applySetup(args);
|
|
494
|
+
// Seed this project's search_topics into the DB universe the cycle reads
|
|
495
|
+
// (project_search_topics). Without this a freshly-configured project has
|
|
496
|
+
// topics in config.json but ZERO rows in the DB, so draft_cycle's topic
|
|
497
|
+
// picker raises and the cycle silently returns nothing. Best-effort: a
|
|
498
|
+
// seed hiccup never fails setup — the cycle's fail-loud path still tells
|
|
499
|
+
// the user if topics are missing. Only runs once the project is ready
|
|
500
|
+
// (i.e. it actually has search_topics to seed). (2026-06-02)
|
|
501
|
+
let seedNote = "";
|
|
502
|
+
if (result.ready) {
|
|
503
|
+
const seed = await runPython("scripts/seed_search_topics.py", ["--project", result.project], { timeoutMs: 60_000 });
|
|
504
|
+
if (seed.code === 0) {
|
|
505
|
+
const m = /planned=(\d+)\s+inserted=(\d+)\s+updated=(\d+)/.exec(seed.stdout);
|
|
506
|
+
seedNote = m
|
|
507
|
+
? ` Seeded ${m[1]} search topic(s) into the DB (new: ${m[2]}, updated: ${m[3]}), so draft_cycle has a topic universe to work with.`
|
|
508
|
+
: " Seeded search topics into the DB so draft_cycle has a topic universe to work with.";
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
const tail = (seed.stderr || seed.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
|
|
512
|
+
seedNote = ` (Heads up: couldn't seed search topics into the DB yet — ${tail}. draft_cycle will tell you clearly if topics are missing.)`;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
485
515
|
return jsonContent({
|
|
486
516
|
ok: true,
|
|
487
517
|
project: result.project,
|
|
488
518
|
action: result.created ? "created" : "updated",
|
|
489
519
|
ready: result.ready,
|
|
490
520
|
missing_required: result.missing_required,
|
|
521
|
+
topics_seeded: result.ready,
|
|
491
522
|
config_path: CONFIG_PATH,
|
|
492
523
|
note: result.ready
|
|
493
|
-
? `Project '${result.project}' is fully set up
|
|
524
|
+
? `Project '${result.project}' is fully set up.${seedNote} Next: connect X so the autoposter can post — ` +
|
|
494
525
|
`call setup with action:'connect_x' (it explains itself, then run again with confirm:true). ` +
|
|
495
526
|
`Once X is connected you can run draft_cycle, autopilot, and get_stats.`
|
|
496
527
|
: `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
|
package/mcp/dist/setup.js
CHANGED
|
@@ -26,10 +26,14 @@ export const REQUIRED_FIELDS = [
|
|
|
26
26
|
"description",
|
|
27
27
|
"icp",
|
|
28
28
|
"voice",
|
|
29
|
+
// search_topics is required: the cycle's topic picker reads the DB universe
|
|
30
|
+
// (project_search_topics) seeded FROM these on setup. With zero topics the
|
|
31
|
+
// picker raises and the whole draft cycle silently returns nothing, so a
|
|
32
|
+
// project is NOT ready until it has at least one topic to seed. (2026-06-02)
|
|
33
|
+
"search_topics",
|
|
29
34
|
];
|
|
30
35
|
export const RECOMMENDED_FIELDS = [
|
|
31
36
|
"differentiator",
|
|
32
|
-
"search_topics",
|
|
33
37
|
"get_started_link",
|
|
34
38
|
"content_guardrails",
|
|
35
39
|
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"1.6.40"}
|
package/package.json
CHANGED
package/scripts/log_post.py
CHANGED
|
@@ -58,6 +58,29 @@ import sys
|
|
|
58
58
|
|
|
59
59
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
60
60
|
import http_api
|
|
61
|
+
|
|
62
|
+
# --- API error-envelope helpers (2026-06-02) -------------------------------
|
|
63
|
+
# The API returns failures as a NESTED object: {"ok": false, "error": {"code",
|
|
64
|
+
# "message", "details"}} (see social-autoposter-website response.ts). http_api
|
|
65
|
+
# may also surface a FLAT {"error": "conflict"} on a 409 it couldn't parse. The
|
|
66
|
+
# old `resp.get("error") in (...)` string check missed the nested shape, so a
|
|
67
|
+
# duplicate_thread 409 fell through and printed {"logged": true, "post_id":
|
|
68
|
+
# null} -- a false success that looked like a logging gap. These helpers read
|
|
69
|
+
# either shape so dedups are recognized and reported correctly.
|
|
70
|
+
def _api_error_code(resp):
|
|
71
|
+
e = (resp or {}).get("error")
|
|
72
|
+
if isinstance(e, dict):
|
|
73
|
+
return e.get("code")
|
|
74
|
+
return e # flat string or None
|
|
75
|
+
|
|
76
|
+
def _api_error_detail(resp, key):
|
|
77
|
+
e = (resp or {}).get("error")
|
|
78
|
+
if isinstance(e, dict):
|
|
79
|
+
d = e.get("details")
|
|
80
|
+
if isinstance(d, dict) and d.get(key) is not None:
|
|
81
|
+
return d.get(key)
|
|
82
|
+
return (resp or {}).get(key)
|
|
83
|
+
|
|
61
84
|
import linkedin_url as li_url
|
|
62
85
|
from db import load_env
|
|
63
86
|
from twitter_account import resolve_handle as resolve_twitter_handle
|
|
@@ -283,11 +306,11 @@ def log_rejected(args):
|
|
|
283
306
|
body["autoposter_version"] = autoposter_version
|
|
284
307
|
|
|
285
308
|
resp = http_api.api_post("/api/v1/posts", body, ok_on_conflict=True)
|
|
286
|
-
if resp and resp
|
|
309
|
+
if resp and _api_error_code(resp) in ("duplicate_thread", "conflict"):
|
|
287
310
|
print(json.dumps({
|
|
288
311
|
"error": "DUPLICATE_THREAD",
|
|
289
312
|
"message": "Already have a row for this thread",
|
|
290
|
-
"existing_post_id": resp
|
|
313
|
+
"existing_post_id": _api_error_detail(resp, "existing_post_id"),
|
|
291
314
|
}))
|
|
292
315
|
return
|
|
293
316
|
# See note in main() about the resp.data.post.id shape.
|
|
@@ -540,12 +563,12 @@ def main():
|
|
|
540
563
|
}), file=sys.stderr)
|
|
541
564
|
|
|
542
565
|
resp = http_api.api_post("/api/v1/posts", body, ok_on_conflict=True)
|
|
543
|
-
if resp and resp
|
|
566
|
+
if resp and _api_error_code(resp) in ("duplicate_thread", "conflict"):
|
|
544
567
|
print(json.dumps({
|
|
545
568
|
"error": "DUPLICATE_THREAD",
|
|
546
569
|
"message": "Already posted in this thread",
|
|
547
|
-
"existing_post_id": resp
|
|
548
|
-
"content_preview": resp
|
|
570
|
+
"existing_post_id": _api_error_detail(resp, "existing_post_id"),
|
|
571
|
+
"content_preview": _api_error_detail(resp, "content_preview"),
|
|
549
572
|
}))
|
|
550
573
|
return
|
|
551
574
|
# API response shape is {"ok":true,"data":{"post":{"id":N,...}}}.
|
|
@@ -303,6 +303,80 @@ def _collect_x_cookies(send) -> list:
|
|
|
303
303
|
return [c for c in cks if any(w in (c.get("domain") or "") for w in wanted)]
|
|
304
304
|
|
|
305
305
|
|
|
306
|
+
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
307
|
+
_CONFIG_JSON = os.path.join(_REPO_ROOT, "config.json")
|
|
308
|
+
_HANDLE_PLACEHOLDERS = {"", "your-twitter-handle", "@your-twitter-handle"}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _resolve_live_handle(send) -> "str | None":
|
|
312
|
+
"""Read the logged-in @handle from the LIVE x.com session via CDP DOM.
|
|
313
|
+
|
|
314
|
+
resolve_handle() only reads config.json (which on a fresh install is the
|
|
315
|
+
template placeholder), so it can't discover the real account. This reads the
|
|
316
|
+
actual logged-in handle from the page so connect_x can persist it. Best
|
|
317
|
+
effort: returns None on any failure and never raises into the connect flow.
|
|
318
|
+
"""
|
|
319
|
+
js = r"""(function(){
|
|
320
|
+
function fromHref(sel){var a=document.querySelector(sel);if(a){var h=a.getAttribute('href')||'';var m=h.match(/^\/([A-Za-z0-9_]{1,15})$/);if(m)return m[1];}return '';}
|
|
321
|
+
var h=fromHref('a[data-testid="AppTabBar_Profile_Link"]');
|
|
322
|
+
if(h)return h;
|
|
323
|
+
var b=document.querySelector('[data-testid="SideNav_AccountSwitcher_Button"]');
|
|
324
|
+
if(b){var m=(b.textContent||'').match(/@([A-Za-z0-9_]{1,15})/);if(m)return m[1];}
|
|
325
|
+
return '';
|
|
326
|
+
})()"""
|
|
327
|
+
try:
|
|
328
|
+
send("Page.enable")
|
|
329
|
+
u = _current_url(send)
|
|
330
|
+
if "x.com" not in u and "twitter.com" not in u:
|
|
331
|
+
send("Page.navigate", {"url": "https://x.com/home"})
|
|
332
|
+
time.sleep(3)
|
|
333
|
+
for _ in range(8):
|
|
334
|
+
r = send("Runtime.evaluate", {"expression": js, "returnByValue": True})
|
|
335
|
+
v = (r.get("result", {}).get("result", {}) or {}).get("value", "") or ""
|
|
336
|
+
v = v.strip().lstrip("@")
|
|
337
|
+
if v:
|
|
338
|
+
return v
|
|
339
|
+
time.sleep(1)
|
|
340
|
+
except Exception:
|
|
341
|
+
return None
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _write_handle_to_config(handle: "str | None") -> bool:
|
|
346
|
+
"""Persist the discovered handle to config.json accounts.twitter.handle, but
|
|
347
|
+
ONLY when the configured value is empty or the template placeholder, so we
|
|
348
|
+
never clobber a handle the user set on purpose. Returns True if written.
|
|
349
|
+
|
|
350
|
+
This is what makes account_resolver.resolve('twitter') return the REAL
|
|
351
|
+
account, so our_account (attribution, own-reply skip, account-keyed ops) is
|
|
352
|
+
correct instead of the poisonous 'your-twitter-handle' default. (2026-06-02)
|
|
353
|
+
"""
|
|
354
|
+
if not handle:
|
|
355
|
+
return False
|
|
356
|
+
try:
|
|
357
|
+
with open(_CONFIG_JSON, encoding="utf-8") as f:
|
|
358
|
+
cfg = json.load(f)
|
|
359
|
+
except Exception:
|
|
360
|
+
return False
|
|
361
|
+
accounts = cfg.setdefault("accounts", {})
|
|
362
|
+
if not isinstance(accounts, dict):
|
|
363
|
+
return False
|
|
364
|
+
tw = accounts.setdefault("twitter", {})
|
|
365
|
+
if not isinstance(tw, dict):
|
|
366
|
+
return False
|
|
367
|
+
cur = (tw.get("handle") or "").strip()
|
|
368
|
+
if cur.lower() not in _HANDLE_PLACEHOLDERS:
|
|
369
|
+
return False # a real handle is already set; do not overwrite
|
|
370
|
+
tw["handle"] = "@" + handle.lstrip("@")
|
|
371
|
+
try:
|
|
372
|
+
with open(_CONFIG_JSON, "w", encoding="utf-8") as f:
|
|
373
|
+
json.dump(cfg, f, indent=2)
|
|
374
|
+
f.write("\n")
|
|
375
|
+
return True
|
|
376
|
+
except Exception:
|
|
377
|
+
return False
|
|
378
|
+
|
|
379
|
+
|
|
306
380
|
def _persist_session() -> None:
|
|
307
381
|
"""Persist the validated live X session for auto-restore after ANY logout
|
|
308
382
|
(hard kill, crash, keychain re-lock wiping Chrome's Cookies DB, or AppMaker
|
|
@@ -334,8 +408,16 @@ def _persist_session() -> None:
|
|
|
334
408
|
if not cookies:
|
|
335
409
|
return
|
|
336
410
|
|
|
337
|
-
handle
|
|
338
|
-
|
|
411
|
+
# Prefer the LIVE logged-in handle so a fresh install records the real
|
|
412
|
+
# account instead of the config.json placeholder; persist it so the cycle's
|
|
413
|
+
# account_resolver (our_account) is correct. Fall back to the configured
|
|
414
|
+
# handle. All best-effort: never abort connect_x.
|
|
415
|
+
handle = _resolve_live_handle(send)
|
|
416
|
+
if handle and _write_handle_to_config(handle):
|
|
417
|
+
print(f"setup_twitter_auth: recorded live X handle @{handle} in config.json "
|
|
418
|
+
"(accounts.twitter.handle); attribution + own-reply dedup now scoped "
|
|
419
|
+
"to the real account", file=sys.stderr)
|
|
420
|
+
if not handle and resolve_handle is not None:
|
|
339
421
|
try:
|
|
340
422
|
handle = resolve_handle()
|
|
341
423
|
except Exception:
|
|
@@ -242,13 +242,16 @@ def already_posted_to_thread(thread_url: str) -> tuple[bool, int | None]:
|
|
|
242
242
|
that fires once per cycle. log_post.py's post-INSERT dedup is still
|
|
243
243
|
the final backstop.
|
|
244
244
|
"""
|
|
245
|
-
# Scope
|
|
246
|
-
#
|
|
247
|
-
#
|
|
245
|
+
# Scope MUST match the server-side insert dedup, which is keyed on
|
|
246
|
+
# (platform, thread_url) ONLY -- NOT our_account (see social-autoposter-
|
|
247
|
+
# website /api/v1/posts route: "Enforces dedup on (platform, thread_url)").
|
|
248
|
+
# The old per-account scoping here made the probe NARROWER than the server:
|
|
249
|
+
# it passed when a post existed under a different/placeholder our_account,
|
|
250
|
+
# so the cycle posted a SECOND reply to a thread the server then rejected
|
|
251
|
+
# with duplicate_thread -- after the reply was already live on X. Querying
|
|
252
|
+
# thread-only makes the pre-post guard catch exactly what the insert would
|
|
253
|
+
# reject, so we never burn that wasted second reply. (2026-06-02)
|
|
248
254
|
dedupe_q = {"platform": "twitter", "thread_url": thread_url}
|
|
249
|
-
_twitter_handle = _resolve_account("twitter")
|
|
250
|
-
if _twitter_handle:
|
|
251
|
-
dedupe_q["our_account"] = _twitter_handle
|
|
252
255
|
try:
|
|
253
256
|
resp = api_get(
|
|
254
257
|
"/api/v1/posts/lookup",
|
|
@@ -470,7 +470,8 @@ python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase p
|
|
|
470
470
|
# mechanically append these as `-term` operators to whatever query it drafts
|
|
471
471
|
# for the project. See scripts/project_excludes.py for proposal/activation/
|
|
472
472
|
# decay rules.
|
|
473
|
-
|
|
473
|
+
_PJ_ERR="$(mktemp)"
|
|
474
|
+
PROJECTS_JSON=$(python3 - 2>"$_PJ_ERR" <<'PY'
|
|
474
475
|
import json, os, subprocess, sys
|
|
475
476
|
REPO = os.path.expanduser('~/social-autoposter')
|
|
476
477
|
sys.path.insert(0, os.path.join(REPO, 'scripts'))
|
|
@@ -549,6 +550,37 @@ for p in picked:
|
|
|
549
550
|
print(json.dumps(chosen, indent=2))
|
|
550
551
|
PY
|
|
551
552
|
)
|
|
553
|
+
_PJ_RC=$?
|
|
554
|
+
# Fail loud when the project/topic universe can't be built. The heredoc above
|
|
555
|
+
# exits non-zero (PROJECTS_JSON empty) when pick_topic_for_project finds zero
|
|
556
|
+
# active rows in project_search_topics for the selected project, or the topics
|
|
557
|
+
# API is unreachable; it also yields "[]" when no project is eligible. Without
|
|
558
|
+
# this guard the empty PROJECTS_JSON silently falls through to "0 queries -> 0
|
|
559
|
+
# tweets -> batch expired -> zero", which reads to the user as "nothing to post"
|
|
560
|
+
# when the real cause is "this project was never seeded with search topics".
|
|
561
|
+
# Seeding now happens in the MCP setup tool; this is the defense-in-depth net
|
|
562
|
+
# so a missing universe is surfaced, never swallowed. (2026-06-02)
|
|
563
|
+
if [ "$_PJ_RC" -ne 0 ] || ! printf '%s' "$PROJECTS_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if isinstance(d,list) and d else 1)' 2>/dev/null; then
|
|
564
|
+
_PJ_REASON="project_selection_failed"
|
|
565
|
+
if grep -q "no active search topics" "$_PJ_ERR" 2>/dev/null; then
|
|
566
|
+
_PJ_REASON="no_search_topics"
|
|
567
|
+
elif grep -qiE "project-search-topics API|API unreachable" "$_PJ_ERR" 2>/dev/null; then
|
|
568
|
+
_PJ_REASON="topics_api_unreachable"
|
|
569
|
+
fi
|
|
570
|
+
log "Project/topic universe build FAILED (reason=$_PJ_REASON); stopping cycle before scan. Last error lines:"
|
|
571
|
+
tail -15 "$_PJ_ERR" 2>/dev/null | sed 's/^/ /' | tee -a "$LOG_FILE"
|
|
572
|
+
rm -f "$_PJ_ERR"
|
|
573
|
+
# Surface the reason to the MCP draft_cycle wrapper (stdout marker) in manual mode.
|
|
574
|
+
if [ "${DRAFT_ONLY:-0}" = "1" ]; then
|
|
575
|
+
echo "DRAFT_ONLY_BLOCKED=$_PJ_REASON"
|
|
576
|
+
fi
|
|
577
|
+
# Record a dashboard-visible failure row (best-effort) and exit cleanly.
|
|
578
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "post_twitter" --posted 0 --skipped 0 --failed 1 \
|
|
579
|
+
--failure-reasons "${_PJ_REASON}:1" --cost "0.0000" --elapsed $(( $(date +%s) - RUN_START )) 2>/dev/null || true
|
|
580
|
+
_SA_RUN_SUMMARY_EMITTED=1
|
|
581
|
+
exit 0
|
|
582
|
+
fi
|
|
583
|
+
rm -f "$_PJ_ERR"
|
|
552
584
|
|
|
553
585
|
log "Selected projects: $(echo "$PROJECTS_JSON" | python3 -c 'import json,sys; print(", ".join(p["name"] for p in json.load(sys.stdin)))')"
|
|
554
586
|
EXCLUDES_TOTAL=$(echo "$PROJECTS_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(sum(len(p.get("excludes_for_search") or []) for p in d))')
|