social-autoposter 1.6.39 → 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/bin/cli.js CHANGED
@@ -26,20 +26,6 @@ const COPY_TARGETS = [
26
26
  'mcp',
27
27
  ];
28
28
 
29
- const ENV_TEMPLATE = `# social-autoposter environment variables
30
- # Fill in your values below.
31
-
32
- # Moltbook API key (required for Moltbook posting/scanning)
33
- # Get it from: https://www.moltbook.com/settings/api
34
- MOLTBOOK_API_KEY=
35
-
36
- # s4l.ai HTTP API. The pipelines read and write all state through this API
37
- # (no Postgres required). Defaults to https://s4l.ai when unset; set
38
- # AUTOPOSTER_API_KEY only if your install uses a bearer token.
39
- # AUTOPOSTER_API_BASE=https://s4l.ai
40
- # AUTOPOSTER_API_KEY=
41
- `;
42
-
43
29
  // Never overwrite these user files during update
44
30
  const USER_FILES = new Set(['config.json', '.env', 'SKILL.md']);
45
31
 
@@ -843,15 +829,12 @@ function init() {
843
829
  console.log(' config.json exists — skipping');
844
830
  }
845
831
 
846
- // .env only if it doesn't exist. Written from an in-package template so
847
- // the NPM tarball no longer ships a credential-bearing .env.example file.
848
- const envDest = path.join(DEST, '.env');
849
- if (!fs.existsSync(envDest)) {
850
- fs.writeFileSync(envDest, ENV_TEMPLATE);
851
- console.log(' created .env from template (fill in MOLTBOOK_API_KEY; AUTOPOSTER_API_KEY only if your install uses one)');
852
- } else {
853
- console.log(' .env exists — skipping');
854
- }
832
+ // No .env is created. X/Twitter and the rest of the pipeline run with zero
833
+ // keys state syncs through the s4l.ai HTTP API and the browser session
834
+ // lives in the harness Chrome profile. Optional integrations read their keys
835
+ // straight from the environment when set (MOLTBOOK_API_KEY for Moltbook,
836
+ // AUTOPOSTER_API_KEY only if your s4l.ai install uses a bearer token); every
837
+ // script guards `.env` with `[ -f .env ]`, so its absence is a no-op.
855
838
 
856
839
  installPythonDeps();
857
840
  removeLegacyEngagementStylesSidecar();
@@ -8,8 +8,9 @@
8
8
  "login_method": "browser"
9
9
  },
10
10
  "twitter": {
11
- "handle": "@your-twitter-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. Next: connect X so the autoposter can post — ` +
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.39",
3
+ "version": "1.6.41",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -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.get("error") in ("duplicate_thread", "conflict"):
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.get("existing_post_id"),
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.get("error") in ("duplicate_thread", "conflict"):
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.get("existing_post_id"),
548
- "content_preview": resp.get("content_preview"),
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 = None
338
- if resolve_handle is not None:
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 per-account so the pre-post race guard only fires when THIS
246
- # machine's handle already has a post in the thread. Otherwise the mk0r
247
- # VM (@matt_diak) would skip every thread @m13v_ already touched.
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
- PROJECTS_JSON=$(python3 - <<'PY'
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))')