@m13v/s4l 1.6.197-rc.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1314 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +497 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reply state mutations for the engage bots.
|
|
3
|
+
|
|
4
|
+
All write paths (processing/replied/skipped/skip_batch/set_project) route
|
|
5
|
+
through the public HTTPS endpoint /api/v1/replies/{id} on $AUTOPOSTER_API_BASE
|
|
6
|
+
(default https://s4l.ai), carrying the X-Installation header from
|
|
7
|
+
scripts/identity.py. The retry loop in _http_patch handles transient s4l.ai
|
|
8
|
+
blips (DNS, timeout, 5xx) so a single curl FAIL does not strand a row in
|
|
9
|
+
'processing'. 4xx fast-fails because retrying a deterministic client error
|
|
10
|
+
just burns the budget.
|
|
11
|
+
|
|
12
|
+
The 'status' command is the per-cycle heartbeat for skill/engage*.sh — prints
|
|
13
|
+
counts grouped by reply status. As of 2026-05-12 this also routes through
|
|
14
|
+
HTTP (/api/v1/replies/counts) so there is no remaining direct-SQL path in
|
|
15
|
+
the Reddit pipeline.
|
|
16
|
+
"""
|
|
17
|
+
import sys, json, os
|
|
18
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
19
|
+
from version import read_version as read_autoposter_version
|
|
20
|
+
try:
|
|
21
|
+
from account_resolver import resolve as _resolve_account
|
|
22
|
+
except Exception:
|
|
23
|
+
def _resolve_account(_platform): # type: ignore[unused-arg]
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
CLAUDE_SESSION_ID = os.environ.get("CLAUDE_SESSION_ID") or None
|
|
27
|
+
API_BASE = (os.environ.get("AUTOPOSTER_API_BASE") or "https://s4l.ai").rstrip("/")
|
|
28
|
+
AUTOPOSTER_VERSION = read_autoposter_version()
|
|
29
|
+
# Resolved once at import time from config.json. Used ONLY as a fallback when
|
|
30
|
+
# we have no URL to derive the live handle from (e.g. Reddit, where the
|
|
31
|
+
# permalink doesn't carry the author). For Twitter we prefer _handle_from_url
|
|
32
|
+
# below because playwright-extension attaches to the user's running Chrome and
|
|
33
|
+
# whichever account is logged in there at post time may not match config.json
|
|
34
|
+
# (persona drift bug surfaced 2026-05-27: 97 MacBook rows stamped m13v_/NULL
|
|
35
|
+
# while x.com URL said matt_diak).
|
|
36
|
+
OUR_ACCOUNT = _resolve_account("twitter")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _handle_from_url(url):
|
|
40
|
+
"""Extract the canonical posting handle from an our_reply_url, or None.
|
|
41
|
+
|
|
42
|
+
Twitter/X URLs are shaped `https://x.com/<handle>/status/<id>` and
|
|
43
|
+
`https://twitter.com/<handle>/status/<id>`. The handle in the URL is the
|
|
44
|
+
ground truth for which account actually posted (X mints the URL after the
|
|
45
|
+
POST succeeds against the logged-in session), so it beats config.json /
|
|
46
|
+
AUTOPOSTER_TWITTER_HANDLE env var, which can disagree with the live Chrome
|
|
47
|
+
when playwright-extension is attached.
|
|
48
|
+
|
|
49
|
+
Reddit, LinkedIn, and GitHub URLs don't include the author in the path
|
|
50
|
+
shape we use, so this returns None for them and the caller falls back to
|
|
51
|
+
the module-level OUR_ACCOUNT (config.json) — which is fine on those
|
|
52
|
+
platforms because they don't have the playwright-extension multi-account
|
|
53
|
+
drift problem.
|
|
54
|
+
"""
|
|
55
|
+
if not url or not isinstance(url, str):
|
|
56
|
+
return None
|
|
57
|
+
import re
|
|
58
|
+
m = re.match(r"^https?://(?:www\.)?(?:x\.com|twitter\.com)/([^/?#]+)/status/", url)
|
|
59
|
+
if not m:
|
|
60
|
+
return None
|
|
61
|
+
handle = m.group(1).strip()
|
|
62
|
+
if handle.startswith("@"):
|
|
63
|
+
handle = handle[1:]
|
|
64
|
+
return handle or None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _http_patch(rid: int, body: dict) -> None:
|
|
68
|
+
"""PATCH /api/v1/replies/{rid} with body, attaching X-Installation header.
|
|
69
|
+
|
|
70
|
+
Drops keys whose values are None so the server's COALESCE-style endpoint
|
|
71
|
+
preserves existing column values.
|
|
72
|
+
|
|
73
|
+
Retries on transient failures (network errors, HTTP 5xx) up to 3 attempts
|
|
74
|
+
with exponential backoff (1s, 3s, 9s) so a brief s4l.ai blip does not
|
|
75
|
+
strand a row in 'processing'. 4xx responses are deterministic client
|
|
76
|
+
errors and fail fast without retry. Raises SystemExit on final failure
|
|
77
|
+
so the calling shell sees a non-zero exit.
|
|
78
|
+
"""
|
|
79
|
+
import urllib.request, urllib.error, time
|
|
80
|
+
from identity import get_identity_header # local module
|
|
81
|
+
|
|
82
|
+
payload = {k: v for k, v in body.items() if v is not None}
|
|
83
|
+
data = json.dumps(payload).encode("utf8")
|
|
84
|
+
url = f"{API_BASE}/api/v1/replies/{rid}"
|
|
85
|
+
|
|
86
|
+
attempts = 3
|
|
87
|
+
backoff_s = [1, 3, 9]
|
|
88
|
+
last_err = None
|
|
89
|
+
for i in range(attempts):
|
|
90
|
+
req = urllib.request.Request(
|
|
91
|
+
url,
|
|
92
|
+
data=data,
|
|
93
|
+
method="PATCH",
|
|
94
|
+
headers={
|
|
95
|
+
"content-type": "application/json",
|
|
96
|
+
"x-installation": get_identity_header(),
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
101
|
+
resp.read()
|
|
102
|
+
return # success
|
|
103
|
+
except urllib.error.HTTPError as e:
|
|
104
|
+
# 4xx is deterministic (bad payload, missing row, auth); never
|
|
105
|
+
# going to succeed on retry, so fail fast with the server body.
|
|
106
|
+
if 400 <= e.code < 500:
|
|
107
|
+
body_txt = ""
|
|
108
|
+
try:
|
|
109
|
+
body_txt = e.read().decode("utf8", errors="ignore")
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
raise SystemExit(f"http {e.code} from PATCH {url}: {body_txt}")
|
|
113
|
+
# 5xx: transient (502/503/504 from upstream). Retry.
|
|
114
|
+
last_err = f"http {e.code}"
|
|
115
|
+
except urllib.error.URLError as e:
|
|
116
|
+
# Network-level failure: DNS resolution, connection refused,
|
|
117
|
+
# socket timeout. All worth retrying.
|
|
118
|
+
last_err = f"network error {e}"
|
|
119
|
+
if i < attempts - 1:
|
|
120
|
+
print(
|
|
121
|
+
f"[reply_db] PATCH {url} attempt {i+1}/{attempts} failed: "
|
|
122
|
+
f"{last_err}; retrying in {backoff_s[i]}s",
|
|
123
|
+
file=sys.stderr,
|
|
124
|
+
)
|
|
125
|
+
time.sleep(backoff_s[i])
|
|
126
|
+
raise SystemExit(
|
|
127
|
+
f"PATCH {url} failed after {attempts} attempts: {last_err}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
cmd = sys.argv[1]
|
|
132
|
+
if cmd == "processing":
|
|
133
|
+
# reply_db.py processing ID
|
|
134
|
+
# Mark as in-progress BEFORE browser action to prevent re-processing on crash
|
|
135
|
+
rid = int(sys.argv[2])
|
|
136
|
+
_http_patch(rid, {"status": "processing"})
|
|
137
|
+
print(f"ok {rid}")
|
|
138
|
+
elif cmd == "replied":
|
|
139
|
+
# reply_db.py replied ID "content" [url] [engagement_style] [is_recommendation]
|
|
140
|
+
# is_recommendation is "1" / "true" to mark this reply as a project mention;
|
|
141
|
+
# anything else (or absent) leaves the column at its default FALSE. Style
|
|
142
|
+
# and is_recommendation are independent: style is TONE, is_recommendation
|
|
143
|
+
# is INTENT. Do not pass style="recommendation" — that value is deprecated.
|
|
144
|
+
rid, content = int(sys.argv[2]), sys.argv[3]
|
|
145
|
+
url = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else None
|
|
146
|
+
style = sys.argv[5] if len(sys.argv) > 5 and sys.argv[5] else None
|
|
147
|
+
is_rec_arg = sys.argv[6] if len(sys.argv) > 6 and sys.argv[6] else None
|
|
148
|
+
is_rec = is_rec_arg is not None and is_rec_arg.lower() in ("1", "true", "yes")
|
|
149
|
+
body = {
|
|
150
|
+
"status": "replied",
|
|
151
|
+
"our_reply_content": content,
|
|
152
|
+
"our_reply_url": url,
|
|
153
|
+
"engagement_style": style,
|
|
154
|
+
"claude_session_id": CLAUDE_SESSION_ID,
|
|
155
|
+
# autoposter_version: stamp on the replied transition so we can
|
|
156
|
+
# attribute reply engagement back to the release that produced
|
|
157
|
+
# this comment. None when package.json + env are both missing.
|
|
158
|
+
"autoposter_version": AUTOPOSTER_VERSION,
|
|
159
|
+
# our_account: stamp the persona on every transition. Prefer the
|
|
160
|
+
# handle baked into our_reply_url because X mints that URL against
|
|
161
|
+
# the actually-logged-in session, so it's the ground truth for which
|
|
162
|
+
# account posted. Fall back to OUR_ACCOUNT (config.json / env) only
|
|
163
|
+
# when the URL is missing or non-Twitter-shaped. Server uses COALESCE
|
|
164
|
+
# so subsequent transitions don't overwrite an earlier good stamp.
|
|
165
|
+
"our_account": _handle_from_url(url) or OUR_ACCOUNT,
|
|
166
|
+
}
|
|
167
|
+
# Server uses COALESCE for is_recommendation: only send TRUE so we
|
|
168
|
+
# never accidentally clobber an existing TRUE flag back to FALSE.
|
|
169
|
+
if is_rec:
|
|
170
|
+
body["is_recommendation"] = True
|
|
171
|
+
_http_patch(rid, body)
|
|
172
|
+
print(f"ok {rid}")
|
|
173
|
+
elif cmd == "skipped":
|
|
174
|
+
# reply_db.py skipped ID "reason"
|
|
175
|
+
rid, reason = int(sys.argv[2]), sys.argv[3]
|
|
176
|
+
_http_patch(rid, {
|
|
177
|
+
"status": "skipped",
|
|
178
|
+
"skip_reason": reason,
|
|
179
|
+
"claude_session_id": CLAUDE_SESSION_ID,
|
|
180
|
+
})
|
|
181
|
+
print(f"ok {rid}")
|
|
182
|
+
elif cmd == "skip_batch":
|
|
183
|
+
# reply_db.py skip_batch '{"ids":[1,2,3],"reason":"..."}'
|
|
184
|
+
data = json.loads(sys.argv[2])
|
|
185
|
+
for rid in data["ids"]:
|
|
186
|
+
_http_patch(rid, {
|
|
187
|
+
"status": "skipped",
|
|
188
|
+
"skip_reason": data["reason"],
|
|
189
|
+
"claude_session_id": CLAUDE_SESSION_ID,
|
|
190
|
+
})
|
|
191
|
+
print(f"ok {len(data['ids'])}")
|
|
192
|
+
elif cmd == "set_project":
|
|
193
|
+
# reply_db.py set_project ID "project_name"
|
|
194
|
+
# Used by engage_reddit.py to attribute a posted reply to a recommended
|
|
195
|
+
# project after the fact. Routes through the same PATCH endpoint as the
|
|
196
|
+
# other status mutations (no SQL injection risk: project name travels
|
|
197
|
+
# as a JSON body field, not interpolated into a shell command).
|
|
198
|
+
rid, project = int(sys.argv[2]), sys.argv[3]
|
|
199
|
+
_http_patch(rid, {"project_name": project})
|
|
200
|
+
print(f"ok {rid}")
|
|
201
|
+
elif cmd == "status":
|
|
202
|
+
# Per-cycle heartbeat used by skill/engage*.sh. Routes through the
|
|
203
|
+
# /api/v1/replies/counts aggregate endpoint so this module has zero
|
|
204
|
+
# direct-SQL paths.
|
|
205
|
+
from http_api import api_get
|
|
206
|
+
platform = sys.argv[2] if len(sys.argv) > 2 else None
|
|
207
|
+
query = {"platform": platform} if platform else None
|
|
208
|
+
resp = api_get("/api/v1/replies/counts", query=query)
|
|
209
|
+
counts = ((resp or {}).get("data") or {}).get("counts") or []
|
|
210
|
+
for row in counts:
|
|
211
|
+
print(f"{row.get('status', '')} {row.get('count', 0)}")
|
|
212
|
+
elif cmd == "blocklist":
|
|
213
|
+
# reply_db.py blocklist <subcmd> ...
|
|
214
|
+
#
|
|
215
|
+
# The escape hatch for the engagement-loop / bot defense. The Twitter,
|
|
216
|
+
# LinkedIn, and GitHub engage prompts call:
|
|
217
|
+
# blocklist add <platform> <handle> --reason "<one-line judgment>"
|
|
218
|
+
# [--classification bot|engagement_loop] [--severity hard|soft]
|
|
219
|
+
# [--source-reply-id N]
|
|
220
|
+
# when the model identifies a handle that should be permanently
|
|
221
|
+
# filtered. Future candidates from the same handle are dropped silently
|
|
222
|
+
# at /api/v1/replies POST time (server-side gate). See
|
|
223
|
+
# migrations/2026-05-27_author_blocklist.sql for the full design.
|
|
224
|
+
#
|
|
225
|
+
# Also exposes:
|
|
226
|
+
# blocklist list [platform] -> print active blocks for the install
|
|
227
|
+
# blocklist remove <platform> <handle>
|
|
228
|
+
# blocklist check <platform> <handle> -> exit 0 if blocked, 1 if not
|
|
229
|
+
from http_api import api_get, api_post
|
|
230
|
+
sub = sys.argv[2] if len(sys.argv) > 2 else None
|
|
231
|
+
if sub == "add":
|
|
232
|
+
# blocklist add <platform> <handle> --reason "..." [opts]
|
|
233
|
+
platform = sys.argv[3]
|
|
234
|
+
handle = sys.argv[4]
|
|
235
|
+
# naive arg parsing: --flag value pairs after position 5
|
|
236
|
+
opts = {}
|
|
237
|
+
i = 5
|
|
238
|
+
while i < len(sys.argv):
|
|
239
|
+
key = sys.argv[i]
|
|
240
|
+
if key.startswith("--") and i + 1 < len(sys.argv):
|
|
241
|
+
opts[key[2:].replace("-", "_")] = sys.argv[i + 1]
|
|
242
|
+
i += 2
|
|
243
|
+
else:
|
|
244
|
+
i += 1
|
|
245
|
+
body = {
|
|
246
|
+
"platform": platform,
|
|
247
|
+
"handle": handle,
|
|
248
|
+
"reason": opts.get("reason") or "engage prompt flagged",
|
|
249
|
+
"classification": opts.get("classification", "bot"),
|
|
250
|
+
"severity": opts.get("severity", "hard"),
|
|
251
|
+
"added_by": opts.get("added_by", "engage_llm"),
|
|
252
|
+
"source_session_id": CLAUDE_SESSION_ID,
|
|
253
|
+
}
|
|
254
|
+
if opts.get("source_reply_id"):
|
|
255
|
+
try:
|
|
256
|
+
body["source_reply_id"] = int(opts["source_reply_id"])
|
|
257
|
+
except (TypeError, ValueError):
|
|
258
|
+
pass
|
|
259
|
+
if opts.get("project"):
|
|
260
|
+
body["project"] = opts["project"]
|
|
261
|
+
resp = api_post("/api/v1/blocklist", body=body)
|
|
262
|
+
data = (resp or {}).get("data") or {}
|
|
263
|
+
action = data.get("action", "?")
|
|
264
|
+
row = data.get("row") or {}
|
|
265
|
+
print(f"ok blocklist {action} {row.get('platform', platform)}/{row.get('handle', handle)} severity={row.get('severity', '?')}")
|
|
266
|
+
# Stable stderr marker so log_run.py / grep-based observability can
|
|
267
|
+
# count escape-hatch firings without re-querying the DB. Velocity-gate
|
|
268
|
+
# auto-blocks land directly via the route.ts SQL path and do NOT pass
|
|
269
|
+
# through here, so this marker is specific to LLM-judgment-driven
|
|
270
|
+
# adds (or manual operator adds).
|
|
271
|
+
classification = body["classification"]
|
|
272
|
+
source = body.get("source_reply_id", "")
|
|
273
|
+
reason_safe = (body.get("reason") or "").replace("\n", " ").replace("|", "/")[:200]
|
|
274
|
+
print(
|
|
275
|
+
f"[escape_hatch] platform={body['platform']} handle={body['handle']} "
|
|
276
|
+
f"classification={classification} severity={body['severity']} "
|
|
277
|
+
f"source_reply_id={source} action={action} reason=\"{reason_safe}\"",
|
|
278
|
+
file=sys.stderr,
|
|
279
|
+
)
|
|
280
|
+
elif sub == "list":
|
|
281
|
+
platform = sys.argv[3] if len(sys.argv) > 3 else None
|
|
282
|
+
query = {"platform": platform} if platform else None
|
|
283
|
+
resp = api_get("/api/v1/blocklist", query=query)
|
|
284
|
+
rows = ((resp or {}).get("data") or {}).get("rows") or []
|
|
285
|
+
if not rows:
|
|
286
|
+
print("(no active blocks)")
|
|
287
|
+
for r in rows:
|
|
288
|
+
print(
|
|
289
|
+
f"{r.get('platform','')} @{r.get('handle','')} "
|
|
290
|
+
f"sev={r.get('severity','?')} "
|
|
291
|
+
f"cls={r.get('classification','?')} "
|
|
292
|
+
f"by={r.get('added_by','?')} "
|
|
293
|
+
f"hits={r.get('hit_count', 0)} "
|
|
294
|
+
f"reason={(r.get('reason') or '')[:80]}"
|
|
295
|
+
)
|
|
296
|
+
elif sub == "remove":
|
|
297
|
+
import urllib.request, urllib.parse
|
|
298
|
+
from identity import get_identity_header
|
|
299
|
+
platform = sys.argv[3]
|
|
300
|
+
handle = sys.argv[4].lstrip("@").lower()
|
|
301
|
+
url = f"{API_BASE}/api/v1/blocklist/{urllib.parse.quote(platform)}/{urllib.parse.quote(handle)}"
|
|
302
|
+
req = urllib.request.Request(
|
|
303
|
+
url,
|
|
304
|
+
method="DELETE",
|
|
305
|
+
headers={"x-installation": get_identity_header()},
|
|
306
|
+
)
|
|
307
|
+
try:
|
|
308
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
309
|
+
resp.read()
|
|
310
|
+
print(f"ok removed {platform}/{handle}")
|
|
311
|
+
except Exception as e:
|
|
312
|
+
raise SystemExit(f"DELETE {url} failed: {e}")
|
|
313
|
+
elif sub == "check":
|
|
314
|
+
platform = sys.argv[3]
|
|
315
|
+
handle = sys.argv[4].lstrip("@").lower()
|
|
316
|
+
resp = api_get(
|
|
317
|
+
"/api/v1/blocklist",
|
|
318
|
+
query={"platform": platform},
|
|
319
|
+
)
|
|
320
|
+
rows = ((resp or {}).get("data") or {}).get("rows") or []
|
|
321
|
+
match = next(
|
|
322
|
+
(r for r in rows if (r.get("handle") or "").lower() == handle and r.get("severity") == "hard"),
|
|
323
|
+
None,
|
|
324
|
+
)
|
|
325
|
+
if match:
|
|
326
|
+
print(f"BLOCKED {platform}/{handle} cls={match.get('classification','?')} reason={(match.get('reason') or '')[:100]}")
|
|
327
|
+
sys.exit(0)
|
|
328
|
+
else:
|
|
329
|
+
print(f"not blocked {platform}/{handle}")
|
|
330
|
+
sys.exit(1)
|
|
331
|
+
else:
|
|
332
|
+
raise SystemExit(
|
|
333
|
+
"usage: reply_db.py blocklist {add|list|remove|check} ..."
|
|
334
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared reply-insertion helper for scan_*_replies.py scripts.
|
|
3
|
+
|
|
4
|
+
`insert_reply` returns the status string on a NEW insert, or None if the row
|
|
5
|
+
already existed. Callers use the return value to update their discovered /
|
|
6
|
+
skipped counters.
|
|
7
|
+
|
|
8
|
+
2026-05-12: dedup now happens exclusively server-side. The /api/v1/replies
|
|
9
|
+
POST endpoint has a UNIQUE (platform, their_comment_id) index and uses
|
|
10
|
+
ON CONFLICT DO NOTHING; a duplicate returns 409 with the existing row, and
|
|
11
|
+
api_post(ok_on_conflict=True) surfaces that as a body with an "error" key.
|
|
12
|
+
Previously this module did a `SELECT COUNT(*)` probe before posting; that
|
|
13
|
+
was the last direct-SQL hop in the scan-reddit-replies path and has been
|
|
14
|
+
removed.
|
|
15
|
+
"""
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def insert_reply(
|
|
23
|
+
db,
|
|
24
|
+
post_id,
|
|
25
|
+
platform,
|
|
26
|
+
comment_id,
|
|
27
|
+
author,
|
|
28
|
+
content,
|
|
29
|
+
comment_url,
|
|
30
|
+
parent_reply_id=None,
|
|
31
|
+
depth=1,
|
|
32
|
+
status="pending",
|
|
33
|
+
skip_reason=None,
|
|
34
|
+
moltbook_post_uuid=None,
|
|
35
|
+
moltbook_parent_comment_uuid=None,
|
|
36
|
+
our_reply_id=None,
|
|
37
|
+
our_reply_content=None,
|
|
38
|
+
our_reply_url=None,
|
|
39
|
+
replied_at=None,
|
|
40
|
+
):
|
|
41
|
+
"""Insert a reply via /api/v1/replies POST.
|
|
42
|
+
|
|
43
|
+
The `db` arg is preserved in the signature for backwards compatibility
|
|
44
|
+
with callers that still pass a psycopg connection — the value is IGNORED.
|
|
45
|
+
All writes go through HTTP now.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
status string when this call performed the INSERT
|
|
49
|
+
None when the (platform, their_comment_id) was already in the table
|
|
50
|
+
"""
|
|
51
|
+
comment_id = str(comment_id)
|
|
52
|
+
|
|
53
|
+
from http_api import api_post
|
|
54
|
+
body = {
|
|
55
|
+
"platform": platform,
|
|
56
|
+
"their_comment_id": comment_id,
|
|
57
|
+
"status": status,
|
|
58
|
+
}
|
|
59
|
+
if post_id is not None:
|
|
60
|
+
body["post_id"] = post_id
|
|
61
|
+
if author is not None:
|
|
62
|
+
body["their_author"] = author
|
|
63
|
+
if content is not None:
|
|
64
|
+
body["their_content"] = content
|
|
65
|
+
if comment_url is not None:
|
|
66
|
+
body["their_comment_url"] = comment_url
|
|
67
|
+
if parent_reply_id is not None:
|
|
68
|
+
body["parent_reply_id"] = parent_reply_id
|
|
69
|
+
if depth != 1:
|
|
70
|
+
body["depth"] = depth
|
|
71
|
+
if skip_reason is not None:
|
|
72
|
+
body["skip_reason"] = skip_reason
|
|
73
|
+
if moltbook_post_uuid is not None:
|
|
74
|
+
body["moltbook_post_uuid"] = moltbook_post_uuid
|
|
75
|
+
if moltbook_parent_comment_uuid is not None:
|
|
76
|
+
body["moltbook_parent_comment_uuid"] = moltbook_parent_comment_uuid
|
|
77
|
+
if our_reply_id is not None:
|
|
78
|
+
body["our_reply_id"] = our_reply_id
|
|
79
|
+
if our_reply_content is not None:
|
|
80
|
+
body["our_reply_content"] = our_reply_content
|
|
81
|
+
if our_reply_url is not None:
|
|
82
|
+
body["our_reply_url"] = our_reply_url
|
|
83
|
+
if replied_at is not None:
|
|
84
|
+
body["replied_at"] = (
|
|
85
|
+
replied_at.isoformat() if hasattr(replied_at, "isoformat") else str(replied_at)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
resp = api_post("/api/v1/replies", body, ok_on_conflict=True)
|
|
89
|
+
if resp is None:
|
|
90
|
+
return None
|
|
91
|
+
# 409 path returns a body with an "error" key (duplicate_reply); treat as
|
|
92
|
+
# "already in DB" -> None to mirror the previous behavior.
|
|
93
|
+
if resp.get("error"):
|
|
94
|
+
return None
|
|
95
|
+
data = resp.get("data") if isinstance(resp, dict) else None
|
|
96
|
+
if not data or not data.get("reply"):
|
|
97
|
+
return None
|
|
98
|
+
return status
|