@m13v/s4l 1.6.197-rc.7

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 +1314 -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 +497 -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,334 @@
1
+ #!/usr/bin/env python3
2
+ """Reply state mutations for the engage bots.
3
+
4
+ All write paths (processing/replied/skipped/skip_batch/set_project) route
5
+ through the public HTTPS endpoint /api/v1/replies/{id} on $AUTOPOSTER_API_BASE
6
+ (default https://s4l.ai), carrying the X-Installation header from
7
+ scripts/identity.py. The retry loop in _http_patch handles transient s4l.ai
8
+ blips (DNS, timeout, 5xx) so a single curl FAIL does not strand a row in
9
+ 'processing'. 4xx fast-fails because retrying a deterministic client error
10
+ just burns the budget.
11
+
12
+ The 'status' command is the per-cycle heartbeat for skill/engage*.sh — prints
13
+ counts grouped by reply status. As of 2026-05-12 this also routes through
14
+ HTTP (/api/v1/replies/counts) so there is no remaining direct-SQL path in
15
+ the Reddit pipeline.
16
+ """
17
+ import sys, json, os
18
+ sys.path.insert(0, os.path.dirname(__file__))
19
+ from version import read_version as read_autoposter_version
20
+ try:
21
+ from account_resolver import resolve as _resolve_account
22
+ except Exception:
23
+ def _resolve_account(_platform): # type: ignore[unused-arg]
24
+ return None
25
+
26
+ CLAUDE_SESSION_ID = os.environ.get("CLAUDE_SESSION_ID") or None
27
+ API_BASE = (os.environ.get("AUTOPOSTER_API_BASE") or "https://s4l.ai").rstrip("/")
28
+ AUTOPOSTER_VERSION = read_autoposter_version()
29
+ # Resolved once at import time from config.json. Used ONLY as a fallback when
30
+ # we have no URL to derive the live handle from (e.g. Reddit, where the
31
+ # permalink doesn't carry the author). For Twitter we prefer _handle_from_url
32
+ # below because playwright-extension attaches to the user's running Chrome and
33
+ # whichever account is logged in there at post time may not match config.json
34
+ # (persona drift bug surfaced 2026-05-27: 97 MacBook rows stamped m13v_/NULL
35
+ # while x.com URL said matt_diak).
36
+ OUR_ACCOUNT = _resolve_account("twitter")
37
+
38
+
39
+ def _handle_from_url(url):
40
+ """Extract the canonical posting handle from an our_reply_url, or None.
41
+
42
+ Twitter/X URLs are shaped `https://x.com/<handle>/status/<id>` and
43
+ `https://twitter.com/<handle>/status/<id>`. The handle in the URL is the
44
+ ground truth for which account actually posted (X mints the URL after the
45
+ POST succeeds against the logged-in session), so it beats config.json /
46
+ AUTOPOSTER_TWITTER_HANDLE env var, which can disagree with the live Chrome
47
+ when playwright-extension is attached.
48
+
49
+ Reddit, LinkedIn, and GitHub URLs don't include the author in the path
50
+ shape we use, so this returns None for them and the caller falls back to
51
+ the module-level OUR_ACCOUNT (config.json) — which is fine on those
52
+ platforms because they don't have the playwright-extension multi-account
53
+ drift problem.
54
+ """
55
+ if not url or not isinstance(url, str):
56
+ return None
57
+ import re
58
+ m = re.match(r"^https?://(?:www\.)?(?:x\.com|twitter\.com)/([^/?#]+)/status/", url)
59
+ if not m:
60
+ return None
61
+ handle = m.group(1).strip()
62
+ if handle.startswith("@"):
63
+ handle = handle[1:]
64
+ return handle or None
65
+
66
+
67
+ def _http_patch(rid: int, body: dict) -> None:
68
+ """PATCH /api/v1/replies/{rid} with body, attaching X-Installation header.
69
+
70
+ Drops keys whose values are None so the server's COALESCE-style endpoint
71
+ preserves existing column values.
72
+
73
+ Retries on transient failures (network errors, HTTP 5xx) up to 3 attempts
74
+ with exponential backoff (1s, 3s, 9s) so a brief s4l.ai blip does not
75
+ strand a row in 'processing'. 4xx responses are deterministic client
76
+ errors and fail fast without retry. Raises SystemExit on final failure
77
+ so the calling shell sees a non-zero exit.
78
+ """
79
+ import urllib.request, urllib.error, time
80
+ from identity import get_identity_header # local module
81
+
82
+ payload = {k: v for k, v in body.items() if v is not None}
83
+ data = json.dumps(payload).encode("utf8")
84
+ url = f"{API_BASE}/api/v1/replies/{rid}"
85
+
86
+ attempts = 3
87
+ backoff_s = [1, 3, 9]
88
+ last_err = None
89
+ for i in range(attempts):
90
+ req = urllib.request.Request(
91
+ url,
92
+ data=data,
93
+ method="PATCH",
94
+ headers={
95
+ "content-type": "application/json",
96
+ "x-installation": get_identity_header(),
97
+ },
98
+ )
99
+ try:
100
+ with urllib.request.urlopen(req, timeout=30) as resp:
101
+ resp.read()
102
+ return # success
103
+ except urllib.error.HTTPError as e:
104
+ # 4xx is deterministic (bad payload, missing row, auth); never
105
+ # going to succeed on retry, so fail fast with the server body.
106
+ if 400 <= e.code < 500:
107
+ body_txt = ""
108
+ try:
109
+ body_txt = e.read().decode("utf8", errors="ignore")
110
+ except Exception:
111
+ pass
112
+ raise SystemExit(f"http {e.code} from PATCH {url}: {body_txt}")
113
+ # 5xx: transient (502/503/504 from upstream). Retry.
114
+ last_err = f"http {e.code}"
115
+ except urllib.error.URLError as e:
116
+ # Network-level failure: DNS resolution, connection refused,
117
+ # socket timeout. All worth retrying.
118
+ last_err = f"network error {e}"
119
+ if i < attempts - 1:
120
+ print(
121
+ f"[reply_db] PATCH {url} attempt {i+1}/{attempts} failed: "
122
+ f"{last_err}; retrying in {backoff_s[i]}s",
123
+ file=sys.stderr,
124
+ )
125
+ time.sleep(backoff_s[i])
126
+ raise SystemExit(
127
+ f"PATCH {url} failed after {attempts} attempts: {last_err}"
128
+ )
129
+
130
+
131
+ cmd = sys.argv[1]
132
+ if cmd == "processing":
133
+ # reply_db.py processing ID
134
+ # Mark as in-progress BEFORE browser action to prevent re-processing on crash
135
+ rid = int(sys.argv[2])
136
+ _http_patch(rid, {"status": "processing"})
137
+ print(f"ok {rid}")
138
+ elif cmd == "replied":
139
+ # reply_db.py replied ID "content" [url] [engagement_style] [is_recommendation]
140
+ # is_recommendation is "1" / "true" to mark this reply as a project mention;
141
+ # anything else (or absent) leaves the column at its default FALSE. Style
142
+ # and is_recommendation are independent: style is TONE, is_recommendation
143
+ # is INTENT. Do not pass style="recommendation" — that value is deprecated.
144
+ rid, content = int(sys.argv[2]), sys.argv[3]
145
+ url = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else None
146
+ style = sys.argv[5] if len(sys.argv) > 5 and sys.argv[5] else None
147
+ is_rec_arg = sys.argv[6] if len(sys.argv) > 6 and sys.argv[6] else None
148
+ is_rec = is_rec_arg is not None and is_rec_arg.lower() in ("1", "true", "yes")
149
+ body = {
150
+ "status": "replied",
151
+ "our_reply_content": content,
152
+ "our_reply_url": url,
153
+ "engagement_style": style,
154
+ "claude_session_id": CLAUDE_SESSION_ID,
155
+ # autoposter_version: stamp on the replied transition so we can
156
+ # attribute reply engagement back to the release that produced
157
+ # this comment. None when package.json + env are both missing.
158
+ "autoposter_version": AUTOPOSTER_VERSION,
159
+ # our_account: stamp the persona on every transition. Prefer the
160
+ # handle baked into our_reply_url because X mints that URL against
161
+ # the actually-logged-in session, so it's the ground truth for which
162
+ # account posted. Fall back to OUR_ACCOUNT (config.json / env) only
163
+ # when the URL is missing or non-Twitter-shaped. Server uses COALESCE
164
+ # so subsequent transitions don't overwrite an earlier good stamp.
165
+ "our_account": _handle_from_url(url) or OUR_ACCOUNT,
166
+ }
167
+ # Server uses COALESCE for is_recommendation: only send TRUE so we
168
+ # never accidentally clobber an existing TRUE flag back to FALSE.
169
+ if is_rec:
170
+ body["is_recommendation"] = True
171
+ _http_patch(rid, body)
172
+ print(f"ok {rid}")
173
+ elif cmd == "skipped":
174
+ # reply_db.py skipped ID "reason"
175
+ rid, reason = int(sys.argv[2]), sys.argv[3]
176
+ _http_patch(rid, {
177
+ "status": "skipped",
178
+ "skip_reason": reason,
179
+ "claude_session_id": CLAUDE_SESSION_ID,
180
+ })
181
+ print(f"ok {rid}")
182
+ elif cmd == "skip_batch":
183
+ # reply_db.py skip_batch '{"ids":[1,2,3],"reason":"..."}'
184
+ data = json.loads(sys.argv[2])
185
+ for rid in data["ids"]:
186
+ _http_patch(rid, {
187
+ "status": "skipped",
188
+ "skip_reason": data["reason"],
189
+ "claude_session_id": CLAUDE_SESSION_ID,
190
+ })
191
+ print(f"ok {len(data['ids'])}")
192
+ elif cmd == "set_project":
193
+ # reply_db.py set_project ID "project_name"
194
+ # Used by engage_reddit.py to attribute a posted reply to a recommended
195
+ # project after the fact. Routes through the same PATCH endpoint as the
196
+ # other status mutations (no SQL injection risk: project name travels
197
+ # as a JSON body field, not interpolated into a shell command).
198
+ rid, project = int(sys.argv[2]), sys.argv[3]
199
+ _http_patch(rid, {"project_name": project})
200
+ print(f"ok {rid}")
201
+ elif cmd == "status":
202
+ # Per-cycle heartbeat used by skill/engage*.sh. Routes through the
203
+ # /api/v1/replies/counts aggregate endpoint so this module has zero
204
+ # direct-SQL paths.
205
+ from http_api import api_get
206
+ platform = sys.argv[2] if len(sys.argv) > 2 else None
207
+ query = {"platform": platform} if platform else None
208
+ resp = api_get("/api/v1/replies/counts", query=query)
209
+ counts = ((resp or {}).get("data") or {}).get("counts") or []
210
+ for row in counts:
211
+ print(f"{row.get('status', '')} {row.get('count', 0)}")
212
+ elif cmd == "blocklist":
213
+ # reply_db.py blocklist <subcmd> ...
214
+ #
215
+ # The escape hatch for the engagement-loop / bot defense. The Twitter,
216
+ # LinkedIn, and GitHub engage prompts call:
217
+ # blocklist add <platform> <handle> --reason "<one-line judgment>"
218
+ # [--classification bot|engagement_loop] [--severity hard|soft]
219
+ # [--source-reply-id N]
220
+ # when the model identifies a handle that should be permanently
221
+ # filtered. Future candidates from the same handle are dropped silently
222
+ # at /api/v1/replies POST time (server-side gate). See
223
+ # migrations/2026-05-27_author_blocklist.sql for the full design.
224
+ #
225
+ # Also exposes:
226
+ # blocklist list [platform] -> print active blocks for the install
227
+ # blocklist remove <platform> <handle>
228
+ # blocklist check <platform> <handle> -> exit 0 if blocked, 1 if not
229
+ from http_api import api_get, api_post
230
+ sub = sys.argv[2] if len(sys.argv) > 2 else None
231
+ if sub == "add":
232
+ # blocklist add <platform> <handle> --reason "..." [opts]
233
+ platform = sys.argv[3]
234
+ handle = sys.argv[4]
235
+ # naive arg parsing: --flag value pairs after position 5
236
+ opts = {}
237
+ i = 5
238
+ while i < len(sys.argv):
239
+ key = sys.argv[i]
240
+ if key.startswith("--") and i + 1 < len(sys.argv):
241
+ opts[key[2:].replace("-", "_")] = sys.argv[i + 1]
242
+ i += 2
243
+ else:
244
+ i += 1
245
+ body = {
246
+ "platform": platform,
247
+ "handle": handle,
248
+ "reason": opts.get("reason") or "engage prompt flagged",
249
+ "classification": opts.get("classification", "bot"),
250
+ "severity": opts.get("severity", "hard"),
251
+ "added_by": opts.get("added_by", "engage_llm"),
252
+ "source_session_id": CLAUDE_SESSION_ID,
253
+ }
254
+ if opts.get("source_reply_id"):
255
+ try:
256
+ body["source_reply_id"] = int(opts["source_reply_id"])
257
+ except (TypeError, ValueError):
258
+ pass
259
+ if opts.get("project"):
260
+ body["project"] = opts["project"]
261
+ resp = api_post("/api/v1/blocklist", body=body)
262
+ data = (resp or {}).get("data") or {}
263
+ action = data.get("action", "?")
264
+ row = data.get("row") or {}
265
+ print(f"ok blocklist {action} {row.get('platform', platform)}/{row.get('handle', handle)} severity={row.get('severity', '?')}")
266
+ # Stable stderr marker so log_run.py / grep-based observability can
267
+ # count escape-hatch firings without re-querying the DB. Velocity-gate
268
+ # auto-blocks land directly via the route.ts SQL path and do NOT pass
269
+ # through here, so this marker is specific to LLM-judgment-driven
270
+ # adds (or manual operator adds).
271
+ classification = body["classification"]
272
+ source = body.get("source_reply_id", "")
273
+ reason_safe = (body.get("reason") or "").replace("\n", " ").replace("|", "/")[:200]
274
+ print(
275
+ f"[escape_hatch] platform={body['platform']} handle={body['handle']} "
276
+ f"classification={classification} severity={body['severity']} "
277
+ f"source_reply_id={source} action={action} reason=\"{reason_safe}\"",
278
+ file=sys.stderr,
279
+ )
280
+ elif sub == "list":
281
+ platform = sys.argv[3] if len(sys.argv) > 3 else None
282
+ query = {"platform": platform} if platform else None
283
+ resp = api_get("/api/v1/blocklist", query=query)
284
+ rows = ((resp or {}).get("data") or {}).get("rows") or []
285
+ if not rows:
286
+ print("(no active blocks)")
287
+ for r in rows:
288
+ print(
289
+ f"{r.get('platform','')} @{r.get('handle','')} "
290
+ f"sev={r.get('severity','?')} "
291
+ f"cls={r.get('classification','?')} "
292
+ f"by={r.get('added_by','?')} "
293
+ f"hits={r.get('hit_count', 0)} "
294
+ f"reason={(r.get('reason') or '')[:80]}"
295
+ )
296
+ elif sub == "remove":
297
+ import urllib.request, urllib.parse
298
+ from identity import get_identity_header
299
+ platform = sys.argv[3]
300
+ handle = sys.argv[4].lstrip("@").lower()
301
+ url = f"{API_BASE}/api/v1/blocklist/{urllib.parse.quote(platform)}/{urllib.parse.quote(handle)}"
302
+ req = urllib.request.Request(
303
+ url,
304
+ method="DELETE",
305
+ headers={"x-installation": get_identity_header()},
306
+ )
307
+ try:
308
+ with urllib.request.urlopen(req, timeout=30) as resp:
309
+ resp.read()
310
+ print(f"ok removed {platform}/{handle}")
311
+ except Exception as e:
312
+ raise SystemExit(f"DELETE {url} failed: {e}")
313
+ elif sub == "check":
314
+ platform = sys.argv[3]
315
+ handle = sys.argv[4].lstrip("@").lower()
316
+ resp = api_get(
317
+ "/api/v1/blocklist",
318
+ query={"platform": platform},
319
+ )
320
+ rows = ((resp or {}).get("data") or {}).get("rows") or []
321
+ match = next(
322
+ (r for r in rows if (r.get("handle") or "").lower() == handle and r.get("severity") == "hard"),
323
+ None,
324
+ )
325
+ if match:
326
+ print(f"BLOCKED {platform}/{handle} cls={match.get('classification','?')} reason={(match.get('reason') or '')[:100]}")
327
+ sys.exit(0)
328
+ else:
329
+ print(f"not blocked {platform}/{handle}")
330
+ sys.exit(1)
331
+ else:
332
+ raise SystemExit(
333
+ "usage: reply_db.py blocklist {add|list|remove|check} ..."
334
+ )
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """Shared reply-insertion helper for scan_*_replies.py scripts.
3
+
4
+ `insert_reply` returns the status string on a NEW insert, or None if the row
5
+ already existed. Callers use the return value to update their discovered /
6
+ skipped counters.
7
+
8
+ 2026-05-12: dedup now happens exclusively server-side. The /api/v1/replies
9
+ POST endpoint has a UNIQUE (platform, their_comment_id) index and uses
10
+ ON CONFLICT DO NOTHING; a duplicate returns 409 with the existing row, and
11
+ api_post(ok_on_conflict=True) surfaces that as a body with an "error" key.
12
+ Previously this module did a `SELECT COUNT(*)` probe before posting; that
13
+ was the last direct-SQL hop in the scan-reddit-replies path and has been
14
+ removed.
15
+ """
16
+ import os
17
+ import sys
18
+
19
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
+
21
+
22
+ def insert_reply(
23
+ db,
24
+ post_id,
25
+ platform,
26
+ comment_id,
27
+ author,
28
+ content,
29
+ comment_url,
30
+ parent_reply_id=None,
31
+ depth=1,
32
+ status="pending",
33
+ skip_reason=None,
34
+ moltbook_post_uuid=None,
35
+ moltbook_parent_comment_uuid=None,
36
+ our_reply_id=None,
37
+ our_reply_content=None,
38
+ our_reply_url=None,
39
+ replied_at=None,
40
+ ):
41
+ """Insert a reply via /api/v1/replies POST.
42
+
43
+ The `db` arg is preserved in the signature for backwards compatibility
44
+ with callers that still pass a psycopg connection — the value is IGNORED.
45
+ All writes go through HTTP now.
46
+
47
+ Returns:
48
+ status string when this call performed the INSERT
49
+ None when the (platform, their_comment_id) was already in the table
50
+ """
51
+ comment_id = str(comment_id)
52
+
53
+ from http_api import api_post
54
+ body = {
55
+ "platform": platform,
56
+ "their_comment_id": comment_id,
57
+ "status": status,
58
+ }
59
+ if post_id is not None:
60
+ body["post_id"] = post_id
61
+ if author is not None:
62
+ body["their_author"] = author
63
+ if content is not None:
64
+ body["their_content"] = content
65
+ if comment_url is not None:
66
+ body["their_comment_url"] = comment_url
67
+ if parent_reply_id is not None:
68
+ body["parent_reply_id"] = parent_reply_id
69
+ if depth != 1:
70
+ body["depth"] = depth
71
+ if skip_reason is not None:
72
+ body["skip_reason"] = skip_reason
73
+ if moltbook_post_uuid is not None:
74
+ body["moltbook_post_uuid"] = moltbook_post_uuid
75
+ if moltbook_parent_comment_uuid is not None:
76
+ body["moltbook_parent_comment_uuid"] = moltbook_parent_comment_uuid
77
+ if our_reply_id is not None:
78
+ body["our_reply_id"] = our_reply_id
79
+ if our_reply_content is not None:
80
+ body["our_reply_content"] = our_reply_content
81
+ if our_reply_url is not None:
82
+ body["our_reply_url"] = our_reply_url
83
+ if replied_at is not None:
84
+ body["replied_at"] = (
85
+ replied_at.isoformat() if hasattr(replied_at, "isoformat") else str(replied_at)
86
+ )
87
+
88
+ resp = api_post("/api/v1/replies", body, ok_on_conflict=True)
89
+ if resp is None:
90
+ return None
91
+ # 409 path returns a body with an "error" key (duplicate_reply); treat as
92
+ # "already in DB" -> None to mirror the previous behavior.
93
+ if resp.get("error"):
94
+ return None
95
+ data = resp.get("data") if isinstance(resp, dict) else None
96
+ if not data or not data.get("reply"):
97
+ return None
98
+ return status