@m13v/s4l 1.6.197-rc.10

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.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Moltbook post/comment helper with automatic verification.
4
+
5
+ Usage:
6
+ python3 scripts/moltbook_post.py post --title "..." --content "..." [--submolt technology]
7
+ python3 scripts/moltbook_post.py comment --post-id UUID --content "..."
8
+
9
+ Handles the obfuscated lobster math CAPTCHA automatically.
10
+ """
11
+ import requests, json, re, sys, os, argparse, time
12
+
13
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+ try:
15
+ from moltbook_tools import note_rate_limited as _note_rate_limited
16
+ except Exception:
17
+ def _note_rate_limited(_retry):
18
+ pass
19
+
20
+
21
+ def get_api_key():
22
+ key = os.environ.get("MOLTBOOK_API_KEY")
23
+ if not key:
24
+ env_file = os.path.expanduser("~/social-autoposter/.env")
25
+ if os.path.exists(env_file):
26
+ with open(env_file) as f:
27
+ for line in f:
28
+ if line.startswith("MOLTBOOK_API_KEY="):
29
+ key = line.strip().split("=", 1)[1]
30
+ break
31
+ if not key:
32
+ print("ERROR: MOLTBOOK_API_KEY not found", file=sys.stderr)
33
+ sys.exit(1)
34
+ return key
35
+
36
+ BASE = "https://www.moltbook.com/api/v1"
37
+
38
+ NUMBER_WORDS = {
39
+ 'zero':0,'one':1,'two':2,'three':3,'four':4,'five':5,'six':6,'seven':7,
40
+ 'eight':8,'nine':9,'ten':10,'eleven':11,'twelve':12,'thirteen':13,
41
+ 'fourteen':14,'fifteen':15,'sixteen':16,'seventeen':17,'eighteen':18,
42
+ 'nineteen':19,'twenty':20,'thirty':30,'forty':40,'fifty':50,'sixty':60,
43
+ 'seventy':70,'eighty':80,'ninety':90
44
+ }
45
+
46
+ def solve_challenge(challenge_text):
47
+ """Solve Moltbook's obfuscated lobster math CAPTCHA.
48
+
49
+ Strategy: strip ALL non-alpha chars (handles fragmented words like "tH iR tY"),
50
+ scan for number words using greedy longest-first matching, detect operation,
51
+ try all number pairs with all operations via brute force if needed.
52
+ """
53
+ # Strip non-alpha, join everything
54
+ nospace = re.sub(r'[^a-zA-Z]', '', challenge_text).lower()
55
+
56
+ # Build regex patterns that match each number word with optional repeated chars
57
+ # e.g., "three" -> "t+h+r+e+e+" matches "tthhrreeee"
58
+ def make_fuzzy_pattern(word):
59
+ return ''.join(c + '+' for c in word)
60
+
61
+ sorted_words = sorted(NUMBER_WORDS.keys(), key=len, reverse=True)
62
+ fuzzy_patterns = [(w, re.compile(make_fuzzy_pattern(w))) for w in sorted_words]
63
+
64
+ # Scan for number words using fuzzy matching on the raw stripped text
65
+ nums_raw = []
66
+ remaining = nospace
67
+ while remaining:
68
+ found = False
69
+ for word, pattern in fuzzy_patterns:
70
+ m = pattern.match(remaining)
71
+ if m:
72
+ nums_raw.append(NUMBER_WORDS[word])
73
+ remaining = remaining[m.end():]
74
+ found = True
75
+ break
76
+ if not found:
77
+ remaining = remaining[1:]
78
+
79
+ # Combine tens+ones (e.g., twenty + three = 23)
80
+ nums = []
81
+ i = 0
82
+ while i < len(nums_raw):
83
+ val = nums_raw[i]
84
+ if val >= 20 and val < 100 and i+1 < len(nums_raw) and nums_raw[i+1] < 10:
85
+ nums.append(val + nums_raw[i+1])
86
+ i += 2
87
+ else:
88
+ nums.append(val)
89
+ i += 1
90
+
91
+ # Filter to reasonable candidates (5-999)
92
+ candidates = [n for n in nums if 5 <= n <= 999]
93
+ if len(candidates) < 2:
94
+ candidates = [n for n in nums if n > 0]
95
+
96
+ # Detect primary operation (check raw, stripped, and deduped text)
97
+ lower = challenge_text.lower()
98
+ stripped_lower = nospace # already lowercase stripped
99
+ deduped_lower = re.sub(r'(.)\1+', r'\1', stripped_lower)
100
+ check_texts = [lower, stripped_lower, deduped_lower]
101
+ if any(any(w in t for t in check_texts) for w in ['multipl', 'product', 'times', 'triple', 'double']) or '*' in challenge_text:
102
+ primary = 'mul'
103
+ elif any(any(w in t for t in check_texts) for w in ['differ', 'subtract', 'less', 'minus', 'remain', 'reduc', 'loses', 'lose', 'lost', 'slow']):
104
+ primary = 'sub'
105
+ else:
106
+ primary = 'add'
107
+
108
+ return candidates, primary
109
+
110
+ def verify_with_brute_force(candidates, primary_op, verification_code, headers):
111
+ """Try all number pair + operation combinations to verify."""
112
+ ops = {
113
+ 'add': lambda a, b: a + b,
114
+ 'sub': lambda a, b: abs(a - b),
115
+ 'mul': lambda a, b: a * b,
116
+ }
117
+
118
+ # Try primary op first with last two candidates
119
+ op_order = [primary_op] + [o for o in ['add', 'sub', 'mul'] if o != primary_op]
120
+
121
+ for op_name in op_order:
122
+ for i in range(len(candidates)):
123
+ for j in range(len(candidates)):
124
+ if i == j:
125
+ continue
126
+ a, b = candidates[i], candidates[j]
127
+ answer = f"{ops[op_name](a, b):.2f}"
128
+ try:
129
+ r = requests.post(
130
+ f"{BASE}/verify",
131
+ headers=headers,
132
+ json={"answer": answer, "verification_code": verification_code},
133
+ timeout=15,
134
+ )
135
+ if r.json().get("success"):
136
+ return True, answer, f"{op_name}({a},{b})"
137
+ except Exception:
138
+ continue
139
+
140
+ return False, None, None
141
+
142
+ def create_post(title, content, submolt, api_key):
143
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
144
+
145
+ r = requests.post(
146
+ f"{BASE}/posts",
147
+ headers=headers,
148
+ json={"title": title, "content": content, "type": "text", "submolt_name": submolt},
149
+ timeout=30,
150
+ )
151
+
152
+ if r.status_code == 429:
153
+ retry = r.json().get("retry_after_seconds", 160)
154
+ _note_rate_limited(retry)
155
+ print(f"Rate limited. Retry after {retry}s", file=sys.stderr)
156
+ sys.exit(2)
157
+
158
+ d = r.json()
159
+ if not d.get("success"):
160
+ print(f"Create failed: {d.get('message', '')}", file=sys.stderr)
161
+ sys.exit(1)
162
+
163
+ post = d["post"]
164
+ post_id = post["id"]
165
+ verification = post.get("verification", {})
166
+ challenge = verification.get("challenge_text", "")
167
+ code = verification.get("verification_code", "")
168
+
169
+ print(f"Post created: {post_id}")
170
+
171
+ if not challenge or not code:
172
+ print("No verification challenge (unexpected)")
173
+ return post_id, False
174
+
175
+ print(f"Challenge: {challenge}")
176
+
177
+ candidates, primary_op = solve_challenge(challenge)
178
+ print(f"Numbers found: {candidates}, primary op: {primary_op}")
179
+
180
+ if len(candidates) < 2:
181
+ print("ERROR: Could not find enough numbers in challenge", file=sys.stderr)
182
+ return post_id, False
183
+
184
+ ok, answer, expr = verify_with_brute_force(candidates, primary_op, code, headers)
185
+ if ok:
186
+ print(f"VERIFIED: {expr} = {answer}")
187
+ return post_id, True
188
+ else:
189
+ print("VERIFICATION FAILED - delete and retry", file=sys.stderr)
190
+ return post_id, False
191
+
192
+ def create_comment(post_id, content, api_key, parent_id=None):
193
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
194
+
195
+ body = {"content": content}
196
+ if parent_id:
197
+ body["parent_id"] = parent_id
198
+
199
+ r = requests.post(
200
+ f"{BASE}/posts/{post_id}/comments",
201
+ headers=headers,
202
+ json=body,
203
+ timeout=30,
204
+ )
205
+
206
+ if r.status_code == 429:
207
+ retry = r.json().get("retry_after_seconds", 160)
208
+ _note_rate_limited(retry)
209
+ print(f"Rate limited. Retry after {retry}s", file=sys.stderr)
210
+ sys.exit(2)
211
+
212
+ d = r.json()
213
+ if not d.get("success"):
214
+ msg_raw = d.get("message", "")
215
+ msg = " ".join(msg_raw) if isinstance(msg_raw, list) else str(msg_raw)
216
+ if "suspend" in msg.lower():
217
+ print(f"SUSPENDED: {msg}", file=sys.stderr)
218
+ sys.exit(3)
219
+ print(f"Comment failed: {msg}", file=sys.stderr)
220
+ sys.exit(1)
221
+
222
+ comment = d.get("comment", d)
223
+ comment_id = comment.get("id", "?")
224
+ print(f"Comment created: {comment_id}")
225
+
226
+ # Comments require verification
227
+ verification = comment.get("verification", d.get("verification", {}))
228
+ if verification.get("challenge_text") and verification.get("verification_code"):
229
+ challenge_text = verification["challenge_text"]
230
+ ver_code = verification["verification_code"]
231
+ print(f"Challenge: {challenge_text}")
232
+ candidates, primary_op = solve_challenge(challenge_text)
233
+ print(f"Numbers found: {candidates}, primary op: {primary_op}")
234
+ if len(candidates) < 2:
235
+ print("ERROR: Not enough numbers found", file=sys.stderr)
236
+ return comment_id, False
237
+ ok, answer, expr = verify_with_brute_force(
238
+ candidates, primary_op, ver_code, headers
239
+ )
240
+ if ok:
241
+ print(f"VERIFIED: {expr} = {answer}")
242
+ else:
243
+ print("VERIFICATION FAILED", file=sys.stderr)
244
+ return comment_id, False
245
+
246
+ return comment_id, True
247
+
248
+ def self_upvote(item_type, item_id, api_key):
249
+ """Self-upvote a post or comment after verification."""
250
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
251
+ if item_type == "post":
252
+ url = f"{BASE}/posts/{item_id}/upvote"
253
+ else:
254
+ url = f"{BASE}/comments/{item_id}/upvote"
255
+ try:
256
+ r = requests.post(url, headers=headers, timeout=15)
257
+ d = r.json()
258
+ if d.get("success") or d.get("upvoted"):
259
+ print(f"Self-upvoted {item_type} {item_id[:12]}")
260
+ return True
261
+ retry = d.get("retry_after_seconds")
262
+ if retry:
263
+ _note_rate_limited(retry)
264
+ time.sleep(retry + 1)
265
+ r = requests.post(url, headers=headers, timeout=15)
266
+ if r.json().get("success") or r.json().get("upvoted"):
267
+ print(f"Self-upvoted {item_type} {item_id[:12]} (retry)")
268
+ return True
269
+ except Exception as e:
270
+ print(f"Upvote failed: {e}", file=sys.stderr)
271
+ return False
272
+
273
+
274
+ def main():
275
+ parser = argparse.ArgumentParser(description="Moltbook post/comment with auto-verification")
276
+ sub = parser.add_subparsers(dest="action")
277
+
278
+ post_p = sub.add_parser("post")
279
+ post_p.add_argument("--title", required=True)
280
+ post_p.add_argument("--content", required=True)
281
+ post_p.add_argument("--submolt", default="general")
282
+ post_p.add_argument("--no-upvote", action="store_true", help="Skip self-upvote")
283
+
284
+ comment_p = sub.add_parser("comment")
285
+ comment_p.add_argument("--post-id", required=True)
286
+ comment_p.add_argument("--content", required=True)
287
+ comment_p.add_argument("--parent-id", default=None,
288
+ help="UUID of comment to reply to (for threaded replies)")
289
+ comment_p.add_argument("--no-upvote", action="store_true", help="Skip self-upvote")
290
+
291
+ args = parser.parse_args()
292
+ api_key = get_api_key()
293
+
294
+ if args.action == "post":
295
+ post_id, verified = create_post(args.title, args.content, args.submolt, api_key)
296
+ if not verified:
297
+ print(f"Deleting unverified post {post_id}...")
298
+ requests.delete(
299
+ f"{BASE}/posts/{post_id}",
300
+ headers={"Authorization": f"Bearer {api_key}"},
301
+ )
302
+ sys.exit(1)
303
+ if not args.no_upvote:
304
+ self_upvote("post", post_id, api_key)
305
+ url = f"https://www.moltbook.com/post/{post_id}"
306
+ print(json.dumps({"post_id": post_id, "verified": True, "url": url}))
307
+ elif args.action == "comment":
308
+ comment_id, ok = create_comment(args.post_id, args.content, api_key,
309
+ parent_id=args.parent_id)
310
+ if ok and not args.no_upvote:
311
+ self_upvote("comment", str(comment_id), api_key)
312
+ url = f"https://www.moltbook.com/post/{args.post_id}#{comment_id}"
313
+ print(json.dumps({"ok": bool(ok), "comment_id": str(comment_id),
314
+ "verified": ok, "url": url}))
315
+ else:
316
+ parser.print_help()
317
+ sys.exit(1)
318
+
319
+ if __name__ == "__main__":
320
+ main()
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env python3
2
+ """Shared Moltbook API helpers with cooperative cross-process rate-limit state.
3
+
4
+ Mirrors the pattern in scripts/reddit_tools.py so multiple concurrent Moltbook
5
+ callers (scan_moltbook_replies.py, stats.py, find_threads.py,
6
+ moltbook_post.py) back off together when any one of them hits a 429.
7
+
8
+ State file: /tmp/moltbook_ratelimit.json
9
+ {"remaining": int, "reset_at": epoch_seconds}
10
+
11
+ On 429, the Moltbook API returns a JSON body with `retry_after_seconds`.
12
+ We persist that reset into the shared file so the next caller (in any process)
13
+ can decide to wait inline or raise MoltbookRateLimitedError to exit early.
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import sys
19
+ import time
20
+ import urllib.error
21
+ import urllib.request
22
+
23
+
24
+ RATELIMIT_FILE = "/tmp/moltbook_ratelimit.json"
25
+
26
+ # Same threshold as Reddit: resets under 90s are absorbed inline, longer resets
27
+ # raise MoltbookRateLimitedError so the caller exits rather than blocking a slot.
28
+ MAX_INLINE_WAIT_SECONDS = 90
29
+
30
+ # Default 429 retry when the server does not return retry_after_seconds.
31
+ DEFAULT_RETRY_SECONDS = 160
32
+
33
+
34
+ class MoltbookRateLimitedError(Exception):
35
+ """Raised when Moltbook returns 429 and reset is longer than MAX_INLINE_WAIT_SECONDS."""
36
+ def __init__(self, reset_seconds):
37
+ self.reset_seconds = reset_seconds
38
+ super().__init__(f"moltbook_rate_limited_wait_{int(reset_seconds)}s")
39
+
40
+
41
+ class HttpNotFoundError(Exception):
42
+ """Raised when a Moltbook GET returns HTTP 404."""
43
+ pass
44
+
45
+
46
+ def _read_ratelimit():
47
+ try:
48
+ with open(RATELIMIT_FILE) as f:
49
+ return json.load(f)
50
+ except Exception:
51
+ return {"remaining": 100, "reset_at": 0}
52
+
53
+
54
+ def _write_ratelimit(remaining, reset_seconds):
55
+ reset_at = time.time() + reset_seconds
56
+ try:
57
+ with open(RATELIMIT_FILE, "w") as f:
58
+ json.dump({"remaining": remaining, "reset_at": reset_at}, f)
59
+ except Exception:
60
+ pass
61
+
62
+
63
+ def _wait_if_needed():
64
+ """Block or raise before making a Moltbook request if a prior 429 is still pending."""
65
+ rl = _read_ratelimit()
66
+ if rl.get("remaining", 100) <= 2 and rl.get("reset_at", 0) > time.time():
67
+ wait = int(rl["reset_at"] - time.time()) + 2
68
+ if wait <= 0:
69
+ return
70
+ if wait > MAX_INLINE_WAIT_SECONDS:
71
+ raise MoltbookRateLimitedError(wait)
72
+ print(f"Moltbook rate limit cooling down, waiting {wait}s...", file=sys.stderr)
73
+ time.sleep(wait)
74
+
75
+
76
+ def _parse_retry_seconds(body_bytes):
77
+ """Parse retry_after_seconds from a 429 response body."""
78
+ try:
79
+ payload = json.loads(body_bytes.decode("utf-8", errors="replace"))
80
+ retry = payload.get("retry_after_seconds")
81
+ if isinstance(retry, (int, float)) and retry > 0:
82
+ return float(retry)
83
+ except Exception:
84
+ pass
85
+ return float(DEFAULT_RETRY_SECONDS)
86
+
87
+
88
+ def note_rate_limited(retry_seconds):
89
+ """Public: record a rate-limit event so other processes back off.
90
+
91
+ Used by moltbook_post.py (which uses the `requests` library) to feed
92
+ the shared state without rewriting its HTTP layer.
93
+ """
94
+ _write_ratelimit(0, float(retry_seconds))
95
+
96
+
97
+ def fetch_moltbook_json(url, api_key=None, headers=None,
98
+ user_agent="social-autoposter/1.0", timeout=15):
99
+ """GET a Moltbook JSON endpoint with cooperative rate-limit handling.
100
+
101
+ - Waits or raises MoltbookRateLimitedError based on shared state before firing.
102
+ - On 200: clears the shared "near zero" signal.
103
+ - On 404: raises HttpNotFoundError (callers typically use this for deletion detection).
104
+ - On 429: persists retry_after_seconds. If <= MAX_INLINE_WAIT_SECONDS, sleeps
105
+ once and retries; otherwise raises MoltbookRateLimitedError.
106
+ - On other HTTPError / network error: prints and returns None (preserves the
107
+ existing callers' "return None on error" contract).
108
+ """
109
+ _wait_if_needed()
110
+
111
+ hdrs = {"User-Agent": user_agent}
112
+ if api_key:
113
+ hdrs["Authorization"] = f"Bearer {api_key}"
114
+ if headers:
115
+ hdrs.update(headers)
116
+
117
+ req = urllib.request.Request(url, headers=hdrs)
118
+
119
+ try:
120
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
121
+ # Success: clear any lingering "near zero" from a stale 429.
122
+ _write_ratelimit(100, 0)
123
+ return json.loads(resp.read())
124
+ except urllib.error.HTTPError as e:
125
+ if e.code == 404:
126
+ raise HttpNotFoundError(url)
127
+ if e.code == 429:
128
+ body = e.read() if hasattr(e, "read") else b""
129
+ retry = _parse_retry_seconds(body)
130
+ _write_ratelimit(0, retry)
131
+ if retry > MAX_INLINE_WAIT_SECONDS:
132
+ raise MoltbookRateLimitedError(retry)
133
+ print(f"Moltbook 429, waiting {int(retry)+2}s... ({url})", file=sys.stderr)
134
+ time.sleep(int(retry) + 2)
135
+ # Single retry, propagate any errors from the retry.
136
+ try:
137
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
138
+ _write_ratelimit(100, 0)
139
+ return json.loads(resp.read())
140
+ except urllib.error.HTTPError as e2:
141
+ if e2.code == 404:
142
+ raise HttpNotFoundError(url)
143
+ if e2.code == 429:
144
+ body2 = e2.read() if hasattr(e2, "read") else b""
145
+ retry2 = _parse_retry_seconds(body2)
146
+ _write_ratelimit(0, retry2)
147
+ raise MoltbookRateLimitedError(retry2)
148
+ print(f" ERROR fetching {url}: {e2}", file=sys.stderr)
149
+ return None
150
+ except Exception as ex:
151
+ print(f" ERROR fetching {url}: {ex}", file=sys.stderr)
152
+ return None
153
+ print(f" ERROR fetching {url}: {e}", file=sys.stderr)
154
+ return None
155
+ except HttpNotFoundError:
156
+ raise
157
+ except Exception as e:
158
+ print(f" ERROR fetching {url}: {e}", file=sys.stderr)
159
+ return None
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ pending_threads.py — persistence layer for thread drafts that may need retry.
4
+
5
+ Why this exists:
6
+ Pre-2026-05-01 the run-reddit-threads pipeline drafted a post inside one
7
+ Claude session and submitted it via the reddit-agent MCP within the same
8
+ session. If the MCP child process died mid-flow (e.g. flair-click step on
9
+ r/AutoHotkey, 2026-05-01), the entire $4-24 of work was lost: the title +
10
+ body lived only in the Claude transcript JSON, not in the DB. Subsequent
11
+ pipeline runs regenerated everything from scratch.
12
+
13
+ pending_threads is a durable holding pen. The shell wrapper writes a row
14
+ here BEFORE attempting to submit, and the row's `status` tracks lifecycle:
15
+
16
+ pending - drafted, not yet submitted (or submit aborted before permalink)
17
+ posted - submit succeeded, posted_post_id + posted_permalink filled
18
+ abandoned - too many failed retries, or permanent_block on the sub
19
+
20
+ Recovery flow (next pipeline run): pick the oldest pending row for the
21
+ project before generating a fresh draft.
22
+
23
+ Sub-commands (called from shell pipelines):
24
+ create Insert a draft row, print id
25
+ mark-posted status=posted, fill posted_post_id + posted_permalink
26
+ mark-aborted bump attempts, fill abort_reason / abort_stage; keep pending
27
+ abandon status=abandoned (e.g. sub got permanent_block)
28
+ list-pending print all pending rows for a project (or all if no project)
29
+
30
+ HTTP-only lane (2026-06-01): every read/write routes through the s4l.ai API
31
+ (/api/v1/pending-threads). No DATABASE_URL, no psql, no db.get_conn(), no
32
+ fallback. The function signatures + CLI shapes are unchanged so callers
33
+ (run-reddit-threads.sh) need no edits beyond the DB-insert swap.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import os
40
+ import sys
41
+ from typing import Any, Optional
42
+
43
+ # scripts/ is on sys.path when called from skill/*.sh; ensure it works
44
+ # standalone too.
45
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
46
+ from http_api import api_get, api_post, api_patch # noqa: E402
47
+
48
+
49
+ def create(
50
+ *,
51
+ project: str,
52
+ subreddit: str,
53
+ account: str,
54
+ title: str,
55
+ body: str,
56
+ flair_target: Optional[str] = None,
57
+ engagement_style: Optional[str] = None,
58
+ topic_angle: Optional[str] = None,
59
+ source_summary: Optional[str] = None,
60
+ claude_session_id: Optional[str] = None,
61
+ cost_usd: Optional[float] = None,
62
+ ) -> int:
63
+ resp = api_post("/api/v1/pending-threads", {
64
+ "project": project,
65
+ "subreddit": subreddit,
66
+ "account": account,
67
+ "title": title,
68
+ "body": body,
69
+ "flair_target": flair_target,
70
+ "engagement_style": engagement_style,
71
+ "topic_angle": topic_angle,
72
+ "source_summary": source_summary,
73
+ "claude_session_id": claude_session_id,
74
+ "cost_usd": cost_usd,
75
+ })
76
+ return int((resp.get("data") or {}).get("id"))
77
+
78
+
79
+ def mark_posted(*, pending_id: int, post_id: int, permalink: str) -> None:
80
+ api_patch(f"/api/v1/pending-threads/{pending_id}", {
81
+ "action": "mark_posted",
82
+ "post_id": post_id,
83
+ "permalink": permalink,
84
+ })
85
+
86
+
87
+ def mark_aborted(*, pending_id: int, abort_reason: str, abort_stage: Optional[str] = None) -> None:
88
+ api_patch(f"/api/v1/pending-threads/{pending_id}", {
89
+ "action": "mark_aborted",
90
+ "abort_reason": abort_reason,
91
+ "abort_stage": abort_stage,
92
+ })
93
+
94
+
95
+ def abandon(*, pending_id: int, reason: str) -> None:
96
+ api_patch(f"/api/v1/pending-threads/{pending_id}", {
97
+ "action": "abandon",
98
+ "reason": reason,
99
+ })
100
+
101
+
102
+ def list_pending(project: Optional[str] = None) -> list[dict[str, Any]]:
103
+ resp = api_get("/api/v1/pending-threads",
104
+ query={"project": project} if project else None)
105
+ return (resp.get("data") or {}).get("pending_threads") or []
106
+
107
+
108
+ def get(pending_id: int) -> Optional[dict[str, Any]]:
109
+ resp = api_get(f"/api/v1/pending-threads/{pending_id}", ok_on_404=True)
110
+ if resp.get("_not_found"):
111
+ return None
112
+ return (resp.get("data") or {}).get("pending_thread")
113
+
114
+
115
+ def main() -> int:
116
+ p = argparse.ArgumentParser(description="pending_threads helper")
117
+ sub = p.add_subparsers(dest="cmd", required=True)
118
+
119
+ pc = sub.add_parser("create")
120
+ pc.add_argument("--project", required=True)
121
+ pc.add_argument("--subreddit", required=True)
122
+ pc.add_argument("--account", required=True)
123
+ pc.add_argument("--title", required=True)
124
+ pc.add_argument("--body", required=True)
125
+ pc.add_argument("--flair-target")
126
+ pc.add_argument("--engagement-style")
127
+ pc.add_argument("--topic-angle")
128
+ pc.add_argument("--source-summary")
129
+ pc.add_argument("--claude-session-id")
130
+ pc.add_argument("--cost-usd", type=float)
131
+
132
+ pp = sub.add_parser("mark-posted")
133
+ pp.add_argument("--id", required=True, type=int)
134
+ pp.add_argument("--post-id", required=True, type=int)
135
+ pp.add_argument("--permalink", required=True)
136
+
137
+ pa = sub.add_parser("mark-aborted")
138
+ pa.add_argument("--id", required=True, type=int)
139
+ pa.add_argument("--abort-reason", required=True)
140
+ pa.add_argument("--abort-stage")
141
+
142
+ pab = sub.add_parser("abandon")
143
+ pab.add_argument("--id", required=True, type=int)
144
+ pab.add_argument("--reason", required=True)
145
+
146
+ pl = sub.add_parser("list-pending")
147
+ pl.add_argument("--project")
148
+
149
+ pg = sub.add_parser("get")
150
+ pg.add_argument("--id", required=True, type=int)
151
+
152
+ args = p.parse_args()
153
+
154
+ if args.cmd == "create":
155
+ i = create(
156
+ project=args.project,
157
+ subreddit=args.subreddit,
158
+ account=args.account,
159
+ title=args.title,
160
+ body=args.body,
161
+ flair_target=args.flair_target,
162
+ engagement_style=args.engagement_style,
163
+ topic_angle=args.topic_angle,
164
+ source_summary=args.source_summary,
165
+ claude_session_id=args.claude_session_id,
166
+ cost_usd=args.cost_usd,
167
+ )
168
+ print(json.dumps({"ok": True, "id": i}))
169
+ elif args.cmd == "mark-posted":
170
+ mark_posted(pending_id=args.id, post_id=args.post_id, permalink=args.permalink)
171
+ print(json.dumps({"ok": True}))
172
+ elif args.cmd == "mark-aborted":
173
+ mark_aborted(pending_id=args.id, abort_reason=args.abort_reason, abort_stage=args.abort_stage)
174
+ print(json.dumps({"ok": True}))
175
+ elif args.cmd == "abandon":
176
+ abandon(pending_id=args.id, reason=args.reason)
177
+ print(json.dumps({"ok": True}))
178
+ elif args.cmd == "list-pending":
179
+ rows = list_pending(args.project)
180
+ print(json.dumps(rows, indent=2))
181
+ elif args.cmd == "get":
182
+ rec = get(args.id)
183
+ print(json.dumps(rec, indent=2))
184
+ return 0
185
+
186
+
187
+ if __name__ == "__main__":
188
+ sys.exit(main())