@m13v/s4l 1.6.197-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1314 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +497 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,783 @@
1
+ #!/usr/bin/env python3
2
+ """Strike escalation rail.
3
+
4
+ Background scan that emails i@m13v.com whenever a previously-active post
5
+ flips to status='deleted' or status='removed'. We do not want a comment
6
+ disappearing without us hearing about it, e.g. the antiwork/gumroad block
7
+ on 2026-05-01 was found via inbound notification email, not via our own
8
+ pipeline.
9
+
10
+ Idempotency: posts.strike_email_sent_at TIMESTAMPTZ. NULL = not yet
11
+ emailed. Set to NOW() after a successful send. Historical strikes were
12
+ backfilled to a non-NULL value at column creation so we only alert NEW
13
+ strikes from then forward.
14
+
15
+ Usage:
16
+ # default sweep (used by launchd plist)
17
+ python3 scripts/strike_alert.py --sweep
18
+
19
+ # target a single post (manual re-fire / smoke test)
20
+ python3 scripts/strike_alert.py --post-id 22200
21
+
22
+ # see what would be sent without sending
23
+ python3 scripts/strike_alert.py --sweep --dry-run
24
+
25
+ # cap the batch (sanity gate against a wide-spread moderation event)
26
+ python3 scripts/strike_alert.py --sweep --limit 10
27
+
28
+ Patterned after seo/escalate.py: same Gmail token, same dash-scrubbing,
29
+ same recipient default (NOTIFICATION_EMAIL env override). Independent
30
+ from stats.py so a Python error in the sweeper cannot break the
31
+ stats refresh.
32
+ """
33
+
34
+ import argparse
35
+ import base64
36
+ import json
37
+ import os
38
+ import re
39
+ import shutil
40
+ import subprocess
41
+ import sys
42
+ import time
43
+ import urllib.error
44
+ import urllib.request
45
+ from datetime import datetime, timezone
46
+ from email.mime.text import MIMEText
47
+ from urllib.parse import urlparse
48
+
49
+
50
+ def _resolve_gh():
51
+ """Locate the `gh` binary. Returns the absolute path or None.
52
+
53
+ The launchd plist sets PATH=/opt/homebrew/bin:..., but anyone running
54
+ this script from a shell where /opt/homebrew/bin is not on PATH (or
55
+ from a future cron that drops the path) will silently fall back to
56
+ `state=unknown`, defeating the repo-gone filter. Resolve once at
57
+ import and log loudly on miss."""
58
+ p = shutil.which("gh")
59
+ if p:
60
+ return p
61
+ for c in ("/opt/homebrew/bin/gh", "/usr/local/bin/gh"):
62
+ if os.path.isfile(c) and os.access(c, os.X_OK):
63
+ return c
64
+ return None
65
+
66
+
67
+ _GH_BIN = _resolve_gh()
68
+ if _GH_BIN is None:
69
+ print(
70
+ "[strike_alert] WARNING: `gh` binary not found on PATH or in "
71
+ "/opt/homebrew/bin /usr/local/bin. Repo-gone filter will be "
72
+ "disabled and every github strike will email.",
73
+ file=sys.stderr,
74
+ )
75
+
76
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
77
+ sys.path.insert(0, SCRIPT_DIR)
78
+ from http_api import api_get, api_patch, load_env # noqa: E402
79
+
80
+ GMAIL_TOKEN_PATH = os.path.expanduser("~/gmail-api/token_i_at_m13v.com.json")
81
+ GMAIL_SCOPES = ["https://mail.google.com/"]
82
+ NOTIFICATION_EMAIL = os.environ.get("NOTIFICATION_EMAIL", "i@m13v.com")
83
+ DEFAULT_LIMIT = 25
84
+
85
+
86
+ def _scrub_dashes(s):
87
+ if not s:
88
+ return s
89
+ return s.replace("—", ",").replace("–", ",")
90
+
91
+
92
+ def _gmail_service():
93
+ from google.auth.transport.requests import Request
94
+ from google.oauth2.credentials import Credentials
95
+ from googleapiclient.discovery import build
96
+
97
+ creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
98
+ if creds.expired and creds.refresh_token:
99
+ creds.refresh(Request())
100
+ with open(GMAIL_TOKEN_PATH, "w") as f:
101
+ f.write(creds.to_json())
102
+ return build("gmail", "v1", credentials=creds)
103
+
104
+
105
+ _REPO_STATE_CACHE = {}
106
+
107
+
108
+ def _github_repo_state(thread_url):
109
+ """Return one of:
110
+
111
+ - 'repo_gone' : parent repo 404s (owner deleted the whole repo)
112
+ - 'issue_deleted' : repo is live but the specific issue/PR thread is
113
+ gone (HTTP 410 'This issue was deleted', or 404
114
+ on `repos/{o}/{r}/issues/{n}`). Every comment
115
+ under that thread vanishes at once; not a
116
+ moderation strike against our comment.
117
+ - 'feature_disabled' : repo is live but the feature this comment lived
118
+ on is turned off (e.g. /issues/123 on a repo
119
+ with has_issues=false, or /discussions/N on a
120
+ repo with has_discussions=false). ALL issues
121
+ or ALL discussions vanish at once; this is not
122
+ a moderation strike against our comment.
123
+ - 'live' : repo is alive AND the relevant feature is on.
124
+ Our comment is gone in isolation, true strike.
125
+ - 'unknown' : network error, non-github URL, etc.
126
+
127
+ Distinguishes moderation strikes (our content was hidden/deleted on a
128
+ live, fully-featured repo) from collateral damage (owner restructured
129
+ the project). Cached per-process; the gh-api fetch is at most two
130
+ round-trips per (repo, issue#) pair per sweep."""
131
+ if not thread_url:
132
+ return "unknown"
133
+ parts = urlparse(thread_url).path.strip("/").split("/")
134
+ if len(parts) < 2 or not parts[0] or not parts[1]:
135
+ return "unknown"
136
+ owner, repo = parts[0], parts[1]
137
+ # which sub-feature did this URL live on? issues / discussions / other.
138
+ feature = None
139
+ issue_number = None
140
+ if len(parts) >= 3:
141
+ if parts[2] == "issues":
142
+ feature = "issues"
143
+ elif parts[2] == "discussions":
144
+ feature = "discussions"
145
+ elif parts[2] == "pull":
146
+ feature = "pull"
147
+ if feature in ("issues", "pull") and len(parts) >= 4:
148
+ try:
149
+ issue_number = int(parts[3])
150
+ except (ValueError, IndexError):
151
+ issue_number = None
152
+ key = f"{owner}/{repo}".lower()
153
+ if key in _REPO_STATE_CACHE:
154
+ cached = _REPO_STATE_CACHE[key]
155
+ else:
156
+ if _GH_BIN is None:
157
+ # gh not found at import-time; logged once at module load.
158
+ # Returning 'unknown' here means the in-loop filter will not
159
+ # skip this row, so the email DOES fire. That is intentional
160
+ # graceful degradation: better to send a noisy email than to
161
+ # silently drop a real moderation strike.
162
+ _REPO_STATE_CACHE[key] = {"state": "unknown"}
163
+ return "unknown"
164
+ try:
165
+ proc = subprocess.run(
166
+ [_GH_BIN, "api", f"repos/{owner}/{repo}"],
167
+ capture_output=True, text=True, timeout=20,
168
+ )
169
+ except FileNotFoundError as e:
170
+ print(
171
+ f"[strike_alert] gh subprocess FileNotFoundError "
172
+ f"({_GH_BIN}): {e}", file=sys.stderr,
173
+ )
174
+ _REPO_STATE_CACHE[key] = {"state": "unknown"}
175
+ return "unknown"
176
+ except Exception as e:
177
+ print(
178
+ f"[strike_alert] gh subprocess error for {owner}/{repo}: "
179
+ f"{e}", file=sys.stderr,
180
+ )
181
+ _REPO_STATE_CACHE[key] = {"state": "unknown"}
182
+ return "unknown"
183
+ if proc.returncode == 0:
184
+ try:
185
+ data = json.loads(proc.stdout or "{}")
186
+ except Exception:
187
+ data = {}
188
+ cached = {
189
+ "state": "live",
190
+ "has_issues": bool(data.get("has_issues", True)),
191
+ "has_discussions": bool(data.get("has_discussions", True)),
192
+ }
193
+ else:
194
+ err = ((proc.stderr or "") + (proc.stdout or "")).lower()
195
+ if "not found" in err or "http 404" in err:
196
+ cached = {"state": "repo_gone"}
197
+ else:
198
+ cached = {"state": "unknown"}
199
+ _REPO_STATE_CACHE[key] = cached
200
+
201
+ base = cached.get("state", "unknown")
202
+ if base != "live":
203
+ return base
204
+ # Repo is alive; check whether the specific feature the URL points at is on.
205
+ if feature == "issues" and not cached.get("has_issues", True):
206
+ return "feature_disabled"
207
+ if feature == "discussions" and not cached.get("has_discussions", True):
208
+ return "feature_disabled"
209
+ # Repo + feature both live; check the individual issue/PR thread. Cached
210
+ # separately so multiple comments on the same thread share one call.
211
+ if feature in ("issues", "pull") and issue_number is not None:
212
+ issue_key = f"{owner}/{repo}#{issue_number}".lower()
213
+ if issue_key in _REPO_STATE_CACHE:
214
+ issue_state = _REPO_STATE_CACHE[issue_key]
215
+ else:
216
+ try:
217
+ proc = subprocess.run(
218
+ [_GH_BIN, "api", f"repos/{owner}/{repo}/issues/{issue_number}"],
219
+ capture_output=True, text=True, timeout=20,
220
+ )
221
+ except Exception as e:
222
+ print(
223
+ f"[strike_alert] gh subprocess error for "
224
+ f"{owner}/{repo}/issues/{issue_number}: {e}",
225
+ file=sys.stderr,
226
+ )
227
+ _REPO_STATE_CACHE[issue_key] = {"state": "unknown"}
228
+ return "live" # graceful: assume live so email fires
229
+ if proc.returncode == 0:
230
+ issue_state = {"state": "live"}
231
+ else:
232
+ err = ((proc.stderr or "") + (proc.stdout or "")).lower()
233
+ if ("not found" in err or "http 404" in err
234
+ or "http 410" in err
235
+ or "this issue was deleted" in err):
236
+ issue_state = {"state": "issue_deleted"}
237
+ else:
238
+ issue_state = {"state": "unknown"}
239
+ _REPO_STATE_CACHE[issue_key] = issue_state
240
+ if issue_state["state"] == "issue_deleted":
241
+ return "issue_deleted"
242
+ return "live"
243
+
244
+
245
+ def _reddit_live_recheck(our_url, our_account, user_agent):
246
+ """Pre-send Reddit live re-check (added 2026-05-16).
247
+
248
+ Before firing a strike email, fetch the comment URL one more time. If
249
+ the comment body is real content (not [deleted]/[removed]), stats.py
250
+ false-flagged it (transient parse error, rate-limit miss, etc.) and the
251
+ strike is bogus. Return one of:
252
+
253
+ 'alive' - comment is visible with real content. Caller should flip
254
+ status back to 'active', reset deletion_detect_count, and
255
+ skip the email.
256
+ 'dead' - comment is confirmed [deleted]/[removed] or 404. Real
257
+ strike, send the email.
258
+ 'unknown' - couldn't determine (rate limit, network error, malformed
259
+ response). Fail-open: send the email anyway. Mirrors the
260
+ github _github_repo_state='unknown' graceful-degradation
261
+ pattern: better to send a noisy email than silently drop
262
+ a real moderation strike.
263
+
264
+ Self-healing rationale: even with the weekly resurrect job, the alert
265
+ fires at T+0 detection while resurrect runs later. Without this guard
266
+ a brittle 2-detection threshold + a couple of bad scrapes was enough
267
+ to send a false-positive email (see post #23005 / #23223 on 2026-05-07,
268
+ both alive at the time the strike emails went out).
269
+ """
270
+ if not our_url or not our_url.startswith("http"):
271
+ return "unknown"
272
+
273
+ json_url = re.sub(r"www\.reddit\.com", "old.reddit.com", our_url).rstrip("/") + ".json"
274
+ req = urllib.request.Request(json_url, headers={"User-Agent": user_agent})
275
+ for attempt in range(2):
276
+ try:
277
+ with urllib.request.urlopen(req, timeout=15) as resp:
278
+ body = resp.read()
279
+ if not body:
280
+ return "unknown"
281
+ try:
282
+ data = json.loads(body)
283
+ except Exception:
284
+ return "unknown"
285
+ break
286
+ except urllib.error.HTTPError as e:
287
+ if e.code == 404:
288
+ return "dead"
289
+ if e.code == 429 and attempt == 0:
290
+ time.sleep(10)
291
+ continue
292
+ return "unknown"
293
+ except Exception:
294
+ if attempt == 0:
295
+ time.sleep(5)
296
+ continue
297
+ return "unknown"
298
+ else:
299
+ return "unknown"
300
+
301
+ if not isinstance(data, list) or len(data) < 2:
302
+ return "unknown"
303
+
304
+ has_comment_id = bool(
305
+ re.search(r"/comment/[a-z0-9]+", our_url) or
306
+ re.search(r"/comments/[a-z0-9]+/[^/]+/[a-z0-9]+", our_url)
307
+ )
308
+
309
+ if has_comment_id:
310
+ children = data[1].get("data", {}).get("children", [])
311
+ if not children:
312
+ return "dead"
313
+ cd = children[0].get("data", {})
314
+ cbody = cd.get("body", "")
315
+ cauthor = cd.get("author", "")
316
+ if cbody in ("[deleted]", "[removed]") or cauthor == "[deleted]":
317
+ return "dead"
318
+ if cbody.strip():
319
+ return "alive"
320
+ return "unknown"
321
+ else:
322
+ thread = data[0].get("data", {}).get("children", [{}])[0].get("data", {})
323
+ thread_author = thread.get("author", "")
324
+ if our_account and thread_author.lower() == our_account.lower():
325
+ if thread.get("removed_by_category") or thread.get("selftext") in ("[removed]", "[deleted]"):
326
+ return "dead"
327
+ return "alive"
328
+ children = data[1].get("data", {}).get("children", [])
329
+ for child in children:
330
+ cd = child.get("data", {})
331
+ if our_account and cd.get("author", "").lower() == our_account.lower():
332
+ cbody = cd.get("body", "")
333
+ if cbody in ("[deleted]", "[removed]"):
334
+ return "dead"
335
+ if cbody.strip():
336
+ return "alive"
337
+ break
338
+ return "unknown"
339
+
340
+
341
+ def _twitter_live_recheck(our_url):
342
+ """Pre-send Twitter live re-check (added 2026-06-05).
343
+
344
+ Mirrors _reddit_live_recheck. stats.py marks a tweet 'deleted' after 2
345
+ fxtwitter 404s, but fxtwitter is an UNAUTHENTICATED guest API: for
346
+ Community-scoped posts and some replies it returns a *tombstone*
347
+ (type="tombstone", reason="unavailable") even though the tweet is alive
348
+ to a logged-in viewer. On 2026-06-05, 5 of 6 twitter strike emails were
349
+ tombstone-unavailable rows that were live in the authenticated harness
350
+ (#35715/#35712 Community posts; #31131/#31130/#29509 normal replies).
351
+
352
+ stats.py was patched the same day to stop counting tombstones as
353
+ deletions; this is the second safety net for rows that were flagged
354
+ before that fix shipped, or for any future guest-API blind spot. We re-hit
355
+ fxtwitter and key on the SAME signal:
356
+
357
+ 'alive' - fxtwitter returns a real tweet OR a tombstone (guest-API
358
+ blind spot, not a deletion). Caller flips status back to
359
+ 'active', resets deletion_detect_count, skips the email.
360
+ 'dead' - genuine NOT_FOUND (code 404 with tweet=None, no tombstone).
361
+ Real deletion, send the email.
362
+ 'unknown' - network error / unparseable. Fail-open: send the email.
363
+ Mirrors the github 'unknown' graceful-degradation pattern.
364
+ """
365
+ if not our_url or not our_url.startswith("http"):
366
+ return "unknown"
367
+ m = re.search(r"(?:twitter|x)\.com/([^/]+)/status/(\d+)", our_url)
368
+ if not m:
369
+ return "unknown"
370
+ username, tweet_id = m.group(1), m.group(2)
371
+ api_url = f"https://api.fxtwitter.com/{username}/status/{tweet_id}"
372
+ req = urllib.request.Request(
373
+ api_url, headers={"User-Agent": "social-autoposter/1.0"}
374
+ )
375
+ for attempt in range(2):
376
+ try:
377
+ with urllib.request.urlopen(req, timeout=15) as resp:
378
+ body = resp.read()
379
+ try:
380
+ data = json.loads(body) if body else None
381
+ except Exception:
382
+ return "unknown"
383
+ break
384
+ except urllib.error.HTTPError as e:
385
+ # fxtwitter answers 404 with a JSON body (tombstone OR null tweet).
386
+ # Read it so we can distinguish the two; a bare HTTPError without a
387
+ # parseable body is 'unknown'.
388
+ try:
389
+ data = json.loads(e.read() or b"")
390
+ except Exception:
391
+ if attempt == 0:
392
+ time.sleep(3)
393
+ continue
394
+ return "unknown"
395
+ break
396
+ except Exception:
397
+ if attempt == 0:
398
+ time.sleep(3)
399
+ continue
400
+ return "unknown"
401
+ else:
402
+ return "unknown"
403
+
404
+ if not isinstance(data, dict):
405
+ return "unknown"
406
+ tweet = data.get("tweet")
407
+ if isinstance(tweet, dict) and tweet.get("type") == "tombstone":
408
+ # Guest-API blind spot (Community post / restricted reply). Alive.
409
+ return "alive"
410
+ if isinstance(tweet, dict):
411
+ # Real tweet object came back -> definitely alive.
412
+ return "alive"
413
+ code = data.get("code", 0)
414
+ if code == 404 or tweet is None:
415
+ return "dead"
416
+ return "unknown"
417
+
418
+
419
+ def _resurrect_post(post_id):
420
+ """Flip a Reddit strike row back to 'active' after a live re-check confirms
421
+ the comment is still visible. Mirrors update_reddit_resurrect's UPDATE."""
422
+ api_patch(f"/api/v1/posts/{int(post_id)}", {
423
+ "status": "active",
424
+ "reset_deletion_detect_count": True,
425
+ "stamp_resurrected_now": True,
426
+ "stamp_status_checked_now": True,
427
+ })
428
+
429
+
430
+ def _owner_strike_count(owner, days=90):
431
+ """How many of our posts under this owner have been moderated in the
432
+ last `days` days, excluding posts whose entire parent repo is now 404
433
+ (repo-gone is not a moderation strike). Mirrors the same filtering used
434
+ by github_tools._dynamic_owner_blocklist so the email body and the
435
+ search-time blocklist stay in sync."""
436
+ if not owner:
437
+ return (0, 0)
438
+ prefix = f"https://github.com/{owner.lower()}/"
439
+ resp = api_get("/api/v1/posts/thread-urls", query={
440
+ "platform": "github", "moderated_within_days": int(days),
441
+ })
442
+ all_urls = (resp.get("data") or {}).get("thread_urls") or []
443
+ raw_count = 0
444
+ live_count = 0
445
+ for url in all_urls:
446
+ if not url or not url.lower().startswith(prefix):
447
+ continue
448
+ raw_count += 1
449
+ state = _github_repo_state(url)
450
+ # repo_gone, issue_deleted, and feature_disabled are all "owner
451
+ # restructured" cases, not moderation. Don't count them against
452
+ # the owner.
453
+ if state not in ("repo_gone", "issue_deleted", "feature_disabled"):
454
+ live_count += 1
455
+ return (live_count, raw_count)
456
+
457
+
458
+ def _format_subject(post, repo_state=None):
459
+ platform = post["platform"] or "?"
460
+ status = post["status"] or "?"
461
+ tag = "STRIKE"
462
+ if platform == "github" and repo_state == "repo_gone":
463
+ # Owner nuked the whole repo. Not a moderation strike against us.
464
+ status = "repo-deleted"
465
+ tag = "STRIKE-REPOGONE"
466
+ elif platform == "github" and repo_state == "issue_deleted":
467
+ # Owner deleted the specific issue/PR thread (HTTP 410). Every
468
+ # comment under it vanishes, not just ours.
469
+ status = "issue-deleted"
470
+ tag = "STRIKE-ISSUEGONE"
471
+ elif platform == "github" and repo_state == "feature_disabled":
472
+ # Repo is alive but Issues/Discussions feature was disabled. Our
473
+ # comment vanished as collateral, not a moderation action.
474
+ status = "feature-disabled"
475
+ tag = "STRIKE-FEATURE-OFF"
476
+ project = post["project_name"] or "(no project)"
477
+ title = (post["thread_title"] or "")[:60]
478
+ return _scrub_dashes(
479
+ f"[{tag} #{post['id']}] {platform} {status}: {project} / {title}"
480
+ )
481
+
482
+
483
+ def _ts(v):
484
+ """Render a timestamp field. The HTTP API returns ISO strings; tolerate a
485
+ datetime too (defensive, in case a caller passes one)."""
486
+ if not v:
487
+ return "?"
488
+ return v.isoformat() if hasattr(v, "isoformat") else str(v)
489
+
490
+
491
+ def _format_body(post, repo_state=None):
492
+ platform = post["platform"] or "?"
493
+ status = post["status"] or "?"
494
+ project = post["project_name"] or "(no project)"
495
+ account = post["our_account"] or "?"
496
+ posted_at = _ts(post["posted_at"])
497
+ checked_at = _ts(post["status_checked_at"])
498
+ thread_url = post["thread_url"] or "?"
499
+ our_url = post["our_url"] or "(no comment URL)"
500
+ title = post["thread_title"] or "(no title)"
501
+ content = (post["our_content"] or "(no content)").strip()
502
+ content_preview = content[:600] + ("..." if len(content) > 600 else "")
503
+ style = post["engagement_style"] or "(none)"
504
+ detect_count = post["deletion_detect_count"] or 0
505
+
506
+ owner_block = ""
507
+ repo_block = ""
508
+ if platform == "github" and thread_url:
509
+ if repo_state == "repo_gone":
510
+ repo_block = (
511
+ "Repo state: GONE (parent repo returns 404). "
512
+ "Owner nuked the whole repo, this is not a moderation strike "
513
+ "against our comment specifically.\n"
514
+ )
515
+ elif repo_state == "issue_deleted":
516
+ repo_block = (
517
+ "Repo state: live but ISSUE/PR THREAD DELETED (HTTP 410 or "
518
+ "404 on the specific issue/PR endpoint). Owner deleted the "
519
+ "entire thread; every comment under it vanishes, not just "
520
+ "ours. Collateral damage, not a moderation strike.\n"
521
+ )
522
+ elif repo_state == "feature_disabled":
523
+ repo_block = (
524
+ "Repo state: live but FEATURE DISABLED (has_issues=false or "
525
+ "has_discussions=false on the repo). The entire issues/"
526
+ "discussions surface was turned off by the owner; every "
527
+ "comment under it 404s, not just ours. This is collateral "
528
+ "damage, not a moderation strike.\n"
529
+ )
530
+ elif repo_state == "live":
531
+ repo_block = "Repo state: live (only our comment is gone, true strike).\n"
532
+ parts = urlparse(thread_url).path.strip("/").split("/")
533
+ owner = parts[0] if parts else None
534
+ if owner:
535
+ live_n, raw_n = _owner_strike_count(owner)
536
+ from github_tools import DYNAMIC_BLOCK_THRESHOLD as THR
537
+ verdict = (
538
+ "AUTO-BLOCKLISTED" if live_n >= THR
539
+ else f"under threshold ({live_n}/{THR})"
540
+ )
541
+ extra = (
542
+ f" ({raw_n - live_n} excluded as repo-gone)"
543
+ if raw_n > live_n else ""
544
+ )
545
+ owner_block = (
546
+ f"Owner: {owner} ({live_n} real strikes in last 90 days{extra}, "
547
+ f"{verdict})\n"
548
+ )
549
+
550
+ body = (
551
+ f"Strike on social-autoposter post #{post['id']}\n"
552
+ f"\n"
553
+ f"Platform: {platform}\n"
554
+ f"Status: {status} (deletion_detect_count={detect_count})\n"
555
+ f"Project: {project}\n"
556
+ f"Account: {account}\n"
557
+ f"Style: {style}\n"
558
+ f"Posted: {posted_at}\n"
559
+ f"Detected: {checked_at}\n"
560
+ f"{repo_block}"
561
+ f"{owner_block}"
562
+ f"\n"
563
+ f"Thread: {thread_url}\n"
564
+ f"Title: {title}\n"
565
+ f"Comment: {our_url}\n"
566
+ f"\n"
567
+ f"--- Our content ---\n"
568
+ f"{content_preview}\n"
569
+ f"\n"
570
+ f"--- Next steps ---\n"
571
+ f"1. Inspect the thread to see if the comment was deleted, hidden,\n"
572
+ f" or if the whole account was blocked.\n"
573
+ f"2. If the owner should be hard-blocked, add it to\n"
574
+ f" config.json -> exclusions.github_repos. Owner-level entries\n"
575
+ f" match all repos under that owner.\n"
576
+ f"3. The auto-blocklist (github_tools._dynamic_owner_blocklist)\n"
577
+ f" already covers any owner with >=2 strikes in 90 days.\n"
578
+ f"\n"
579
+ f"To re-fire this alert: python3 scripts/strike_alert.py --post-id {post['id']}\n"
580
+ )
581
+ return _scrub_dashes(body)
582
+
583
+
584
+ def _send_email(subject, body):
585
+ msg = MIMEText(body, "plain", "utf-8")
586
+ msg["to"] = NOTIFICATION_EMAIL
587
+ msg["subject"] = subject
588
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
589
+ service = _gmail_service()
590
+ return service.users().messages().send(userId="me", body={"raw": raw}).execute()
591
+
592
+
593
+ def _select_pending(post_id=None, limit=None):
594
+ if post_id is not None:
595
+ resp = api_get(f"/api/v1/posts/{int(post_id)}", ok_on_404=True)
596
+ post = (resp.get("data") or {}).get("post") if resp else None
597
+ return [post] if post else []
598
+ # Mentions live in the dedicated `mentions` table now (2026-05-23 cutover);
599
+ # no posts-level filter needed. Previously this clause excluded placeholder
600
+ # `posts` rows where our_content = '(mention - no original post)' to avoid
601
+ # alerting on third-party tweets that fxtwitter 404'd (spammer accounts
602
+ # getting cleaned up). Those rows are gone after
603
+ # migrate_mentions_out_of_posts.py --commit-delete; the posts table now
604
+ # only contains content we authored, so every status='deleted' row IS a
605
+ # real moderation strike against us.
606
+ resp = api_get("/api/v1/posts/pending-strikes",
607
+ query={"limit": int(limit)} if limit else None)
608
+ return (resp.get("data") or {}).get("posts") or []
609
+
610
+
611
+ def _mark_sent(post_id):
612
+ api_patch(f"/api/v1/posts/{int(post_id)}", {"stamp_strike_email_sent_now": True})
613
+
614
+
615
+ def main():
616
+ parser = argparse.ArgumentParser(description=__doc__)
617
+ parser.add_argument("--sweep", action="store_true",
618
+ help="Scan posts for unalerted strikes (default mode).")
619
+ parser.add_argument("--post-id", type=int,
620
+ help="Target a single post id; overrides --sweep gating "
621
+ "and ignores strike_email_sent_at.")
622
+ parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT,
623
+ help=f"Max alerts per run (default {DEFAULT_LIMIT}). "
624
+ f"Sanity gate against a wide moderation event.")
625
+ parser.add_argument("--dry-run", action="store_true",
626
+ help="Print what would be sent without sending or marking.")
627
+ args = parser.parse_args()
628
+
629
+ load_env()
630
+
631
+ # Reddit user-agent for the live re-check. Mirrors stats.py:1924
632
+ # so the pre-send re-check uses the same UA Reddit already saw on the
633
+ # ingest side.
634
+ try:
635
+ from project_config import load_config as _load_cfg # type: ignore
636
+ _cfg = _load_cfg()
637
+ except Exception:
638
+ try:
639
+ with open(os.path.join(os.path.dirname(SCRIPT_DIR), "config.json")) as _f:
640
+ _cfg = json.load(_f)
641
+ except Exception:
642
+ _cfg = {}
643
+ _reddit_username = (_cfg.get("accounts", {}) or {}).get("reddit", {}).get("username", "")
644
+ _reddit_ua = (
645
+ f"social-autoposter/1.0 (u/{_reddit_username})"
646
+ if _reddit_username else "social-autoposter/1.0"
647
+ )
648
+
649
+ rows = _select_pending(post_id=args.post_id, limit=args.limit)
650
+ if not rows:
651
+ print("[strike_alert] no pending strikes")
652
+ return
653
+
654
+ sent = 0
655
+ skipped = 0
656
+ filtered = 0
657
+ failed = 0
658
+ for r in rows:
659
+ # When --post-id is used, allow re-fire even if already sent.
660
+ if args.post_id is None and r["strike_email_sent_at"] is not None:
661
+ skipped += 1
662
+ continue
663
+
664
+ # Reddit live re-check (added 2026-05-16). stats.py uses a
665
+ # 2-detection threshold which is brittle to transient scrape failures
666
+ # and rate-limit misses. Confirmed false positives on 2026-05-07
667
+ # (post 23005 /r/PAstudent/Active recall with Anki, post 23223 /r/
668
+ # UniversityOfHouston/UH Finals Study App): both comments alive at
669
+ # the time the strike email was sent. This guard fetches the
670
+ # comment URL one more time right before the email goes out; if it's
671
+ # still visible we flip status back to 'active' and skip the alert,
672
+ # eliminating that class of false positive without weakening the
673
+ # detection signal for real strikes.
674
+ if args.post_id is None and r["platform"] == "reddit":
675
+ live_state = _reddit_live_recheck(
676
+ r["our_url"], r["our_account"], _reddit_ua
677
+ )
678
+ print(
679
+ f"[strike_alert] id={r['id']} platform=reddit "
680
+ f"live_recheck={live_state} url={r['our_url']}",
681
+ flush=True,
682
+ )
683
+ if live_state == "alive":
684
+ if not args.dry_run:
685
+ _resurrect_post(r["id"])
686
+ filtered += 1
687
+ print(
688
+ f"[strike_alert] filtered id={r['id']} reason=reddit-alive "
689
+ f"(false positive, status flipped back to active, no email)",
690
+ flush=True,
691
+ )
692
+ continue
693
+
694
+ # Twitter live re-check (added 2026-06-05). stats.py marks a tweet
695
+ # 'deleted' after 2 fxtwitter 404s, but fxtwitter's guest API returns a
696
+ # tombstone for Community posts and some replies that are alive to a
697
+ # logged-in viewer (5 of 6 strikes on 2026-06-05 were this false
698
+ # positive). Re-hit fxtwitter; if it's a tombstone or a real tweet the
699
+ # row is alive, flip it back to 'active' and skip the email.
700
+ if args.post_id is None and r["platform"] == "twitter":
701
+ live_state = _twitter_live_recheck(r["our_url"])
702
+ print(
703
+ f"[strike_alert] id={r['id']} platform=twitter "
704
+ f"live_recheck={live_state} url={r['our_url']}",
705
+ flush=True,
706
+ )
707
+ if live_state == "alive":
708
+ if not args.dry_run:
709
+ _resurrect_post(r["id"])
710
+ filtered += 1
711
+ print(
712
+ f"[strike_alert] filtered id={r['id']} reason=twitter-alive "
713
+ f"(false positive, status flipped back to active, no email)",
714
+ flush=True,
715
+ )
716
+ continue
717
+
718
+ repo_state = None
719
+ if r["platform"] == "github":
720
+ repo_state = _github_repo_state(r["thread_url"])
721
+ print(
722
+ f"[strike_alert] id={r['id']} platform=github "
723
+ f"repo_state={repo_state} thread={r['thread_url']}",
724
+ flush=True,
725
+ )
726
+
727
+ # GitHub collateral damage: when the parent repo 404s, or when
728
+ # the issues/discussions feature is disabled on a live repo, the
729
+ # comment vanished as part of a structural change, not a
730
+ # moderation action against us. Don't email; mark sent so the
731
+ # row drops out of the pending queue and stops being evaluated
732
+ # every hour by the cron. The row is retained in the table for
733
+ # archaeology and the dashboard still shows status='deleted'.
734
+ # See the May 15 audit (15 of 27 strikes were REPOGONE) for the
735
+ # canonical false-positive batch.
736
+ if args.post_id is None and repo_state in (
737
+ "repo_gone", "issue_deleted", "feature_disabled"
738
+ ):
739
+ if not args.dry_run:
740
+ _mark_sent(r["id"])
741
+ filtered += 1
742
+ reason_map = {
743
+ "repo_gone": "repo-gone",
744
+ "issue_deleted": "issue-deleted",
745
+ "feature_disabled": "feature-disabled",
746
+ }
747
+ reason = reason_map[repo_state]
748
+ print(
749
+ f"[strike_alert] filtered id={r['id']} reason={reason} "
750
+ f"(marked sent, no email)",
751
+ flush=True,
752
+ )
753
+ continue
754
+
755
+ subject = _format_subject(r, repo_state=repo_state)
756
+ body = _format_body(r, repo_state=repo_state)
757
+ if args.dry_run:
758
+ print(f"[strike_alert] DRY RUN id={r['id']}")
759
+ print(f" subject: {subject}")
760
+ print(" body:")
761
+ for line in body.split("\n"):
762
+ print(f" {line}")
763
+ sent += 1
764
+ continue
765
+ try:
766
+ _send_email(subject, body)
767
+ _mark_sent(r["id"])
768
+ sent += 1
769
+ print(f"[strike_alert] alerted id={r['id']} ({r['platform']} {r['status']})")
770
+ except Exception as e:
771
+ failed += 1
772
+ print(f"[strike_alert] FAILED id={r['id']}: {e}", file=sys.stderr)
773
+
774
+ print(
775
+ f"[strike_alert] sent={sent} skipped={skipped} "
776
+ f"filtered={filtered} failed={failed}"
777
+ )
778
+ if failed:
779
+ sys.exit(1)
780
+
781
+
782
+ if __name__ == "__main__":
783
+ main()