@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,206 @@
1
+ #!/usr/bin/env python3
2
+ """Shared HTTP helper for the s4l.ai API endpoints.
3
+
4
+ All Reddit-pipeline (and friends) writes/reads route through here, carrying
5
+ either:
6
+ - X-Installation header (default lane, open-source identity), or
7
+ - Authorization: Bearer $AUTOPOSTER_API_KEY when the key is set in env
8
+ (server-internal callers / endpoints that still use requireApiKey).
9
+
10
+ Both headers are sent on every request when both are available, so a route
11
+ that uses resolveAuth picks the install lane while a route that uses
12
+ requireApiKey picks the bearer lane. No caller-side branching needed.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import ssl
19
+ import sys
20
+ import time
21
+ import urllib.error
22
+ import urllib.parse
23
+ import urllib.request
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+
27
+ # Best-effort Sentry init (no-op if sentry-sdk missing or DSN unset). http_api
28
+ # is the central HTTP-lane client (~100 pipeline scripts import it), so this one
29
+ # hook gives the whole Python pipeline error capture. Mirrors mcp/src/telemetry.ts.
30
+ try:
31
+ import sentry_init as _sentry_init
32
+
33
+ _sentry_init.init()
34
+ except Exception:
35
+ pass
36
+
37
+
38
+ def _build_ssl_context() -> ssl.SSLContext:
39
+ """Pin a known-good trust store, immune to a bad inherited SSL_CERT_FILE.
40
+
41
+ The MCP/host app can inject SSL_CERT_FILE / SSL_CERT_DIR pointing at a
42
+ bundle that lacks the right roots, which makes urllib raise
43
+ CERTIFICATE_VERIFY_FAILED only inside the spawned subprocess (TLS works
44
+ fine in a normal shell). We resolve the trust store deliberately here
45
+ instead of trusting whatever env we inherit:
46
+
47
+ 1. inherited SSL_CERT_FILE, but only if the path exists AND yields a
48
+ context with at least one trusted root;
49
+ 2. the platform default store;
50
+ 3. certifi, if installed.
51
+
52
+ The get_ca_certs() check is what rejects a bad inherited path: an empty
53
+ trust store silently falls through to the next candidate.
54
+ """
55
+ candidates = []
56
+ env_file = os.environ.get("SSL_CERT_FILE")
57
+ if env_file and os.path.exists(env_file):
58
+ candidates.append(env_file)
59
+ candidates.append(None) # platform default
60
+ for cafile in candidates:
61
+ try:
62
+ ctx = ssl.create_default_context(cafile=cafile)
63
+ if ctx.get_ca_certs():
64
+ return ctx
65
+ except Exception:
66
+ continue
67
+ try:
68
+ import certifi
69
+ return ssl.create_default_context(cafile=certifi.where())
70
+ except Exception:
71
+ return ssl.create_default_context()
72
+
73
+
74
+ _SSL_CONTEXT = _build_ssl_context()
75
+
76
+ ENV_PATH = os.path.expanduser("~/social-autoposter/.env")
77
+
78
+
79
+ def load_env():
80
+ """Load ~/social-autoposter/.env into os.environ (setdefault, never clobber).
81
+
82
+ Generic dotenv loader, not DB-specific: callers need it for keys like
83
+ MOLTBOOK_API_KEY / AUTOPOSTER_API_KEY / AUTOPOSTER_API_BASE. Lives here
84
+ (rather than the now-removed db.py) so HTTP-only scripts have one import.
85
+ """
86
+ if os.path.exists(ENV_PATH):
87
+ with open(ENV_PATH) as f:
88
+ for line in f:
89
+ line = line.strip()
90
+ if line and not line.startswith("#") and "=" in line:
91
+ k, v = line.split("=", 1)
92
+ os.environ.setdefault(k.strip(), v.strip())
93
+
94
+
95
+ def _base_url():
96
+ return os.environ.get("AUTOPOSTER_API_BASE", "https://s4l.ai").rstrip("/")
97
+
98
+
99
+ def _headers():
100
+ from identity import get_identity_header
101
+ headers = {
102
+ "Content-Type": "application/json",
103
+ "X-Installation": get_identity_header(),
104
+ }
105
+ bearer = (os.environ.get("AUTOPOSTER_API_KEY") or "").strip()
106
+ if bearer:
107
+ headers["Authorization"] = f"Bearer {bearer}"
108
+ headers["x-api-key"] = bearer
109
+ return headers
110
+
111
+
112
+ def _request(method: str, path: str, body: dict | None = None,
113
+ query: dict | None = None, ok_on_conflict: bool = False,
114
+ ok_on_404: bool = False):
115
+ """Generic request runner with retries.
116
+
117
+ Returns parsed JSON. Raises SystemExit on terminal failure.
118
+
119
+ method: GET | POST | PATCH | DELETE
120
+ body: JSON body for write methods
121
+ query: optional dict for ?k=v query-string (GET / DELETE)
122
+ ok_on_conflict: when True, a 409 body is returned (not raised)
123
+ ok_on_404: when True, a 404 returns {"_not_found": True}
124
+ """
125
+ url = f"{_base_url()}{path}"
126
+ if query:
127
+ # Drop None values so we don't send 'key=None' string.
128
+ qs = urllib.parse.urlencode(
129
+ {k: v for k, v in query.items() if v is not None},
130
+ doseq=True,
131
+ )
132
+ if qs:
133
+ sep = "&" if "?" in url else "?"
134
+ url = f"{url}{sep}{qs}"
135
+
136
+ data = None
137
+ if body is not None:
138
+ data = json.dumps(body).encode()
139
+
140
+ delays = [1, 3, 9]
141
+ last_err = None
142
+ for attempt, delay in enumerate(delays, 1):
143
+ try:
144
+ req = urllib.request.Request(
145
+ url, data=data, headers=_headers(), method=method,
146
+ )
147
+ with urllib.request.urlopen(
148
+ req, timeout=30, context=_SSL_CONTEXT
149
+ ) as resp:
150
+ raw = resp.read()
151
+ if not raw:
152
+ return {}
153
+ return json.loads(raw)
154
+ except urllib.error.HTTPError as e:
155
+ body_txt = e.read().decode(errors="replace")
156
+ if e.code == 409 and ok_on_conflict:
157
+ try:
158
+ return json.loads(body_txt)
159
+ except Exception:
160
+ return {"error": "conflict"}
161
+ if e.code == 404 and ok_on_404:
162
+ return {"_not_found": True}
163
+ if 400 <= e.code < 500:
164
+ raise SystemExit(
165
+ f"[http_api] {method} {path} HTTP {e.code}: {body_txt}"
166
+ )
167
+ last_err = e
168
+ print(
169
+ f"[http_api] {method} {path} HTTP {e.code} attempt {attempt}: "
170
+ f"{body_txt[:120]}",
171
+ file=sys.stderr,
172
+ )
173
+ except Exception as e:
174
+ last_err = e
175
+ print(
176
+ f"[http_api] {method} {path} attempt {attempt}: {e}",
177
+ file=sys.stderr,
178
+ )
179
+ if attempt < len(delays):
180
+ time.sleep(delay)
181
+ raise SystemExit(
182
+ f"[http_api] {method} {path} failed after {len(delays)} attempts: "
183
+ f"{last_err}"
184
+ )
185
+
186
+
187
+ def api_get(path: str, query: dict | None = None, ok_on_404: bool = False):
188
+ """GET path with optional query dict. Returns parsed JSON."""
189
+ return _request("GET", path, query=query, ok_on_404=ok_on_404)
190
+
191
+
192
+ def api_post(path: str, body: dict, ok_on_conflict: bool = False, ok_on_404: bool = False):
193
+ """POST body to path. ok_on_conflict=True returns the 409 body;
194
+ ok_on_404=True returns {_not_found: True} on 404 instead of raising."""
195
+ return _request("POST", path, body=body, ok_on_conflict=ok_on_conflict, ok_on_404=ok_on_404)
196
+
197
+
198
+ def api_patch(path: str, body: dict, ok_on_conflict: bool = False, ok_on_404: bool = False):
199
+ """PATCH body to path. ok_on_conflict=True returns the 409 body;
200
+ ok_on_404=True returns {_not_found: True} on 404 instead of raising."""
201
+ return _request("PATCH", path, body=body, ok_on_conflict=ok_on_conflict, ok_on_404=ok_on_404)
202
+
203
+
204
+ def api_delete(path: str, query: dict | None = None):
205
+ """DELETE path. Optional query string."""
206
+ return _request("DELETE", path, query=query)
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env python3
2
+ """human_dm_replies_helper.py — shell-friendly entrypoints for Phase 0 of
3
+ engage-dm-replies.sh, replacing the inline `psql "$DATABASE_URL"` blocks that
4
+ read and mutate the `human_dm_replies` queue.
5
+
6
+ Everything routes through /api/v1/human-dm-replies* on the website. No
7
+ psycopg2, no DATABASE_URL — there is intentionally NO direct-DB fallback.
8
+
9
+ Subcommands:
10
+
11
+ pending [--platform reddit|twitter|x|linkedin]
12
+ GET /api/v1/human-dm-replies?mode=pending[&platform=...]
13
+ -> prints the pending+retry queue as a JSON array (the same shape the
14
+ legacy `SELECT json_agg(q) FROM (... human_dm_replies h JOIN dms ...)`
15
+ produced). Prints NOTHING (empty output) when zero rows, so the bash
16
+ `[ -n "$HUMAN_REPLIES" ]` guard falls through to the "no replies"
17
+ branch exactly like psql's NULL -> empty string did.
18
+
19
+ kb [--limit 20]
20
+ GET /api/v1/human-dm-replies?mode=kb&limit=N
21
+ -> prints the last N SENT instructions as a JSON array (the Human Reply
22
+ Knowledge Base). Empty -> empty output (same as the legacy query).
23
+
24
+ patch --id N [--status S] [--last-error E] [--public-reply-id M]
25
+ [--increment-attempts] [--stamp-sent]
26
+ PATCH /api/v1/human-dm-replies/N
27
+ -> the four Phase 0 status transitions:
28
+ cancelled : --status cancelled --last-error "human reclassified: ..."
29
+ paired : --public-reply-id M
30
+ sent : --status sent
31
+ failed : --status failed --increment-attempts --last-error "..."
32
+ (sent_at auto-stamps server-side on sent/cancelled; --stamp-sent forces
33
+ it otherwise.)
34
+
35
+ insert-public-reply --post-id N|--no-post-id --platform P --comment-id C
36
+ --author A --comment-url U --our-content TEXT --our-url URL [--depth 2]
37
+ POST /api/v1/replies
38
+ -> inserts the public reply row the delivery bot just posted and prints
39
+ the new replies.id to stdout (so it can be paired back via
40
+ `patch --public-reply-id`). 409 (duplicate their_comment_id) returns the
41
+ existing row's id, never an error.
42
+ """
43
+ import argparse
44
+ import json
45
+ import os
46
+ import sys
47
+
48
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
49
+ from http_api import api_get, api_patch, api_post
50
+
51
+
52
+ def _print_rows_or_empty(rows):
53
+ """Mirror psql `json_agg` -t -A output: a JSON array when rows exist,
54
+ an empty string (nothing) when there are none."""
55
+ if not rows:
56
+ return
57
+ json.dump(rows, sys.stdout)
58
+ print("")
59
+
60
+
61
+ def _cmd_pending(args):
62
+ query = {"mode": "pending"}
63
+ if args.platform:
64
+ query["platform"] = args.platform
65
+ resp = api_get("/api/v1/human-dm-replies", query=query)
66
+ rows = ((resp or {}).get("data") or {}).get("rows") or []
67
+ _print_rows_or_empty(rows)
68
+
69
+
70
+ def _cmd_kb(args):
71
+ query = {"mode": "kb", "limit": args.limit}
72
+ resp = api_get("/api/v1/human-dm-replies", query=query)
73
+ rows = ((resp or {}).get("data") or {}).get("rows") or []
74
+ _print_rows_or_empty(rows)
75
+
76
+
77
+ def _cmd_patch(args):
78
+ body = {}
79
+ if args.status is not None:
80
+ body["status"] = args.status
81
+ if args.last_error is not None:
82
+ body["last_error"] = args.last_error
83
+ if args.public_reply_id is not None:
84
+ body["public_reply_id"] = int(args.public_reply_id)
85
+ if args.increment_attempts:
86
+ body["increment_attempts"] = True
87
+ if args.stamp_sent:
88
+ body["stamp_sent_now"] = True
89
+ if not body:
90
+ print("patch: nothing to update (pass at least one field)", file=sys.stderr)
91
+ sys.exit(2)
92
+ resp = api_patch(f"/api/v1/human-dm-replies/{int(args.id)}", body)
93
+ row = ((resp or {}).get("data") or {}).get("human_dm_reply") or {}
94
+ print(json.dumps({"id": row.get("id"), "status": row.get("status"),
95
+ "attempts": row.get("attempts"),
96
+ "public_reply_id": row.get("public_reply_id")}))
97
+
98
+
99
+ def _cmd_insert_public_reply(args):
100
+ body = {
101
+ "platform": args.platform,
102
+ "their_comment_id": args.comment_id,
103
+ "their_author": args.author,
104
+ "their_comment_url": args.comment_url,
105
+ "our_reply_content": args.our_content,
106
+ "our_reply_url": args.our_url,
107
+ "depth": args.depth,
108
+ "status": "replied",
109
+ "replied_at": "now",
110
+ }
111
+ if args.post_id is not None:
112
+ body["post_id"] = int(args.post_id)
113
+ # ok_on_conflict so a duplicate their_comment_id returns the existing row
114
+ # instead of raising; we still want its id to pair back.
115
+ resp = api_post("/api/v1/replies", body, ok_on_conflict=True)
116
+ data = (resp or {}).get("data") or {}
117
+ row = data.get("reply") or data.get("row") or data
118
+ rid = row.get("id") if isinstance(row, dict) else None
119
+ if rid is None and isinstance(data, dict):
120
+ # 409 path may nest the existing row under error.details
121
+ err = (resp or {}).get("error") or {}
122
+ details = err.get("details") if isinstance(err, dict) else None
123
+ if isinstance(details, dict):
124
+ rid = (details.get("reply") or details).get("id")
125
+ if rid is None:
126
+ print("insert-public-reply: no id in response: "
127
+ + json.dumps(resp)[:300], file=sys.stderr)
128
+ sys.exit(1)
129
+ print(int(rid))
130
+
131
+
132
+ def main():
133
+ p = argparse.ArgumentParser(description="human_dm_replies Phase 0 helper (HTTP-only)")
134
+ sub = p.add_subparsers(dest="cmd", required=True)
135
+
136
+ sp = sub.add_parser("pending")
137
+ sp.add_argument("--platform", default=None)
138
+ sp.set_defaults(func=_cmd_pending)
139
+
140
+ sk = sub.add_parser("kb")
141
+ sk.add_argument("--limit", type=int, default=20)
142
+ sk.set_defaults(func=_cmd_kb)
143
+
144
+ spt = sub.add_parser("patch")
145
+ spt.add_argument("--id", required=True)
146
+ spt.add_argument("--status", default=None)
147
+ spt.add_argument("--last-error", dest="last_error", default=None)
148
+ spt.add_argument("--public-reply-id", dest="public_reply_id", default=None)
149
+ spt.add_argument("--increment-attempts", dest="increment_attempts", action="store_true")
150
+ spt.add_argument("--stamp-sent", dest="stamp_sent", action="store_true")
151
+ spt.set_defaults(func=_cmd_patch)
152
+
153
+ si = sub.add_parser("insert-public-reply")
154
+ si.add_argument("--post-id", dest="post_id", default=None)
155
+ si.add_argument("--platform", required=True)
156
+ si.add_argument("--comment-id", dest="comment_id", required=True)
157
+ si.add_argument("--author", required=True)
158
+ si.add_argument("--comment-url", dest="comment_url", required=True)
159
+ si.add_argument("--our-content", dest="our_content", required=True)
160
+ si.add_argument("--our-url", dest="our_url", required=True)
161
+ si.add_argument("--depth", type=int, default=2)
162
+ si.set_defaults(func=_cmd_insert_public_reply)
163
+
164
+ args = p.parse_args()
165
+ args.func(args)
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env python3
2
+ """Passive installation identity for the open-source social-autoposter client.
3
+
4
+ Generates and stores a stable install_id on first run plus a snapshot of
5
+ machine fingerprint fields. Every API call to social-autoposter-website
6
+ carries this as an X-Installation header (base64 JSON) so the server can
7
+ attribute writes per install, rate-limit, and surface usage without
8
+ requiring any user signup.
9
+
10
+ NO data is sent until the pipeline actually calls the API. NO secrets are
11
+ collected. Every field captured is documented in PRIVACY.md at the repo
12
+ root.
13
+
14
+ CLI:
15
+ python3 scripts/identity.py show # print identity JSON
16
+ python3 scripts/identity.py header # print base64 X-Installation value
17
+ python3 scripts/identity.py reset # delete identity.json
18
+ python3 scripts/identity.py path # print path to identity.json
19
+
20
+ Library:
21
+ from scripts.identity import get_identity, get_identity_header
22
+ headers = {"X-Installation": get_identity_header()}
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import json
29
+ import os
30
+ import platform
31
+ import subprocess
32
+ import sys
33
+ import time
34
+ import uuid
35
+ from pathlib import Path
36
+
37
+ IDENTITY_DIR = Path.home() / ".social-autoposter"
38
+ IDENTITY_FILE = IDENTITY_DIR / "identity.json"
39
+
40
+
41
+ def _safe(fn, *args, **kwargs):
42
+ try:
43
+ return fn(*args, **kwargs)
44
+ except Exception:
45
+ return None
46
+
47
+
48
+ def _hardware_uuid_macos():
49
+ out = _safe(
50
+ subprocess.check_output,
51
+ ["ioreg", "-d2", "-c", "IOPlatformExpertDevice"],
52
+ stderr=subprocess.DEVNULL, timeout=5,
53
+ )
54
+ if not out:
55
+ return None
56
+ for line in out.decode("utf8", errors="ignore").splitlines():
57
+ if "IOPlatformUUID" in line:
58
+ parts = line.split('"')
59
+ if len(parts) >= 4:
60
+ return parts[3].strip()
61
+ return None
62
+
63
+
64
+ def _hardware_uuid_linux():
65
+ for p in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
66
+ try:
67
+ with open(p) as f:
68
+ v = f.read().strip()
69
+ if v:
70
+ return v
71
+ except Exception:
72
+ continue
73
+ return None
74
+
75
+
76
+ def _hardware_uuid_windows():
77
+ out = _safe(
78
+ subprocess.check_output,
79
+ ["reg", "query",
80
+ r"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography",
81
+ "/v", "MachineGuid"],
82
+ stderr=subprocess.DEVNULL, timeout=5,
83
+ )
84
+ if not out:
85
+ return None
86
+ for line in out.decode("utf8", errors="ignore").splitlines():
87
+ if "MachineGuid" in line:
88
+ tokens = line.split()
89
+ if tokens:
90
+ return tokens[-1].strip()
91
+ return None
92
+
93
+
94
+ def _hardware_uuid():
95
+ sys_name = platform.system().lower()
96
+ if sys_name == "darwin":
97
+ return _hardware_uuid_macos()
98
+ if sys_name == "linux":
99
+ return _hardware_uuid_linux()
100
+ if sys_name == "windows":
101
+ return _hardware_uuid_windows()
102
+ return None
103
+
104
+
105
+ def _hostname():
106
+ sys_name = platform.system().lower()
107
+ if sys_name == "darwin":
108
+ out = _safe(
109
+ subprocess.check_output,
110
+ ["scutil", "--get", "ComputerName"],
111
+ stderr=subprocess.DEVNULL, timeout=5,
112
+ )
113
+ if out:
114
+ v = out.decode("utf8", errors="ignore").strip()
115
+ if v:
116
+ return v
117
+ try:
118
+ import socket
119
+ return socket.gethostname() or None
120
+ except Exception:
121
+ return None
122
+
123
+
124
+ def _git_email():
125
+ out = _safe(
126
+ subprocess.check_output,
127
+ ["git", "config", "--global", "user.email"],
128
+ stderr=subprocess.DEVNULL, timeout=3,
129
+ )
130
+ if not out:
131
+ return None
132
+ v = out.decode("utf8", errors="ignore").strip()
133
+ return v or None
134
+
135
+
136
+ def _node_version():
137
+ out = _safe(
138
+ subprocess.check_output,
139
+ ["node", "--version"],
140
+ stderr=subprocess.DEVNULL, timeout=3,
141
+ )
142
+ if not out:
143
+ return None
144
+ v = out.decode("utf8", errors="ignore").strip()
145
+ return v.lstrip("v") or None
146
+
147
+
148
+ def _app_version():
149
+ """Version of the installed S4L plugin.
150
+
151
+ On a .mcpb box the extension dir has manifest.json + package.json at its
152
+ root (one level above scripts/); read whichever resolves first. Honors
153
+ S4L_REPO_DIR / REPO_DIR when the pipeline sets it (launchd plists do).
154
+ """
155
+ root = Path(
156
+ os.environ.get("S4L_REPO_DIR")
157
+ or os.environ.get("REPO_DIR")
158
+ or Path(__file__).resolve().parents[1]
159
+ )
160
+ for name in ("manifest.json", "package.json"):
161
+ try:
162
+ data = json.loads((root / name).read_text())
163
+ except Exception:
164
+ continue
165
+ v = data.get("version")
166
+ if v:
167
+ return str(v).strip() or None
168
+ return None
169
+
170
+
171
+ def _claude_desktop_version():
172
+ """CFBundleShortVersionString of the Claude Desktop app (macOS), or None.
173
+
174
+ Stamped into the install identity (and thus the X-Installation header on every
175
+ heartbeat) so the install-lane digest can correlate leaks/regressions with the
176
+ Desktop version. This is the variable we could not answer for Karol's box. Reads
177
+ Info.plist directly via plistlib; best-effort, never raises."""
178
+ if (platform.system() or "").lower() != "darwin":
179
+ return None
180
+ candidates = [
181
+ Path("/Applications/Claude.app/Contents/Info.plist"),
182
+ Path.home() / "Applications" / "Claude.app" / "Contents" / "Info.plist",
183
+ ]
184
+ for plist in candidates:
185
+ try:
186
+ if not plist.exists():
187
+ continue
188
+ import plistlib
189
+
190
+ with plist.open("rb") as f:
191
+ data = plistlib.load(f)
192
+ v = data.get("CFBundleShortVersionString") or data.get("CFBundleVersion")
193
+ if v:
194
+ return str(v).strip() or None
195
+ except Exception:
196
+ continue
197
+ return None
198
+
199
+
200
+ def _tz():
201
+ try:
202
+ from datetime import datetime
203
+ tz = datetime.now().astimezone().tzinfo
204
+ if tz is not None:
205
+ name = tz.tzname(datetime.now())
206
+ if name:
207
+ return name
208
+ except Exception:
209
+ pass
210
+ return os.environ.get("TZ") or None
211
+
212
+
213
+ def _build_fresh_identity():
214
+ return {
215
+ "install_id": str(uuid.uuid4()),
216
+ "hardware_uuid": _hardware_uuid(),
217
+ "hostname": _hostname(),
218
+ "os": (platform.system() or "").lower() or None,
219
+ "os_version": platform.release() or None,
220
+ "cpu_arch": platform.machine() or None,
221
+ "python_version": platform.python_version() or None,
222
+ "node_version": _node_version(),
223
+ "app_version": _app_version(),
224
+ "claude_desktop_version": _claude_desktop_version(),
225
+ "git_email": _git_email(),
226
+ "tz": _tz(),
227
+ "first_seen_at": int(time.time()),
228
+ }
229
+
230
+
231
+ def get_identity(refresh: bool = False) -> dict:
232
+ """Read identity.json, creating it on first call.
233
+
234
+ refresh=True re-snapshots the volatile fields (versions, hostname,
235
+ git_email, tz) while preserving install_id and first_seen_at.
236
+ """
237
+ IDENTITY_DIR.mkdir(parents=True, exist_ok=True)
238
+
239
+ if not IDENTITY_FILE.exists():
240
+ ident = _build_fresh_identity()
241
+ IDENTITY_FILE.write_text(json.dumps(ident, indent=2))
242
+ try:
243
+ os.chmod(IDENTITY_FILE, 0o600)
244
+ except Exception:
245
+ pass
246
+ return ident
247
+
248
+ try:
249
+ ident = json.loads(IDENTITY_FILE.read_text())
250
+ except Exception:
251
+ # Corrupt file; rebuild rather than crashing the pipeline.
252
+ ident = _build_fresh_identity()
253
+ IDENTITY_FILE.write_text(json.dumps(ident, indent=2))
254
+ return ident
255
+
256
+ if refresh:
257
+ snap = _build_fresh_identity()
258
+ # preserve stable identifiers across refresh
259
+ snap["install_id"] = ident.get("install_id") or snap["install_id"]
260
+ snap["first_seen_at"] = ident.get("first_seen_at") or snap["first_seen_at"]
261
+ if snap != ident:
262
+ try:
263
+ IDENTITY_FILE.write_text(json.dumps(snap, indent=2))
264
+ except Exception:
265
+ pass
266
+ return snap
267
+ return ident
268
+
269
+
270
+ def get_identity_header(refresh: bool = False) -> str:
271
+ """Return the base64 value to put in the X-Installation HTTP header."""
272
+ ident = get_identity(refresh=refresh)
273
+ payload = {
274
+ k: v for k, v in ident.items()
275
+ if k != "first_seen_at" and v is not None
276
+ }
277
+ raw = json.dumps(payload, separators=(",", ":")).encode("utf8")
278
+ return base64.b64encode(raw).decode("ascii")
279
+
280
+
281
+ def main():
282
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "show"
283
+ if cmd == "show":
284
+ print(json.dumps(get_identity(refresh=True), indent=2))
285
+ elif cmd == "header":
286
+ print(get_identity_header(refresh=True))
287
+ elif cmd == "reset":
288
+ if IDENTITY_FILE.exists():
289
+ IDENTITY_FILE.unlink()
290
+ print(f"deleted {IDENTITY_FILE}")
291
+ else:
292
+ print(f"no identity at {IDENTITY_FILE}")
293
+ elif cmd == "path":
294
+ print(str(IDENTITY_FILE))
295
+ else:
296
+ print(f"unknown cmd: {cmd}", file=sys.stderr)
297
+ print("usage: identity.py [show|header|reset|path]", file=sys.stderr)
298
+ sys.exit(2)
299
+
300
+
301
+ if __name__ == "__main__":
302
+ main()