@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,146 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Social Autoposter - Reddit engagement loop
|
|
3
|
+
# Runs scan_reddit_replies.py every 10 min via launchd.
|
|
4
|
+
# Inbox-based discovery + engage_reddit.py --limit 5 in one job.
|
|
5
|
+
# Skip-if-locked (timeout 0) since runs are frequent and a previous tick may still be engaging.
|
|
6
|
+
#
|
|
7
|
+
# Renamed 2026-04-29 from run-scan-reddit-replies.sh / com.m13v.social-scan-reddit-replies
|
|
8
|
+
# to engage-reddit.sh / com.m13v.social-engage-reddit so the file/plist/log names
|
|
9
|
+
# match what the dashboard already calls this job ("Engage Reddit"). The Python
|
|
10
|
+
# discovery module (scripts/scan_reddit_replies.py) keeps its name since other
|
|
11
|
+
# helpers still import from it.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
source "$(dirname "$0")/lock.sh"
|
|
17
|
+
# reddit-harness backend (2026-05-29): exports REDDIT_CDP_URL=:9557 so
|
|
18
|
+
# reddit_browser.py (shelled from engage_reddit.py) attaches to the harness
|
|
19
|
+
# Chrome instead of ps-discovering the reddit-agent profile. Source after
|
|
20
|
+
# lock.sh, before acquire_lock / browser pre-flight.
|
|
21
|
+
source "$(dirname "$0")/lib/reddit-backend.sh"
|
|
22
|
+
acquire_lock "engage-reddit" 0
|
|
23
|
+
|
|
24
|
+
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
25
|
+
|
|
26
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
27
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
28
|
+
mkdir -p "$LOG_DIR"
|
|
29
|
+
LOG_FILE="$LOG_DIR/engage-reddit-$(date +%Y-%m-%d_%H%M%S).log"
|
|
30
|
+
|
|
31
|
+
# Per-cycle batch id stamped onto every claude_sessions row spawned by this
|
|
32
|
+
# engagement run (via SA_CYCLE_ID env -> log_claude_session.py). Lets the
|
|
33
|
+
# dashboard / get_run_cost.py --cycle-id report exact per-cycle cost instead
|
|
34
|
+
# of the legacy script+since query that bleeds across concurrent runs.
|
|
35
|
+
# 2026-05-10 cycle_id rollout.
|
|
36
|
+
BATCH_ID="enrdt-$(date +%Y%m%d-%H%M%S)-$$"
|
|
37
|
+
export SA_CYCLE_ID="$BATCH_ID"
|
|
38
|
+
|
|
39
|
+
echo "=== Engage Reddit Run: $(date) (cycle=$BATCH_ID) ===" | tee "$LOG_FILE"
|
|
40
|
+
START_TS=$(date +%s)
|
|
41
|
+
|
|
42
|
+
# Reddit-browser lease strategy (migrated 2026-05-13): the lease is now
|
|
43
|
+
# acquired/released PER REPLY inside engage_reddit.py's main() while-loop,
|
|
44
|
+
# around each Claude+CDP iteration. Previously we held the lease around the
|
|
45
|
+
# whole engage_reddit.py run, so a 5-reply batch monopolised the browser for
|
|
46
|
+
# ~10-25 min while peer reddit pipelines (run-reddit-search post phase,
|
|
47
|
+
# run-reddit-threads, link-edit-reddit, dm-outreach-reddit, engage-dm-replies)
|
|
48
|
+
# sat blocked even during the per-reply Claude "thinking" gaps. Mirrors the
|
|
49
|
+
# pattern shipped to post_reddit.py + run-reddit-search.sh on 2026-05-13 and
|
|
50
|
+
# to link-edit-reddit.sh + dm-outreach-reddit.sh in May 2026.
|
|
51
|
+
#
|
|
52
|
+
# Pre-flight: brief acquire+ensure_browser_healthy+release ONCE per cycle so
|
|
53
|
+
# orphan-Chrome cleanup / Singleton-lock clear runs before the first reply.
|
|
54
|
+
# Best-effort: if acquire is BUSY (peer pipeline mid-post), warn and proceed.
|
|
55
|
+
echo "[engage-reddit] Pre-flight: brief reddit-browser acquire + ensure_browser_healthy + release..." | tee -a "$LOG_FILE"
|
|
56
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" acquire --timeout 60 --ttl 30 2>&1 | tee -a "$LOG_FILE" || \
|
|
57
|
+
echo "[engage-reddit] WARNING: pre-flight acquire BUSY; harness bootstrap will run anyway; per-reply acquires inside engage_reddit.py will retry." | tee -a "$LOG_FILE"
|
|
58
|
+
if ! ensure_reddit_browser_for_backend 2>&1 | tee -a "$LOG_FILE"; then
|
|
59
|
+
echo "[engage-reddit] WARNING: reddit-harness bootstrap failed; falling back to ensure_browser_healthy reddit" | tee -a "$LOG_FILE"
|
|
60
|
+
ensure_browser_healthy "reddit"
|
|
61
|
+
fi
|
|
62
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
|
|
63
|
+
|
|
64
|
+
# Belt-and-suspenders trap: free the lease on any exit path. Idempotent.
|
|
65
|
+
# With the per-reply pattern the lease is normally released at the end of
|
|
66
|
+
# each iteration, but a SIGTERM mid-iteration would otherwise leave it
|
|
67
|
+
# held for ~90s before peers could steal it.
|
|
68
|
+
#
|
|
69
|
+
# Trap chaining: lock.sh sourced above installed `_sa_release_locks` on
|
|
70
|
+
# EXIT INT TERM HUP. Bash trap REPLACES, not appends, so we re-set with
|
|
71
|
+
# both handlers. Order: release the lease first (cheap, lets peers in),
|
|
72
|
+
# then release pipeline locks. Mirrors run-reddit-search.sh.
|
|
73
|
+
_release_reddit_lease() {
|
|
74
|
+
timeout 3 python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
|
|
75
|
+
}
|
|
76
|
+
trap '_release_reddit_lease; _sa_release_locks' EXIT INT TERM HUP
|
|
77
|
+
|
|
78
|
+
PYTHONUNBUFFERED=1 python3 "$REPO_DIR/scripts/scan_reddit_replies.py" 2>&1 | tee -a "$LOG_FILE" || true
|
|
79
|
+
|
|
80
|
+
ELAPSED=$(( $(date +%s) - START_TS ))
|
|
81
|
+
# Pull scan-stage counters out of the "Inbox scan complete:" line so the
|
|
82
|
+
# dashboard Result column can show "scanned N / new N / excluded N" on empty
|
|
83
|
+
# cycles instead of all-zeros. Format on disk:
|
|
84
|
+
# Inbox scan complete: seen=51 new_pending=0 backfill_skipped=0 \
|
|
85
|
+
# already_replied=0 excluded_author=1 unmatched_thread=0
|
|
86
|
+
# We rename the keys to short forms (seen->scanned, new_pending->new,
|
|
87
|
+
# excluded_author->excluded, unmatched_thread->unmatched) before passing to
|
|
88
|
+
# log_run.py via --scan.
|
|
89
|
+
SCAN_LINE=$(grep -m1 "^Inbox scan complete:" "$LOG_FILE" 2>/dev/null || true)
|
|
90
|
+
SCAN_ARG=""
|
|
91
|
+
if [ -n "$SCAN_LINE" ]; then
|
|
92
|
+
scan_seen=$(echo "$SCAN_LINE" | grep -oE "seen=[0-9]+" | head -1 | cut -d= -f2)
|
|
93
|
+
scan_new=$(echo "$SCAN_LINE" | grep -oE "new_pending=[0-9]+" | head -1 | cut -d= -f2)
|
|
94
|
+
scan_excl=$(echo "$SCAN_LINE" | grep -oE "excluded_author=[0-9]+" | head -1 | cut -d= -f2)
|
|
95
|
+
scan_unm=$(echo "$SCAN_LINE" | grep -oE "unmatched_thread=[0-9]+" | head -1 | cut -d= -f2)
|
|
96
|
+
scan_back=$(echo "$SCAN_LINE" | grep -oE "backfill_skipped=[0-9]+" | head -1 | cut -d= -f2)
|
|
97
|
+
parts=""
|
|
98
|
+
[ -n "$scan_seen" ] && parts="${parts}scanned=${scan_seen},"
|
|
99
|
+
[ -n "$scan_new" ] && parts="${parts}new=${scan_new},"
|
|
100
|
+
[ -n "$scan_excl" ] && parts="${parts}excluded=${scan_excl},"
|
|
101
|
+
[ -n "$scan_unm" ] && parts="${parts}unmatched=${scan_unm},"
|
|
102
|
+
[ -n "$scan_back" ] && parts="${parts}backfill=${scan_back},"
|
|
103
|
+
SCAN_ARG="${parts%,}"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# Pull engage-stage counters from the canonical LOG_RUN_SUMMARY line that
|
|
107
|
+
# engage_reddit.py prints right before exiting. Previously engage_reddit.py
|
|
108
|
+
# wrote its own log_run.py row AND we wrote one here, producing two rows per
|
|
109
|
+
# cycle in run_monitor.log -- the python-side row had no scan info and showed
|
|
110
|
+
# as zeros on the dashboard. Now python emits the line, the shell parses it,
|
|
111
|
+
# and we write ONE row that combines engage + scan counters.
|
|
112
|
+
SUMMARY_LINE=$(grep -m1 "^\[engage_reddit\] LOG_RUN_SUMMARY" "$LOG_FILE" 2>/dev/null || true)
|
|
113
|
+
ENG_POSTED=0; ENG_SKIPPED=0; ENG_FAILED=0; ENG_COST="0.0000"; ENG_ELAPSED="$ELAPSED"; ENG_FAILURE_REASONS=""
|
|
114
|
+
if [ -n "$SUMMARY_LINE" ]; then
|
|
115
|
+
ENG_POSTED=$(echo "$SUMMARY_LINE" | grep -oE "posted=[0-9]+" | head -1 | cut -d= -f2)
|
|
116
|
+
ENG_SKIPPED=$(echo "$SUMMARY_LINE" | grep -oE "skipped=[0-9]+" | head -1 | cut -d= -f2)
|
|
117
|
+
ENG_FAILED=$(echo "$SUMMARY_LINE" | grep -oE "failed=[0-9]+" | head -1 | cut -d= -f2)
|
|
118
|
+
ENG_COST=$(echo "$SUMMARY_LINE" | grep -oE "cost=[0-9.]+" | head -1 | cut -d= -f2)
|
|
119
|
+
# `failure_reasons=` is the last key on the line; it may be empty. Capture
|
|
120
|
+
# everything after the literal token, trim trailing whitespace.
|
|
121
|
+
ENG_FAILURE_REASONS=$(echo "$SUMMARY_LINE" | sed -n 's/.* failure_reasons=\([^ ]*\).*/\1/p')
|
|
122
|
+
: "${ENG_POSTED:=0}" "${ENG_SKIPPED:=0}" "${ENG_FAILED:=0}" "${ENG_COST:=0.0000}"
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
# Rescue Anthropic-side failures the python-side classifier didn't catch.
|
|
126
|
+
# When engage_reddit.py died mid-run (stream idle timeout, monthly cap hit,
|
|
127
|
+
# context overflow), SUMMARY_LINE may be missing OR present-with-empty-reasons.
|
|
128
|
+
# Either way, scan the log for a real cause before falling back to a silent
|
|
129
|
+
# row on the dashboard.
|
|
130
|
+
if [ -z "$ENG_FAILURE_REASONS" ] && [ -f "$LOG_FILE" ]; then
|
|
131
|
+
API_REASON=$(python3 "$REPO_DIR/scripts/classify_run_error.py" "$LOG_FILE" 2>/dev/null)
|
|
132
|
+
if [ -n "$API_REASON" ]; then
|
|
133
|
+
ENG_FAILURE_REASONS="${API_REASON}:1"
|
|
134
|
+
# Bump failed count by 1 if the classifier found something but the python
|
|
135
|
+
# side reported zero failures (i.e. the process died before it could log).
|
|
136
|
+
[ "$ENG_FAILED" = "0" ] && ENG_FAILED=1
|
|
137
|
+
fi
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
LOG_RUN_ARGS=(--script "engage_reddit" --posted "$ENG_POSTED" --skipped "$ENG_SKIPPED" --failed "$ENG_FAILED" --cost "$ENG_COST" --elapsed "$ENG_ELAPSED")
|
|
141
|
+
[ -n "$SCAN_ARG" ] && LOG_RUN_ARGS+=(--scan "$SCAN_ARG")
|
|
142
|
+
[ -n "$ENG_FAILURE_REASONS" ] && LOG_RUN_ARGS+=(--failure-reasons "$ENG_FAILURE_REASONS")
|
|
143
|
+
python3 "$REPO_DIR/scripts/log_run.py" "${LOG_RUN_ARGS[@]}" || true
|
|
144
|
+
|
|
145
|
+
echo "=== Engage Reddit complete: $(date) (elapsed ${ELAPSED}s) ===" | tee -a "$LOG_FILE"
|
|
146
|
+
find "$LOG_DIR" -name "engage-reddit-*.log" -mtime +7 -delete 2>/dev/null || true
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# engage-twitter.sh — X/Twitter engagement loop
|
|
3
|
+
# Phase A: Discover replies/mentions via Twitter API (no browser needed)
|
|
4
|
+
# Phase B: Respond to pending Twitter replies via browser (API can't reply to most tweets)
|
|
5
|
+
# Called by launchd every 3 hours.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
# Bootstrap log paths early so the singleton-cleanup output below gets captured
|
|
11
|
+
# in the same log file the rest of the run uses.
|
|
12
|
+
LOG_DIR="$HOME/social-autoposter/skill/logs"
|
|
13
|
+
mkdir -p "$LOG_DIR"
|
|
14
|
+
LOG_FILE="$LOG_DIR/engage-twitter-$(date +%Y-%m-%d_%H%M%S).log"
|
|
15
|
+
|
|
16
|
+
# Per-cycle batch id stamped onto every claude_sessions row spawned by this
|
|
17
|
+
# engagement run (via SA_CYCLE_ID env -> log_claude_session.py). 2026-05-10
|
|
18
|
+
# cycle_id rollout.
|
|
19
|
+
BATCH_ID="entw-$(date +%Y%m%d-%H%M%S)-$$"
|
|
20
|
+
export SA_CYCLE_ID="$BATCH_ID"
|
|
21
|
+
|
|
22
|
+
# Browser-profile lock first (shared with other twitter pipelines), then pipeline lock.
|
|
23
|
+
source "$(dirname "$0")/lock.sh"
|
|
24
|
+
# Harness-only browser bootstrap (twitter-agent path fully removed 2026-05-19).
|
|
25
|
+
# Sets MCP_CONFIG_FILE, BROWSER_INSTRUCTIONS, exports TWITTER_CDP_URL=9555.
|
|
26
|
+
source "$(dirname "$0")/lib/twitter-backend.sh"
|
|
27
|
+
|
|
28
|
+
echo "[$(date +%H:%M:%S)] Acquiring twitter-browser lock (pid=$$)..." | tee -a "$LOG_FILE"
|
|
29
|
+
acquire_lock "twitter-browser" 3600 2>>"$LOG_FILE"
|
|
30
|
+
echo "[$(date +%H:%M:%S)] twitter-browser lock held (pid=$$)" | tee -a "$LOG_FILE"
|
|
31
|
+
# Probe + launch harness Chrome on port 9555 if needed, then sweep leftover tabs.
|
|
32
|
+
ensure_twitter_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
|
|
33
|
+
echo "[$(date +%H:%M:%S)] Acquiring twitter (pipeline) lock (pid=$$)..." | tee -a "$LOG_FILE"
|
|
34
|
+
acquire_lock "twitter" 3600 2>>"$LOG_FILE"
|
|
35
|
+
|
|
36
|
+
# Load secrets
|
|
37
|
+
# shellcheck source=/dev/null
|
|
38
|
+
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
39
|
+
|
|
40
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
41
|
+
SKILL_FILE="$REPO_DIR/SKILL.md"
|
|
42
|
+
BATCH_SIZE=500
|
|
43
|
+
|
|
44
|
+
# All Twitter engage DB I/O routes through scripts/engage_twitter_helper.py
|
|
45
|
+
# (HTTP API at /api/v1/*) since 2026-05-18. DATABASE_URL is no longer
|
|
46
|
+
# required for this script and is left for downstream tooling only.
|
|
47
|
+
ENGAGE_TWITTER_HELPER="$REPO_DIR/scripts/engage_twitter_helper.py"
|
|
48
|
+
# (LOG_DIR/LOG_FILE bootstrapped at top of script.)
|
|
49
|
+
|
|
50
|
+
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
|
51
|
+
|
|
52
|
+
RUN_START=$(date +%s)
|
|
53
|
+
log "=== Twitter Engagement Run: $(date) ==="
|
|
54
|
+
|
|
55
|
+
# Load exclusions from config
|
|
56
|
+
EXCLUDED_AUTHORS=$(python3 -c "import json; c=json.load(open('$REPO_DIR/config.json')); print(', '.join(c.get('exclusions',{}).get('authors',[])))" 2>/dev/null || echo "")
|
|
57
|
+
EXCLUDED_TWITTER=$(python3 -c "import json; c=json.load(open('$REPO_DIR/config.json')); print(', '.join(c.get('exclusions',{}).get('twitter_accounts',[])))" 2>/dev/null || echo "")
|
|
58
|
+
|
|
59
|
+
# ═══════════════════════════════════════════════════════
|
|
60
|
+
# PHASE A: Discover new replies/mentions from Twitter notifications
|
|
61
|
+
# ═══════════════════════════════════════════════════════
|
|
62
|
+
log "Phase A: Scanning Twitter mentions via browser (no API cost)..."
|
|
63
|
+
NOTIFS_JSON=$(mktemp -t twitter_notifs.XXXXXX.json)
|
|
64
|
+
python3 "$REPO_DIR/scripts/twitter_browser.py" notifications 8 > "$NOTIFS_JSON" 2>>"$LOG_FILE" \
|
|
65
|
+
|| log "WARNING: twitter_browser.py notifications failed"
|
|
66
|
+
python3 "$REPO_DIR/scripts/scan_twitter_mentions_browser.py" --json-file "$NOTIFS_JSON" 2>&1 \
|
|
67
|
+
| tee -a "$LOG_FILE" \
|
|
68
|
+
|| log "WARNING: Phase A scan_twitter_mentions_browser.py exited with code $?"
|
|
69
|
+
rm -f "$NOTIFS_JSON"
|
|
70
|
+
|
|
71
|
+
# ═══════════════════════════════════════════════════════
|
|
72
|
+
# PHASE B: Respond to pending Twitter replies
|
|
73
|
+
# ═══════════════════════════════════════════════════════
|
|
74
|
+
|
|
75
|
+
# Reset any 'processing' items older than 2 hours back to 'pending'.
|
|
76
|
+
# Server-side WHERE in /api/v1/replies/reset-stuck mirrors the old SQL.
|
|
77
|
+
RESET_COUNT=$(python3 "$ENGAGE_TWITTER_HELPER" reset-stuck-replies)
|
|
78
|
+
[ "$RESET_COUNT" -gt 0 ] && log "Phase B: Reset $RESET_COUNT stuck 'processing' Twitter items back to pending"
|
|
79
|
+
|
|
80
|
+
PENDING_COUNT=$(python3 "$ENGAGE_TWITTER_HELPER" pending-count)
|
|
81
|
+
|
|
82
|
+
if [ "$PENDING_COUNT" -eq 0 ]; then
|
|
83
|
+
log "Phase B: No pending Twitter replies. Done!"
|
|
84
|
+
else
|
|
85
|
+
log "Phase B: $PENDING_COUNT pending Twitter replies to process"
|
|
86
|
+
|
|
87
|
+
# /api/v1/replies/next-pending returns the SAME join (replies + posts)
|
|
88
|
+
# with the SAME priority ordering (our_thread first, then discovered_at
|
|
89
|
+
# ASC) the previous json_agg() build emitted; the helper just reshapes
|
|
90
|
+
# the response into the legacy field set the prompt expects.
|
|
91
|
+
PENDING_DATA=$(python3 "$ENGAGE_TWITTER_HELPER" pending-data --batch-size "$BATCH_SIZE")
|
|
92
|
+
|
|
93
|
+
# JOIN-aware emptiness guard (2026-05-26). /api/v1/replies/counts returns
|
|
94
|
+
# the raw pending count (no JOIN), but /api/v1/replies/next-pending INNER
|
|
95
|
+
# JOINs posts; orphan replies whose post_id no longer exists make these
|
|
96
|
+
# two disagree. Without this guard, Phase B burns the full gtimeout
|
|
97
|
+
# holding the twitter-browser lock while Claude finds nothing to do,
|
|
98
|
+
# starving dm-outreach-twitter and dm-replies-twitter in the lock queue
|
|
99
|
+
# for 30+ min. Skip Phase B and release the browser lock early when
|
|
100
|
+
# /next-pending returns 0 rows.
|
|
101
|
+
PENDING_REAL_COUNT=$(echo "$PENDING_DATA" | python3 -c "
|
|
102
|
+
import sys, json
|
|
103
|
+
try:
|
|
104
|
+
d = json.loads(sys.stdin.read() or '{}')
|
|
105
|
+
if isinstance(d, dict):
|
|
106
|
+
rows = d.get('replies', [])
|
|
107
|
+
elif isinstance(d, list):
|
|
108
|
+
rows = d
|
|
109
|
+
else:
|
|
110
|
+
rows = []
|
|
111
|
+
print(len(rows))
|
|
112
|
+
except Exception:
|
|
113
|
+
print(0)
|
|
114
|
+
" 2>/dev/null || echo 0)
|
|
115
|
+
|
|
116
|
+
if [ "$PENDING_REAL_COUNT" -eq 0 ]; then
|
|
117
|
+
log "Phase B: counts says $PENDING_COUNT pending but JOIN returned 0 rows (likely orphan replies whose post_id is missing). Skipping Phase B."
|
|
118
|
+
log "Releasing twitter + twitter-browser locks so other pipelines (dm-outreach-twitter, dm-replies-twitter) can run."
|
|
119
|
+
release_lock "twitter" 2>>"$LOG_FILE" || true
|
|
120
|
+
release_lock "twitter-browser" 2>>"$LOG_FILE" || true
|
|
121
|
+
# (2026-06-16) session-lock rm removed (defect b); dead holders self-reclaim
|
|
122
|
+
# in twitter_browser.py now. Do NOT re-add. See docs/twitter_browser_lock.md.
|
|
123
|
+
RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
|
|
124
|
+
log "=== Twitter Engagement Run done (no real work): elapsed=${RUN_ELAPSED}s ==="
|
|
125
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "engage_twitter" --posted 0 --skipped 0 --failed 0 --cost 0 --elapsed "$RUN_ELAPSED" 2>/dev/null || true
|
|
126
|
+
exit 0
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
log "Phase B: $PENDING_REAL_COUNT pending Twitter replies confirmed via JOIN (counts said $PENDING_COUNT)"
|
|
130
|
+
|
|
131
|
+
# Per-project voice map (so each reply can be drafted in the matched project's voice)
|
|
132
|
+
PROJECTS_VOICE_JSON=$(python3 -c "
|
|
133
|
+
import json
|
|
134
|
+
c = json.load(open('$REPO_DIR/config.json'))
|
|
135
|
+
print(json.dumps({p['name']: p.get('voice', {}) for p in c.get('projects', []) if p.get('voice')}, indent=2))
|
|
136
|
+
" 2>/dev/null || echo "{}")
|
|
137
|
+
|
|
138
|
+
# Engagement-style picker (2026-05-19): pick ONE assigned style per
|
|
139
|
+
# reply iteration. The picked style flows two places: (1) --style
|
|
140
|
+
# filter for top_performers.py so the per-style exemplars match the
|
|
141
|
+
# assignment, (2) saps_render_style_block so the prompt embeds the
|
|
142
|
+
# same assignment. On invent mode picked_style is empty and
|
|
143
|
+
# top_performers stays unfiltered.
|
|
144
|
+
source "$REPO_DIR/skill/styles.sh"
|
|
145
|
+
STYLE_ASSIGN_FILE=$(mktemp -t saps_twitter_eng_assign_XXXXXX.json)
|
|
146
|
+
saps_pick_style twitter replying "$STYLE_ASSIGN_FILE" >/dev/null 2>&1 || true
|
|
147
|
+
PICKED_STYLE=$(python3 -c "
|
|
148
|
+
import json
|
|
149
|
+
try:
|
|
150
|
+
with open('$STYLE_ASSIGN_FILE') as f:
|
|
151
|
+
d = json.load(f)
|
|
152
|
+
print(d.get('style') or '')
|
|
153
|
+
except Exception:
|
|
154
|
+
print('')
|
|
155
|
+
" 2>/dev/null)
|
|
156
|
+
PICKED_MODE=$(python3 -c "
|
|
157
|
+
import json
|
|
158
|
+
try:
|
|
159
|
+
with open('$STYLE_ASSIGN_FILE') as f:
|
|
160
|
+
d = json.load(f)
|
|
161
|
+
print(d.get('mode') or 'use')
|
|
162
|
+
except Exception:
|
|
163
|
+
print('use')
|
|
164
|
+
" 2>/dev/null)
|
|
165
|
+
STYLES_BLOCK=$(saps_render_style_block "$STYLE_ASSIGN_FILE" twitter replying)
|
|
166
|
+
rm -f "$STYLE_ASSIGN_FILE" 2>/dev/null || true
|
|
167
|
+
|
|
168
|
+
# Top performers feedback report — filtered to the picked style when
|
|
169
|
+
# in 'use' mode so the few-shot exemplars match the assignment.
|
|
170
|
+
if [ -n "$PICKED_STYLE" ]; then
|
|
171
|
+
TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter --style "$PICKED_STYLE" 2>/dev/null || echo "(top performers report unavailable)")
|
|
172
|
+
else
|
|
173
|
+
TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform twitter 2>/dev/null || echo "(top performers report unavailable)")
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
# Precompute active Twitter campaign suffix + sample_rate + id for the
|
|
177
|
+
# prompt to inline. Phase B replies go through the browser typing tool
|
|
178
|
+
# (twitter_browser.py reply wedges against the same profile), so tool-level
|
|
179
|
+
# injection is unavailable; the LLM has to flip a coin and append the
|
|
180
|
+
# literal suffix by hand. When no active campaign exists, all three vars
|
|
181
|
+
# resolve to empty strings and the prompt's "if empty, do nothing extra"
|
|
182
|
+
# branch fires. Mirrors the Reddit MCP-fallback pattern in
|
|
183
|
+
# engage-dm-replies.sh.
|
|
184
|
+
# Three psql one-liners collapsed into one HTTP call via active-campaign.
|
|
185
|
+
# Returns JSON {} when no active campaign matches, or
|
|
186
|
+
# {id, suffix, sample_rate}. Same WHERE (status='active', platform
|
|
187
|
+
# contains twitter, budget remaining, non-empty suffix) runs server-side.
|
|
188
|
+
TWITTER_CAMPAIGN_JSON=$(python3 "$ENGAGE_TWITTER_HELPER" active-campaign 2>/dev/null || echo "{}")
|
|
189
|
+
TWITTER_CAMPAIGN_SUFFIX_LITERAL=$(echo "$TWITTER_CAMPAIGN_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.stdout.write(d.get('suffix','') or '')" 2>/dev/null || echo "")
|
|
190
|
+
TWITTER_CAMPAIGN_SAMPLE_RATE=$(echo "$TWITTER_CAMPAIGN_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.stdout.write(str(d.get('sample_rate','') or ''))" 2>/dev/null || echo "")
|
|
191
|
+
TWITTER_CAMPAIGN_ID=$(echo "$TWITTER_CAMPAIGN_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.stdout.write(str(d.get('id','') or ''))" 2>/dev/null || echo "")
|
|
192
|
+
|
|
193
|
+
PHASE_B_PROMPT=$(mktemp)
|
|
194
|
+
cat > "$PHASE_B_PROMPT" <<PROMPT_EOF
|
|
195
|
+
You are the Social Autoposter Twitter/X engagement bot.
|
|
196
|
+
|
|
197
|
+
$BROWSER_INSTRUCTIONS
|
|
198
|
+
|
|
199
|
+
Read $SKILL_FILE for the full workflow, content rules, and platform details.
|
|
200
|
+
|
|
201
|
+
EXCLUSIONS - do NOT engage with these accounts (skip and mark as 'skipped' with reason 'excluded_author'):
|
|
202
|
+
- Excluded authors: $EXCLUDED_AUTHORS
|
|
203
|
+
- Excluded Twitter accounts: $EXCLUDED_TWITTER
|
|
204
|
+
|
|
205
|
+
### BOT / ENGAGEMENT-LOOP ESCAPE HATCH (use sparingly, but use it)
|
|
206
|
+
We maintain a universal author blocklist in Postgres (\`author_blocklist\`),
|
|
207
|
+
consulted at /api/v1/replies POST time. A single block recorded by ANY of
|
|
208
|
+
our accounts/installs applies to EVERY future engagement from EVERY of our
|
|
209
|
+
accounts — universal scope, by design. The velocity gate already covers
|
|
210
|
+
"this handle has gotten too many replies from us in 24h/7d"; this lane is
|
|
211
|
+
for the LLM-judgment cases velocity cannot catch.
|
|
212
|
+
|
|
213
|
+
When to add a block (your judgment, exercised CONSERVATIVELY):
|
|
214
|
+
- The handle is plainly an AI/bot account: templated phrasing, generic
|
|
215
|
+
filler answers, name pattern like \`SomethingAI\` / \`Foo_GPT\`, bio reads
|
|
216
|
+
"AI agent that replies to…"
|
|
217
|
+
- We are clearly stuck in a reciprocal engagement loop with this handle
|
|
218
|
+
(they reply to every one of our posts, we reply to every one of theirs,
|
|
219
|
+
no substance is exchanged)
|
|
220
|
+
- The handle is engagement farming (mass low-effort replies across the
|
|
221
|
+
platform, not actually engaging with the topic)
|
|
222
|
+
|
|
223
|
+
DO NOT add a block for: someone we disagree with, a hostile-but-human
|
|
224
|
+
critic, a low-quality but human reply, or a single bad interaction.
|
|
225
|
+
Skip those (status='skipped') — blocking is permanent until manually
|
|
226
|
+
removed and applies to all our accounts.
|
|
227
|
+
|
|
228
|
+
How to add the block (run BEFORE marking the current reply skipped):
|
|
229
|
+
python3 \$REPO_DIR/scripts/reply_db.py blocklist add x HANDLE \\
|
|
230
|
+
--reason "<one-line judgment, e.g. 'AI-named account, templated replies>" \\
|
|
231
|
+
--classification {bot|engagement_loop} \\
|
|
232
|
+
--source-reply-id REPLY_ID
|
|
233
|
+
|
|
234
|
+
Then mark the current reply skipped with a clear reason:
|
|
235
|
+
python3 \$REPO_DIR/scripts/reply_db.py skipped REPLY_ID "blocklist_added:HANDLE"
|
|
236
|
+
|
|
237
|
+
You can verify with:
|
|
238
|
+
python3 \$REPO_DIR/scripts/reply_db.py blocklist check x HANDLE
|
|
239
|
+
|
|
240
|
+
CRITICAL - Reply posting: Use the SAME browser session you used in Step 2 (navigate), via the tools described in the BROWSER BACKEND block above. Do NOT call scripts/twitter_browser.py reply: that launches a second Chromium against the same profile dir, which wedges x.com on a Loading state and times out. NEVER use any other browser MCP (playwright-extension, isolated-browser, macos-use, etc.) for posting.
|
|
241
|
+
CRITICAL: If a click or type fails (stale ref, button not found, page not ready, page wedged on Loading), re-snapshot the page (per the BROWSER BACKEND block) and retry up to 2 times.
|
|
242
|
+
CRITICAL: TECHNICAL FAILURES ARE NOT TERMINAL. If after retries the post still failed for any technical reason (browser, network, MCP, x.com unreachable, page rendering issue), DO NOT call reply_db.py skipped. Leave the row in 'processing' status (i.e., do nothing further with it) and move on to the next pending item. The post-run cleanup will reset 'processing' rows back to 'pending' so the next engage run retries automatically.
|
|
243
|
+
CRITICAL: ONLY call reply_db.py skipped for content/policy reasons (e.g., light_acknowledgment, drive_by_self_promo_link_drop, hostile_user, off_topic, troll, mod_removal, excluded_author). NEVER skip for technical browser/network failures: those must be retry-able.
|
|
244
|
+
|
|
245
|
+
## Respond to pending Twitter/X replies ($PENDING_COUNT total)
|
|
246
|
+
|
|
247
|
+
### Priority order:
|
|
248
|
+
1. **Replies on our original posts** (is_our_original_post=1) - highest priority
|
|
249
|
+
2. **Direct questions** ("what tool", "how do you", "can you share")
|
|
250
|
+
3. **Everything else** - general engagement
|
|
251
|
+
|
|
252
|
+
### Tiered link strategy:
|
|
253
|
+
- **Tier 1 (default):** No link. Genuine engagement, expand topic.
|
|
254
|
+
- **Tier 2 (natural mention):** Conversation touches a topic matching a project in config. Recommend it casually as a tool you've come across.
|
|
255
|
+
- **Tier 3 (direct ask):** They ask for link/tool/source. Give it immediately.
|
|
256
|
+
|
|
257
|
+
## FEEDBACK FROM PAST PERFORMANCE (use this to write better replies):
|
|
258
|
+
$TOP_REPORT
|
|
259
|
+
|
|
260
|
+
$STYLES_BLOCK
|
|
261
|
+
|
|
262
|
+
## Per-project voice map
|
|
263
|
+
For each reply you draft, look up the matched project's voice block below and apply it: follow \`voice.tone\`, never violate any item in \`voice.never\`, mirror \`voice.examples\` / \`voice.examples_good\` when present.
|
|
264
|
+
$PROJECTS_VOICE_JSON
|
|
265
|
+
|
|
266
|
+
## Resolving the parent post (replaces the old prompt-blob index)
|
|
267
|
+
Each pending row's \`project_name\` is a best-effort guess made at scan time. After navigating the thread (Step 2), extract the parent tweet ID from the page URL/DOM and resolve it via:
|
|
268
|
+
python3 $REPO_DIR/scripts/lookup_post.py twitter PARENT_TWEET_ID
|
|
269
|
+
Returns JSON: {"project": "fazm", "our_content": "...full text...", "thread_url": "..."} or {"project": null} if it's not one of our posts.
|
|
270
|
+
|
|
271
|
+
Here are the replies to process:
|
|
272
|
+
$PENDING_DATA
|
|
273
|
+
|
|
274
|
+
NOTE ON MEDIA: a row may include a non-empty \`their_media_block\` describing images / videos / GIFs / link-cards attached to the comment you are replying to (captured at scan time). When present, treat it as part of the comment: react to what it VISUALLY shows, not just the text. When absent or empty, the comment was text-only (or media was not captured), so reply to the text as usual. You will also see the media live when you navigate the thread in Step 2; the block is the persisted fallback.
|
|
275
|
+
|
|
276
|
+
CRITICAL: Reply in the SAME LANGUAGE as the message you are responding to. Match the language exactly.
|
|
277
|
+
CRITICAL: Process EVERY reply. For each: either post a response and mark as 'replied', OR mark as 'skipped' with a skip_reason.
|
|
278
|
+
|
|
279
|
+
CRITICAL: For ALL database operations, use the reply_db.py helper (NOT raw psql):
|
|
280
|
+
python3 $REPO_DIR/scripts/reply_db.py processing ID # BEFORE posting
|
|
281
|
+
python3 $REPO_DIR/scripts/reply_db.py replied ID "reply text" [url] [engagement_style] [is_recommendation] # AFTER posting. engagement_style is TONE (critic, storyteller, etc). is_recommendation is "1" ONLY when you casually mentioned a project (Tier 2/3); leave blank otherwise. Tone and intent are independent.
|
|
282
|
+
python3 $REPO_DIR/scripts/reply_db.py skipped ID "reason"
|
|
283
|
+
python3 $REPO_DIR/scripts/reply_db.py skip_batch '{"ids":[1,2,3],"reason":"..."}'
|
|
284
|
+
python3 $REPO_DIR/scripts/reply_db.py status
|
|
285
|
+
NEVER use psql directly for reply status updates.
|
|
286
|
+
|
|
287
|
+
### Project tracking on replies
|
|
288
|
+
When you recommend a project in a reply (Tier 2 or Tier 3), set project_name on the reply via reply_db.py (which writes through /api/v1/replies/:id — DO NOT shell out to psql):
|
|
289
|
+
python3 $REPO_DIR/scripts/reply_db.py set_project REPLY_ID "PROJECT_NAME"
|
|
290
|
+
This lets the DM pipeline know which project the conversation is about.
|
|
291
|
+
|
|
292
|
+
MANDATORY reply flow for every item:
|
|
293
|
+
Step 1: python3 reply_db.py processing ID <- mark BEFORE posting
|
|
294
|
+
Step 2: NAVIGATE TO THE THREAD AND READ CONTEXT (mandatory, do NOT skip).
|
|
295
|
+
Do NOT draft a reply from the notification snippet alone — the snippet
|
|
296
|
+
is truncated and lacks the parent tweet content + sibling replies.
|
|
297
|
+
a) Navigate to their_comment_url (use the navigate tool from the BROWSER BACKEND block above)
|
|
298
|
+
b) Snapshot or query the DOM (per the BROWSER BACKEND block) to read:
|
|
299
|
+
- the FULL parent tweet text (our original post if this is on our thread)
|
|
300
|
+
- the immediate ancestors of their_comment_id (so you understand the
|
|
301
|
+
conversational beat being replied to)
|
|
302
|
+
- sibling replies (so you don't repeat what someone else already said)
|
|
303
|
+
c) Extract the parent tweet ID (the long numeric string after \`/status/\`)
|
|
304
|
+
from the URL chain or page DOM. Resolve it:
|
|
305
|
+
python3 $REPO_DIR/scripts/lookup_post.py twitter PARENT_TWEET_ID
|
|
306
|
+
If the response has a non-null \"project\", that's our post — OVERRIDE
|
|
307
|
+
the reply row and use that project's voice for drafting:
|
|
308
|
+
python3 $REPO_DIR/scripts/reply_db.py set_project REPLY_ID "RESOLVED_PROJECT"
|
|
309
|
+
Use the returned \"our_content\" as the FULL text of the post being
|
|
310
|
+
replied to (more accurate than the truncated our_content in PENDING_DATA).
|
|
311
|
+
If \"project\" is null, we're a guest in someone else's thread; keep
|
|
312
|
+
the existing project_name and follow global content rules.
|
|
313
|
+
Step 3: Draft the reply using the resolved project's voice + chosen engagement
|
|
314
|
+
style. One sentence or a tight fragment — aim for under 100 chars. NEVER em dashes. Match parent tweet language.
|
|
315
|
+
Step 3a: ACTIVE CAMPAIGN SUFFIX (MCP fallback, mirrors Reddit's pattern).
|
|
316
|
+
The phase-B reply path goes through the browser typing tool
|
|
317
|
+
(twitter_browser.py reply wedges against the same profile), so
|
|
318
|
+
the tool layer can NOT inject the campaign suffix — the LLM has to
|
|
319
|
+
do it by hand. Active Twitter campaign (precomputed by the shell):
|
|
320
|
+
TWITTER_CAMPAIGN_ID=$TWITTER_CAMPAIGN_ID
|
|
321
|
+
TWITTER_CAMPAIGN_SUFFIX_LITERAL='$TWITTER_CAMPAIGN_SUFFIX_LITERAL'
|
|
322
|
+
TWITTER_CAMPAIGN_SAMPLE_RATE=$TWITTER_CAMPAIGN_SAMPLE_RATE
|
|
323
|
+
If TWITTER_CAMPAIGN_SUFFIX_LITERAL is empty, do nothing extra.
|
|
324
|
+
Otherwise: flip a coin at TWITTER_CAMPAIGN_SAMPLE_RATE. On heads,
|
|
325
|
+
append the literal suffix verbatim to YOUR_REPLY_TEXT and remember
|
|
326
|
+
that the campaign fired (CAMPAIGN_FIRED=1 + CAMPAIGN_ID_TO_BUMP=$TWITTER_CAMPAIGN_ID).
|
|
327
|
+
On tails, leave the text alone and CAMPAIGN_FIRED=0.
|
|
328
|
+
Do this BEFORE Step 4, since the typed text in Step 4c must include
|
|
329
|
+
the suffix. The literal text rule is the entire point: never paraphrase
|
|
330
|
+
or reformat the suffix.
|
|
331
|
+
Step 3b: SHORT-LINK ANY URL IN YOUR_REPLY_TEXT. Mandatory for Tier 2/3 replies
|
|
332
|
+
that drop a brand URL (runner.now, agora.xyz, podlog.io, fazm.ai,
|
|
333
|
+
usenightowl.com, etc.). The browser type path has NO Python
|
|
334
|
+
wrap layer, so a bare URL would be posted as-is and we lose all
|
|
335
|
+
per-post click attribution. Mint a real /r/<code> short link the
|
|
336
|
+
SAME way the post/link-edit pipelines do, in bash:
|
|
337
|
+
WRAP_RESULT=\$(python3 \$REPO_DIR/scripts/dm_short_links.py wrap-post-text \\
|
|
338
|
+
--platform twitter \\
|
|
339
|
+
--project RESOLVED_PROJECT_NAME \\
|
|
340
|
+
--text "YOUR_REPLY_TEXT")
|
|
341
|
+
RESOLVED_PROJECT_NAME must be the EXACT \`name\` field from config.json
|
|
342
|
+
(case-sensitive; e.g. "fazm" lowercase, "Cyrano", "WhatsApp MCP").
|
|
343
|
+
Parse the JSON output (\`{ok, text, minted_session, ...}\`):
|
|
344
|
+
- Use \`text\` (every URL replaced with a /r/<code> short link on
|
|
345
|
+
the project's own domain) as YOUR_REPLY_TEXT going forward; this
|
|
346
|
+
is what you type in Step 4.
|
|
347
|
+
- Save \`minted_session\` as MINTED_SESSION for the Step 5b backfill.
|
|
348
|
+
If \`ok\` is false, log the error and SKIP this reply (leave it to be
|
|
349
|
+
reset to 'pending' on the next run); do NOT type a bare URL. If
|
|
350
|
+
YOUR_REPLY_TEXT contains zero URLs (Tier 1, default case),
|
|
351
|
+
wrap-post-text is a no-op: it returns the text unchanged and
|
|
352
|
+
minted_session is null (that's fine), carry on and skip Step 5b.
|
|
353
|
+
|
|
354
|
+
Step 4: Post the reply via the SAME browser session from Step 2 (use the
|
|
355
|
+
tools described in the BROWSER BACKEND block).
|
|
356
|
+
a) Re-snapshot the page to refresh element state.
|
|
357
|
+
b) Find the reply textbox: role="textbox" with name like "Post your reply"
|
|
358
|
+
or "Post text". Click it.
|
|
359
|
+
c) Type YOUR_REPLY_TEXT (post-Step-3b short-link-wrapped form, post-Step-3a
|
|
360
|
+
suffix) into that textbox. Do NOT auto-submit; we click the Reply
|
|
361
|
+
button explicitly in step e.
|
|
362
|
+
d) Re-snapshot the page (refs can shift after typing).
|
|
363
|
+
e) Find the submit button: role="button" with name="Reply", or selector
|
|
364
|
+
[data-testid="tweetButtonInline"]. Click it.
|
|
365
|
+
Do NOT match a generic "Reply" by accessible name without checking testid:
|
|
366
|
+
every reply icon on the page also reads as "Reply" and you'll click the
|
|
367
|
+
wrong one.
|
|
368
|
+
f) Wait ~3s, then re-snapshot to confirm the textbox is empty
|
|
369
|
+
(= post landed). If your draft text is still in the textbox after
|
|
370
|
+
8s, treat as a failed click and retry per the rule above.
|
|
371
|
+
g) Capture REPLY_URL:
|
|
372
|
+
- Navigate to https://x.com/m13v_/with_replies
|
|
373
|
+
- Snapshot the page
|
|
374
|
+
- Find the topmost link matching /m13v_/status/<digits>. That is REPLY_URL.
|
|
375
|
+
If no fresh reply URL appears within 30s, leave REPLY_URL empty and
|
|
376
|
+
continue to Step 5 (the reply IS posted; we just lack the URL link).
|
|
377
|
+
Step 5: python3 reply_db.py replied ID "reply text" REPLY_URL ENGAGEMENT_STYLE [IS_RECOMMENDATION] <- mark AFTER success. ENGAGEMENT_STYLE is TONE (e.g. critic, storyteller). Pass IS_RECOMMENDATION="1" ONLY when the reply casually recommends a project (Tier 2/3); leave unset otherwise. Tone and intent are independent. Use the FINAL TYPED TEXT (with any campaign suffix from Step 3a) as "reply text" so the stored content matches what was posted.
|
|
378
|
+
Step 5a: If CAMPAIGN_FIRED=1 from Step 3a, attribute this reply to the
|
|
379
|
+
campaign and advance the counter. The reply id is the ID you passed
|
|
380
|
+
to reply_db.py in Step 5 (it returns the row id; or query
|
|
381
|
+
\`SELECT id FROM replies ORDER BY id DESC LIMIT 1\` if you can't parse it):
|
|
382
|
+
python3 $REPO_DIR/scripts/campaign_bump.py --table replies --id REPLY_ROW_ID --campaign-id CAMPAIGN_ID_TO_BUMP
|
|
383
|
+
If CAMPAIGN_FIRED=0, skip this step entirely.
|
|
384
|
+
Step 5b: BACKFILL SHORT-LINK ATTRIBUTION. If you minted a short link in Step 3b
|
|
385
|
+
(MINTED_SESSION is non-empty AND not the string "null"), stamp it onto
|
|
386
|
+
this reply row now that Step 5 succeeded. The reply id is the same ID
|
|
387
|
+
you passed to reply_db.py in Step 5:
|
|
388
|
+
python3 $REPO_DIR/scripts/dm_short_links.py backfill-reply --minted-session MINTED_SESSION --reply-id ID
|
|
389
|
+
This sets post_links.reply_id so the /r/<code> clicks attribute to this
|
|
390
|
+
engagement reply (same mechanism link-edit pipelines use via backfill-post).
|
|
391
|
+
If Step 3b minted nothing (no URL in the reply, MINTED_SESSION null), skip this step.
|
|
392
|
+
If Step 5 fails, the item stays 'processing' and will be reset to 'pending' on the next run.
|
|
393
|
+
If the tweet has been deleted or is unavailable, mark as 'skipped' with reason 'tweet_not_found'.
|
|
394
|
+
|
|
395
|
+
After every 10 replies, run: python3 $REPO_DIR/scripts/reply_db.py status
|
|
396
|
+
PROMPT_EOF
|
|
397
|
+
|
|
398
|
+
# Phase B Claude timeout: 30 min (was 5400=90 min). Real engage runs
|
|
399
|
+
# complete in 5-15 min. The 90-min cap let a single broken-data run hold
|
|
400
|
+
# the twitter-browser lock for 45+ min and starve DM lanes (see 2026-05-26
|
|
401
|
+
# incident). 30 min is a generous ceiling for legitimate work; the
|
|
402
|
+
# JOIN-aware guard above already cuts the no-op case to <3 s.
|
|
403
|
+
gtimeout 1800 "$REPO_DIR/scripts/run_claude.sh" "engage-twitter-phaseB" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" --output-format stream-json --verbose -p "$(cat "$PHASE_B_PROMPT")" 2>&1 | tee -a "$LOG_FILE" || log "WARNING: Phase B claude exited with code $?"
|
|
404
|
+
rm -f "$PHASE_B_PROMPT"
|
|
405
|
+
fi
|
|
406
|
+
|
|
407
|
+
# Reset any items left in 'processing' after subprocess exit. The
|
|
408
|
+
# /api/v1/replies/reset-stuck route requires a positive
|
|
409
|
+
# older_than_hours; we use 1h here so a freshly-stuck row from this run's
|
|
410
|
+
# Claude subprocess gets reset on the next cycle's pre-Phase-B sweep
|
|
411
|
+
# instead of immediately (so we don't race a still-progressing Claude that
|
|
412
|
+
# JUST set processing_at = NOW()).
|
|
413
|
+
POST_RESET=$(python3 "$ENGAGE_TWITTER_HELPER" post-reset)
|
|
414
|
+
[ "$POST_RESET" -gt 0 ] && log "Post-run: Reset $POST_RESET 'processing' Twitter items back to pending"
|
|
415
|
+
|
|
416
|
+
# ═══════════════════════════════════════════════════════
|
|
417
|
+
# Cleanup
|
|
418
|
+
# ═══════════════════════════════════════════════════════
|
|
419
|
+
# One HTTP roundtrip for all three counts instead of three psql one-liners.
|
|
420
|
+
COUNTS_JSON=$(python3 "$ENGAGE_TWITTER_HELPER" reply-counts 2>/dev/null || echo '{"pending":0,"replied":0,"skipped":0}')
|
|
421
|
+
TOTAL_PENDING=$(echo "$COUNTS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('pending',0))" 2>/dev/null || echo "0")
|
|
422
|
+
TOTAL_REPLIED=$(echo "$COUNTS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('replied',0))" 2>/dev/null || echo "0")
|
|
423
|
+
TOTAL_SKIPPED=$(echo "$COUNTS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('skipped',0))" 2>/dev/null || echo "0")
|
|
424
|
+
|
|
425
|
+
log "Twitter summary: pending=$TOTAL_PENDING replied=$TOTAL_REPLIED skipped=$TOTAL_SKIPPED"
|
|
426
|
+
|
|
427
|
+
# Log run to persistent monitor
|
|
428
|
+
RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
|
|
429
|
+
_COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START" --scripts "engage-twitter-phaseB" 2>/dev/null || echo "0.0000")
|
|
430
|
+
# Pull Phase A scan-stage counters out of the log so the dashboard Result
|
|
431
|
+
# column shows "scanned N / new N / excluded N" on engage runs. Phase A prints:
|
|
432
|
+
# Processing N mentions...
|
|
433
|
+
# Summary: N new, N already tracked, N excluded, N own account, N too short, N no_tweet_id
|
|
434
|
+
# We normalize to scanned/new/excluded/unmatched. Empty (Phase A failed before
|
|
435
|
+
# printing) -> no --scan arg, dashboard falls back to old rendering.
|
|
436
|
+
TW_SCAN_PROC_LINE=$(grep -m1 -E "^Processing [0-9]+ mentions\.\.\.$" "$LOG_FILE" 2>/dev/null || true)
|
|
437
|
+
TW_SCAN_SUMMARY_LINE=$(grep -m1 -E "^Summary: [0-9]+ new" "$LOG_FILE" 2>/dev/null || true)
|
|
438
|
+
TW_SCAN_ARG=""
|
|
439
|
+
if [ -n "$TW_SCAN_PROC_LINE" ] || [ -n "$TW_SCAN_SUMMARY_LINE" ]; then
|
|
440
|
+
tw_scanned=$(echo "$TW_SCAN_PROC_LINE" | grep -oE "[0-9]+" | head -1)
|
|
441
|
+
tw_new=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ new" | grep -oE "[0-9]+" | head -1)
|
|
442
|
+
tw_already=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ already tracked" | grep -oE "[0-9]+" | head -1)
|
|
443
|
+
tw_excluded=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ excluded" | grep -oE "[0-9]+" | head -1)
|
|
444
|
+
tw_own=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ own account" | grep -oE "[0-9]+" | head -1)
|
|
445
|
+
tw_short=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ too short" | grep -oE "[0-9]+" | head -1)
|
|
446
|
+
tw_noid=$(echo "$TW_SCAN_SUMMARY_LINE" | grep -oE "[0-9]+ no tweet_id" | grep -oE "[0-9]+" | head -1)
|
|
447
|
+
# excluded pill = excluded + own_account; unmatched pill = too_short + no_tweet_id
|
|
448
|
+
tw_excl_total=$(( ${tw_excluded:-0} + ${tw_own:-0} ))
|
|
449
|
+
tw_unm_total=$(( ${tw_short:-0} + ${tw_noid:-0} ))
|
|
450
|
+
parts=""
|
|
451
|
+
[ -n "$tw_scanned" ] && parts="${parts}scanned=${tw_scanned},"
|
|
452
|
+
[ -n "$tw_new" ] && parts="${parts}new=${tw_new},"
|
|
453
|
+
[ -n "$tw_already" ] && parts="${parts}already=${tw_already},"
|
|
454
|
+
[ "$tw_excl_total" -gt 0 ] && parts="${parts}excluded=${tw_excl_total},"
|
|
455
|
+
[ "$tw_unm_total" -gt 0 ] && parts="${parts}unmatched=${tw_unm_total},"
|
|
456
|
+
TW_SCAN_ARG="${parts%,}"
|
|
457
|
+
fi
|
|
458
|
+
if [ -n "$TW_SCAN_ARG" ]; then
|
|
459
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "engage_twitter" --posted "$TOTAL_REPLIED" --skipped "$TOTAL_SKIPPED" --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED" --scan "$TW_SCAN_ARG"
|
|
460
|
+
else
|
|
461
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "engage_twitter" --posted "$TOTAL_REPLIED" --skipped "$TOTAL_SKIPPED" --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED"
|
|
462
|
+
fi
|
|
463
|
+
|
|
464
|
+
# Delete old logs
|
|
465
|
+
find "$LOG_DIR" -name "engage-twitter-*.log" -mtime +7 -delete 2>/dev/null || true
|
|
466
|
+
|
|
467
|
+
log "=== Twitter engagement complete: $(date) ==="
|