@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,377 @@
1
+ #!/usr/bin/env python3
2
+ """Scan Reddit inbox for new replies, then engage up to N of them.
3
+
4
+ Replaces the legacy per-post anonymous scan that was rate-limited by
5
+ old.reddit.com. Reads /message/inbox/.json with the logged-in
6
+ reddit-agent profile cookies (refreshed by bootstrap_reddit_cookies.py),
7
+ inserts new rows into `replies`, and immediately fires engage_reddit.py
8
+ with --limit so the loop runs end-to-end every 5 min.
9
+
10
+ Inbox cannot tell us depth/parent_reply_id (it shows comment-replies and
11
+ post-replies identically). We insert depth=1 / parent_reply_id=NULL; the
12
+ engage step reads the live thread URL anyway.
13
+
14
+ Items older than BACKFILL_HOURS that aren't already in the DB are marked
15
+ status='skipped' / skip_reason='backfill_old' so they show in the
16
+ dashboard without being responded to.
17
+
18
+ Usage:
19
+ python3 scripts/scan_reddit_replies.py [--reddit-account NAME]
20
+ [--engage-limit N]
21
+ [--no-engage]
22
+ [--no-jitter]
23
+ """
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import random
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ import time
33
+ import urllib.error
34
+ import urllib.request
35
+
36
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37
+ from http_api import api_get
38
+ from reply_insert import insert_reply as _insert_reply
39
+
40
+ CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
41
+ COOKIES_PATH = os.path.expanduser("~/.config/social-autoposter/reddit-cookies.json")
42
+ ENGAGE_SCRIPT = os.path.expanduser("~/social-autoposter/scripts/engage_reddit.py")
43
+
44
+ INBOX_URL = "https://old.reddit.com/message/inbox/.json"
45
+ PAGE_LIMIT = 100
46
+ MAX_PAGES = 10 # caps pagination at ~1000 items; inbox retention is shorter than that anyway
47
+ BACKFILL_HOURS = 48
48
+ JITTER_MAX_SECS = 60
49
+ PAGE_PAUSE_SECS = 1.5
50
+ OWN_COMMENTS_PAGES = 20 # hard cap on pagination depth (max 2000 items)
51
+ OWN_COMMENTS_LOOKBACK_DAYS = 30 # stop once we pass this many days back
52
+
53
+ THREAD_ID_RE = re.compile(r"/comments/([a-z0-9]+)/")
54
+
55
+
56
+ def load_config():
57
+ if os.path.exists(CONFIG_PATH):
58
+ with open(CONFIG_PATH) as f:
59
+ return json.load(f)
60
+ return {}
61
+
62
+
63
+ def load_cookies():
64
+ if not os.path.exists(COOKIES_PATH):
65
+ return None
66
+ with open(COOKIES_PATH) as f:
67
+ cookies = json.load(f)
68
+ return "; ".join(f"{c['name']}={c['value']}" for c in cookies)
69
+
70
+
71
+ def fetch_inbox(cookie_header, user_agent, after=None):
72
+ url = f"{INBOX_URL}?limit={PAGE_LIMIT}"
73
+ if after:
74
+ url += f"&after={after}"
75
+ req = urllib.request.Request(
76
+ url,
77
+ headers={
78
+ "User-Agent": user_agent,
79
+ "Cookie": cookie_header,
80
+ "Accept": "application/json",
81
+ },
82
+ )
83
+ with urllib.request.urlopen(req, timeout=20) as resp:
84
+ ct = resp.headers.get("Content-Type", "")
85
+ if "application/json" not in ct:
86
+ raise SessionInvalidError(f"non-JSON response (likely login redirect): {ct}")
87
+ data = json.loads(resp.read())
88
+ if data.get("kind") != "Listing":
89
+ raise SessionInvalidError(f"unexpected kind: {data.get('kind')}")
90
+ return data["data"]
91
+
92
+
93
+ class SessionInvalidError(Exception):
94
+ pass
95
+
96
+
97
+ def fetch_own_replies(reddit_account, cookie_header, user_agent,
98
+ pages=OWN_COMMENTS_PAGES, lookback_days=OWN_COMMENTS_LOOKBACK_DAYS):
99
+ """Build {parent_comment_id: {reply_id, reply_url, reply_content, replied_at}}
100
+ by paging /user/<account>/comments.json. Used to detect comments the account
101
+ already replied to outside the pipeline (e.g., manual browser replies).
102
+ Stops when a page's oldest comment is older than lookback_days, or after
103
+ `pages` pages, whichever comes first."""
104
+ out = {}
105
+ after = None
106
+ cutoff = time.time() - lookback_days * 86400
107
+ url_base = f"https://old.reddit.com/user/{reddit_account}/comments/.json?limit={PAGE_LIMIT}"
108
+ for page in range(pages):
109
+ url = url_base + (f"&after={after}" if after else "")
110
+ req = urllib.request.Request(url, headers={
111
+ "User-Agent": user_agent, "Cookie": cookie_header, "Accept": "application/json",
112
+ })
113
+ try:
114
+ with urllib.request.urlopen(req, timeout=20) as resp:
115
+ if "application/json" not in resp.headers.get("Content-Type", ""):
116
+ return out # non-fatal; just skip the map
117
+ data = json.loads(resp.read()).get("data", {})
118
+ except Exception as e:
119
+ print(f" own-replies fetch failed on page {page+1}: {e}")
120
+ return out
121
+ children = data.get("children", []) or []
122
+ oldest_on_page = 0
123
+ for c in children:
124
+ d = c.get("data") or {}
125
+ created = float(d.get("created_utc") or 0)
126
+ if created and (oldest_on_page == 0 or created < oldest_on_page):
127
+ oldest_on_page = created
128
+ parent = (d.get("parent_id") or "")
129
+ if not parent.startswith("t1_"):
130
+ continue # only comment-parents; post-parents handled via inbox matching
131
+ parent_id = parent.removeprefix("t1_")
132
+ if parent_id in out:
133
+ continue
134
+ reply_id = d.get("id")
135
+ permalink = d.get("permalink")
136
+ out[parent_id] = {
137
+ "our_reply_id": reply_id,
138
+ "our_reply_url": f"https://old.reddit.com{permalink}" if permalink else None,
139
+ "our_reply_content": d.get("body") or "",
140
+ "replied_at": created or None,
141
+ }
142
+ after = data.get("after")
143
+ if not after:
144
+ break
145
+ if oldest_on_page and oldest_on_page < cutoff:
146
+ break # we've reached lookback horizon
147
+ time.sleep(PAGE_PAUSE_SECS)
148
+ return out
149
+
150
+
151
+ class InboxScanner:
152
+ def __init__(self, reddit_account, user_agent, cookie_header, excluded_authors=None,
153
+ own_replies_map=None):
154
+ # No DB handle anymore — every read/write hits the API. The `db` field
155
+ # is kept for back-compat with `_insert_reply(self.db, ...)` callers
156
+ # (they pass it through unchanged); the helper itself ignores the value.
157
+ self.db = None
158
+ self.reddit_account = reddit_account
159
+ self.reddit_account_lower = reddit_account.lower()
160
+ self.user_agent = user_agent
161
+ self.cookie_header = cookie_header
162
+ self.excluded = {a.lower() for a in (excluded_authors or set())}
163
+ self.excluded.update({"automoderator", "[deleted]", self.reddit_account_lower})
164
+ self.own_replies_map = own_replies_map or {}
165
+ # Cache thread_id -> post_id lookups across a single scan so we don't
166
+ # hit /api/v1/posts once per inbox entry (the same thread often
167
+ # appears multiple times in a single page).
168
+ self._post_id_cache = {}
169
+ self.discovered = 0
170
+ self.skipped_old = 0
171
+ self.skipped_other = 0
172
+ self.already_replied = 0
173
+ self.unmatched = 0
174
+ self.total_seen = 0
175
+
176
+ def _post_id_for_context(self, context):
177
+ m = THREAD_ID_RE.search(context or "")
178
+ if not m:
179
+ return None
180
+ thread_id = m.group(1)
181
+ if thread_id in self._post_id_cache:
182
+ return self._post_id_cache[thread_id]
183
+ # /api/v1/posts GET supports a platform filter but not LIKE on
184
+ # thread_url. We fetch a window of recent reddit posts and match
185
+ # locally on the thread_id substring, falling back to the lookup
186
+ # endpoint with the same thread_id prefix.
187
+ post_id = None
188
+ try:
189
+ resp = api_get(
190
+ "/api/v1/posts",
191
+ query={"platform": "reddit", "limit": 500},
192
+ )
193
+ posts = ((resp or {}).get("data") or {}).get("posts") or []
194
+ for p in posts:
195
+ tu = (p.get("thread_url") or "").lower()
196
+ if f"/comments/{thread_id}/" in tu:
197
+ post_id = int(p.get("id"))
198
+ break
199
+ except Exception:
200
+ post_id = None
201
+ self._post_id_cache[thread_id] = post_id
202
+ return post_id
203
+
204
+ def _insert(self, post_id, comment_id, author, content, comment_url, status, skip_reason=None):
205
+ override = self.own_replies_map.get(comment_id)
206
+ if override:
207
+ from datetime import datetime, timezone
208
+ ts = override.get("replied_at")
209
+ replied_at = datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None
210
+ result = _insert_reply(
211
+ self.db, post_id, "reddit", comment_id, author, content, comment_url,
212
+ parent_reply_id=None, depth=1, status="replied", skip_reason=None,
213
+ our_reply_id=override.get("our_reply_id"),
214
+ our_reply_content=override.get("our_reply_content"),
215
+ our_reply_url=override.get("our_reply_url"),
216
+ replied_at=replied_at,
217
+ )
218
+ if result == "replied":
219
+ self.already_replied += 1
220
+ return
221
+ result = _insert_reply(
222
+ self.db, post_id, "reddit", comment_id, author, content, comment_url,
223
+ parent_reply_id=None, depth=1, status=status, skip_reason=skip_reason,
224
+ )
225
+ if result == "pending":
226
+ self.discovered += 1
227
+ elif result == "skipped":
228
+ self.skipped_old += 1
229
+
230
+ def scan(self):
231
+ print(f"Scanning inbox for u/{self.reddit_account}...")
232
+ backfill_cutoff = time.time() - BACKFILL_HOURS * 3600
233
+ after = None
234
+ consecutive_known = 0
235
+ for page in range(1, MAX_PAGES + 1):
236
+ data = fetch_inbox(self.cookie_header, self.user_agent, after=after)
237
+ children = data.get("children", [])
238
+ print(f" page {page}: {len(children)} items (after={after or 'start'})")
239
+ if not children:
240
+ break
241
+ for c in children:
242
+ self.total_seen += 1
243
+ d = c.get("data", {})
244
+ comment_id = (d.get("name") or "").removeprefix("t1_").removeprefix("t4_")
245
+ if not comment_id:
246
+ continue
247
+ author = d.get("author") or "[deleted]"
248
+ if author.lower() in self.excluded:
249
+ self.skipped_other += 1
250
+ continue
251
+ context = d.get("context") or ""
252
+ post_id = self._post_id_for_context(context)
253
+ if not post_id:
254
+ self.unmatched += 1
255
+ continue
256
+ comment_url = "https://old.reddit.com" + context.split("?")[0]
257
+ content = d.get("body") or ""
258
+ created = float(d.get("created_utc") or 0)
259
+ if created and created < backfill_cutoff:
260
+ pre = self.discovered + self.skipped_old
261
+ self._insert(post_id, comment_id, author, content, comment_url,
262
+ status="skipped", skip_reason="backfill_old")
263
+ if (self.discovered + self.skipped_old) == pre:
264
+ consecutive_known += 1
265
+ else:
266
+ consecutive_known = 0
267
+ else:
268
+ pre = self.discovered
269
+ self._insert(post_id, comment_id, author, content, comment_url,
270
+ status="pending")
271
+ if self.discovered == pre:
272
+ consecutive_known += 1
273
+ else:
274
+ consecutive_known = 0
275
+ # Always finish processing the current page before deciding whether
276
+ # to fetch the next one. Bailing mid-page (the previous behavior)
277
+ # could miss out-of-order items on the same page; the cost of
278
+ # finishing the page is essentially zero (idempotent INSERTs only).
279
+ # The 50-consecutive-known threshold now gates pagination only.
280
+ if consecutive_known >= 50:
281
+ print(f" hit {consecutive_known} consecutive already-known items on page {page}, stopping pagination")
282
+ return
283
+ after = data.get("after")
284
+ if not after:
285
+ break
286
+ if page < MAX_PAGES:
287
+ time.sleep(PAGE_PAUSE_SECS)
288
+
289
+ def finish(self):
290
+ # All writes go through the HTTP API; nothing to commit/close locally.
291
+ print(
292
+ f"Inbox scan complete: seen={self.total_seen} "
293
+ f"new_pending={self.discovered} backfill_skipped={self.skipped_old} "
294
+ f"already_replied={self.already_replied} "
295
+ f"excluded_author={self.skipped_other} unmatched_thread={self.unmatched}"
296
+ )
297
+ return {
298
+ "discovered": self.discovered,
299
+ "backfill_skipped": self.skipped_old,
300
+ "already_replied": self.already_replied,
301
+ "excluded": self.skipped_other,
302
+ "unmatched": self.unmatched,
303
+ "total_seen": self.total_seen,
304
+ }
305
+
306
+
307
+ def run_engage(limit, timeout):
308
+ print(f"\nFiring engage_reddit.py --platform reddit --limit {limit}...")
309
+ proc = subprocess.run(
310
+ ["python3", ENGAGE_SCRIPT, "--platform", "reddit", "--limit", str(limit), "--timeout", str(timeout)],
311
+ cwd=os.path.dirname(ENGAGE_SCRIPT),
312
+ )
313
+ print(f"engage_reddit exit code: {proc.returncode}")
314
+ return proc.returncode
315
+
316
+
317
+ def main():
318
+ parser = argparse.ArgumentParser(description="Scan Reddit inbox for new replies, then engage")
319
+ parser.add_argument("--reddit-account", default=None)
320
+ parser.add_argument("--engage-limit", type=int, default=5,
321
+ help="Max replies to post per run (default: 5; 0 = skip engage)")
322
+ parser.add_argument("--engage-timeout", type=int, default=600,
323
+ help="Total seconds for the engage subprocess (default: 600)")
324
+ parser.add_argument("--no-engage", action="store_true",
325
+ help="Discovery only, don't fire engage_reddit.py")
326
+ parser.add_argument("--no-jitter", action="store_true",
327
+ help="Skip the random startup jitter (use for manual runs)")
328
+ args = parser.parse_args()
329
+
330
+ config = load_config()
331
+ reddit_account = args.reddit_account or config.get("accounts", {}).get("reddit", {}).get("username", "")
332
+ if not reddit_account:
333
+ print("ERROR: Reddit account not configured. Set it in config.json or pass --reddit-account")
334
+ sys.exit(1)
335
+
336
+ if not args.no_jitter:
337
+ jitter = random.uniform(0, JITTER_MAX_SECS)
338
+ print(f"Jitter: sleeping {jitter:.1f}s before scan")
339
+ time.sleep(jitter)
340
+
341
+ cookie_header = load_cookies()
342
+ if not cookie_header:
343
+ print(f"SESSION_INVALID: no cookie file at {COOKIES_PATH}. Run bootstrap_reddit_cookies.py.")
344
+ sys.exit(0)
345
+
346
+ user_agent = f"social-autoposter/1.0 (u/{reddit_account} inbox-scan)"
347
+ excluded_authors = {a for a in config.get("exclusions", {}).get("authors", [])}
348
+ own_replies_map = fetch_own_replies(reddit_account, cookie_header, user_agent)
349
+ print(f"Own-replies map: {len(own_replies_map)} parent comment_ids we've already replied to")
350
+ scanner = InboxScanner(reddit_account, user_agent, cookie_header,
351
+ excluded_authors=excluded_authors,
352
+ own_replies_map=own_replies_map)
353
+ try:
354
+ scanner.scan()
355
+ except SessionInvalidError as e:
356
+ print(f"SESSION_INVALID: {e}")
357
+ scanner.finish()
358
+ sys.exit(0)
359
+ except urllib.error.HTTPError as e:
360
+ if e.code in (401, 403):
361
+ print(f"SESSION_INVALID: HTTP {e.code} on inbox endpoint. Refresh cookies via bootstrap_reddit_cookies.py.")
362
+ scanner.finish()
363
+ sys.exit(0)
364
+ print(f"ERROR: HTTP {e.code} {e.reason}")
365
+ scanner.finish()
366
+ sys.exit(1)
367
+ result = scanner.finish()
368
+
369
+ if args.no_engage or args.engage_limit <= 0:
370
+ print("Skipping engage step (per flags)")
371
+ sys.exit(0)
372
+
373
+ run_engage(args.engage_limit, args.engage_timeout)
374
+
375
+
376
+ if __name__ == "__main__":
377
+ main()