@m13v/s4l 1.7.6-rc.13 → 1.7.6-rc.15
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/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +35 -3
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/merge_review_queue.py +8 -0
- package/scripts/platform_strike_events.py +54 -5
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/reddit_ban_check.py +220 -0
- package/scripts/release-mcpb.sh +38 -7
- package/scripts/strike_alert.py +161 -12
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.6-rc.
|
|
5
|
+
"version": "1.7.6-rc.15",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -331,7 +331,7 @@ def _truncate(s, n=320):
|
|
|
331
331
|
return s if len(s) <= n else s[: n - 1] + "…"
|
|
332
332
|
|
|
333
333
|
|
|
334
|
-
def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
|
|
334
|
+
def _set_thread_text(thread_tv, text, link_url, *, font_size=12, title=None):
|
|
335
335
|
"""Set the thread-quote text on `thread_tv`: the FULL text, never
|
|
336
336
|
trimmed (2026-07-16 user direction -- the box scrolls instead when it
|
|
337
337
|
doesn't fit; see its NSScrollView wrapper in _render()). A leading
|
|
@@ -340,8 +340,15 @@ def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
|
|
|
340
340
|
of how long the thread is or where the box happens to be scrolled to
|
|
341
341
|
(the old trailing-link placement could end up scrolled out of view, or
|
|
342
342
|
-- before scrolling existed at all -- get silently truncated off the
|
|
343
|
-
end; see the superseded _fit_thread_body this replaced).
|
|
343
|
+
end; see the superseded _fit_thread_body this replaced).
|
|
344
|
+
|
|
345
|
+
`title` (2026-07-17 user direction, Reddit): when given, it's drawn BOLD
|
|
346
|
+
on its own line ahead of `text`, so a Reddit card shows the post title in
|
|
347
|
+
bold with the selftext body beneath it in the same quote box. Twitter and
|
|
348
|
+
title-only Reddit threads pass title=None and render `text` alone,
|
|
349
|
+
exactly as before."""
|
|
344
350
|
text = (text or "").strip()
|
|
351
|
+
title = (title or "").strip()
|
|
345
352
|
body = NSMutableAttributedString.alloc().initWithString_attributes_("", {})
|
|
346
353
|
if link_url:
|
|
347
354
|
body.appendAttributedString_(
|
|
@@ -356,6 +363,19 @@ def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
|
|
|
356
363
|
},
|
|
357
364
|
)
|
|
358
365
|
)
|
|
366
|
+
if title:
|
|
367
|
+
# Bold title on its own line; a trailing newline separates it from the
|
|
368
|
+
# body only when there IS a body (a title with no selftext shouldn't
|
|
369
|
+
# leave a dangling blank line).
|
|
370
|
+
body.appendAttributedString_(
|
|
371
|
+
NSAttributedString.alloc().initWithString_attributes_(
|
|
372
|
+
title + ("\n" if text else ""),
|
|
373
|
+
{
|
|
374
|
+
NSFontAttributeName: NSFont.boldSystemFontOfSize_(font_size),
|
|
375
|
+
NSForegroundColorAttributeName: NSColor.labelColor(),
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
)
|
|
359
379
|
body.appendAttributedString_(
|
|
360
380
|
NSAttributedString.alloc().initWithString_attributes_(
|
|
361
381
|
text,
|
|
@@ -1598,7 +1618,19 @@ class _ReviewController(NSObject):
|
|
|
1598
1618
|
pass
|
|
1599
1619
|
if thread_url:
|
|
1600
1620
|
thread_tv.setDelegate_(self)
|
|
1601
|
-
|
|
1621
|
+
# Reddit (2026-07-17 user direction): the thread has a TITLE and a
|
|
1622
|
+
# BODY (selftext). Render the title bold with the body beneath it in
|
|
1623
|
+
# this same quote box. thread_text carries the title (see
|
|
1624
|
+
# merge_review_queue._reddit_plan_to_candidates); thread_selftext is
|
|
1625
|
+
# the body, empty for link posts / title-only threads (then this falls
|
|
1626
|
+
# through to the plain title-only render). Twitter has no selftext, so
|
|
1627
|
+
# it always takes the else branch and renders exactly as before.
|
|
1628
|
+
_thread_title = thread_en or d.get("thread_text")
|
|
1629
|
+
_thread_body = (d.get("thread_selftext") or "").strip() if _plat == "reddit" else ""
|
|
1630
|
+
if _thread_body:
|
|
1631
|
+
_set_thread_text(thread_tv, _thread_body, thread_url, title=_thread_title)
|
|
1632
|
+
else:
|
|
1633
|
+
_set_thread_text(thread_tv, _thread_title, thread_url)
|
|
1602
1634
|
thread_scroll.setDocumentView_(thread_tv)
|
|
1603
1635
|
content.addSubview_(thread_scroll)
|
|
1604
1636
|
# Reply heading — bold. A concise "project/lane · viral N" tag rides
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.6-rc.
|
|
3
|
+
"version": "1.7.6-rc.15",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -229,7 +229,14 @@ def _recent_batches_not_progressing(min_batches: int = BATCH_PROGRESSION_MIN_BAT
|
|
|
229
229
|
import http_api # noqa: E402
|
|
230
230
|
|
|
231
231
|
resp = http_api.api_get("/api/v1/twitter-batches", {"list": "1", "limit": str(min_batches)})
|
|
232
|
-
|
|
232
|
+
# http_api.api_get returns the RAW envelope {"ok":..,"data":{"batches":[..],
|
|
233
|
+
# "owner_host":..}} — it does NOT unwrap "data". The batches live at
|
|
234
|
+
# resp["data"]["batches"], never at resp["batches"]. Reading the wrong key
|
|
235
|
+
# made this return [] -> len<min -> False on EVERY call, so batches_stuck was
|
|
236
|
+
# hardwired False and the "drafting is not actually happening" stall cause
|
|
237
|
+
# could never be selected (missed Nhat's 07-15 phase2b-prep pileup entirely).
|
|
238
|
+
# Do NOT collapse back to resp.get("batches").
|
|
239
|
+
batches = ((resp or {}).get("data") or {}).get("batches") or []
|
|
233
240
|
if len(batches) < min_batches:
|
|
234
241
|
return False # not enough history yet to conclude a stall
|
|
235
242
|
return all((b or {}).get("current_phase") not in BATCH_PROGRESSED_PHASES for b in batches)
|
|
@@ -168,6 +168,14 @@ def _reddit_plan_to_candidates(plan: dict) -> list:
|
|
|
168
168
|
"thread_url": d.get("thread_url"),
|
|
169
169
|
"thread_author": d.get("thread_author"),
|
|
170
170
|
"thread_text": d.get("thread_title") or "",
|
|
171
|
+
# Reddit threads carry a title (thread_text, above) AND a body
|
|
172
|
+
# (selftext). Both matter to a reviewer: the title alone often
|
|
173
|
+
# can't be judged without the post body it heads. The card renders
|
|
174
|
+
# the title bold and the selftext beneath it in the same quote box
|
|
175
|
+
# (2026-07-17 user direction). Link posts / title-only threads have
|
|
176
|
+
# an empty selftext, so the card falls back to title-only. Decisions
|
|
177
|
+
# carry it as `selftext` (from the candidate) or `thread_selftext`.
|
|
178
|
+
"thread_selftext": d.get("selftext") or d.get("thread_selftext") or "",
|
|
171
179
|
"matched_project": plan.get("project_name"),
|
|
172
180
|
"reply_text": d.get("text"),
|
|
173
181
|
"engagement_style": d.get("engagement_style"),
|
|
@@ -106,12 +106,20 @@ def _venue(post: dict) -> str | None:
|
|
|
106
106
|
return None
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def _removed_event(post: dict) -> dict:
|
|
109
|
+
def _removed_event(post: dict, venue_count: int = 0) -> dict:
|
|
110
110
|
venue = _venue(post)
|
|
111
111
|
is_own_thread = bool(post.get("thread_url")) and post.get("thread_url") == post.get("our_url")
|
|
112
112
|
kind = "top-level post" if is_own_thread else "comment/reply"
|
|
113
113
|
posted = str(post.get("posted_at") or "")[:10]
|
|
114
114
|
detected = str(post.get("status_checked_at") or "")[:10]
|
|
115
|
+
# venue_count = how many of our posts were removed in this same venue inside
|
|
116
|
+
# the sweep window. Surfaces clustering (one venue removing many of our
|
|
117
|
+
# posts is a venue-fit lesson, not N unrelated content lessons) directly in
|
|
118
|
+
# the note the digest reads.
|
|
119
|
+
cluster = ""
|
|
120
|
+
if venue and venue_count > 1:
|
|
121
|
+
cluster = (f" This is 1 of {venue_count} of our posts removed in"
|
|
122
|
+
f" {venue} in the recent window (a venue-level pattern).")
|
|
115
123
|
note = (
|
|
116
124
|
f"Platform moderation strike: our {post.get('platform')} {kind}"
|
|
117
125
|
f"{' in ' + venue if venue else ''} is now status='{post.get('status')}'"
|
|
@@ -120,6 +128,7 @@ def _removed_event(post: dict) -> dict:
|
|
|
120
128
|
f" account {post.get('our_account')}."
|
|
121
129
|
f" Machine-derived signal, not a human card decision: a moderator or"
|
|
122
130
|
f" platform filter judged this content unwelcome in that venue."
|
|
131
|
+
f"{cluster}"
|
|
123
132
|
)
|
|
124
133
|
return {
|
|
125
134
|
"event_uuid": str(uuid.uuid5(EVENT_NS, f"platform_removed:post:{post['id']}")),
|
|
@@ -156,7 +165,7 @@ def _ban_event(entry: dict) -> dict:
|
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
|
|
159
|
-
def collect_events(window_days: int) -> tuple[list[dict], int, int]:
|
|
168
|
+
def collect_events(window_days: int, dry_run: bool = False) -> tuple[list[dict], int, int]:
|
|
160
169
|
"""Returns (events, removed_count, ban_count)."""
|
|
161
170
|
cfg = _load_config()
|
|
162
171
|
local_projects = {p.get("name") for p in (cfg.get("projects") or []) if p.get("name")}
|
|
@@ -170,7 +179,7 @@ def collect_events(window_days: int) -> tuple[list[dict], int, int]:
|
|
|
170
179
|
})
|
|
171
180
|
posts = ((resp or {}).get("data") or {}).get("posts") or []
|
|
172
181
|
|
|
173
|
-
|
|
182
|
+
fresh = []
|
|
174
183
|
for p in posts:
|
|
175
184
|
if p.get("project_name") not in local_projects:
|
|
176
185
|
continue
|
|
@@ -179,7 +188,47 @@ def collect_events(window_days: int) -> tuple[list[dict], int, int]:
|
|
|
179
188
|
detected = _parse_ts(p.get("status_checked_at"))
|
|
180
189
|
if detected is None or detected < cutoff:
|
|
181
190
|
continue
|
|
182
|
-
|
|
191
|
+
fresh.append(p)
|
|
192
|
+
|
|
193
|
+
# Per-venue removal counts (clustering signal surfaced in each event note).
|
|
194
|
+
venue_counts: dict[str, int] = {}
|
|
195
|
+
for p in fresh:
|
|
196
|
+
v = _venue(p)
|
|
197
|
+
if v:
|
|
198
|
+
venue_counts[v] = venue_counts.get(v, 0) + 1
|
|
199
|
+
removed_events = [_removed_event(p, venue_counts.get(_venue(p) or "", 0))
|
|
200
|
+
for p in fresh]
|
|
201
|
+
|
|
202
|
+
# Deterministic community-ban detection (2026-07-17): before reading the
|
|
203
|
+
# ban denylist, inspect the logged-in ban state of every reddit subreddit
|
|
204
|
+
# that struck us this window and persist newly-confirmed bans (reason
|
|
205
|
+
# 'account_blocked_in_sub'). This is what turns a pile of per-post removals
|
|
206
|
+
# into a single platform_banned learning signal, and upgrades weak manual
|
|
207
|
+
# denylist entries so their ban event can finally fire. Read-only browser
|
|
208
|
+
# attach; no-ops gracefully (returns unknown) on installs without a live
|
|
209
|
+
# Reddit session, and can be disabled with S4L_STRIKE_BAN_CHECK=0.
|
|
210
|
+
if os.environ.get("S4L_STRIKE_BAN_CHECK", "1") not in ("0", "false", "no"):
|
|
211
|
+
reddit_subs = sorted({
|
|
212
|
+
re.search(r"reddit\.com/r/([^/]+)/", (p.get("thread_url") or p.get("our_url") or "")).group(1)
|
|
213
|
+
for p in fresh
|
|
214
|
+
if re.search(r"reddit\.com/r/([^/]+)/", (p.get("thread_url") or p.get("our_url") or ""))
|
|
215
|
+
})
|
|
216
|
+
if reddit_subs:
|
|
217
|
+
try:
|
|
218
|
+
import reddit_ban_check
|
|
219
|
+
if dry_run:
|
|
220
|
+
# read-only: detect and report, never persist
|
|
221
|
+
st = reddit_ban_check.banned_state(reddit_subs)
|
|
222
|
+
banned = [s for s, v in st.items() if v is True]
|
|
223
|
+
log(f"ban_check (dry run) subs={len(reddit_subs)} banned={banned}")
|
|
224
|
+
else:
|
|
225
|
+
res = reddit_ban_check.record_confirmed_bans(reddit_subs)
|
|
226
|
+
log(f"ban_check subs={len(reddit_subs)} banned={len(res.get('banned') or [])}"
|
|
227
|
+
f" recorded={res.get('recorded')} upgraded={res.get('upgraded')}")
|
|
228
|
+
if res.get("recorded") or res.get("upgraded"):
|
|
229
|
+
cfg = _load_config() # reload so new ban entries below are seen
|
|
230
|
+
except Exception as e:
|
|
231
|
+
log(f"ban_check_error (non-fatal): {e}")
|
|
183
232
|
|
|
184
233
|
ban_events = []
|
|
185
234
|
for entry in ((cfg.get("subreddit_bans") or {}).get("comment_blocked") or []):
|
|
@@ -194,7 +243,7 @@ def collect_events(window_days: int) -> tuple[list[dict], int, int]:
|
|
|
194
243
|
|
|
195
244
|
|
|
196
245
|
def sweep(window_days: int = 14, dry_run: bool = False) -> dict:
|
|
197
|
-
events, n_removed, n_banned = collect_events(window_days)
|
|
246
|
+
events, n_removed, n_banned = collect_events(window_days, dry_run=dry_run)
|
|
198
247
|
inserted = 0
|
|
199
248
|
duplicates = 0
|
|
200
249
|
if dry_run:
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Scratch preview: renders one Reddit review card with a title + selftext,
|
|
2
|
+
# to visually verify the bold-title-over-body thread box (2026-07-17).
|
|
3
|
+
import os, sys
|
|
4
|
+
os.environ["S4L_STATE_DIR"] = "/tmp/s4l-card-preview-state"
|
|
5
|
+
os.makedirs("/tmp/s4l-card-preview-state", exist_ok=True)
|
|
6
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp", "menubar"))
|
|
7
|
+
from AppKit import NSApplication
|
|
8
|
+
import s4l_card
|
|
9
|
+
|
|
10
|
+
drafts = [{
|
|
11
|
+
"n": 1,
|
|
12
|
+
"platform": "reddit",
|
|
13
|
+
"thread_author": "SureThing07",
|
|
14
|
+
"thread_text": "Im fu**ed. I've realised the whole game is a placeholder",
|
|
15
|
+
"thread_selftext": (
|
|
16
|
+
"I've been building this solo for eight months and I just opened the "
|
|
17
|
+
"codebase after a week off. Half of it is stubs I meant to come back "
|
|
18
|
+
"to. The save system? Placeholder. The combat? Placeholder wired to a "
|
|
19
|
+
"real animation so it LOOKED done. I genuinely can't tell how much of "
|
|
20
|
+
"this game actually exists anymore. How do you all deal with this?"
|
|
21
|
+
),
|
|
22
|
+
"reply_text": (
|
|
23
|
+
"my rule of thumb from solo projects: by month six, maybe a third of "
|
|
24
|
+
"the code is actually load-bearing and the rest is scaffolding you "
|
|
25
|
+
"forgot you wrote."
|
|
26
|
+
),
|
|
27
|
+
"thread_url": "https://www.reddit.com/r/gamedev/comments/abc123/placeholder/",
|
|
28
|
+
"candidate_id": "rd-preview1",
|
|
29
|
+
"project": "podlog",
|
|
30
|
+
"engagement_style": "empathize",
|
|
31
|
+
"search_topic": "solo dev burnout",
|
|
32
|
+
"stats": {"replies": 34, "author_followers": 0,
|
|
33
|
+
"tweet_posted_at": "2026-07-17T14:00:00Z"},
|
|
34
|
+
}]
|
|
35
|
+
app = NSApplication.sharedApplication()
|
|
36
|
+
s4l_card.present_review(drafts, on_decision=lambda d: None,
|
|
37
|
+
on_complete=lambda ds: app.terminate_(None))
|
|
38
|
+
app.run()
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic Reddit community-ban detection.
|
|
3
|
+
|
|
4
|
+
When a post is discovered removed, "was that one post moderated, or is our
|
|
5
|
+
account BANNED from the whole community?" is not answerable from the removal
|
|
6
|
+
object itself: a mod-removed comment scrubs to author=[deleted]/body=[removed]
|
|
7
|
+
with no reason, and a ban does NOT retroactively delete our older comments, so
|
|
8
|
+
"our comments are still visible there" is NOT evidence we are un-banned.
|
|
9
|
+
|
|
10
|
+
The authoritative signal is the logged-in view of `/r/<sub>/about.json`, whose
|
|
11
|
+
`user_is_banned` field is populated per the requesting account's session. We
|
|
12
|
+
already hold a logged-in Reddit browser (the reddit-agent / reddit-harness
|
|
13
|
+
Chrome); this module CDP-attaches to it read-only (one navigation, N same-origin
|
|
14
|
+
fetches, no clicks/posts) and reads that field. Mirrors the read-only carve-out
|
|
15
|
+
pattern used elsewhere (linkedin_browser sidebar pre-check).
|
|
16
|
+
|
|
17
|
+
Graceful degradation: if no Reddit session is reachable (customer install with
|
|
18
|
+
no browser, wrong account, session logged out), every checker returns None and
|
|
19
|
+
callers MUST treat None as "unknown, do nothing" — never as "not banned".
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
python3 scripts/reddit_ban_check.py ClaudeAI theravada rust # ad-hoc
|
|
23
|
+
python3 scripts/reddit_ban_check.py --record --project podlog r/X # detect+persist
|
|
24
|
+
|
|
25
|
+
Public API:
|
|
26
|
+
banned_state(subs) -> {sub_lower: True|False|None}
|
|
27
|
+
is_banned(sub) -> True|False|None
|
|
28
|
+
record_confirmed_bans(subs, project=None)
|
|
29
|
+
-> {"banned": [...], "recorded": [...], "upgraded": [...], "unknown": [...]}
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
40
|
+
|
|
41
|
+
# about.json JS: fetch each sub's logged-in metadata, return the ban flags.
|
|
42
|
+
_ABOUT_JS = r"""
|
|
43
|
+
async (subs) => {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const s of subs) {
|
|
46
|
+
try {
|
|
47
|
+
const r = await fetch(`https://old.reddit.com/r/${s}/about.json`,
|
|
48
|
+
{headers:{'Accept':'application/json'}, credentials:'include'});
|
|
49
|
+
if (!r.ok) { out[s] = {http: r.status}; continue; }
|
|
50
|
+
const d = (await r.json()).data || {};
|
|
51
|
+
out[s] = { banned: d.user_is_banned === true,
|
|
52
|
+
muted: d.user_is_muted === true,
|
|
53
|
+
has_flag: ('user_is_banned' in d) };
|
|
54
|
+
} catch (e) { out[s] = {err: String(e).slice(0,80)}; }
|
|
55
|
+
await new Promise(x => setTimeout(x, 250));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _norm(sub: str) -> str:
|
|
63
|
+
s = (sub or "").strip()
|
|
64
|
+
m = re.search(r"/r/([^/]+)", s)
|
|
65
|
+
if m:
|
|
66
|
+
s = m.group(1)
|
|
67
|
+
return s.strip().strip("/").lower()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reddit_page(pw):
|
|
71
|
+
"""Attach to the running logged-in Reddit Chrome and return (browser, page).
|
|
72
|
+
|
|
73
|
+
Prefers REDDIT_CDP_URL, else discovers the reddit-agent port via
|
|
74
|
+
reddit_browser.find_reddit_cdp_port. Opens a DEDICATED page so we never
|
|
75
|
+
fight the live harness tab. Returns (None, None) if unreachable."""
|
|
76
|
+
import urllib.request
|
|
77
|
+
|
|
78
|
+
ws = None
|
|
79
|
+
cdp_url = (os.environ.get("REDDIT_CDP_URL") or "").strip()
|
|
80
|
+
if cdp_url:
|
|
81
|
+
ws = cdp_url
|
|
82
|
+
else:
|
|
83
|
+
try:
|
|
84
|
+
from reddit_browser import find_reddit_cdp_port
|
|
85
|
+
port = find_reddit_cdp_port()
|
|
86
|
+
if not port:
|
|
87
|
+
return None, None
|
|
88
|
+
with urllib.request.urlopen(
|
|
89
|
+
f"http://127.0.0.1:{port}/json/version", timeout=3
|
|
90
|
+
) as r:
|
|
91
|
+
ws = json.load(r)["webSocketDebuggerUrl"]
|
|
92
|
+
except Exception:
|
|
93
|
+
return None, None
|
|
94
|
+
try:
|
|
95
|
+
browser = pw.chromium.connect_over_cdp(ws)
|
|
96
|
+
ctx = browser.contexts[0] if browser.contexts else browser.new_context()
|
|
97
|
+
page = ctx.new_page()
|
|
98
|
+
page.set_default_timeout(20000)
|
|
99
|
+
return browser, page
|
|
100
|
+
except Exception:
|
|
101
|
+
return None, None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def banned_state(subs) -> dict:
|
|
105
|
+
"""Return {sub_lower: True|False|None} for each sub. None = unknown
|
|
106
|
+
(no session / fetch failed): callers MUST NOT treat None as un-banned."""
|
|
107
|
+
norm = []
|
|
108
|
+
for s in subs:
|
|
109
|
+
n = _norm(s)
|
|
110
|
+
if n and n not in norm:
|
|
111
|
+
norm.append(n)
|
|
112
|
+
result = {n: None for n in norm}
|
|
113
|
+
if not norm:
|
|
114
|
+
return result
|
|
115
|
+
try:
|
|
116
|
+
from playwright.sync_api import sync_playwright
|
|
117
|
+
except Exception:
|
|
118
|
+
return result
|
|
119
|
+
with sync_playwright() as pw:
|
|
120
|
+
browser, page = _reddit_page(pw)
|
|
121
|
+
if page is None:
|
|
122
|
+
return result
|
|
123
|
+
try:
|
|
124
|
+
page.goto("https://old.reddit.com/", wait_until="domcontentloaded",
|
|
125
|
+
timeout=20000)
|
|
126
|
+
# confirm we actually have a logged-in session; a logged-out view
|
|
127
|
+
# reports user_is_banned=false for everything, a false negative.
|
|
128
|
+
me = page.evaluate(
|
|
129
|
+
"async () => { try { const r = await fetch("
|
|
130
|
+
"'https://old.reddit.com/api/me.json', {credentials:'include'}); "
|
|
131
|
+
"const d = await r.json(); return (d.data && d.data.name) || null; } "
|
|
132
|
+
"catch(e){ return null; } }"
|
|
133
|
+
)
|
|
134
|
+
if not me:
|
|
135
|
+
return result # not logged in -> all unknown
|
|
136
|
+
raw = page.evaluate(_ABOUT_JS, norm)
|
|
137
|
+
except Exception:
|
|
138
|
+
return result
|
|
139
|
+
finally:
|
|
140
|
+
try:
|
|
141
|
+
page.close()
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
for n in norm:
|
|
145
|
+
v = (raw or {}).get(n) or {}
|
|
146
|
+
if v.get("has_flag"):
|
|
147
|
+
result[n] = bool(v.get("banned"))
|
|
148
|
+
else:
|
|
149
|
+
result[n] = None # http error / missing field -> unknown
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_banned(sub) -> "bool|None":
|
|
154
|
+
return banned_state([sub]).get(_norm(sub))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def record_confirmed_bans(subs, project=None) -> dict:
|
|
158
|
+
"""Check each sub and persist newly-confirmed community bans into
|
|
159
|
+
config.json subreddit_bans.comment_blocked with reason
|
|
160
|
+
'account_blocked_in_sub' (the reason platform_strike_events reads to emit
|
|
161
|
+
platform_banned learning events). Upgrades pre-existing weak entries
|
|
162
|
+
(reason=None) to the confirmed reason. Never records on None (unknown)."""
|
|
163
|
+
from config import config_path
|
|
164
|
+
from post_reddit import _make_ban_entry, _ban_entry_sub
|
|
165
|
+
|
|
166
|
+
state = banned_state(subs)
|
|
167
|
+
banned = [s for s, v in state.items() if v is True]
|
|
168
|
+
unknown = [s for s, v in state.items() if v is None]
|
|
169
|
+
recorded, upgraded = [], []
|
|
170
|
+
if not banned:
|
|
171
|
+
return {"banned": [], "recorded": [], "upgraded": [], "unknown": unknown}
|
|
172
|
+
|
|
173
|
+
path = config_path()
|
|
174
|
+
with open(path) as f:
|
|
175
|
+
cfg = json.load(f)
|
|
176
|
+
bans = cfg.setdefault("subreddit_bans", {})
|
|
177
|
+
blocked = bans.setdefault("comment_blocked", [])
|
|
178
|
+
by_sub = {(_ban_entry_sub(e) or "").lower(): e for e in blocked}
|
|
179
|
+
|
|
180
|
+
for sub in banned:
|
|
181
|
+
entry = by_sub.get(sub)
|
|
182
|
+
if entry is None:
|
|
183
|
+
blocked.append(_make_ban_entry(sub, "account_blocked_in_sub", project))
|
|
184
|
+
recorded.append(sub)
|
|
185
|
+
elif entry.get("reason") != "account_blocked_in_sub":
|
|
186
|
+
# upgrade a weak/manual entry so the digest ban event can fire
|
|
187
|
+
from datetime import datetime, timezone
|
|
188
|
+
entry["reason"] = "account_blocked_in_sub"
|
|
189
|
+
if not entry.get("added_at"):
|
|
190
|
+
entry["added_at"] = datetime.now(timezone.utc).strftime(
|
|
191
|
+
"%Y-%m-%dT%H:%M:%SZ")
|
|
192
|
+
entry.setdefault("noticed_by_project", project)
|
|
193
|
+
upgraded.append(sub)
|
|
194
|
+
|
|
195
|
+
if recorded or upgraded:
|
|
196
|
+
blocked.sort(key=lambda e: (_ban_entry_sub(e) or ""))
|
|
197
|
+
with open(path, "w") as f:
|
|
198
|
+
json.dump(cfg, f, indent=2)
|
|
199
|
+
f.write("\n")
|
|
200
|
+
return {"banned": banned, "recorded": recorded, "upgraded": upgraded,
|
|
201
|
+
"unknown": unknown}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main() -> int:
|
|
205
|
+
import argparse
|
|
206
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
207
|
+
ap.add_argument("subs", nargs="+", help="subreddit names or /r/x URLs")
|
|
208
|
+
ap.add_argument("--record", action="store_true",
|
|
209
|
+
help="persist confirmed bans to config.json")
|
|
210
|
+
ap.add_argument("--project", default=None, help="audit breadcrumb")
|
|
211
|
+
args = ap.parse_args()
|
|
212
|
+
if args.record:
|
|
213
|
+
print(json.dumps(record_confirmed_bans(args.subs, args.project), indent=2))
|
|
214
|
+
else:
|
|
215
|
+
print(json.dumps(banned_state(args.subs), indent=2))
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
sys.exit(main())
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -384,6 +384,28 @@ NODE_RUNTIME_PLATFORM="darwin-arm64"
|
|
|
384
384
|
NODE_VENDOR_DIR="$MCP_DIR/vendor/node-$NODE_RUNTIME_PLATFORM"
|
|
385
385
|
NODE_VENDOR_BIN="$NODE_VENDOR_DIR/bin/node"
|
|
386
386
|
NODE_CACHE_DIR="$REPO_ROOT/.cache/node-runtime"
|
|
387
|
+
# Can THIS build host execute the vendored target binary? The .mcpb always
|
|
388
|
+
# vendors darwin-arm64 (that is where it runs), but the release itself is cut
|
|
389
|
+
# from CI on linux-x64. Executing a darwin-arm64 node on a linux-x64 runner is
|
|
390
|
+
# an `Exec format error` (exit 126) -- what broke every rc from 1.7.6-rc.11 on.
|
|
391
|
+
# Run the exec-based `--version` sanity checks ONLY when the host os/arch matches
|
|
392
|
+
# the vendored target; otherwise verify by presence + executable bit, which is
|
|
393
|
+
# all a cross-build can honestly assert.
|
|
394
|
+
case "$(uname -s)" in
|
|
395
|
+
Darwin) HOST_OS="darwin" ;;
|
|
396
|
+
Linux) HOST_OS="linux" ;;
|
|
397
|
+
*) HOST_OS="unknown" ;;
|
|
398
|
+
esac
|
|
399
|
+
case "$(uname -m)" in
|
|
400
|
+
arm64|aarch64) HOST_ARCH="arm64" ;;
|
|
401
|
+
x86_64|amd64) HOST_ARCH="x64" ;;
|
|
402
|
+
*) HOST_ARCH="unknown" ;;
|
|
403
|
+
esac
|
|
404
|
+
if [[ "$HOST_OS-$HOST_ARCH" == "$NODE_RUNTIME_PLATFORM" ]]; then
|
|
405
|
+
HOST_CAN_EXEC_VENDORED_NODE=1
|
|
406
|
+
else
|
|
407
|
+
HOST_CAN_EXEC_VENDORED_NODE=0
|
|
408
|
+
fi
|
|
387
409
|
say "Vendoring Node $NODE_RUNTIME_VERSION ($NODE_RUNTIME_PLATFORM) for the .mcpb"
|
|
388
410
|
if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
|
|
389
411
|
NODE_TARBALL="node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM.tar.gz"
|
|
@@ -406,9 +428,13 @@ if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
|
|
|
406
428
|
chmod +x "$NODE_VENDOR_BIN"
|
|
407
429
|
fi
|
|
408
430
|
[[ -x "$NODE_VENDOR_BIN" ]] || die "vendored node binary missing/not executable at $NODE_VENDOR_BIN"
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
431
|
+
if [[ "$HOST_CAN_EXEC_VENDORED_NODE" == "1" ]]; then
|
|
432
|
+
VENDORED_NODE_VER="$("$NODE_VENDOR_BIN" --version)"
|
|
433
|
+
[[ "$VENDORED_NODE_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node reports $VENDORED_NODE_VER, expected $NODE_RUNTIME_VERSION"
|
|
434
|
+
echo " vendored node: $VENDORED_NODE_VER at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1))"
|
|
435
|
+
else
|
|
436
|
+
echo " vendored node: present at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1)); --version self-check skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
|
|
437
|
+
fi
|
|
412
438
|
|
|
413
439
|
# ---- 4. Pack the .mcpb ------------------------------------------------------
|
|
414
440
|
say "Packing $BUNDLE"
|
|
@@ -474,10 +500,15 @@ VENDOR_CHECK_DIR="$(mktemp -d)"
|
|
|
474
500
|
unzip -q -o "$BUNDLE" "vendor/node-$NODE_RUNTIME_PLATFORM/bin/node" -d "$VENDOR_CHECK_DIR"
|
|
475
501
|
VENDOR_CHECK_BIN="$VENDOR_CHECK_DIR/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node"
|
|
476
502
|
[[ -x "$VENDOR_CHECK_BIN" ]] || die "vendored node in bundle lost its executable bit"
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
503
|
+
if [[ "$HOST_CAN_EXEC_VENDORED_NODE" == "1" ]]; then
|
|
504
|
+
VENDOR_CHECK_VER="$("$VENDOR_CHECK_BIN" --version)"
|
|
505
|
+
[[ "$VENDOR_CHECK_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node in bundle reports $VENDOR_CHECK_VER, expected $NODE_RUNTIME_VERSION"
|
|
506
|
+
rm -rf "$VENDOR_CHECK_DIR"
|
|
507
|
+
echo " vendored node runs from bundle: $VENDOR_CHECK_VER ok"
|
|
508
|
+
else
|
|
509
|
+
rm -rf "$VENDOR_CHECK_DIR"
|
|
510
|
+
echo " vendored node survives bundle round-trip with exec bit intact; --version run skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
|
|
511
|
+
fi
|
|
481
512
|
|
|
482
513
|
if [[ "$DO_RELEASE" == "0" ]]; then
|
|
483
514
|
say "Done (--no-release). Bundle ready at: $BUNDLE"
|
package/scripts/strike_alert.py
CHANGED
|
@@ -455,10 +455,93 @@ def _owner_strike_count(owner, days=90):
|
|
|
455
455
|
return (live_count, raw_count)
|
|
456
456
|
|
|
457
457
|
|
|
458
|
-
def
|
|
458
|
+
def _subreddit_from_url(url):
|
|
459
|
+
if not url:
|
|
460
|
+
return None
|
|
461
|
+
m = re.search(r"reddit\.com/r/([^/]+)", url)
|
|
462
|
+
return m.group(1).lower() if m else None
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _subreddit_strike_count(sub, days=90):
|
|
466
|
+
"""How many of our reddit posts in r/<sub> were moderated (removed/deleted)
|
|
467
|
+
in the last `days` days. The reddit analogue of _owner_strike_count: turns a
|
|
468
|
+
single strike email into 'this is the Nth removal in this community', which
|
|
469
|
+
is the clustering signal that separates a chronic bad-fit venue from a
|
|
470
|
+
one-off removal. Returns 0 on any lookup failure (never blocks the email)."""
|
|
471
|
+
if not sub:
|
|
472
|
+
return 0
|
|
473
|
+
try:
|
|
474
|
+
resp = api_get("/api/v1/posts/thread-urls", query={
|
|
475
|
+
"platform": "reddit", "moderated_within_days": int(days),
|
|
476
|
+
})
|
|
477
|
+
except Exception:
|
|
478
|
+
return 0
|
|
479
|
+
urls = (resp.get("data") or {}).get("thread_urls") or []
|
|
480
|
+
return sum(1 for u in urls if _subreddit_from_url(u) == sub)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _recorded_ban(sub, config, account):
|
|
484
|
+
"""True if r/<sub> is already on the config.json comment_blocked denylist
|
|
485
|
+
with reason 'account_blocked_in_sub' for this account (the recorded output
|
|
486
|
+
of the deterministic ban check). Account-scoped, matching
|
|
487
|
+
reddit_tools._load_comment_blocked_subs semantics."""
|
|
488
|
+
if not sub:
|
|
489
|
+
return False
|
|
490
|
+
acct = (account or "").lower()
|
|
491
|
+
for entry in ((config.get("subreddit_bans") or {}).get("comment_blocked") or []):
|
|
492
|
+
if not isinstance(entry, dict):
|
|
493
|
+
continue
|
|
494
|
+
if (entry.get("sub") or "").strip().lower() != sub:
|
|
495
|
+
continue
|
|
496
|
+
if entry.get("reason") != "account_blocked_in_sub":
|
|
497
|
+
continue
|
|
498
|
+
ent_acct = (entry.get("account") or "").lower()
|
|
499
|
+
if ent_acct and acct and ent_acct != acct:
|
|
500
|
+
continue
|
|
501
|
+
return True
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _reddit_ban_verdict(subs, config, account):
|
|
506
|
+
"""Return {sub: 'banned'|'not_banned'|'unknown'} for reddit strike subs.
|
|
507
|
+
|
|
508
|
+
Prefers a single live deterministic check (reddit_ban_check.banned_state,
|
|
509
|
+
one browser attach for the whole sweep, read-only) and falls back to the
|
|
510
|
+
recorded config denylist when no session is reachable or the check is
|
|
511
|
+
disabled via S4L_STRIKE_BAN_CHECK=0. A recorded ban is authoritative even
|
|
512
|
+
when the live check says unknown (session down) so we never downgrade a
|
|
513
|
+
known ban to 'single-post removal'."""
|
|
514
|
+
verdict = {}
|
|
515
|
+
live = {}
|
|
516
|
+
if subs and os.environ.get("S4L_STRIKE_BAN_CHECK", "1") not in ("0", "false", "no"):
|
|
517
|
+
try:
|
|
518
|
+
import reddit_ban_check
|
|
519
|
+
live = reddit_ban_check.banned_state(list(subs)) or {}
|
|
520
|
+
except Exception as e:
|
|
521
|
+
print(f"[strike_alert] reddit_ban_check unavailable (non-fatal): {e}",
|
|
522
|
+
file=sys.stderr)
|
|
523
|
+
for sub in subs:
|
|
524
|
+
lv = live.get(sub)
|
|
525
|
+
if lv is True:
|
|
526
|
+
verdict[sub] = "banned"
|
|
527
|
+
elif _recorded_ban(sub, config, account):
|
|
528
|
+
verdict[sub] = "banned"
|
|
529
|
+
elif lv is False:
|
|
530
|
+
verdict[sub] = "not_banned"
|
|
531
|
+
else:
|
|
532
|
+
verdict[sub] = "unknown"
|
|
533
|
+
return verdict
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _format_subject(post, repo_state=None, sub_ban=None):
|
|
459
537
|
platform = post["platform"] or "?"
|
|
460
538
|
status = post["status"] or "?"
|
|
461
539
|
tag = "STRIKE"
|
|
540
|
+
if platform == "reddit" and sub_ban == "banned":
|
|
541
|
+
# Account is banned from the whole community, not just this post
|
|
542
|
+
# removed. Loud subject tag so a ban is visually distinct from the
|
|
543
|
+
# far more common single-post removal.
|
|
544
|
+
tag = "STRIKE-BANNED"
|
|
462
545
|
if platform == "github" and repo_state == "repo_gone":
|
|
463
546
|
# Owner nuked the whole repo. Not a moderation strike against us.
|
|
464
547
|
status = "repo-deleted"
|
|
@@ -488,7 +571,7 @@ def _ts(v):
|
|
|
488
571
|
return v.isoformat() if hasattr(v, "isoformat") else str(v)
|
|
489
572
|
|
|
490
573
|
|
|
491
|
-
def _format_body(post, repo_state=None):
|
|
574
|
+
def _format_body(post, repo_state=None, sub_ban=None, sub_count=None):
|
|
492
575
|
platform = post["platform"] or "?"
|
|
493
576
|
status = post["status"] or "?"
|
|
494
577
|
project = post["project_name"] or "(no project)"
|
|
@@ -505,6 +588,35 @@ def _format_body(post, repo_state=None):
|
|
|
505
588
|
|
|
506
589
|
owner_block = ""
|
|
507
590
|
repo_block = ""
|
|
591
|
+
reddit_block = ""
|
|
592
|
+
if platform == "reddit":
|
|
593
|
+
sub = _subreddit_from_url(thread_url) or _subreddit_from_url(our_url)
|
|
594
|
+
if sub:
|
|
595
|
+
n = sub_count if sub_count is not None else _subreddit_strike_count(sub)
|
|
596
|
+
count_line = (
|
|
597
|
+
f"Subreddit: r/{sub} ({n} of our post(s) moderated here in the "
|
|
598
|
+
f"last 90 days)\n"
|
|
599
|
+
)
|
|
600
|
+
if sub_ban == "banned":
|
|
601
|
+
ban_line = (
|
|
602
|
+
f"Ban state: ACCOUNT BANNED from r/{sub} (deterministic "
|
|
603
|
+
f"user_is_banned check). This is not a single-post removal: "
|
|
604
|
+
f"every future post here will be removed. The sub is on the "
|
|
605
|
+
f"comment_blocked denylist so the drafter will stop targeting "
|
|
606
|
+
f"it, and a platform_banned learning signal feeds the digest.\n"
|
|
607
|
+
)
|
|
608
|
+
elif sub_ban == "not_banned":
|
|
609
|
+
ban_line = (
|
|
610
|
+
f"Ban state: not banned (user_is_banned=false). This is a "
|
|
611
|
+
f"single-post removal; the account can still post in r/{sub}.\n"
|
|
612
|
+
)
|
|
613
|
+
else:
|
|
614
|
+
ban_line = (
|
|
615
|
+
f"Ban state: unknown (no live Reddit session for the check and "
|
|
616
|
+
f"not on the recorded denylist). Treated as a single-post "
|
|
617
|
+
f"removal until confirmed.\n"
|
|
618
|
+
)
|
|
619
|
+
reddit_block = count_line + ban_line
|
|
508
620
|
if platform == "github" and thread_url:
|
|
509
621
|
if repo_state == "repo_gone":
|
|
510
622
|
repo_block = (
|
|
@@ -559,6 +671,7 @@ def _format_body(post, repo_state=None):
|
|
|
559
671
|
f"Detected: {checked_at}\n"
|
|
560
672
|
f"{repo_block}"
|
|
561
673
|
f"{owner_block}"
|
|
674
|
+
f"{reddit_block}"
|
|
562
675
|
f"\n"
|
|
563
676
|
f"Thread: {thread_url}\n"
|
|
564
677
|
f"Title: {title}\n"
|
|
@@ -567,20 +680,38 @@ def _format_body(post, repo_state=None):
|
|
|
567
680
|
f"--- Our content ---\n"
|
|
568
681
|
f"{content_preview}\n"
|
|
569
682
|
f"\n"
|
|
570
|
-
f"
|
|
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"
|
|
683
|
+
f"{_next_steps(platform)}"
|
|
578
684
|
f"\n"
|
|
579
685
|
f"To re-fire this alert: python3 scripts/strike_alert.py --post-id {post['id']}\n"
|
|
580
686
|
)
|
|
581
687
|
return _scrub_dashes(body)
|
|
582
688
|
|
|
583
689
|
|
|
690
|
+
def _next_steps(platform):
|
|
691
|
+
if platform == "reddit":
|
|
692
|
+
return (
|
|
693
|
+
"--- Next steps ---\n"
|
|
694
|
+
"1. Ban state above is deterministic (logged-in user_is_banned).\n"
|
|
695
|
+
" BANNED subs are auto-added to config.json subreddit_bans\n"
|
|
696
|
+
" .comment_blocked and feed a platform_banned learning event;\n"
|
|
697
|
+
" no manual action needed to stop targeting them.\n"
|
|
698
|
+
"2. A single-post removal is one community's judgment on the content;\n"
|
|
699
|
+
" the platform_removed event already teaches the digest that\n"
|
|
700
|
+
" pattern. Watch the 'moderated here in last 90 days' count: a\n"
|
|
701
|
+
" rising count on a not-yet-banned sub is an early bad-fit signal.\n"
|
|
702
|
+
)
|
|
703
|
+
return (
|
|
704
|
+
"--- Next steps ---\n"
|
|
705
|
+
"1. Inspect the thread to see if the comment was deleted, hidden,\n"
|
|
706
|
+
" or if the whole account was blocked.\n"
|
|
707
|
+
"2. If the owner should be hard-blocked, add it to\n"
|
|
708
|
+
" config.json -> exclusions.github_repos. Owner-level entries\n"
|
|
709
|
+
" match all repos under that owner.\n"
|
|
710
|
+
"3. The auto-blocklist (github_tools._dynamic_owner_blocklist)\n"
|
|
711
|
+
" already covers any owner with >=2 strikes in 90 days.\n"
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
|
|
584
715
|
def _send_email(subject, body):
|
|
585
716
|
msg = MIMEText(body, "plain", "utf-8")
|
|
586
717
|
msg["to"] = NOTIFICATION_EMAIL
|
|
@@ -651,6 +782,19 @@ def main():
|
|
|
651
782
|
print("[strike_alert] no pending strikes")
|
|
652
783
|
return
|
|
653
784
|
|
|
785
|
+
# Deterministic reddit ban verdict, computed ONCE for the whole sweep (a
|
|
786
|
+
# single read-only browser attach for all distinct subs, not one per email).
|
|
787
|
+
# Falls back to the recorded denylist when no session is reachable.
|
|
788
|
+
reddit_subs = {
|
|
789
|
+
s for s in (
|
|
790
|
+
_subreddit_from_url(r.get("thread_url")) or _subreddit_from_url(r.get("our_url"))
|
|
791
|
+
for r in rows if r.get("platform") == "reddit"
|
|
792
|
+
) if s
|
|
793
|
+
}
|
|
794
|
+
reddit_ban = _reddit_ban_verdict(reddit_subs, _cfg, _reddit_username)
|
|
795
|
+
if reddit_subs:
|
|
796
|
+
print(f"[strike_alert] reddit_ban_verdict {reddit_ban}", flush=True)
|
|
797
|
+
|
|
654
798
|
sent = 0
|
|
655
799
|
skipped = 0
|
|
656
800
|
filtered = 0
|
|
@@ -752,8 +896,13 @@ def main():
|
|
|
752
896
|
)
|
|
753
897
|
continue
|
|
754
898
|
|
|
755
|
-
|
|
756
|
-
|
|
899
|
+
_sub = None
|
|
900
|
+
_sub_ban = None
|
|
901
|
+
if r["platform"] == "reddit":
|
|
902
|
+
_sub = _subreddit_from_url(r.get("thread_url")) or _subreddit_from_url(r.get("our_url"))
|
|
903
|
+
_sub_ban = reddit_ban.get(_sub)
|
|
904
|
+
subject = _format_subject(r, repo_state=repo_state, sub_ban=_sub_ban)
|
|
905
|
+
body = _format_body(r, repo_state=repo_state, sub_ban=_sub_ban)
|
|
757
906
|
if args.dry_run:
|
|
758
907
|
print(f"[strike_alert] DRY RUN id={r['id']}")
|
|
759
908
|
print(f" subject: {subject}")
|