@m13v/s4l 1.6.197-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1336 -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 +513 -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,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()
|