@m13v/s4l 1.7.7 → 1.7.8-rc.2
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/index.js +48 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +29 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/browser_lifecycle.py +23 -9
- package/scripts/claude_job.py +191 -2
- package/scripts/draft_provider.py +120 -0
- package/scripts/foreground_event_capture.sh +44 -0
- package/scripts/install.sh +119 -0
- package/scripts/scan_reddit_replies.py +111 -27
- package/scripts/schedule_state.py +16 -0
- package/scripts/setup_reddit_auth.py +62 -36
- package/scripts/setup_twitter_auth.py +121 -42
- package/scripts/virality.py +12 -1
- package/skill/engage-dm-replies.sh +22 -5
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# S4L universal installer: curl -fsSL https://s4l.ai/install | sh
|
|
3
|
+
#
|
|
4
|
+
# Zero-toolchain by design: needs only stock macOS (curl, unzip, python3).
|
|
5
|
+
# Downloads the release bundle (the .mcpb zip, which vendors its own Node
|
|
6
|
+
# runtime and the whole pipeline), extracts it to a durable host-neutral dir,
|
|
7
|
+
# registers the MCP server with whichever agent app is present (ChatGPT/Codex
|
|
8
|
+
# and/or Claude Code), and stamps the draft provider for the invoking host.
|
|
9
|
+
# The heavy runtime (Python venv, Chromium) self-provisions on first server
|
|
10
|
+
# boot, exactly as it does for .mcpb installs.
|
|
11
|
+
#
|
|
12
|
+
# Host detection order:
|
|
13
|
+
# 1. Invoking agent env: CODEX_THREAD_ID => codex chat; CLAUDE_CODE_*/CLAUDECODE
|
|
14
|
+
# => Claude Code chat. The invoking host's engine becomes the draft provider.
|
|
15
|
+
# 2. Otherwise: whatever is installed (both is fine; provider prefers claude-p
|
|
16
|
+
# when the claude CLI exists, else codex-exec).
|
|
17
|
+
#
|
|
18
|
+
# Channels: S4L_CHANNEL=staging pulls the staging-latest prerelease instead of
|
|
19
|
+
# the stable release. Optional S4L_INSTALL_HOST=codex|claude forces the provider
|
|
20
|
+
# choice regardless of detection.
|
|
21
|
+
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
REPO="m13v/s4l"
|
|
25
|
+
STABLE_URL="https://github.com/$REPO/releases/latest/download/social-autoposter.mcpb"
|
|
26
|
+
STAGING_URL="https://github.com/$REPO/releases/download/staging-latest/social-autoposter.mcpb"
|
|
27
|
+
STATE_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}"
|
|
28
|
+
APP_DIR="$STATE_DIR/app"
|
|
29
|
+
CODEX_BIN="/Applications/ChatGPT.app/Contents/Resources/codex"
|
|
30
|
+
|
|
31
|
+
log() { echo "[s4l-install] $*"; }
|
|
32
|
+
die() { echo "[s4l-install] ERROR: $*" >&2; exit 1; }
|
|
33
|
+
|
|
34
|
+
[ "$(uname -s)" = "Darwin" ] || die "macOS only for now."
|
|
35
|
+
[ "$(uname -m)" = "arm64" ] || die "Apple Silicon only for now (the bundle vendors a darwin-arm64 Node)."
|
|
36
|
+
|
|
37
|
+
URL="$STABLE_URL"
|
|
38
|
+
[ "${S4L_CHANNEL:-}" = "staging" ] && URL="$STAGING_URL"
|
|
39
|
+
|
|
40
|
+
# 1. Download + extract. The .mcpb is a plain zip.
|
|
41
|
+
TMP="$(mktemp -d /tmp/s4l-install.XXXXXX)"
|
|
42
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
43
|
+
log "downloading $URL"
|
|
44
|
+
/usr/bin/curl -fsSL -o "$TMP/s4l.mcpb" "$URL" || die "download failed"
|
|
45
|
+
mkdir -p "$TMP/app"
|
|
46
|
+
/usr/bin/unzip -q "$TMP/s4l.mcpb" -d "$TMP/app" || die "unzip failed"
|
|
47
|
+
[ -f "$TMP/app/dist/index.js" ] || die "bundle missing dist/index.js (bad download?)"
|
|
48
|
+
[ -x "$TMP/app/vendor/node-darwin-arm64/bin/node" ] || die "bundle missing vendored node"
|
|
49
|
+
|
|
50
|
+
mkdir -p "$STATE_DIR"
|
|
51
|
+
rm -rf "$APP_DIR.new"
|
|
52
|
+
mv "$TMP/app" "$APP_DIR.new"
|
|
53
|
+
if [ -d "$APP_DIR" ]; then rm -rf "$APP_DIR.old" && mv "$APP_DIR" "$APP_DIR.old"; fi
|
|
54
|
+
mv "$APP_DIR.new" "$APP_DIR"
|
|
55
|
+
rm -rf "$APP_DIR.old"
|
|
56
|
+
log "installed bundle to $APP_DIR"
|
|
57
|
+
|
|
58
|
+
NODE="$APP_DIR/vendor/node-darwin-arm64/bin/node"
|
|
59
|
+
SERVER="$APP_DIR/dist/index.js"
|
|
60
|
+
SAFE_PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
61
|
+
|
|
62
|
+
# 2. Which host is driving this install?
|
|
63
|
+
HOST="${S4L_INSTALL_HOST:-}"
|
|
64
|
+
if [ -z "$HOST" ]; then
|
|
65
|
+
if [ -n "${CODEX_THREAD_ID:-}" ]; then HOST="codex"
|
|
66
|
+
elif [ -n "${CLAUDECODE:-}" ] || [ -n "${CLAUDE_CODE_ENTRYPOINT:-}" ]; then HOST="claude"
|
|
67
|
+
fi
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
HAVE_CODEX=0; [ -x "$CODEX_BIN" ] && HAVE_CODEX=1
|
|
71
|
+
HAVE_CLAUDE=0; command -v claude >/dev/null 2>&1 && HAVE_CLAUDE=1
|
|
72
|
+
[ "$HAVE_CODEX" = "1" ] || [ "$HAVE_CLAUDE" = "1" ] || die "neither the ChatGPT app nor the claude CLI was found; install one first (or double-click the .mcpb for Claude Desktop)."
|
|
73
|
+
|
|
74
|
+
# 3. Register the MCP server with every host present (registration is cheap and
|
|
75
|
+
# idempotent; the PROVIDER below is what decides who answers drafting turns).
|
|
76
|
+
if [ "$HAVE_CODEX" = "1" ]; then
|
|
77
|
+
"$CODEX_BIN" mcp remove s4l >/dev/null 2>&1 || true
|
|
78
|
+
"$CODEX_BIN" mcp add s4l --env "S4L_HOST=codex" --env "PATH=$SAFE_PATH" -- "$NODE" "$SERVER" \
|
|
79
|
+
&& log "registered MCP server with the ChatGPT app (codex)" \
|
|
80
|
+
|| log "WARN: codex mcp add failed; register manually with: codex mcp add s4l --env S4L_HOST=codex -- $NODE $SERVER"
|
|
81
|
+
fi
|
|
82
|
+
if [ "$HAVE_CLAUDE" = "1" ]; then
|
|
83
|
+
claude mcp remove -s user s4l >/dev/null 2>&1 || true
|
|
84
|
+
claude mcp add -s user s4l --env "PATH=$SAFE_PATH" -- "$NODE" "$SERVER" \
|
|
85
|
+
&& log "registered MCP server with Claude Code" \
|
|
86
|
+
|| log "WARN: claude mcp add failed; register manually with: claude mcp add -s user s4l -- $NODE $SERVER"
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
# 4. Stamp the draft provider (single source of truth for which engine answers
|
|
90
|
+
# drafting turns; see scripts/draft_provider.py in the bundle).
|
|
91
|
+
PROVIDER=""
|
|
92
|
+
case "$HOST" in
|
|
93
|
+
codex) PROVIDER="codex-exec" ;;
|
|
94
|
+
claude) PROVIDER="claude-p" ;;
|
|
95
|
+
*) if [ "$HAVE_CLAUDE" = "1" ]; then PROVIDER="claude-p"; else PROVIDER="codex-exec"; fi ;;
|
|
96
|
+
esac
|
|
97
|
+
/usr/bin/python3 - "$STATE_DIR" "$PROVIDER" <<'PY'
|
|
98
|
+
import json, os, sys, time
|
|
99
|
+
sd, provider = sys.argv[1], sys.argv[2]
|
|
100
|
+
p = os.path.join(sd, "draft-provider.json")
|
|
101
|
+
tmp = p + ".tmp"
|
|
102
|
+
with open(tmp, "w") as f:
|
|
103
|
+
json.dump({"provider": provider,
|
|
104
|
+
"set_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
105
|
+
"set_by": "install.sh"}, f, indent=2)
|
|
106
|
+
os.replace(tmp, p)
|
|
107
|
+
PY
|
|
108
|
+
log "draft provider: $PROVIDER"
|
|
109
|
+
|
|
110
|
+
log "done."
|
|
111
|
+
echo
|
|
112
|
+
echo "Next steps:"
|
|
113
|
+
if [ "$HOST" = "codex" ] || { [ -z "$HOST" ] && [ "$HAVE_CODEX" = "1" ]; }; then
|
|
114
|
+
echo " 1. Fully quit and reopen the ChatGPT app so it picks up the new S4L connector."
|
|
115
|
+
echo " 2. In a new chat, say: Set up S4L for my product."
|
|
116
|
+
else
|
|
117
|
+
echo " 1. Restart your Claude session so it picks up the new S4L MCP server."
|
|
118
|
+
echo " 2. In a new chat, say: Set up S4L for my product."
|
|
119
|
+
fi
|
|
@@ -7,9 +7,12 @@ reddit-agent profile cookies (refreshed by bootstrap_reddit_cookies.py),
|
|
|
7
7
|
inserts new rows into `replies`, and immediately fires engage_reddit.py
|
|
8
8
|
with --limit so the loop runs end-to-end every 5 min.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
Thread matching is two-stage: first against `posts` (threads we submitted),
|
|
11
|
+
then via the item's parent_id (t1_<our_comment_id>) against
|
|
12
|
+
replies.our_reply_id (replies to our comments on other people's threads).
|
|
13
|
+
The second stage inserts a followup row (parent_reply_id + depth); without
|
|
14
|
+
it every comment-chain reply is dropped as unmatched_thread, which orphaned
|
|
15
|
+
all comment-chain conversations between 2026-04-17 and 2026-08-05.
|
|
13
16
|
|
|
14
17
|
Items older than BACKFILL_HOURS that aren't already in the DB are marked
|
|
15
18
|
status='skipped' / skip_reason='backfill_old' so they show in the
|
|
@@ -50,11 +53,12 @@ ENGAGE_SCRIPT = os.path.expanduser("~/social-autoposter/scripts/engage_reddit.py
|
|
|
50
53
|
INBOX_URL = "https://old.reddit.com/message/inbox/.json"
|
|
51
54
|
PAGE_LIMIT = 100
|
|
52
55
|
MAX_PAGES = 10 # caps pagination at ~1000 items; inbox retention is shorter than that anyway
|
|
53
|
-
BACKFILL_HOURS = 48
|
|
56
|
+
BACKFILL_HOURS = int(os.environ.get("S4L_REDDIT_BACKFILL_HOURS", "48"))
|
|
54
57
|
JITTER_MAX_SECS = 60
|
|
55
58
|
PAGE_PAUSE_SECS = 1.5
|
|
56
|
-
OWN_COMMENTS_PAGES = 20 # hard cap on pagination depth (
|
|
57
|
-
OWN_COMMENTS_LOOKBACK_DAYS =
|
|
59
|
+
OWN_COMMENTS_PAGES = 20 # hard cap on pagination depth (reddit caps listings at ~1000 items)
|
|
60
|
+
OWN_COMMENTS_LOOKBACK_DAYS = 90 # stop once we pass this many days back; replies to
|
|
61
|
+
# comments older than the (30-day) old value were dropped as unmatched_thread
|
|
58
62
|
|
|
59
63
|
THREAD_ID_RE = re.compile(r"/comments/([a-z0-9]+)/")
|
|
60
64
|
|
|
@@ -97,12 +101,20 @@ class SessionInvalidError(Exception):
|
|
|
97
101
|
|
|
98
102
|
def fetch_own_replies(reddit_account, cookie_header, user_agent,
|
|
99
103
|
pages=OWN_COMMENTS_PAGES, lookback_days=OWN_COMMENTS_LOOKBACK_DAYS):
|
|
100
|
-
"""Build
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
"""Build two maps by paging /user/<account>/comments.json:
|
|
105
|
+
|
|
106
|
+
1. {parent_comment_id: {reply_id, reply_url, reply_content, replied_at}}
|
|
107
|
+
Used to detect comments the account already replied to outside the
|
|
108
|
+
pipeline (e.g., manual browser replies).
|
|
109
|
+
2. {our_comment_id: parent_comment_id} for every comment of ours whose
|
|
110
|
+
parent is a comment (t1_). Used by the comment-chain matcher: an inbox
|
|
111
|
+
reply's parent_id is OUR comment, and OUR comment's parent is the
|
|
112
|
+
their_comment_id recorded on the replies row we posted from.
|
|
113
|
+
|
|
114
|
+
Returns (map1, map2). Stops when a page's oldest comment is older than
|
|
115
|
+
lookback_days, or after `pages` pages, whichever comes first."""
|
|
105
116
|
out = {}
|
|
117
|
+
own_parents = {}
|
|
106
118
|
after = None
|
|
107
119
|
cutoff = time.time() - lookback_days * 86400
|
|
108
120
|
url_base = f"https://old.reddit.com/user/{reddit_account}/comments/.json?limit={PAGE_LIMIT}"
|
|
@@ -114,11 +126,11 @@ def fetch_own_replies(reddit_account, cookie_header, user_agent,
|
|
|
114
126
|
try:
|
|
115
127
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
116
128
|
if "application/json" not in resp.headers.get("Content-Type", ""):
|
|
117
|
-
return out # non-fatal; just skip the map
|
|
129
|
+
return out, own_parents # non-fatal; just skip the map
|
|
118
130
|
data = json.loads(resp.read()).get("data", {})
|
|
119
131
|
except Exception as e:
|
|
120
132
|
print(f" own-replies fetch failed on page {page+1}: {e}")
|
|
121
|
-
return out
|
|
133
|
+
return out, own_parents
|
|
122
134
|
children = data.get("children", []) or []
|
|
123
135
|
oldest_on_page = 0
|
|
124
136
|
for c in children:
|
|
@@ -130,6 +142,9 @@ def fetch_own_replies(reddit_account, cookie_header, user_agent,
|
|
|
130
142
|
if not parent.startswith("t1_"):
|
|
131
143
|
continue # only comment-parents; post-parents handled via inbox matching
|
|
132
144
|
parent_id = parent.removeprefix("t1_")
|
|
145
|
+
our_id = d.get("id")
|
|
146
|
+
if our_id and our_id not in own_parents:
|
|
147
|
+
own_parents[our_id] = parent_id
|
|
133
148
|
if parent_id in out:
|
|
134
149
|
continue
|
|
135
150
|
reply_id = d.get("id")
|
|
@@ -146,12 +161,12 @@ def fetch_own_replies(reddit_account, cookie_header, user_agent,
|
|
|
146
161
|
if oldest_on_page and oldest_on_page < cutoff:
|
|
147
162
|
break # we've reached lookback horizon
|
|
148
163
|
time.sleep(PAGE_PAUSE_SECS)
|
|
149
|
-
return out
|
|
164
|
+
return out, own_parents
|
|
150
165
|
|
|
151
166
|
|
|
152
167
|
class InboxScanner:
|
|
153
168
|
def __init__(self, reddit_account, user_agent, cookie_header, excluded_authors=None,
|
|
154
|
-
own_replies_map=None):
|
|
169
|
+
own_replies_map=None, own_comment_parents=None):
|
|
155
170
|
# No DB handle anymore — every read/write hits the API. The `db` field
|
|
156
171
|
# is kept for back-compat with `_insert_reply(self.db, ...)` callers
|
|
157
172
|
# (they pass it through unchanged); the helper itself ignores the value.
|
|
@@ -163,10 +178,14 @@ class InboxScanner:
|
|
|
163
178
|
self.excluded = {a.lower() for a in (excluded_authors or set())}
|
|
164
179
|
self.excluded.update({"automoderator", "[deleted]", self.reddit_account_lower})
|
|
165
180
|
self.own_replies_map = own_replies_map or {}
|
|
181
|
+
self.own_comment_parents = own_comment_parents or {}
|
|
166
182
|
# Cache thread_id -> post_id lookups across a single scan so we don't
|
|
167
183
|
# hit /api/v1/posts once per inbox entry (the same thread often
|
|
168
184
|
# appears multiple times in a single page).
|
|
169
185
|
self._post_id_cache = {}
|
|
186
|
+
# Cache our_reply_id -> parent replies row for comment-chain matching.
|
|
187
|
+
self._parent_reply_cache = {}
|
|
188
|
+
self.parent_matched = 0
|
|
170
189
|
self.discovered = 0
|
|
171
190
|
self.skipped_old = 0
|
|
172
191
|
self.skipped_other = 0
|
|
@@ -199,10 +218,59 @@ class InboxScanner:
|
|
|
199
218
|
break
|
|
200
219
|
except Exception:
|
|
201
220
|
post_id = None
|
|
221
|
+
if post_id is None:
|
|
222
|
+
# Exact fallback for posts older than the 500-row window (replies
|
|
223
|
+
# keep arriving on months-old submissions).
|
|
224
|
+
try:
|
|
225
|
+
resp = api_get(
|
|
226
|
+
"/api/v1/posts",
|
|
227
|
+
query={"platform": "reddit", "limit": 1,
|
|
228
|
+
"thread_url_contains": f"/comments/{thread_id}/"},
|
|
229
|
+
)
|
|
230
|
+
posts = ((resp or {}).get("data") or {}).get("posts") or []
|
|
231
|
+
if posts:
|
|
232
|
+
post_id = int(posts[0].get("id"))
|
|
233
|
+
except Exception:
|
|
234
|
+
post_id = None
|
|
202
235
|
self._post_id_cache[thread_id] = post_id
|
|
203
236
|
return post_id
|
|
204
237
|
|
|
205
|
-
def
|
|
238
|
+
def _parent_reply_for_item(self, d):
|
|
239
|
+
"""Match a comment_reply inbox item to OUR earlier comment on someone
|
|
240
|
+
else's thread. The posts-table match above only covers threads WE
|
|
241
|
+
submitted; most engagement is comments on other people's threads.
|
|
242
|
+
The DB does not store our comments' reddit ids (our_reply_id is
|
|
243
|
+
NULL on virtually all reddit rows), so the linkage goes through the
|
|
244
|
+
grandparent: the inbox item's parent_id is OUR comment; our own
|
|
245
|
+
comments listing (own_comment_parents) gives that comment's parent,
|
|
246
|
+
which is exactly the their_comment_id recorded on the replies row we
|
|
247
|
+
posted from. Returns that row (dict with id/post_id/depth) or None."""
|
|
248
|
+
parent = d.get("parent_id") or ""
|
|
249
|
+
if not parent.startswith("t1_"):
|
|
250
|
+
return None
|
|
251
|
+
our_id = parent.removeprefix("t1_")
|
|
252
|
+
grandparent_id = self.own_comment_parents.get(our_id)
|
|
253
|
+
if not grandparent_id:
|
|
254
|
+
return None
|
|
255
|
+
if grandparent_id in self._parent_reply_cache:
|
|
256
|
+
return self._parent_reply_cache[grandparent_id]
|
|
257
|
+
row = None
|
|
258
|
+
try:
|
|
259
|
+
resp = api_get(
|
|
260
|
+
"/api/v1/replies",
|
|
261
|
+
query={"platform": "reddit", "their_comment_id": grandparent_id,
|
|
262
|
+
"limit": 1},
|
|
263
|
+
)
|
|
264
|
+
rows = ((resp or {}).get("data") or {}).get("replies") or []
|
|
265
|
+
if rows:
|
|
266
|
+
row = rows[0]
|
|
267
|
+
except Exception:
|
|
268
|
+
row = None
|
|
269
|
+
self._parent_reply_cache[grandparent_id] = row
|
|
270
|
+
return row
|
|
271
|
+
|
|
272
|
+
def _insert(self, post_id, comment_id, author, content, comment_url, status, skip_reason=None,
|
|
273
|
+
parent_reply_id=None, depth=1):
|
|
206
274
|
override = self.own_replies_map.get(comment_id)
|
|
207
275
|
if override:
|
|
208
276
|
from datetime import datetime, timezone
|
|
@@ -210,7 +278,7 @@ class InboxScanner:
|
|
|
210
278
|
replied_at = datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None
|
|
211
279
|
result = _insert_reply(
|
|
212
280
|
self.db, post_id, "reddit", comment_id, author, content, comment_url,
|
|
213
|
-
parent_reply_id=
|
|
281
|
+
parent_reply_id=parent_reply_id, depth=depth, status="replied", skip_reason=None,
|
|
214
282
|
our_reply_id=override.get("our_reply_id"),
|
|
215
283
|
our_reply_content=override.get("our_reply_content"),
|
|
216
284
|
our_reply_url=override.get("our_reply_url"),
|
|
@@ -221,7 +289,7 @@ class InboxScanner:
|
|
|
221
289
|
return
|
|
222
290
|
result = _insert_reply(
|
|
223
291
|
self.db, post_id, "reddit", comment_id, author, content, comment_url,
|
|
224
|
-
parent_reply_id=
|
|
292
|
+
parent_reply_id=parent_reply_id, depth=depth, status=status, skip_reason=skip_reason,
|
|
225
293
|
)
|
|
226
294
|
if result == "pending":
|
|
227
295
|
self.discovered += 1
|
|
@@ -246,21 +314,32 @@ class InboxScanner:
|
|
|
246
314
|
if not comment_id:
|
|
247
315
|
continue
|
|
248
316
|
author = d.get("author") or "[deleted]"
|
|
249
|
-
if author.lower() in self.excluded:
|
|
317
|
+
if author.lower() in self.excluded or author.lower().endswith("-modteam"):
|
|
318
|
+
# *-ModTeam items are removal notices, not conversations;
|
|
319
|
+
# the strike pipeline tracks removals separately.
|
|
250
320
|
self.skipped_other += 1
|
|
251
321
|
continue
|
|
252
322
|
context = d.get("context") or ""
|
|
253
323
|
post_id = self._post_id_for_context(context)
|
|
324
|
+
parent_reply_id = None
|
|
325
|
+
depth = 1
|
|
254
326
|
if not post_id:
|
|
255
|
-
self.
|
|
256
|
-
|
|
327
|
+
parent = self._parent_reply_for_item(d)
|
|
328
|
+
if not parent:
|
|
329
|
+
self.unmatched += 1
|
|
330
|
+
continue
|
|
331
|
+
self.parent_matched += 1
|
|
332
|
+
post_id = parent.get("post_id") # nullable; next-pending LEFT JOINs posts
|
|
333
|
+
parent_reply_id = parent.get("id")
|
|
334
|
+
depth = (parent.get("depth") or 1) + 1
|
|
257
335
|
comment_url = "https://old.reddit.com" + context.split("?")[0]
|
|
258
336
|
content = d.get("body") or ""
|
|
259
337
|
created = float(d.get("created_utc") or 0)
|
|
260
338
|
if created and created < backfill_cutoff:
|
|
261
339
|
pre = self.discovered + self.skipped_old
|
|
262
340
|
self._insert(post_id, comment_id, author, content, comment_url,
|
|
263
|
-
status="skipped", skip_reason="backfill_old"
|
|
341
|
+
status="skipped", skip_reason="backfill_old",
|
|
342
|
+
parent_reply_id=parent_reply_id, depth=depth)
|
|
264
343
|
if (self.discovered + self.skipped_old) == pre:
|
|
265
344
|
consecutive_known += 1
|
|
266
345
|
else:
|
|
@@ -268,7 +347,8 @@ class InboxScanner:
|
|
|
268
347
|
else:
|
|
269
348
|
pre = self.discovered
|
|
270
349
|
self._insert(post_id, comment_id, author, content, comment_url,
|
|
271
|
-
status="pending"
|
|
350
|
+
status="pending",
|
|
351
|
+
parent_reply_id=parent_reply_id, depth=depth)
|
|
272
352
|
if self.discovered == pre:
|
|
273
353
|
consecutive_known += 1
|
|
274
354
|
else:
|
|
@@ -293,7 +373,8 @@ class InboxScanner:
|
|
|
293
373
|
f"Inbox scan complete: seen={self.total_seen} "
|
|
294
374
|
f"new_pending={self.discovered} backfill_skipped={self.skipped_old} "
|
|
295
375
|
f"already_replied={self.already_replied} "
|
|
296
|
-
f"excluded_author={self.skipped_other} unmatched_thread={self.unmatched}"
|
|
376
|
+
f"excluded_author={self.skipped_other} unmatched_thread={self.unmatched} "
|
|
377
|
+
f"parent_matched={self.parent_matched}"
|
|
297
378
|
)
|
|
298
379
|
return {
|
|
299
380
|
"discovered": self.discovered,
|
|
@@ -301,6 +382,7 @@ class InboxScanner:
|
|
|
301
382
|
"already_replied": self.already_replied,
|
|
302
383
|
"excluded": self.skipped_other,
|
|
303
384
|
"unmatched": self.unmatched,
|
|
385
|
+
"parent_matched": self.parent_matched,
|
|
304
386
|
"total_seen": self.total_seen,
|
|
305
387
|
}
|
|
306
388
|
|
|
@@ -349,11 +431,13 @@ def main():
|
|
|
349
431
|
|
|
350
432
|
user_agent = f"social-autoposter/1.0 (u/{reddit_account} inbox-scan)"
|
|
351
433
|
excluded_authors = {a for a in config.get("exclusions", {}).get("authors", [])}
|
|
352
|
-
own_replies_map = fetch_own_replies(reddit_account, cookie_header, user_agent)
|
|
353
|
-
print(f"Own-replies map: {len(own_replies_map)} parent comment_ids we've already replied to"
|
|
434
|
+
own_replies_map, own_comment_parents = fetch_own_replies(reddit_account, cookie_header, user_agent)
|
|
435
|
+
print(f"Own-replies map: {len(own_replies_map)} parent comment_ids we've already replied to; "
|
|
436
|
+
f"{len(own_comment_parents)} own comments mapped for chain matching")
|
|
354
437
|
scanner = InboxScanner(reddit_account, user_agent, cookie_header,
|
|
355
438
|
excluded_authors=excluded_authors,
|
|
356
|
-
own_replies_map=own_replies_map
|
|
439
|
+
own_replies_map=own_replies_map,
|
|
440
|
+
own_comment_parents=own_comment_parents)
|
|
357
441
|
try:
|
|
358
442
|
scanner.scan()
|
|
359
443
|
except SessionInvalidError as e:
|
|
@@ -221,6 +221,22 @@ def compute(glob_pattern: str | None = None) -> str:
|
|
|
221
221
|
the module docstring for why. Pass an explicit glob_pattern to scan a
|
|
222
222
|
literal pattern instead (e.g. a test fixture dir); this bypasses account
|
|
223
223
|
scoping entirely, matching the old unconditional behavior."""
|
|
224
|
+
# Provider layer (2026-08-06): when the box's draft provider is claude-p or
|
|
225
|
+
# codex-exec (scripts/draft_provider.py), drafting runs inline in the
|
|
226
|
+
# launchd cycle itself; NO Claude Desktop worker task is required, so the
|
|
227
|
+
# registry scan below is irrelevant and its 'missing' verdict would be a
|
|
228
|
+
# false alarm on every surface (menubar warning, dashboard "Set up draft
|
|
229
|
+
# schedule", Sentry). Health for those providers is watched by the queue
|
|
230
|
+
# drain-latency rails, which the inline path already stamps.
|
|
231
|
+
if glob_pattern is None:
|
|
232
|
+
try:
|
|
233
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
234
|
+
import draft_provider # noqa: E402
|
|
235
|
+
|
|
236
|
+
if draft_provider.get() != "claude-desktop-queue":
|
|
237
|
+
return "ok"
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
224
240
|
patterns = [glob_pattern] if glob_pattern is not None else _active_registry_glob_patterns()
|
|
225
241
|
newest_epoch, newest_enabled = None, False
|
|
226
242
|
# Track the freshest just-created, enabled, never-yet-fired task so a schedule
|
|
@@ -89,6 +89,9 @@ try:
|
|
|
89
89
|
_classify_import_error,
|
|
90
90
|
_is_headless,
|
|
91
91
|
_keychain_safe_storage_ok,
|
|
92
|
+
_keychain_declined,
|
|
93
|
+
_keychain_mark_declined,
|
|
94
|
+
_keychain_clear_declined,
|
|
92
95
|
)
|
|
93
96
|
except Exception: # pragma: no cover - defensive
|
|
94
97
|
def _classify_import_error(detail): # type: ignore[misc]
|
|
@@ -100,6 +103,15 @@ except Exception: # pragma: no cover - defensive
|
|
|
100
103
|
def _keychain_safe_storage_ok(browser_label="Chrome"): # type: ignore[misc]
|
|
101
104
|
return True, "probe unavailable"
|
|
102
105
|
|
|
106
|
+
def _keychain_declined(): # type: ignore[misc]
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def _keychain_mark_declined(source, detail): # type: ignore[misc]
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
def _keychain_clear_declined(): # type: ignore[misc]
|
|
113
|
+
pass
|
|
114
|
+
|
|
103
115
|
# Vendored cookie copier - stdlib-only browser/profile detection plus the
|
|
104
116
|
# actual keychain-decrypt + CDP-inject copy. Same module connect_x uses,
|
|
105
117
|
# parameterized here by --domains reddit.com.
|
|
@@ -752,31 +764,19 @@ def cmd_connect(args) -> dict:
|
|
|
752
764
|
manual_login = bool(getattr(args, "manual_login", False))
|
|
753
765
|
will_import = has_importable and not manual_login
|
|
754
766
|
|
|
755
|
-
# 1c.
|
|
756
|
-
#
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
"Cookie import requires reading the browser's Safe Storage from "
|
|
769
|
-
"the macOS Keychain, but this process can't access it (probably "
|
|
770
|
-
"running over SSH or another headless context). Run this once in "
|
|
771
|
-
"the same session:\n"
|
|
772
|
-
" security unlock-keychain ~/Library/Keychains/login.keychain-db\n"
|
|
773
|
-
"Then re-run connect_reddit. Or connect Reddit with manual login "
|
|
774
|
-
"instead; that signs in directly in the autoposter's own browser "
|
|
775
|
-
"and needs no keychain."
|
|
776
|
-
),
|
|
777
|
-
"remediation_cmd": "security unlock-keychain ~/Library/Keychains/login.keychain-db",
|
|
778
|
-
"cdp": CDP,
|
|
779
|
-
}
|
|
767
|
+
# 1c. Keychain prompt-once gate (2026-08-06; replaces the headless probe —
|
|
768
|
+
# see setup_twitter_auth.py for the full rationale). The user is asked for
|
|
769
|
+
# keychain access at most ONCE across ALL platforms: the import attempt
|
|
770
|
+
# itself is the single prompt, and a Deny/Cancel recorded by either the X
|
|
771
|
+
# or the reddit importer permanently routes both to manual login.
|
|
772
|
+
if will_import:
|
|
773
|
+
if getattr(args, "retry_keychain_import", False):
|
|
774
|
+
_keychain_clear_declined()
|
|
775
|
+
keychain_declined = _keychain_declined()
|
|
776
|
+
if keychain_declined:
|
|
777
|
+
will_import = False
|
|
778
|
+
else:
|
|
779
|
+
keychain_declined = None
|
|
780
780
|
|
|
781
781
|
# 2. Import from the user's everyday browser.
|
|
782
782
|
attempts = []
|
|
@@ -791,6 +791,12 @@ def cmd_connect(args) -> dict:
|
|
|
791
791
|
"detail": detail,
|
|
792
792
|
"error_type": error_type,
|
|
793
793
|
})
|
|
794
|
+
if error_type == "keychain_acl_denied":
|
|
795
|
+
# The user answered the ONE allowed keychain prompt with
|
|
796
|
+
# Deny/Cancel. Record it (shared latch with connect_x) and stop
|
|
797
|
+
# the sweep — the next source would fire another prompt.
|
|
798
|
+
_keychain_mark_declined(src, detail)
|
|
799
|
+
break
|
|
794
800
|
if not res.get("ok"):
|
|
795
801
|
continue
|
|
796
802
|
fields = _validate_session()
|
|
@@ -816,10 +822,13 @@ def cmd_connect(args) -> dict:
|
|
|
816
822
|
rolled_up_error_type = (
|
|
817
823
|
next(iter(distinct_error_types)) if len(distinct_error_types) == 1 else None
|
|
818
824
|
)
|
|
825
|
+
# A Deny/Cancel on THIS run's prompt, regardless of what other sources
|
|
826
|
+
# failed with (the single-type roll-up above misses mixed-error runs).
|
|
827
|
+
denied_now = any(a.get("error_type") == "keychain_acl_denied" for a in attempts)
|
|
819
828
|
open_login = (
|
|
820
829
|
manual_login
|
|
821
830
|
or not will_import
|
|
822
|
-
or
|
|
831
|
+
or denied_now
|
|
823
832
|
)
|
|
824
833
|
|
|
825
834
|
shown = False
|
|
@@ -834,19 +843,31 @@ def cmd_connect(args) -> dict:
|
|
|
834
843
|
note="You logged in manually; the autoposter detected the live "
|
|
835
844
|
"reddit session in its own browser profile.")
|
|
836
845
|
|
|
837
|
-
if
|
|
846
|
+
if denied_now:
|
|
847
|
+
# Prompt-once policy: the Deny/Cancel is latched; never suggest
|
|
848
|
+
# re-running the import — that advice made setup agents loop and
|
|
849
|
+
# re-prompt. Manual login is the path now.
|
|
850
|
+
note = (
|
|
851
|
+
"You declined the macOS Keychain prompt, so automatic import is off — "
|
|
852
|
+
"we only ask once and won't show that prompt again. "
|
|
853
|
+
"A Chrome window is open at the Reddit login page"
|
|
854
|
+
+ ("" if shown else " (look for a 'Google Chrome' window)")
|
|
855
|
+
+ "; log in there by hand and ask me to re-check. Do NOT re-run "
|
|
856
|
+
"the cookie import. (Auto-import tried: " + ", ".join(sources) + ".)"
|
|
857
|
+
)
|
|
858
|
+
extra = {"remediation": "use_manual_login", "keychain_prompt": "declined_latched"}
|
|
859
|
+
elif keychain_declined:
|
|
860
|
+
# An earlier run (X or reddit) already recorded the Deny/Cancel; this
|
|
861
|
+
# call skipped the import entirely and opened the manual-login window.
|
|
838
862
|
note = (
|
|
839
|
-
"
|
|
840
|
-
"
|
|
841
|
-
"
|
|
842
|
-
"connect_reddit and click Allow (or Always Allow) and the import will "
|
|
843
|
-
"finish on its own. If you'd rather not grant keychain access, there's "
|
|
844
|
-
"already a Chrome window open at the Reddit login page"
|
|
863
|
+
"You previously declined the macOS Keychain prompt, so I skipped the "
|
|
864
|
+
"automatic import (we only ask once). A Chrome window is open at the "
|
|
865
|
+
"Reddit login page"
|
|
845
866
|
+ ("" if shown else " (look for a 'Google Chrome' window)")
|
|
846
|
-
+ ";
|
|
847
|
-
"
|
|
867
|
+
+ "; log in there by hand and ask me to re-check. Do NOT re-run "
|
|
868
|
+
"the cookie import; it will not prompt again."
|
|
848
869
|
)
|
|
849
|
-
extra = {"remediation": "
|
|
870
|
+
extra = {"remediation": "use_manual_login", "keychain_prompt": "declined_latched"}
|
|
850
871
|
elif open_login:
|
|
851
872
|
_why = (
|
|
852
873
|
"" if will_import
|
|
@@ -907,6 +928,11 @@ def main() -> int:
|
|
|
907
928
|
c.add_argument("--login-wait", type=float, default=300.0,
|
|
908
929
|
help="Seconds to wait for a MANUAL login to complete before "
|
|
909
930
|
"returning needs_login (default 300; 0 disables the wait).")
|
|
931
|
+
c.add_argument("--retry-keychain-import", action="store_true",
|
|
932
|
+
help="Clear the shared keychain-declined latch so the import path "
|
|
933
|
+
"may show the macOS keychain prompt ONE more time. The prompt "
|
|
934
|
+
"is once-only by policy; pass this only when the user "
|
|
935
|
+
"explicitly asks to retry the automatic import.")
|
|
910
936
|
args = ap.parse_args()
|
|
911
937
|
|
|
912
938
|
if args.cmd == "detect-sources":
|