@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,710 @@
1
+ #!/usr/bin/env python3
2
+ """Read Reddit Chat state directly from the matrix-js-sdk IndexedDB cache.
3
+
4
+ Reddit Chat is a Matrix (vanilla v3) client. The entire joined-rooms state,
5
+ including per-room unread counts, member displaynames, and recent timeline
6
+ events, is persisted client-side in IndexedDB under
7
+ `matrix-js-sdk:reddit-chat-sync` -> `sync` store.
8
+
9
+ Reading that store lets us answer "which rooms have unread messages and what
10
+ do they contain" WITHOUT scrolling the virtual sidebar and WITHOUT originating
11
+ any API calls. We only read state that the Reddit client itself already
12
+ fetched as part of its normal page hydration. This matches the passive CDP
13
+ pattern in twitter_browser.py's reply_to_tweet() and stays well inside the
14
+ "don't originate calls the human wouldn't" line that took LinkedIn down on
15
+ 2026-04-17.
16
+
17
+ Usage:
18
+ python3 reddit_chat_sync.py list-unread # JSON to stdout
19
+ python3 reddit_chat_sync.py list-unread --pretty # formatted JSON
20
+
21
+ The output is an array of records with fields:
22
+ room_id, chat_url, unread_count, room_name, partner_username,
23
+ partner_mxid, last_event_id, last_event_ts, last_event_body,
24
+ last_event_from_us, timeline (array of recent events).
25
+
26
+ This command is strictly read-only. DB writes come in a later subcommand.
27
+
28
+ Requires: pip install playwright && playwright install chromium
29
+ Shares the reddit-agent Chromium profile + lock used by reddit_browser.py.
30
+ """
31
+
32
+ import argparse
33
+ import atexit
34
+ import json
35
+ import os
36
+ import sys
37
+ import time
38
+ from datetime import datetime, timezone
39
+
40
+ PROFILE_DIR = os.path.expanduser("~/.claude/browser-profiles/reddit")
41
+ LOCK_FILE = os.path.expanduser("~/.claude/reddit-agent-lock.json")
42
+ LOCK_EXPIRY = 300
43
+ LOCK_WAIT_MAX = 45
44
+ LOCK_POLL_INTERVAL = 2
45
+ VIEWPORT = {"width": 911, "height": 1016}
46
+ USER_AGENT = (
47
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
48
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
49
+ )
50
+
51
+ OUR_USERNAME = "Deep_Ad1959"
52
+ _config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.json")
53
+ if os.path.exists(_config_path):
54
+ try:
55
+ with open(_config_path) as f:
56
+ _cfg = json.load(f)
57
+ OUR_USERNAME = (
58
+ _cfg.get("accounts", {}).get("reddit", {}).get("username", OUR_USERNAME)
59
+ )
60
+ except Exception:
61
+ pass
62
+
63
+ _LOCK_SESSION_ID = f"python:{os.getpid()}"
64
+
65
+
66
+ def _release_lock():
67
+ try:
68
+ if os.path.exists(LOCK_FILE):
69
+ with open(LOCK_FILE) as f:
70
+ lock = json.load(f)
71
+ if lock.get("session_id") == _LOCK_SESSION_ID:
72
+ os.remove(LOCK_FILE)
73
+ except (json.JSONDecodeError, OSError):
74
+ pass
75
+
76
+
77
+ atexit.register(_release_lock)
78
+
79
+
80
+ def _acquire_lock():
81
+ deadline = time.time() + LOCK_WAIT_MAX
82
+ while True:
83
+ if os.path.exists(LOCK_FILE):
84
+ try:
85
+ with open(LOCK_FILE) as f:
86
+ lock = json.load(f)
87
+ age = time.time() - lock.get("timestamp", 0)
88
+ if age >= LOCK_EXPIRY:
89
+ break
90
+ holder = lock.get("session_id", "unknown")
91
+ if time.time() >= deadline:
92
+ print(
93
+ json.dumps(
94
+ {
95
+ "success": False,
96
+ "error": f"Reddit browser locked by session {holder} ({int(age)}s); waited {LOCK_WAIT_MAX}s, giving up.",
97
+ }
98
+ )
99
+ )
100
+ sys.exit(1)
101
+ time.sleep(LOCK_POLL_INTERVAL)
102
+ continue
103
+ except (json.JSONDecodeError, OSError):
104
+ pass
105
+ break
106
+ with open(LOCK_FILE, "w") as f:
107
+ json.dump(
108
+ {"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}, f
109
+ )
110
+
111
+
112
+ # JS we run inside the page. Extracts every joined room that has
113
+ # unread_notifications.notification_count > 0, plus enough context to
114
+ # reconstruct the conversation.
115
+ _EXTRACT_JS = r"""
116
+ async () => {
117
+ const REDDIT_SYSTEM_BOT = '@t2_1qwk:reddit.com';
118
+
119
+ const openReq = indexedDB.open('matrix-js-sdk:reddit-chat-sync');
120
+ const conn = await new Promise((res, rej) => {
121
+ openReq.onsuccess = () => res(openReq.result);
122
+ openReq.onerror = () => rej(openReq.error);
123
+ });
124
+
125
+ const row = await new Promise((res, rej) => {
126
+ const tx = conn.transaction('sync', 'readonly');
127
+ const req = tx.objectStore('sync').getAll();
128
+ req.onsuccess = () => res(req.result[0] || null);
129
+ req.onerror = () => rej(req.error);
130
+ });
131
+ conn.close();
132
+
133
+ if (!row || !row.roomsData || !row.roomsData.join) {
134
+ return { ok: false, error: 'no_sync_row', total_joined: 0, unread: [] };
135
+ }
136
+
137
+ const join = row.roomsData.join;
138
+ const unread = [];
139
+
140
+ for (const [roomId, r] of Object.entries(join)) {
141
+ const nc = (r.unread_notifications && r.unread_notifications.notification_count) || 0;
142
+ const hc = (r.unread_notifications && r.unread_notifications.highlight_count) || 0;
143
+ if (nc === 0 && hc === 0) continue;
144
+
145
+ const stateEvents = (r.state && r.state.events) || [];
146
+ const memberEvents = stateEvents.filter(e => e.type === 'm.room.member');
147
+ const nameEv = stateEvents.find(e => e.type === 'm.room.name');
148
+ const roomName = nameEv ? (nameEv.content && nameEv.content.name) || null : null;
149
+
150
+ // Identify our mxid and the partner mxid. The Reddit system bot
151
+ // (@t2_1qwk:reddit.com) is a member of every room and must be excluded
152
+ // from partner resolution. Our mxid is identified by displayname match
153
+ // to OUR_USERNAME.
154
+ const ourMxid = memberEvents.find(m =>
155
+ m.content && m.content.displayname === %OUR_USERNAME_LITERAL%
156
+ )?.state_key || null;
157
+
158
+ const partnerMember = memberEvents.find(m =>
159
+ m.state_key !== ourMxid &&
160
+ m.state_key !== REDDIT_SYSTEM_BOT &&
161
+ m.content && m.content.displayname
162
+ );
163
+
164
+ const timeline = (r.timeline && r.timeline.events) || [];
165
+ // Last human message
166
+ const lastMsg = [...timeline]
167
+ .reverse()
168
+ .find(e => e.type === 'm.room.message');
169
+
170
+ // Return the last ~30 timeline events so the caller has enough context
171
+ // to log each new message without re-fetching. We don't return every
172
+ // event to keep the payload reasonable on old rooms.
173
+ const recentTimeline = timeline.slice(-30).map(e => ({
174
+ event_id: e.event_id,
175
+ ts: e.origin_server_ts,
176
+ sender: e.sender,
177
+ type: e.type,
178
+ body: (e.content && e.content.body) || null,
179
+ msgtype: (e.content && e.content.msgtype) || null,
180
+ from_us: e.sender === ourMxid,
181
+ }));
182
+
183
+ unread.push({
184
+ room_id: roomId,
185
+ chat_url: 'https://www.reddit.com/chat/room/' + encodeURIComponent(roomId),
186
+ unread_count: nc,
187
+ highlight_count: hc,
188
+ room_name: roomName,
189
+ partner_username: (partnerMember && partnerMember.content && partnerMember.content.displayname) || null,
190
+ partner_mxid: (partnerMember && partnerMember.state_key) || null,
191
+ our_mxid: ourMxid,
192
+ last_event_id: (lastMsg && lastMsg.event_id) || null,
193
+ last_event_ts: (lastMsg && lastMsg.origin_server_ts) || null,
194
+ last_event_body: (lastMsg && lastMsg.content && lastMsg.content.body) || null,
195
+ last_event_from_us: (lastMsg && lastMsg.sender === ourMxid) || false,
196
+ timeline: recentTimeline,
197
+ });
198
+ }
199
+
200
+ // Sort by unread_count desc then last_event_ts desc so operators see the
201
+ // loudest threads first.
202
+ unread.sort((a, b) =>
203
+ (b.unread_count - a.unread_count) ||
204
+ ((b.last_event_ts || 0) - (a.last_event_ts || 0))
205
+ );
206
+
207
+ return {
208
+ ok: true,
209
+ total_joined: Object.keys(join).length,
210
+ unread_room_count: unread.length,
211
+ total_unread_messages: unread.reduce((s, r) => s + r.unread_count, 0),
212
+ next_batch: row.nextBatch || null,
213
+ unread,
214
+ };
215
+ }
216
+ """.replace("%OUR_USERNAME_LITERAL%", json.dumps(OUR_USERNAME))
217
+
218
+
219
+ # Minimal extraction: every joined room, just partner resolution + room_id.
220
+ # Used for the full chat_url backfill across the ~737 rooms the user has ever
221
+ # joined, not just the unread subset. No timeline, no message bodies.
222
+ _EXTRACT_ALL_ROOMS_JS = r"""
223
+ async () => {
224
+ const REDDIT_SYSTEM_BOT = '@t2_1qwk:reddit.com';
225
+ const openReq = indexedDB.open('matrix-js-sdk:reddit-chat-sync');
226
+ const conn = await new Promise((res, rej) => {
227
+ openReq.onsuccess = () => res(openReq.result);
228
+ openReq.onerror = () => rej(openReq.error);
229
+ });
230
+ const row = await new Promise((res, rej) => {
231
+ const tx = conn.transaction('sync', 'readonly');
232
+ const req = tx.objectStore('sync').getAll();
233
+ req.onsuccess = () => res(req.result[0] || null);
234
+ req.onerror = () => rej(req.error);
235
+ });
236
+ conn.close();
237
+ if (!row || !row.roomsData || !row.roomsData.join) {
238
+ return { ok: false, error: 'no_sync_row', rooms: [] };
239
+ }
240
+ const join = row.roomsData.join;
241
+ const rooms = [];
242
+ for (const [roomId, r] of Object.entries(join)) {
243
+ const stateEvents = (r.state && r.state.events) || [];
244
+ const memberEvents = stateEvents.filter(e => e.type === 'm.room.member');
245
+ const ourMxid = memberEvents.find(m =>
246
+ m.content && m.content.displayname === %OUR_USERNAME_LITERAL%
247
+ )?.state_key || null;
248
+ const partner = memberEvents.find(m =>
249
+ m.state_key !== ourMxid &&
250
+ m.state_key !== REDDIT_SYSTEM_BOT &&
251
+ m.content && m.content.displayname
252
+ );
253
+ const nc = (r.unread_notifications && r.unread_notifications.notification_count) || 0;
254
+ rooms.push({
255
+ room_id: roomId,
256
+ chat_url: 'https://www.reddit.com/chat/room/' + encodeURIComponent(roomId),
257
+ partner_username: (partner && partner.content && partner.content.displayname) || null,
258
+ partner_mxid: (partner && partner.state_key) || null,
259
+ unread_count: nc,
260
+ });
261
+ }
262
+ return { ok: true, total_joined: rooms.length, rooms };
263
+ }
264
+ """.replace("%OUR_USERNAME_LITERAL%", json.dumps(OUR_USERNAME))
265
+
266
+
267
+ def _open_and_evaluate(js_code, hydration_wait_ms=8000, nav_retries=2):
268
+ """Shared scaffolding: open /chat in a headless Chromium on the reddit
269
+ profile, let matrix-js-sdk finish incremental sync, then run the given
270
+ JS and return its result.
271
+
272
+ Returns either the parsed JS return value or an {ok: false, error} record.
273
+ """
274
+ from playwright.sync_api import sync_playwright
275
+
276
+ _acquire_lock()
277
+ try:
278
+ with sync_playwright() as p:
279
+ deadline = time.time() + LOCK_WAIT_MAX
280
+ context = None
281
+ while True:
282
+ try:
283
+ context = p.chromium.launch_persistent_context(
284
+ PROFILE_DIR,
285
+ headless=True,
286
+ args=["--disable-blink-features=AutomationControlled"],
287
+ viewport=VIEWPORT,
288
+ user_agent=USER_AGENT,
289
+ )
290
+ break
291
+ except Exception as e:
292
+ if time.time() >= deadline:
293
+ return {
294
+ "ok": False,
295
+ "error": f"chromium profile locked by another process; waited {LOCK_WAIT_MAX}s: {e}",
296
+ }
297
+ time.sleep(LOCK_POLL_INTERVAL)
298
+
299
+ try:
300
+ page = context.new_page()
301
+ for attempt in range(nav_retries + 1):
302
+ try:
303
+ page.goto("https://www.reddit.com/chat", wait_until="domcontentloaded", timeout=30000)
304
+ break
305
+ except Exception as e:
306
+ if attempt == nav_retries:
307
+ return {"ok": False, "error": f"navigate_failed: {e}"}
308
+ time.sleep(2)
309
+
310
+ page.wait_for_timeout(hydration_wait_ms)
311
+ return page.evaluate(js_code)
312
+ finally:
313
+ try:
314
+ context.close()
315
+ except Exception:
316
+ pass
317
+ finally:
318
+ _release_lock()
319
+
320
+
321
+ def list_unread(hydration_wait_ms=8000, nav_retries=2):
322
+ """Return every Matrix room with notification_count > 0 along with its
323
+ partner, last message, and last ~30 timeline events."""
324
+ return _open_and_evaluate(_EXTRACT_JS, hydration_wait_ms, nav_retries)
325
+
326
+
327
+ def list_all_rooms(hydration_wait_ms=8000, nav_retries=2):
328
+ """Return every joined room with {room_id, chat_url, partner_username,
329
+ unread_count}. Same IndexedDB source, no timeline payload. Used for the
330
+ full chat_url backfill that fills rows the unread-only scan misses."""
331
+ return _open_and_evaluate(_EXTRACT_ALL_ROOMS_JS, hydration_wait_ms, nav_retries)
332
+
333
+
334
+ def ingest_unread(hydration_wait_ms=8000, dry_run=False):
335
+ """Scan every unread Reddit chat room, upsert a dms row (backfilling
336
+ chat_url), and log each inbound m.room.message to dm_messages with its
337
+ Matrix event_id as the dedup key.
338
+
339
+ Returns a structured summary of what happened.
340
+
341
+ Progress chatter from the helpers is squelched because we emit a single
342
+ JSON doc at the end and don't want it corrupted. The migration to HTTP
343
+ (2026-05-12) removed direct psycopg2 access from this script entirely;
344
+ DM lookups + chat_url updates go through /api/v1/dms*, ensure_dm goes
345
+ through a subprocess to scripts/dm_conversation.py ensure-dm (which is
346
+ the same precedent engage_reddit.py uses), and per-event inbound
347
+ messages POST directly to /api/v1/dms/[id]/messages with event_id
348
+ dedup handled server-side.
349
+ """
350
+ # Lazy import so list-unread doesn't pay for it.
351
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
352
+ import contextlib # noqa: E402
353
+
354
+ scan = list_unread(hydration_wait_ms=hydration_wait_ms)
355
+ if not scan.get("ok"):
356
+ return scan
357
+
358
+ stats = {
359
+ "rooms_scanned": len(scan["unread"]),
360
+ "rooms_new_dms": 0,
361
+ "rooms_existing_dms": 0,
362
+ "chat_urls_backfilled": 0,
363
+ "inbound_inserted": 0,
364
+ "inbound_deduped": 0,
365
+ "skipped_non_message_events": 0,
366
+ "skipped_our_events": 0,
367
+ "rooms_without_partner": 0,
368
+ "errors": [],
369
+ }
370
+ per_room = []
371
+
372
+ # Route any stray helper prints to stderr for the whole loop. We emit
373
+ # one JSON doc at the end on stdout and that's it.
374
+ _redirect_cm = contextlib.redirect_stdout(sys.stderr)
375
+ _redirect_cm.__enter__()
376
+
377
+ try:
378
+ _ingest_rooms(scan, dry_run, stats, per_room)
379
+ finally:
380
+ _redirect_cm.__exit__(None, None, None)
381
+
382
+ return {
383
+ "ok": True,
384
+ "dry_run": dry_run,
385
+ "matrix_total_joined": scan.get("total_joined"),
386
+ "matrix_unread_rooms": scan.get("unread_room_count"),
387
+ "matrix_total_unread_messages": scan.get("total_unread_messages"),
388
+ "matrix_next_batch": scan.get("next_batch"),
389
+ "stats": stats,
390
+ "per_room": per_room,
391
+ }
392
+
393
+
394
+ def _http_lookup_reddit_dm(partner):
395
+ """Return the most-recent reddit dms row for `partner`, or None.
396
+
397
+ Replaces the legacy
398
+ SELECT id, chat_url FROM dms WHERE platform='reddit' AND their_author=%s
399
+ ORDER BY id DESC LIMIT 1
400
+ via GET /api/v1/dms?platform=reddit&their_author=partner&limit=1. The
401
+ route's their_author filter is case-insensitive, matching what the
402
+ legacy LOWER(...) backfill clause expected.
403
+ """
404
+ from http_api import api_get # local import keeps list-unread cheap
405
+ resp = api_get(
406
+ "/api/v1/dms",
407
+ query={"platform": "reddit", "their_author": partner, "limit": 1},
408
+ )
409
+ data = (resp or {}).get("data") or {}
410
+ rows = data.get("dms") or []
411
+ return rows[0] if rows else None
412
+
413
+
414
+ def _http_ensure_reddit_dm(partner, chat_url):
415
+ """Shell out to dm_conversation.py ensure-dm (matches engage_reddit.py
416
+ precedent, which also subprocess-calls the CLI rather than re-implementing
417
+ the cross-link logic over HTTP). Parses `DM_ID=<n>\\n created (...)` from
418
+ stdout. Returns (dm_id:int, created:bool, error:str|None).
419
+ """
420
+ import subprocess
421
+ cmd = [
422
+ "python3",
423
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "dm_conversation.py"),
424
+ "ensure-dm",
425
+ "--platform", "reddit",
426
+ "--author", partner,
427
+ ]
428
+ if chat_url:
429
+ cmd.extend(["--chat-url", chat_url])
430
+ try:
431
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
432
+ except Exception as e:
433
+ return None, False, f"subprocess failed: {e}"
434
+ if result.returncode != 0:
435
+ return None, False, (result.stderr or "ensure-dm rc != 0").strip()
436
+ out = result.stdout.strip().splitlines()
437
+ dm_id = None
438
+ created = False
439
+ for line in out:
440
+ if line.startswith("DM_ID="):
441
+ try:
442
+ dm_id = int(line.split("=", 1)[1])
443
+ except Exception:
444
+ pass
445
+ elif "created" in line.lower():
446
+ created = True
447
+ if dm_id is None:
448
+ return None, False, f"could not parse DM_ID from stdout: {out!r}"
449
+ return dm_id, created, None
450
+
451
+
452
+ def _http_log_inbound(dm_id, partner, body, message_at_iso, event_id):
453
+ """POST /api/v1/dms/<id>/messages with event_id dedup handled server-side.
454
+ Returns ("inserted", None) on insert, ("deduped", dedup_key) on duplicate,
455
+ ("error", reason) on failure.
456
+ """
457
+ from http_api import api_post
458
+ body_payload = {
459
+ "direction": "inbound",
460
+ "author": partner,
461
+ "content": body,
462
+ }
463
+ if message_at_iso:
464
+ body_payload["message_at"] = message_at_iso
465
+ if event_id:
466
+ body_payload["event_id"] = event_id
467
+ try:
468
+ resp = api_post(f"/api/v1/dms/{dm_id}/messages", body_payload)
469
+ except SystemExit as e:
470
+ # http_api raises SystemExit on terminal HTTP failure
471
+ return "error", f"http_api SystemExit: {e}"
472
+ except Exception as e:
473
+ return "error", str(e)
474
+ data = (resp or {}).get("data") or {}
475
+ if data.get("deduped"):
476
+ return "deduped", data.get("dedup_key") or "unknown"
477
+ return "inserted", None
478
+
479
+
480
+ def _ingest_rooms(scan, dry_run, stats, per_room):
481
+ for room in scan["unread"]:
482
+ partner = room.get("partner_username")
483
+ chat_url = room.get("chat_url")
484
+ if not partner:
485
+ stats["rooms_without_partner"] += 1
486
+ continue
487
+
488
+ try:
489
+ existing = _http_lookup_reddit_dm(partner)
490
+ except Exception as e:
491
+ stats["errors"].append({"room_id": room["room_id"], "lookup_error": str(e)})
492
+ continue
493
+ had_chat_url = bool(existing and existing.get("chat_url"))
494
+
495
+ if dry_run:
496
+ dm_id = existing["id"] if existing else None
497
+ created = not existing
498
+ else:
499
+ dm_id, created, err = _http_ensure_reddit_dm(partner, chat_url)
500
+ if err is not None:
501
+ stats["errors"].append({"room_id": room["room_id"], "ensure_dm_error": err})
502
+ continue
503
+
504
+ if existing and not had_chat_url and chat_url:
505
+ stats["chat_urls_backfilled"] += 1
506
+ if created:
507
+ stats["rooms_new_dms"] += 1
508
+ else:
509
+ stats["rooms_existing_dms"] += 1
510
+
511
+ inserted_this_room = 0
512
+ deduped_this_room = 0
513
+ for ev in room.get("timeline", []):
514
+ if ev.get("type") != "m.room.message":
515
+ stats["skipped_non_message_events"] += 1
516
+ continue
517
+ if ev.get("from_us"):
518
+ stats["skipped_our_events"] += 1
519
+ continue
520
+ body = ev.get("body")
521
+ if not body:
522
+ continue
523
+ ts_ms = ev.get("ts")
524
+ message_at_iso = None
525
+ if ts_ms:
526
+ message_at_iso = datetime.fromtimestamp(
527
+ ts_ms / 1000.0, tz=timezone.utc
528
+ ).isoformat()
529
+ event_id = ev.get("event_id")
530
+
531
+ if dry_run:
532
+ # We can't predict dedup without a query; approximate by
533
+ # counting all events as would-insert. The dry-run path is
534
+ # dev-only and the inflated insert count is acceptable.
535
+ inserted_this_room += 1
536
+ continue
537
+
538
+ outcome, detail = _http_log_inbound(
539
+ dm_id, partner, body, message_at_iso, event_id,
540
+ )
541
+ if outcome == "inserted":
542
+ inserted_this_room += 1
543
+ elif outcome == "deduped":
544
+ deduped_this_room += 1
545
+ else:
546
+ stats["errors"].append({
547
+ "room_id": room["room_id"],
548
+ "log_inbound_error": detail,
549
+ "event_id": event_id,
550
+ })
551
+
552
+ stats["inbound_inserted"] += inserted_this_room
553
+ stats["inbound_deduped"] += deduped_this_room
554
+ per_room.append({
555
+ "room_id": room["room_id"],
556
+ "partner": partner,
557
+ "unread_count_matrix": room["unread_count"],
558
+ "inserted": inserted_this_room,
559
+ "deduped": deduped_this_room,
560
+ "created_new_dm": created if not dry_run else (not existing),
561
+ "chat_url_backfilled": bool(existing and not had_chat_url and chat_url),
562
+ })
563
+
564
+ return {
565
+ "ok": True,
566
+ "dry_run": dry_run,
567
+ "matrix_total_joined": scan.get("total_joined"),
568
+ "matrix_unread_rooms": scan.get("unread_room_count"),
569
+ "matrix_total_unread_messages": scan.get("total_unread_messages"),
570
+ "matrix_next_batch": scan.get("next_batch"),
571
+ "stats": stats,
572
+ "per_room": per_room,
573
+ }
574
+
575
+
576
+ def backfill_chat_urls(hydration_wait_ms=8000, dry_run=False):
577
+ """For every joined Reddit chat room, if a platform='reddit' dms row
578
+ exists for that partner with chat_url IS NULL, stamp it with the
579
+ room's chat_url. Read-only lookup when --dry-run is set.
580
+
581
+ Unlike ingest-unread (which only processes rooms with unread notifs),
582
+ this walks ALL joined rooms so we can catch historical DMs that have
583
+ been quiet for months but are still in the sidebar.
584
+
585
+ Routes used:
586
+ GET /api/v1/dms?platform=reddit&their_author=<partner>&limit=200
587
+ (their_author filter is case-insensitive, matching the legacy
588
+ `LOWER(their_author)=LOWER(%s)` clause)
589
+ PATCH /api/v1/dms/<id> with { chat_url }
590
+ """
591
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
592
+ from http_api import api_get, api_patch # noqa: E402
593
+
594
+ scan = list_all_rooms(hydration_wait_ms=hydration_wait_ms)
595
+ if not scan.get("ok"):
596
+ return scan
597
+
598
+ stats = {
599
+ "rooms_in_sidebar": len(scan["rooms"]),
600
+ "rooms_without_partner": 0,
601
+ "matched_dms_already_filled": 0,
602
+ "matched_dms_filled_now": 0,
603
+ "no_matching_dm": 0,
604
+ "multiple_matching_dms": 0,
605
+ }
606
+ filled = []
607
+
608
+ for room in scan["rooms"]:
609
+ partner = room.get("partner_username")
610
+ chat_url = room.get("chat_url")
611
+ if not partner or not chat_url:
612
+ stats["rooms_without_partner"] += 1
613
+ continue
614
+
615
+ resp = api_get(
616
+ "/api/v1/dms",
617
+ query={"platform": "reddit", "their_author": partner, "limit": 200},
618
+ )
619
+ rows = ((resp or {}).get("data") or {}).get("dms") or []
620
+ # Legacy SELECT used ORDER BY id DESC; the route orders by
621
+ # discovered_at DESC which picks the same "most recent" row for
622
+ # our backfill purpose (rows[0]).
623
+
624
+ if not rows:
625
+ stats["no_matching_dm"] += 1
626
+ continue
627
+ if len(rows) > 1:
628
+ stats["multiple_matching_dms"] += 1
629
+ # Fall through and backfill the most recent row only (rows[0]).
630
+
631
+ target = rows[0]
632
+ if target.get("chat_url"):
633
+ stats["matched_dms_already_filled"] += 1
634
+ continue
635
+
636
+ if not dry_run:
637
+ api_patch(f"/api/v1/dms/{target['id']}", {"chat_url": chat_url})
638
+ stats["matched_dms_filled_now"] += 1
639
+ filled.append({
640
+ "dm_id": target["id"],
641
+ "partner": partner,
642
+ "chat_url": chat_url,
643
+ })
644
+
645
+ return {
646
+ "ok": True,
647
+ "dry_run": dry_run,
648
+ "matrix_total_joined": scan.get("total_joined"),
649
+ "stats": stats,
650
+ "filled_sample": filled[:25],
651
+ }
652
+
653
+
654
+ def main():
655
+ ap = argparse.ArgumentParser()
656
+ sub = ap.add_subparsers(dest="command")
657
+
658
+ p_list = sub.add_parser(
659
+ "list-unread",
660
+ help="Emit JSON of every Matrix room with unread notifications (read-only).",
661
+ )
662
+ p_list.add_argument(
663
+ "--pretty",
664
+ action="store_true",
665
+ help="Pretty-print the JSON (human-readable). Default is compact.",
666
+ )
667
+ p_list.add_argument(
668
+ "--hydration-ms",
669
+ type=int,
670
+ default=8000,
671
+ help="Milliseconds to wait after /chat navigation for matrix-js-sdk to incremental-sync (default 8000).",
672
+ )
673
+
674
+ p_ing = sub.add_parser(
675
+ "ingest-unread",
676
+ help="Upsert every unread Reddit chat room into dms + log each new inbound message into dm_messages.",
677
+ )
678
+ p_ing.add_argument("--dry-run", action="store_true", help="Simulate only; no DB writes.")
679
+ p_ing.add_argument("--pretty", action="store_true")
680
+ p_ing.add_argument("--hydration-ms", type=int, default=8000)
681
+
682
+ p_bf = sub.add_parser(
683
+ "backfill-chat-urls",
684
+ help="Walk all joined Reddit chat rooms and fill any platform=reddit dms row with chat_url IS NULL whose author matches.",
685
+ )
686
+ p_bf.add_argument("--dry-run", action="store_true", help="Simulate only; no DB writes.")
687
+ p_bf.add_argument("--pretty", action="store_true")
688
+ p_bf.add_argument("--hydration-ms", type=int, default=8000)
689
+
690
+ args = ap.parse_args()
691
+
692
+ if args.command == "list-unread":
693
+ result = list_unread(hydration_wait_ms=args.hydration_ms)
694
+ elif args.command == "ingest-unread":
695
+ result = ingest_unread(hydration_wait_ms=args.hydration_ms, dry_run=args.dry_run)
696
+ elif args.command == "backfill-chat-urls":
697
+ result = backfill_chat_urls(hydration_wait_ms=args.hydration_ms, dry_run=args.dry_run)
698
+ else:
699
+ ap.print_help()
700
+ sys.exit(2)
701
+
702
+ if args.pretty:
703
+ print(json.dumps(result, indent=2, ensure_ascii=False))
704
+ else:
705
+ print(json.dumps(result, ensure_ascii=False))
706
+ sys.exit(0 if result.get("ok") else 1)
707
+
708
+
709
+ if __name__ == "__main__":
710
+ main()