@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.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1314 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +497 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- 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()
|