@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,1597 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# engage-dm-replies.sh — DM conversation reply loop
|
|
3
|
+
# Scans Reddit Chat, LinkedIn Messages, and X/Twitter DMs for new inbound messages,
|
|
4
|
+
# then replies to continue the conversation.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# engage-dm-replies.sh # Run all platforms
|
|
8
|
+
# engage-dm-replies.sh --platform reddit # Reddit DMs only
|
|
9
|
+
# engage-dm-replies.sh --platform linkedin # LinkedIn DMs only
|
|
10
|
+
# engage-dm-replies.sh --platform twitter # Twitter DMs only
|
|
11
|
+
# Called by launchd every 4 hours.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
# Parse --platform flag
|
|
17
|
+
PLATFORM=""
|
|
18
|
+
while [[ $# -gt 0 ]]; do
|
|
19
|
+
case "$1" in
|
|
20
|
+
--platform) PLATFORM="$2"; shift 2 ;;
|
|
21
|
+
*) echo "Unknown arg: $1"; exit 1 ;;
|
|
22
|
+
esac
|
|
23
|
+
done
|
|
24
|
+
|
|
25
|
+
if [ -n "$PLATFORM" ]; then
|
|
26
|
+
case "$PLATFORM" in
|
|
27
|
+
reddit|linkedin|twitter|x) ;;
|
|
28
|
+
*) echo "ERROR: Unknown platform '$PLATFORM'. Use: reddit, linkedin, twitter"; exit 1 ;;
|
|
29
|
+
esac
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# LinkedIn killswitch (2026-05-27): refuse to run --platform linkedin if a
|
|
33
|
+
# prior fire detected session compromise (http_999, authwall, throttle,
|
|
34
|
+
# li_at cleared). Other platforms unaffected.
|
|
35
|
+
# State: ~/.claude/social-autoposter/linkedin.killswitch
|
|
36
|
+
# Clear: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear
|
|
37
|
+
if [ "$PLATFORM" = "linkedin" ] && [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
|
|
38
|
+
echo "[$(date +%H:%M:%S)] LINKEDIN_KILLSWITCH active. Aborting LinkedIn DM-replies pipeline."
|
|
39
|
+
echo " Re-auth LinkedIn in harness Chrome, then: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear"
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
LOCK_NAME="dm-replies"
|
|
44
|
+
[ -n "$PLATFORM" ] && LOCK_NAME="dm-replies-$PLATFORM"
|
|
45
|
+
|
|
46
|
+
# Per-cycle batch id stamped onto every claude_sessions row spawned by this
|
|
47
|
+
# DM-reply run (via SA_CYCLE_ID env -> log_claude_session.py). 2026-05-10
|
|
48
|
+
# cycle_id rollout. Suffix carries platform so multi-platform fan-outs from
|
|
49
|
+
# the same launchd cadence stay distinguishable.
|
|
50
|
+
BATCH_ID="endm-${PLATFORM:-all}-$(date +%Y%m%d-%H%M%S)-$$"
|
|
51
|
+
export SA_CYCLE_ID="$BATCH_ID"
|
|
52
|
+
|
|
53
|
+
# Pipeline lock at top. Platform-browser locks are acquired later, just
|
|
54
|
+
# before the Claude/MCP step that drives the browser, so peers can use the
|
|
55
|
+
# profile during our Phase 0 (Gmail + matrix-js-sdk IndexedDB ingest), DB
|
|
56
|
+
# scans, and prompt build. Alphabetical order is preserved at acquire time
|
|
57
|
+
# below for multi-platform runs to prevent deadlock.
|
|
58
|
+
source "$(dirname "$0")/lock.sh"
|
|
59
|
+
|
|
60
|
+
# Twitter backend lib: sourced for ensure_twitter_browser_for_backend (probes
|
|
61
|
+
# the harness Chrome on port 9555, launches it if down). Also exports
|
|
62
|
+
# TWITTER_CDP_URL so the python helpers (scripts/twitter_browser.py) attach
|
|
63
|
+
# directly to the harness Chrome instead of doing ps-based discovery. Sets
|
|
64
|
+
# MCP_CONFIG_FILE + BROWSER_INSTRUCTIONS as side effects; this script uses
|
|
65
|
+
# DM_MCP_CONFIG (different variable) so no conflict.
|
|
66
|
+
source "$(dirname "$0")/lib/twitter-backend.sh"
|
|
67
|
+
|
|
68
|
+
# Reddit backend lib (2026-05-29 harness migration): exports REDDIT_CDP_URL=:9557
|
|
69
|
+
# so the Python CDP helpers (scripts/reddit_browser.py send-dm/reply/unread-dms)
|
|
70
|
+
# attach to the dedicated reddit-harness Chrome instead of the retired
|
|
71
|
+
# reddit-agent (port 9222). Also provides ensure_reddit_browser_for_backend
|
|
72
|
+
# (launches harness Chrome on 9557 + tab cleanup). Like twitter-backend it sets
|
|
73
|
+
# MCP_CONFIG_FILE + BROWSER_INSTRUCTIONS, but this script drives the prompt off
|
|
74
|
+
# DM_MCP_CONFIG (set per-platform below) and inlines its own per-platform browser
|
|
75
|
+
# instructions, so those side-effect vars (MCP_CONFIG_FILE, BROWSER_INSTRUCTIONS)
|
|
76
|
+
# are unused here; only REDDIT_CDP_URL + ensure_reddit_browser_for_backend matter.
|
|
77
|
+
source "$(dirname "$0")/lib/reddit-backend.sh"
|
|
78
|
+
|
|
79
|
+
# Skip cleanly if a foreign playwright-mcp wrapper for THIS platform is alive
|
|
80
|
+
# (interactive Fazm Dev / IDE / another cron). Avoids the Chrome SingletonLock
|
|
81
|
+
# crash cascade — see dm-outreach-twitter.sh and engage-twitter.sh.
|
|
82
|
+
# Twitter case routes through defer_if_foreign_for_backend (always returns 1
|
|
83
|
+
# = proceed) since harness CDP supports multiple concurrent clients on the
|
|
84
|
+
# browser-harness profile, so foreign MCP wrappers never block us.
|
|
85
|
+
# LOG_FILE isn't set yet (line 78), so use ${LOG_FILE:-} to satisfy set -u.
|
|
86
|
+
if [ -n "$PLATFORM" ]; then
|
|
87
|
+
case "$PLATFORM" in
|
|
88
|
+
twitter|x)
|
|
89
|
+
if defer_if_foreign_for_backend "${LOG_FILE:-}"; then
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
92
|
+
;;
|
|
93
|
+
*)
|
|
94
|
+
if defer_if_foreign_browser_mcp_active "$PLATFORM" "${LOG_FILE:-}"; then
|
|
95
|
+
exit 0
|
|
96
|
+
fi
|
|
97
|
+
;;
|
|
98
|
+
esac
|
|
99
|
+
fi
|
|
100
|
+
acquire_lock "$LOCK_NAME" 3600
|
|
101
|
+
|
|
102
|
+
# Load secrets
|
|
103
|
+
# shellcheck source=/dev/null
|
|
104
|
+
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
105
|
+
|
|
106
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
107
|
+
SKILL_FILE="$REPO_DIR/SKILL.md"
|
|
108
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
109
|
+
DM_SCRIPT="$REPO_DIR/scripts/dm_conversation.py"
|
|
110
|
+
|
|
111
|
+
# HTTP-only: this pipeline routes every read/write through the s4l.ai HTTP API
|
|
112
|
+
# (scripts/*_helper.py -> /api/v1/*). The direct-Postgres lane was removed
|
|
113
|
+
# 2026-06-01; DATABASE_URL is deliberately ignored, no psql, no fallback.
|
|
114
|
+
mkdir -p "$LOG_DIR"
|
|
115
|
+
LOG_SUFFIX=""
|
|
116
|
+
[ -n "$PLATFORM" ] && LOG_SUFFIX="-$PLATFORM"
|
|
117
|
+
LOG_FILE="$LOG_DIR/engage-dm-replies${LOG_SUFFIX}-$(date +%Y-%m-%d_%H%M%S).log"
|
|
118
|
+
|
|
119
|
+
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
|
120
|
+
|
|
121
|
+
RUN_START=$(date +%s)
|
|
122
|
+
log "=== DM Reply Engagement Run: $(date) (platform: ${PLATFORM:-all}) ==="
|
|
123
|
+
|
|
124
|
+
# Load config
|
|
125
|
+
REDDIT_USERNAME=$(python3 -c "import json; c=json.load(open('$REPO_DIR/config.json')); print(c.get('accounts',{}).get('reddit',{}).get('username',''))" 2>/dev/null || echo "")
|
|
126
|
+
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 "")
|
|
127
|
+
|
|
128
|
+
# Load projects for context (booking link + qualification criteria per project)
|
|
129
|
+
PROJECTS=$(python3 -c "
|
|
130
|
+
import json
|
|
131
|
+
c = json.load(open('$REPO_DIR/config.json'))
|
|
132
|
+
for p in c.get('projects', []):
|
|
133
|
+
line = f\"- {p['name']}: {p.get('description','')} | website: {p.get('website','')} | github: {p.get('github','')}\"
|
|
134
|
+
if p.get('booking_link'):
|
|
135
|
+
line += f\" | booking_link: {p['booking_link']}\"
|
|
136
|
+
if p.get('booking_link_auto_share'):
|
|
137
|
+
line += ' | booking_link_auto_share: true'
|
|
138
|
+
q = p.get('qualification') or {}
|
|
139
|
+
qs = q.get('questions') or ([q['question']] if q.get('question') else [])
|
|
140
|
+
if qs:
|
|
141
|
+
line += ' | qualifying_questions: [' + ' || '.join(qs) + ']'
|
|
142
|
+
if q.get('must_have'):
|
|
143
|
+
line += f\" | must_have: {' ; '.join(q['must_have'])}\"
|
|
144
|
+
if q.get('disqualify'):
|
|
145
|
+
line += f\" | disqualify: {' ; '.join(q['disqualify'])}\"
|
|
146
|
+
print(line)
|
|
147
|
+
" 2>/dev/null || echo "")
|
|
148
|
+
|
|
149
|
+
# ═══════════════════════════════════════════════════════
|
|
150
|
+
# Find conversations needing replies (platform-filtered)
|
|
151
|
+
# ═══════════════════════════════════════════════════════
|
|
152
|
+
|
|
153
|
+
# Platform filtering is now applied server-side by /api/v1/dms/engage (it folds
|
|
154
|
+
# 'x'/'twitter' into one platform, equality otherwise, and treats an absent
|
|
155
|
+
# platform as "all"). The old client-side PLATFORM_SQL_FILTER string was
|
|
156
|
+
# removed with the psql lane on 2026-06-01; $PLATFORM is passed straight to the
|
|
157
|
+
# helper subcommands instead.
|
|
158
|
+
|
|
159
|
+
# Get conversations where the last message is inbound (they replied OR a backend
|
|
160
|
+
# signal like a short-link click landed as a synthetic inbound row). Click signals
|
|
161
|
+
# are inserted into dm_messages by the /r/<code> redirector with
|
|
162
|
+
# direction='inbound', author='__click_signal__', content='[CLICK_SIGNAL] ...'.
|
|
163
|
+
# That makes them surface here on the same `last_in > last_out` rail as typed
|
|
164
|
+
# replies, with no special branching.
|
|
165
|
+
# HTTP-only via /api/v1/dms/engage?mode=pending (scripts/dm_engage_helper.py).
|
|
166
|
+
# The helper prints the JSON array of rows when any exist, else the literal
|
|
167
|
+
# string 'null' (matching psql's `json_agg(...) -> NULL` echoed as "null"), so
|
|
168
|
+
# the guard below is unchanged. Platform filter (x/twitter fold, equality
|
|
169
|
+
# otherwise, no-platform => all) is applied server-side.
|
|
170
|
+
PENDING_PLAT_ARG=""
|
|
171
|
+
[ -n "$PLATFORM" ] && PENDING_PLAT_ARG="--platform $PLATFORM"
|
|
172
|
+
PENDING_CONVOS=$(python3 "$REPO_DIR/scripts/dm_engage_helper.py" pending $PENDING_PLAT_ARG --limit 30 2>/dev/null || echo "null")
|
|
173
|
+
|
|
174
|
+
if [ "$PENDING_CONVOS" = "null" ] || [ -z "$PENDING_CONVOS" ]; then
|
|
175
|
+
log "No conversations needing replies. Checking platforms for new inbound messages..."
|
|
176
|
+
else
|
|
177
|
+
CONVO_COUNT=$(echo "$PENDING_CONVOS" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
|
|
178
|
+
log "Found $CONVO_COUNT conversations needing replies from DB"
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
# ═══════════════════════════════════════════════════════
|
|
182
|
+
# PHASE 0: Send pending human replies from email escalations
|
|
183
|
+
# ═══════════════════════════════════════════════════════
|
|
184
|
+
# Platform filter for Phase 0: when running a specific platform cycle, only
|
|
185
|
+
# process replies targeted at that platform. Empty PLATFORM = all platforms
|
|
186
|
+
# (manual runs). This prevents parallel platform cycles from racing on the
|
|
187
|
+
# same rows and clobbering each other's status updates.
|
|
188
|
+
HR_PLATFORM_ARG=""
|
|
189
|
+
if [ -n "$PLATFORM" ]; then
|
|
190
|
+
# Pass the platform straight through to /api/v1/human-dm-replies, which
|
|
191
|
+
# folds 'x'/'twitter' into one platform server-side (matches the legacy
|
|
192
|
+
# filter). Empty PLATFORM = all platforms (manual runs).
|
|
193
|
+
HR_PLATFORM_ARG="--platform $PLATFORM"
|
|
194
|
+
fi
|
|
195
|
+
|
|
196
|
+
# Ingest any human replies that have landed in the matt@s4l.ai inbox since the
|
|
197
|
+
# last run. Parses [DM #N] from the subject, strips quoted history, inserts
|
|
198
|
+
# into human_dm_replies with status='pending'. Safe to run every cycle (no-op
|
|
199
|
+
# when inbox is empty; deduped by Gmail message id).
|
|
200
|
+
log "Phase 0: ingesting human DM replies from Gmail inbox..."
|
|
201
|
+
python3 "$REPO_DIR/scripts/ingest_human_dm_replies.py" 2>&1 | while IFS= read -r _line; do
|
|
202
|
+
log " [ingest] $_line"
|
|
203
|
+
done || true
|
|
204
|
+
|
|
205
|
+
# Pending + retry queue via /api/v1/human-dm-replies (HTTP-only, no direct DB).
|
|
206
|
+
# Helper prints a JSON array when rows exist and nothing when none, so the
|
|
207
|
+
# guard below falls through to the "no replies" branch exactly like psql's
|
|
208
|
+
# NULL -> empty string did. Non-array output (an API/transport error) is
|
|
209
|
+
# logged loudly and treated as "no replies" instead of being silently parsed.
|
|
210
|
+
_HR_OUT=$(python3 "$REPO_DIR/scripts/human_dm_replies_helper.py" pending $HR_PLATFORM_ARG 2>&1)
|
|
211
|
+
if printf '%s' "$_HR_OUT" | head -c1 | grep -q '\['; then
|
|
212
|
+
HUMAN_REPLIES="$_HR_OUT"
|
|
213
|
+
else
|
|
214
|
+
if [ -n "$_HR_OUT" ]; then
|
|
215
|
+
log "Phase 0: human-dm-replies pending fetch issue (treating as no replies):"
|
|
216
|
+
log "$_HR_OUT"
|
|
217
|
+
fi
|
|
218
|
+
HUMAN_REPLIES=""
|
|
219
|
+
fi
|
|
220
|
+
|
|
221
|
+
if [ "$HUMAN_REPLIES" != "null" ] && [ -n "$HUMAN_REPLIES" ]; then
|
|
222
|
+
HR_COUNT=$(echo "$HUMAN_REPLIES" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
|
|
223
|
+
log "Phase 0: $HR_COUNT pending human replies to send"
|
|
224
|
+
|
|
225
|
+
PHASE0_PROMPT=$(mktemp)
|
|
226
|
+
cat > "$PHASE0_PROMPT" <<PHASE0_EOF
|
|
227
|
+
You are the Social Autoposter DM delivery bot.
|
|
228
|
+
|
|
229
|
+
Read $SKILL_FILE for content rules (tone, anti-AI detection, no em dashes).
|
|
230
|
+
|
|
231
|
+
## Task: Deliver pending human replies on the correct channel(s)
|
|
232
|
+
|
|
233
|
+
The following replies were written by the human operator (via email or the dashboard) as INSTRUCTIONS for how to respond. Use each reply as a prompt, understand the intent, tone, and key points, then craft a natural reply that:
|
|
234
|
+
- Matches the conversational tone of the thread (casual, texting style, 1-3 sentences)
|
|
235
|
+
- Incorporates the human's key points and decisions
|
|
236
|
+
- Sounds like the same person who sent the previous outbound messages in the conversation
|
|
237
|
+
- Follows all the HARD RULES and COMMITMENT GUARDRAILS from Phase D
|
|
238
|
+
|
|
239
|
+
The human's reply is your DIRECTION, not the literal message. Think of it as "the human told you what to say, now say it naturally."
|
|
240
|
+
|
|
241
|
+
Each pending reply has a \`reply_channel\` field that selects the delivery surface:
|
|
242
|
+
- \`dm\` (default, legacy): send only as a private DM
|
|
243
|
+
- \`public\`: post only as a public reply on the original public thread (their comment that started the DM)
|
|
244
|
+
- \`both\`: do BOTH, post the public reply AND send the DM (paired delivery, same instruction text drives both)
|
|
245
|
+
|
|
246
|
+
Pending human replies:
|
|
247
|
+
$HUMAN_REPLIES
|
|
248
|
+
|
|
249
|
+
### Step 0. ESCAPE HATCH — reclassify before delivering
|
|
250
|
+
|
|
251
|
+
Before drafting any message, check whether the human's instruction is actually a directive ABOUT the escalation rather than a message to send. The human writes these by replying to escalation emails, so they sometimes use the reply field to issue meta-commands. Examples:
|
|
252
|
+
|
|
253
|
+
- "Remove this escalation", "cancel", "dismiss", "ignore", "false alarm", "no need to reply"
|
|
254
|
+
- "Skip this one", "don't send", "not relevant"
|
|
255
|
+
- "Mark as handled", "I already replied manually"
|
|
256
|
+
- "Disqualify", "block", "spam"
|
|
257
|
+
|
|
258
|
+
If the instruction text clearly matches one of these intents (use judgment, the human writes casually), DO NOT send anything on any channel. Instead:
|
|
259
|
+
|
|
260
|
+
\`\`\`bash
|
|
261
|
+
# Mark the human reply row as cancelled so it never retries (sent_at is
|
|
262
|
+
# auto-stamped server-side on the cancelled transition)
|
|
263
|
+
cd ~/social-autoposter && python3 scripts/human_dm_replies_helper.py patch --id REPLY_ID --status cancelled --last-error "human reclassified: <SHORT_REASON>"
|
|
264
|
+
|
|
265
|
+
# Optionally update the underlying conversation if the intent says so:
|
|
266
|
+
# - "disqualify"/"block"/"spam" → set-status disqualified
|
|
267
|
+
# - "mark as handled"/"already replied" → set-status active
|
|
268
|
+
# - "skip"/"dismiss"/"remove escalation" → leave conversation as-is, just clear the flag
|
|
269
|
+
cd ~/social-autoposter && python3 scripts/dm_conversation.py set-status --dm-id DM_ID --status STATUS_OR_OMIT
|
|
270
|
+
\`\`\`
|
|
271
|
+
|
|
272
|
+
Log clearly in your summary which rows were reclassified and why. Only proceed to Step A-D for instructions that are genuine messages to send.
|
|
273
|
+
|
|
274
|
+
For each remaining reply, branch on \`reply_channel\`:
|
|
275
|
+
|
|
276
|
+
### Step A. Always read context first
|
|
277
|
+
|
|
278
|
+
\`\`\`bash
|
|
279
|
+
cd ~/social-autoposter && python3 scripts/dm_conversation.py history --dm-id DM_ID
|
|
280
|
+
\`\`\`
|
|
281
|
+
|
|
282
|
+
### Step B. If \`reply_channel\` is \`public\` or \`both\`: deliver the public reply
|
|
283
|
+
|
|
284
|
+
The \`public_target_url\` field is THEIR public comment that originally led to this DM thread (from the joined \`replies\` row via \`dms.reply_id\`). The \`public_target_post_url\` is the parent post URL for context. If \`public_target_url\` is null, fall back to \`our_prior_public_reply_url\` (we can reply under our own previous reply on the same thread).
|
|
285
|
+
|
|
286
|
+
1. Craft a natural public reply based on the human's instructions. Public replies are visible to everyone, so keep them appropriate for a public audience: friendly, helpful, concise, and on-brand. The instruction text typically asks you to share a link, so include it naturally in the public reply.
|
|
287
|
+
2. Navigate to \`public_target_url\` on the correct platform and post the public reply:
|
|
288
|
+
- **Reddit** (mcp__reddit-harness__bh_run tool, CDP-driven real Chrome on port 9557): use bh_run('goto_url("COMMENT_URL"); wait_for_load()') to navigate, then bh_run scripts to click reply on the target comment, type, submit (click_at_xy on the computed center of the textarea, type_text, then click the Comment/Save button). See lib/reddit-backend.sh BROWSER_INSTRUCTIONS for the full Playwright -> bh_run translation table. Capture the resulting comment URL.
|
|
289
|
+
- **LinkedIn** (mcp__linkedin-harness__bh_run tool, CDP-driven real Chrome on port 9556): use bh_run('goto_url("POST_URL"); wait_for_load()') to navigate, then bh_run scripts to expand the target comment and reply (click_at_xy on the computed center of the contenteditable box, type_text, then click the Reply/Post button; never press Enter). See lib/linkedin-backend.sh BROWSER_INSTRUCTIONS for the full Playwright -> bh_run translation table. Capture the resulting comment URL.
|
|
290
|
+
- **X/Twitter** (mcp__twitter-harness__bh_run tool, CDP-driven real Chrome on port 9555): use bh_run('goto_url("TWEET_URL"); wait_for_load()') to navigate, then bh_run scripts to click the reply button, type, and post. See lib/twitter-backend.sh BROWSER_INSTRUCTIONS for the full Playwright -> bh_run translation table. Capture the resulting status URL.
|
|
291
|
+
3. Insert a fresh \`replies\` row capturing the public reply (use the \`their_comment_id\` from \`public_target_comment_id\` so the dedup index does not collide; if null, synthesize a unique id like \`hr_<REPLY_ID>_pub\`):
|
|
292
|
+
\`\`\`bash
|
|
293
|
+
# Prints the new replies.id to stdout. Include --post-id only when
|
|
294
|
+
# PUBLIC_TARGET_POST_ID is not null; omit it otherwise. A duplicate
|
|
295
|
+
# their_comment_id returns the existing row's id (no error).
|
|
296
|
+
cd ~/social-autoposter && python3 scripts/human_dm_replies_helper.py insert-public-reply --platform PLATFORM --comment-id COMMENT_ID --author THEIR_AUTHOR --comment-url PUBLIC_TARGET_URL --our-content "CRAFTED_PUBLIC_REPLY" --our-url OUR_NEW_PUBLIC_REPLY_URL --depth 2 --post-id PUBLIC_TARGET_POST_ID
|
|
297
|
+
\`\`\`
|
|
298
|
+
4. Stamp the \`replies.id\` back onto the human instruction so the dashboard can pair them:
|
|
299
|
+
\`\`\`bash
|
|
300
|
+
cd ~/social-autoposter && python3 scripts/human_dm_replies_helper.py patch --id REPLY_ID --public-reply-id NEW_REPLY_ID
|
|
301
|
+
\`\`\`
|
|
302
|
+
|
|
303
|
+
### Step C. If \`reply_channel\` is \`dm\` or \`both\`: deliver the DM
|
|
304
|
+
|
|
305
|
+
1. Craft a natural DM based on the human's instructions and the conversation context.
|
|
306
|
+
2. Navigate to the conversation on the correct platform using \`chat_url\` (or find the conversation with their_author).
|
|
307
|
+
- **Reddit Chat** (mcp__reddit-harness__bh_run tool, CDP-driven real Chrome on port 9557)
|
|
308
|
+
- **LinkedIn Messages** (mcp__linkedin-harness__bh_run tool, CDP-driven real Chrome on port 9556)
|
|
309
|
+
- **X/Twitter DMs** (mcp__twitter-harness__bh_run tool), if encrypted DM passcode dialog appears, enter: $TWITTER_DM_PASSCODE
|
|
310
|
+
3. Type and send the crafted DM.
|
|
311
|
+
4. Log the outbound message (log what you ACTUALLY SENT, not the human's instructions). Pass --verified ONLY when the browser tool returned verified=true. If verification failed, log nothing and let the next cycle retry; never pass --verified speculatively:
|
|
312
|
+
\`\`\`bash
|
|
313
|
+
cd ~/social-autoposter && python3 scripts/dm_conversation.py log-outbound --dm-id DM_ID --content "THE_CRAFTED_DM_YOU_SENT" --verified
|
|
314
|
+
\`\`\`
|
|
315
|
+
|
|
316
|
+
### Step D. Always finalize after the channel work succeeds
|
|
317
|
+
|
|
318
|
+
ONLY mark the human reply as sent after every required channel succeeded for it. For \`both\`, that means the public reply landed AND the DM landed. Partial success counts as failure (see error handling below).
|
|
319
|
+
|
|
320
|
+
\`\`\`bash
|
|
321
|
+
cd ~/social-autoposter && python3 scripts/human_dm_replies_helper.py patch --id REPLY_ID --status sent
|
|
322
|
+
cd ~/social-autoposter && python3 scripts/dm_conversation.py set-status --dm-id DM_ID --status active
|
|
323
|
+
\`\`\`
|
|
324
|
+
|
|
325
|
+
### Error handling
|
|
326
|
+
|
|
327
|
+
If any required channel fails, increment the attempts counter and record the reason. Use a short error string (single line, no quotes); for partial \`both\` failures include which side failed:
|
|
328
|
+
\`\`\`bash
|
|
329
|
+
cd ~/social-autoposter && python3 scripts/human_dm_replies_helper.py patch --id REPLY_ID --status failed --increment-attempts --last-error "ERROR_REASON"
|
|
330
|
+
\`\`\`
|
|
331
|
+
Rows with \`status = 'failed'\` AND \`attempts < 3\` will be picked up automatically on the next Phase 0 run for this platform. After 3 attempts they stay failed and stop retrying, notify the human in the run summary so they can handle manually.
|
|
332
|
+
|
|
333
|
+
Idempotency for \`both\` retries: if \`public_reply_id\` is already set when you re-process a failed row, the public side is already live, do NOT post it again, only redo the DM side.
|
|
334
|
+
|
|
335
|
+
Note: each Phase 0 run is scoped to a single platform ($PLATFORM), so you will only see replies for that platform here. Do not worry about replies for other platforms.
|
|
336
|
+
PHASE0_EOF
|
|
337
|
+
|
|
338
|
+
# The main Claude agent session will process this prompt alongside phases A-D
|
|
339
|
+
PHASE0_INSTRUCTIONS=$(cat "$PHASE0_PROMPT")
|
|
340
|
+
rm -f "$PHASE0_PROMPT"
|
|
341
|
+
else
|
|
342
|
+
log "Phase 0: No pending human replies"
|
|
343
|
+
PHASE0_INSTRUCTIONS=""
|
|
344
|
+
fi
|
|
345
|
+
|
|
346
|
+
# ═══════════════════════════════════════════════════════
|
|
347
|
+
# PHASE A: Scan Reddit Chat for new inbound messages
|
|
348
|
+
# ═══════════════════════════════════════════════════════
|
|
349
|
+
log "Phase A: Scanning Reddit Chat for new inbound messages..."
|
|
350
|
+
|
|
351
|
+
# Phase A.0: Ingest Reddit Chat inbounds directly from the matrix-js-sdk
|
|
352
|
+
# IndexedDB cache before the LLM runs. Replaces the old "scan sidebar +
|
|
353
|
+
# click into each unread room" flow (which was silently broken: the
|
|
354
|
+
# scan_reddit_chat.js selector hadn't matched the post-migration DOM in
|
|
355
|
+
# weeks, leaving 200+ unread rooms invisible to the pipeline).
|
|
356
|
+
#
|
|
357
|
+
# ingest-unread reads Matrix state the Reddit client already synced, upserts
|
|
358
|
+
# dms rows (backfilling chat_url), and logs each inbound m.room.message with
|
|
359
|
+
# its Matrix event_id as the dedup key. Idempotent — re-runs dedup via
|
|
360
|
+
# dm_messages.event_id's UNIQUE partial index. When this completes, the
|
|
361
|
+
# pending-replies query and dashboard both see the full unread backlog; the
|
|
362
|
+
# LLM then only has to decide who to reply to, not where to find inbounds.
|
|
363
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "reddit" ]; then
|
|
364
|
+
log "Phase A.0: ingesting Reddit Chat inbounds from matrix-js-sdk IndexedDB..."
|
|
365
|
+
_INGEST_OUT=$(mktemp)
|
|
366
|
+
if python3 "$REPO_DIR/scripts/reddit_chat_sync.py" ingest-unread > "$_INGEST_OUT" 2>/dev/null; then
|
|
367
|
+
python3 -c "
|
|
368
|
+
import json, sys
|
|
369
|
+
d = json.load(open('$_INGEST_OUT'))
|
|
370
|
+
s = d.get('stats', {}) or {}
|
|
371
|
+
fields = ['rooms_scanned','rooms_new_dms','chat_urls_backfilled','inbound_inserted','inbound_deduped']
|
|
372
|
+
parts = [f'{k}={s.get(k, 0)}' for k in fields]
|
|
373
|
+
errs = len(s.get('errors') or [])
|
|
374
|
+
parts.append(f'errors={errs}')
|
|
375
|
+
print(' '.join(parts))
|
|
376
|
+
" 2>/dev/null | while IFS= read -r _line; do log " [ingest] $_line"; done
|
|
377
|
+
else
|
|
378
|
+
log " [ingest] WARNING: reddit_chat_sync.py ingest-unread failed; Reddit Chat backlog may be stale"
|
|
379
|
+
fi
|
|
380
|
+
rm -f "$_INGEST_OUT"
|
|
381
|
+
fi
|
|
382
|
+
|
|
383
|
+
# Get list of known Reddit DM authors to match against chat rooms
|
|
384
|
+
KNOWN_REDDIT_AUTHORS=$(python3 "$REPO_DIR/scripts/dm_engage_helper.py" reddit-authors 2>/dev/null || echo "")
|
|
385
|
+
|
|
386
|
+
# Pre-build tool-rule lines and per-platform phase sections OUTSIDE the outer
|
|
387
|
+
# heredoc. bash 3.2 (the macOS system bash) mis-parses nested `$(if cond; then
|
|
388
|
+
# cat <<'EOF' ... EOF fi)` inside an unquoted heredoc when the if is false,
|
|
389
|
+
# treating the inner heredoc body as shell code and reporting "bad substitution:
|
|
390
|
+
# no closing `)'" at the outer heredoc's start line. Building these as plain
|
|
391
|
+
# variables first avoids the parser quirk.
|
|
392
|
+
TOOL_RULE_REDDIT=""
|
|
393
|
+
TOOL_RULE_LINKEDIN=""
|
|
394
|
+
TOOL_RULE_TWITTER=""
|
|
395
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "reddit" ]; then
|
|
396
|
+
TOOL_RULE_REDDIT="- Reddit Chat: use Python CDP scripts (scripts/reddit_browser.py, which attach to the reddit-harness Chrome on port 9557 via REDDIT_CDP_URL) for scanning/reading, fall back to the mcp__reddit-harness__bh_run tool for chat SPA operations. See lib/reddit-backend.sh BROWSER_INSTRUCTIONS for the Playwright -> bh_run translation table. Do NOT use mcp__reddit-agent__* (retired)."
|
|
397
|
+
fi
|
|
398
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "linkedin" ]; then
|
|
399
|
+
TOOL_RULE_LINKEDIN="- LinkedIn Messages: use the mcp__linkedin-harness__bh_run tool ONLY (CDP-driven real Chrome on port 9556). Do NOT call /voyager/api/ endpoints, do NOT run raw Python CDP scripts against LinkedIn. See lib/linkedin-backend.sh BROWSER_INSTRUCTIONS for the Playwright -> bh_run translation table."
|
|
400
|
+
fi
|
|
401
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "twitter" ] || [ "$PLATFORM" = "x" ]; then
|
|
402
|
+
TOOL_RULE_TWITTER="- X/Twitter DMs: use Python CDP scripts (scripts/twitter_browser.py) ONLY"
|
|
403
|
+
fi
|
|
404
|
+
|
|
405
|
+
PHASE0_BLOCK=""
|
|
406
|
+
if [ -n "$PHASE0_INSTRUCTIONS" ]; then
|
|
407
|
+
PHASE0_BLOCK="$PHASE0_INSTRUCTIONS
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
After completing Phase 0 (human replies), proceed with the scanning and auto-reply phases below.
|
|
412
|
+
"
|
|
413
|
+
fi
|
|
414
|
+
|
|
415
|
+
# Human Reply Knowledge Base via /api/v1/human-dm-replies?mode=kb (HTTP-only).
|
|
416
|
+
HUMAN_REPLY_KB=$(python3 "$REPO_DIR/scripts/human_dm_replies_helper.py" kb --limit 20 2>/dev/null || echo "")
|
|
417
|
+
|
|
418
|
+
PHASE_A_BLOCK=""
|
|
419
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "reddit" ]; then
|
|
420
|
+
IFS= read -r -d '' PHASE_A_BLOCK <<'PHASE_A_EOF' || true
|
|
421
|
+
## PHASE A: Scan Reddit for new messages
|
|
422
|
+
|
|
423
|
+
Reddit Chat inbounds were already ingested before you started (Phase A.0 in
|
|
424
|
+
this run's shell log — reddit_chat_sync.py ingest-unread). Every unread chat
|
|
425
|
+
room's last ~30 messages are already in dm_messages with Matrix event_id set,
|
|
426
|
+
partner usernames resolved, chat_urls backfilled, and dms.conversation_status
|
|
427
|
+
flipped to 'needs_reply' for new inbounds. You do NOT need to navigate
|
|
428
|
+
reddit.com/chat, click into any rooms, run scan_reddit_chat.js, or call
|
|
429
|
+
log-inbound for Reddit chat rooms. Doing so is wasted work and risks double-
|
|
430
|
+
counting (event_id dedup will block it but don't even try).
|
|
431
|
+
|
|
432
|
+
1. Scan the legacy Reddit inbox for comment replies and classic PMs:
|
|
433
|
+
```bash
|
|
434
|
+
cd ~/social-autoposter && python3 scripts/reddit_browser.py unread-dms
|
|
435
|
+
```
|
|
436
|
+
Returns JSON with: author, subject, preview_short, time, thread_url, type.
|
|
437
|
+
Type = 'pm' or 'comment_reply' are the ones to handle here. Type='chat'
|
|
438
|
+
entries from this script are unreliable (selector is stale) and should
|
|
439
|
+
be IGNORED — chat rooms were already handled by Phase A.0.
|
|
440
|
+
|
|
441
|
+
2. Find Reddit conversations needing a reply (includes both newly-ingested
|
|
442
|
+
chat rooms and any legacy PMs logged via step 1):
|
|
443
|
+
```bash
|
|
444
|
+
cd ~/social-autoposter && python3 scripts/dm_conversation.py pending
|
|
445
|
+
```
|
|
446
|
+
Scope to Reddit when needed via their_author + platform columns. This is
|
|
447
|
+
the authoritative list; don't reconstruct it from sidebar scrapes.
|
|
448
|
+
|
|
449
|
+
3. For each Reddit PM/comment-reply surfaced by step 1 that isn't already in
|
|
450
|
+
dms, create a row and log the inbound (chat rooms already have rows from
|
|
451
|
+
Phase A.0):
|
|
452
|
+
a. `ensure-dm` is idempotent — returns existing id if present, creates one
|
|
453
|
+
if missing, and auto-links reply_id/post_id from their most recent
|
|
454
|
+
public comment:
|
|
455
|
+
```bash
|
|
456
|
+
cd ~/social-autoposter && python3 scripts/dm_conversation.py ensure-dm --platform reddit --author "USERNAME" --chat-url "THREAD_URL"
|
|
457
|
+
```
|
|
458
|
+
Prints `DM_ID=<n>`. `THREAD_URL` must be the PM thread URL
|
|
459
|
+
`https://old.reddit.com/message/messages/<id>`, NOT a post/subreddit
|
|
460
|
+
URL. A validator rejects anything else; omit the flag if you don't
|
|
461
|
+
have it.
|
|
462
|
+
b. Log inbound (uses event-id dedup when available; plain content match
|
|
463
|
+
otherwise):
|
|
464
|
+
```bash
|
|
465
|
+
python3 scripts/dm_conversation.py log-inbound --dm-id DM_ID --author "USERNAME" --content "MESSAGE_TEXT"
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
For every Reddit chat room flagged as needs_reply by the `pending` query,
|
|
469
|
+
open it in the reddit-harness browser only to SEND a reply — not to read.
|
|
470
|
+
The conversation history is available via:
|
|
471
|
+
```bash
|
|
472
|
+
python3 scripts/dm_conversation.py history --dm-id DM_ID
|
|
473
|
+
```
|
|
474
|
+
PHASE_A_EOF
|
|
475
|
+
fi
|
|
476
|
+
|
|
477
|
+
PHASE_B_BLOCK=""
|
|
478
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "linkedin" ]; then
|
|
479
|
+
IFS= read -r -d '' PHASE_B_BLOCK <<'PHASE_B_EOF' || true
|
|
480
|
+
## PHASE B: Scan LinkedIn Messages for new messages
|
|
481
|
+
|
|
482
|
+
CRITICAL: use the mcp__linkedin-harness__bh_run tool for ALL LinkedIn browser work (CDP-driven real Chrome on port 9556). Do NOT call /voyager/api/ endpoints. Do NOT open individual post permalinks to scrape; stay inside the messaging UI. See lib/linkedin-backend.sh BROWSER_INSTRUCTIONS for the full Playwright -> bh_run translation table.
|
|
483
|
+
|
|
484
|
+
1. Navigate to https://www.linkedin.com/messaging/ : bh_run('new_tab("https://www.linkedin.com/messaging/"); wait_for_load()').
|
|
485
|
+
Capture a screenshot (bh_run('print(capture_screenshot())')) and Read it. If the page is a login/checkpoint/verification challenge, STOP and print SESSION_INVALID, do not attempt to log in.
|
|
486
|
+
|
|
487
|
+
2. Extract the FULL list of conversations (read AND unread) with a single bh_run js() call. We need every visible thread's URL so we can backfill chat_url for historical DM rows, not just the unread cohort:
|
|
488
|
+
|
|
489
|
+
bh_run('''
|
|
490
|
+
print(js("""
|
|
491
|
+
const items = [];
|
|
492
|
+
const threads = document.querySelectorAll('a.msg-conversation-listitem__link, a[href*="/messaging/thread/"]');
|
|
493
|
+
for (const a of threads) {
|
|
494
|
+
const href = a.getAttribute('href') || '';
|
|
495
|
+
if (!href.includes('/messaging/thread/')) continue;
|
|
496
|
+
const container = a.closest('li, article') || a;
|
|
497
|
+
const unreadBadge = container.querySelector('.notification-badge--show, [aria-label*="unread" i], [data-test-unread]');
|
|
498
|
+
const text = (container.innerText || '').trim();
|
|
499
|
+
const nameEl = container.querySelector('h3, .msg-conversation-listitem__participant-names');
|
|
500
|
+
const partner = nameEl ? nameEl.textContent.trim() : '';
|
|
501
|
+
items.push({
|
|
502
|
+
thread_url: href.startsWith('http') ? href : ('https://www.linkedin.com' + href),
|
|
503
|
+
partner,
|
|
504
|
+
preview: text,
|
|
505
|
+
unread: !!unreadBadge,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return JSON.stringify(items);
|
|
509
|
+
"""))
|
|
510
|
+
''')
|
|
511
|
+
|
|
512
|
+
Save the entire returned array (not just unread) to /tmp/linkedin_threads.json, then backfill chat URLs for any existing DM row still missing one (uses `author=partner`, `chat_url=thread_url`):
|
|
513
|
+
```bash
|
|
514
|
+
python3 -c "import json,sys; d=json.load(open('/tmp/linkedin_threads.json')); print(json.dumps([{'author': r['partner'], 'chat_url': r['thread_url']} for r in d]))" \
|
|
515
|
+
| python3 scripts/dm_conversation.py backfill-urls --platform linkedin
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
3. For each thread where unread is true:
|
|
519
|
+
a. Navigate to thread_url: bh_run('goto_url("THREAD_URL"); wait_for_load()').
|
|
520
|
+
b. Capture a screenshot and Read it. Read the last ~5 messages. Determine which are inbound vs from us.
|
|
521
|
+
c. Identify the sender from the partner name.
|
|
522
|
+
d. Ensure a DM row exists (idempotent, auto-links their prior LinkedIn comment engagement if any):
|
|
523
|
+
```bash
|
|
524
|
+
cd ~/social-autoposter && python3 scripts/dm_conversation.py ensure-dm --platform linkedin --author "PERSON_NAME" --chat-url "THREAD_URL"
|
|
525
|
+
```
|
|
526
|
+
Capture the `DM_ID=<n>` line for the next step. `THREAD_URL` must be `https://www.linkedin.com/messaging/thread/<id>/` (the value we scraped from `thread_url` in step 2, never a profile or feed URL). The validator refuses anything else.
|
|
527
|
+
e. Log inbound messages:
|
|
528
|
+
```bash
|
|
529
|
+
python3 scripts/dm_conversation.py log-inbound --dm-id DM_ID --author "PERSON_NAME" --content "MESSAGE_TEXT"
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
4. Do NOT aggressively scroll or click "Load earlier messages" in every thread. Only read what's immediately visible after the initial navigation. If the most recent inbound message is not visible, move on.
|
|
533
|
+
PHASE_B_EOF
|
|
534
|
+
fi
|
|
535
|
+
|
|
536
|
+
PHASE_C_BLOCK=""
|
|
537
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "twitter" ] || [ "$PLATFORM" = "x" ]; then
|
|
538
|
+
IFS= read -r -d '' PHASE_C_BLOCK <<'PHASE_C_EOF' || true
|
|
539
|
+
## PHASE C: Scan X/Twitter DMs for new messages
|
|
540
|
+
|
|
541
|
+
1. Get ALL Twitter DM conversations visible in the sidebar (the script returns the full list, not only unread) and write them to /tmp/twitter_threads.json:
|
|
542
|
+
```bash
|
|
543
|
+
python3 scripts/twitter_browser.py unread-dms > /tmp/twitter_threads.json
|
|
544
|
+
```
|
|
545
|
+
This handles the encrypted DM passcode automatically (loaded from .env TWITTER_DM_PASSCODE).
|
|
546
|
+
Returns JSON array with: author, handle, preview, time, thread_url, is_from_us, has_unread.
|
|
547
|
+
|
|
548
|
+
1a. Backfill chat URLs for any existing X DM row still missing one. Cheap, idempotent, fills buttons for historical rows whose chat is still in the sidebar:
|
|
549
|
+
```bash
|
|
550
|
+
python3 -c "import json,sys; d=json.load(open('/tmp/twitter_threads.json')); print(json.dumps([{'author': r.get('handle') or r.get('author'), 'chat_url': r['thread_url']} for r in (d if isinstance(d, list) else [])]))" \
|
|
551
|
+
| python3 scripts/dm_conversation.py backfill-urls --platform x
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
1b. **REQUIRED:** Filter the sidebar dump down to threads that actually need inspection. The filter combines sidebar signals (is_from_us, has_unread, time) with the DB's last outbound message_at to drop threads where we already sent the most recent message. This is what saves the run from $30+ in unnecessary `read-conversation` calls:
|
|
555
|
+
```bash
|
|
556
|
+
python3 scripts/dm_conversation.py filter-inbox --platform x --file /tmp/twitter_threads.json > /tmp/twitter_threads_to_inspect.json
|
|
557
|
+
```
|
|
558
|
+
The summary line goes to stderr (`in=N kept=M skipped=K (...breakdown...)`). The filtered JSON array on stdout contains only threads worth opening, each enriched with `_filter_reason` (sidebar_unread / no_db_row / outbound_older_than_window) and `_dm_id`. **Use this file as the inspection list in step 2, not the raw scan.**
|
|
559
|
+
|
|
560
|
+
2. For each conversation in `/tmp/twitter_threads_to_inspect.json`, read the full messages:
|
|
561
|
+
```bash
|
|
562
|
+
python3 scripts/twitter_browser.py read-conversation "THREAD_URL"
|
|
563
|
+
```
|
|
564
|
+
Returns JSON with: partner_name, partner_handle, messages (each with sender, content, time, is_from_us), total_found. Do NOT iterate over the raw `/tmp/twitter_threads.json` — that re-introduces the all-threads-every-cycle waste this filter exists to prevent.
|
|
565
|
+
|
|
566
|
+
3. For each conversation:
|
|
567
|
+
a. Identify the sender from the partner_name/partner_handle
|
|
568
|
+
b. **CRITICAL: Only log messages where is_from_us is false as inbound.** Skip our own messages.
|
|
569
|
+
c. Ensure a DM row exists (idempotent, auto-links any prior public reply on X from this handle):
|
|
570
|
+
```bash
|
|
571
|
+
cd ~/social-autoposter && python3 scripts/dm_conversation.py ensure-dm --platform x --author "PARTNER_HANDLE" --chat-url "THREAD_URL"
|
|
572
|
+
```
|
|
573
|
+
Use the printed `DM_ID=<n>` for every subsequent log-inbound on this conversation. `THREAD_URL` must be `https://x.com/i/chat/<ids>` (the value from `thread_url` returned by `twitter_browser.py unread-dms`, never the tweet URL or the profile URL). The validator refuses anything else. If the filter step in 1b already attached `_dm_id`, you can skip this call for that thread.
|
|
574
|
+
d. Log inbound messages:
|
|
575
|
+
```bash
|
|
576
|
+
python3 scripts/dm_conversation.py log-inbound --dm-id DM_ID --author "PARTNER_HANDLE" --content "MESSAGE_TEXT"
|
|
577
|
+
```
|
|
578
|
+
e. **REQUIRED at the end of every read-conversation, even when nothing was logged in step d** (e.g. you saw only old messages already in our DB, or only a reaction/typing-indicator). Stamp `dms.last_inspected_at` so the next cycle's filter-inbox knows we already verified this thread:
|
|
579
|
+
```bash
|
|
580
|
+
python3 scripts/dm_conversation.py mark-inspected --dm-id DM_ID
|
|
581
|
+
```
|
|
582
|
+
Skipping this on a "nothing new" thread is the bug that makes us re-open the same cold conversation every cycle. Always run it. The next filter-inbox will skip the thread for 24h unless a fresh inbound or outbound is logged in the meantime.
|
|
583
|
+
PHASE_C_EOF
|
|
584
|
+
fi
|
|
585
|
+
|
|
586
|
+
# Precompute the active reddit campaign suffix + sample_rate so the prompt
|
|
587
|
+
# can inline the literal text. If the LLM falls back to mcp__reddit-harness__bh_run
|
|
588
|
+
# (skipping the CDP path that injects the suffix at the tool layer), the
|
|
589
|
+
# literal value lets it append the suffix by hand at the documented rate.
|
|
590
|
+
# When no active campaign exists, both vars resolve to empty strings and the
|
|
591
|
+
# prompt's "if empty, do nothing extra" branch fires.
|
|
592
|
+
# HTTP-only via /api/v1/campaigns (scripts/dm_engage_helper.py). Both helper
|
|
593
|
+
# subcommands write with no trailing newline (matching the old `tr -d '\n'`).
|
|
594
|
+
REDDIT_CAMPAIGN_SUFFIX_LITERAL=$(python3 "$REPO_DIR/scripts/dm_engage_helper.py" reddit-campaign-suffix 2>/dev/null || echo "")
|
|
595
|
+
REDDIT_CAMPAIGN_SAMPLE_RATE=$(python3 "$REPO_DIR/scripts/dm_engage_helper.py" reddit-campaign-sample-rate 2>/dev/null || echo "")
|
|
596
|
+
|
|
597
|
+
PHASE_A_PROMPT=$(mktemp)
|
|
598
|
+
cat > "$PHASE_A_PROMPT" <<PROMPT_EOF
|
|
599
|
+
You are the Social Autoposter DM reply engagement bot.
|
|
600
|
+
|
|
601
|
+
Read $SKILL_FILE for content rules (tone, anti-AI detection, no em dashes).
|
|
602
|
+
|
|
603
|
+
## Task: Scan for new inbound DM messages and reply to continue conversations
|
|
604
|
+
|
|
605
|
+
CRITICAL - Tool rules:
|
|
606
|
+
$TOOL_RULE_REDDIT
|
|
607
|
+
$TOOL_RULE_LINKEDIN
|
|
608
|
+
$TOOL_RULE_TWITTER
|
|
609
|
+
NEVER use generic mcp__playwright-extension__*, mcp__isolated-browser__*, or mcp__macos-use__* tools.
|
|
610
|
+
If a script or tool call fails, wait 30 seconds and retry (up to 3 times).
|
|
611
|
+
CRITICAL: Reply in the SAME LANGUAGE as the inbound message. Match the language exactly.
|
|
612
|
+
|
|
613
|
+
## CRITICAL FAILURE MODE: platform-agent MCP tools not registered
|
|
614
|
+
If at the START of this run you cannot see ANY of the platform's browser tools available (mcp__twitter-harness__bh_run for Twitter, mcp__reddit-harness__bh_run for Reddit, mcp__linkedin-harness__bh_run for LinkedIn), OR every browser call fails with "MCP server not connected" / "no such tool" / similar, this is a transient infrastructure failure (Chrome profile collision, wedged MCP wrapper, lock acquired but profile still held by another process). It is NOT an error condition for the conversations in the queue.
|
|
615
|
+
|
|
616
|
+
Do EXACTLY this:
|
|
617
|
+
1. Make NO database changes. Do NOT mark any row as 'error', 'skipped', 'failed', or anything else. Do NOT call log-outbound, mark-skipped, set-status, mark-inspected, increment-attempts, or any other status-mutating helper. Do NOT write skip_reason on any dms/dm_messages/human_dm_replies row.
|
|
618
|
+
2. Print a single line to stdout: \`MCP_UNAVAILABLE: <platform>-agent tools not registered; aborting with rows untouched\`
|
|
619
|
+
3. Exit cleanly. The launchd schedule will retry on the next cycle, by which point the wedged profile holder will likely have timed out.
|
|
620
|
+
|
|
621
|
+
Burning rows with skip_reason='twitter_agent_mcp_unavailable: ...' on outbound DM rows is a regression that on 2026-05-12 nuked 7 warm leads (efemjoba, gpuops, josesaezmerino, AIDailyGems, alkimiadev, RobertDMellish, kunaljeweller). The LEFT JOIN d.id IS NULL filter in scan_dm_candidates.py then permanently blocked them from re-discovery (since fixed 2026-05-13 to treat transient skip_reasons as non-blocking, but the right behavior remains: do not write the row in the first place).
|
|
622
|
+
|
|
623
|
+
$PHASE0_BLOCK
|
|
624
|
+
Our projects (for context when conversations touch relevant topics):
|
|
625
|
+
$PROJECTS
|
|
626
|
+
|
|
627
|
+
## Human Reply Knowledge Base
|
|
628
|
+
|
|
629
|
+
Past human replies to escalated DMs (use as reference for tone and approach when handling similar conversations):
|
|
630
|
+
$HUMAN_REPLY_KB
|
|
631
|
+
|
|
632
|
+
$PHASE_A_BLOCK
|
|
633
|
+
|
|
634
|
+
$PHASE_B_BLOCK
|
|
635
|
+
|
|
636
|
+
$PHASE_C_BLOCK
|
|
637
|
+
|
|
638
|
+
## PHASE D: Reply to all conversations with pending inbound messages
|
|
639
|
+
|
|
640
|
+
After scanning, query for all conversations needing replies:
|
|
641
|
+
\`\`\`bash
|
|
642
|
+
cd ~/social-autoposter && python3 scripts/dm_conversation.py pending
|
|
643
|
+
\`\`\`
|
|
644
|
+
|
|
645
|
+
Known conversations from the database that already need replies:
|
|
646
|
+
$PENDING_CONVOS
|
|
647
|
+
|
|
648
|
+
## SURFACE — read the \`surface\` field on every row before composing
|
|
649
|
+
|
|
650
|
+
Each conversation has a \`surface\` field that tells you what kind of channel the inbound actually came from. The composing rules and tool routing differ. Do not skip this check.
|
|
651
|
+
|
|
652
|
+
- **\`surface\` = "real_dm"** — true private message thread (Reddit Chat, Reddit PM, LinkedIn Messages, X DM). \`chat_url\` is set. Replies go via the DM-send tool (Step 4). Treat this as a normal 1:1 DM conversation.
|
|
653
|
+
|
|
654
|
+
- **\`surface\` = "public_comment_chain"** — the inbound is a PUBLIC comment on one of our threads, not a private message. \`chat_url\` is null. \`origin_public_comment_url\` points to the comment that started this exchange and \`our_prior_public_reply_url\` points to our last public reply on that same thread. The engage_reddit / engage_linkedin pipelines are the primary handler for these threads, not this one. By the time you see this row here, our most recent public reply already exists (\`our_prior_public_reply_url\` is set). Default action: SKIP, no message, no stale flip — the public-reply pipeline owns it. ONLY post here when there's a clear reason this loop should escalate to a private DM (e.g. obvious buying intent in a public reply that warrants moving to DM). When you do escalate, route via the platform's DM tool and set the new chat_url with backfill-urls; never reply publicly from this loop.
|
|
655
|
+
|
|
656
|
+
## CLICK_SIGNAL inbound rows — read this before composing any reply
|
|
657
|
+
|
|
658
|
+
Some "inbound" rows in \`conversation_history\` are NOT typed messages from the prospect. They are backend signals inserted by the short-link redirector when the prospect silently clicks the booking URL we DM'd them. They look like:
|
|
659
|
+
|
|
660
|
+
\`\`\`json
|
|
661
|
+
{ "direction": "inbound", "author": "__click_signal__", "content": "[CLICK_SIGNAL] short link clicked" }
|
|
662
|
+
\`\`\`
|
|
663
|
+
|
|
664
|
+
If the LATEST inbound for a thread starts with \`[CLICK_SIGNAL]\`:
|
|
665
|
+
|
|
666
|
+
1. Treat as a soft trigger to nudge, not a typed message. Do NOT quote the signal back, do NOT acknowledge it, do NOT mention you know they clicked. The prospect must not know the click is observable. Phrases like "saw you checked out the link", "noticed you opened", "in case you missed it" are BANNED.
|
|
667
|
+
2. Compose a soft Mode A nudge, 1-2 sentences max, low-pressure. Acceptable shapes:
|
|
668
|
+
- A genuine follow-up question tied to something specific from an EARLIER real inbound in conversation_history.
|
|
669
|
+
- A casual "how's it going with X" referencing context they shared earlier.
|
|
670
|
+
- If conversation_history has NO real inbound messages from them (we DM'd, they clicked, never replied), prefer SKIP. A cold-click nudge with no prior typed exchange reads as creepy.
|
|
671
|
+
3. Do NOT re-share the booking link. They already clicked it. Re-sending crosses into spam.
|
|
672
|
+
4. Do NOT push for a call, demo, or meeting in a click-driven nudge. Keep the door open, that's all.
|
|
673
|
+
5. The "ONE message per conversation per run" hard rule still applies. The "Last message is outbound -> SKIP" rule in Step 0 does NOT apply when the latest inbound is a CLICK_SIGNAL (the synthetic inbound IS the new signal that puts this row in the queue).
|
|
674
|
+
6. Idempotency is automatic: once you send your nudge, our outbound is newer than the click signal and the row drops out of the queue on the next cycle. If they click again later, a new \`[CLICK_SIGNAL]\` row is inserted and the thread re-enters the queue naturally.
|
|
675
|
+
|
|
676
|
+
## CORE PHILOSOPHY
|
|
677
|
+
|
|
678
|
+
Real conversations with real people. Sound like a person, not a bot. But rapport alone is not the goal: when the topic allows, ask the qualifying question, and share the booking link when the prospect qualifies.
|
|
679
|
+
|
|
680
|
+
## HARD RULES (violating any of these is a critical failure)
|
|
681
|
+
|
|
682
|
+
1. **ONE message per conversation per run.** Never send 2+ messages. If you already sent one, move on.
|
|
683
|
+
2. **NEVER send a message if the last message is outbound.** Check conversation_history first. If we sent last, SKIP entirely.
|
|
684
|
+
3. **Don't open with a pitch or lead with "btw I built..." / "actually I built..." / "I'm working on...".** Leading with a product pitch kills rapport in a cold DM. If your own product genuinely fits the conversation, mention it plainly when it comes up.
|
|
685
|
+
4. **NEVER recommend a product in the first message.** Count the total messages. If there are fewer than 2 messages total (i.e. they haven't replied to us yet), stay in rapport-building mode. No links, no product names. Once they've replied at least once (2+ messages), recommend only if the conversation naturally leads there.
|
|
686
|
+
5. **NEVER fabricate context** like "I've been thinking about your question" or "started putting together a test suite" to create a pretext for sharing.
|
|
687
|
+
|
|
688
|
+
## DECISION FLOW (for each conversation)
|
|
689
|
+
|
|
690
|
+
### Step 0: Should we reply at all?
|
|
691
|
+
Check conversation_history. SKIP (do nothing, don't mark stale) if:
|
|
692
|
+
- Last message is already outbound (we sent last, waiting for their reply)
|
|
693
|
+
- Their message is a PURE polite brush-off with no new question/topic ("thanks", "cool", "will check it out", "good luck"). EXCEPTIONS — do NOT skip if EITHER (a) they thank you AND ask a new question, raise a new point, or float a new idea (even tentatively, even in ESL/translated register like "Thank you for your guidance, these data are encrypted, right?"); OR (b) **ICP rescue** — the thread is ICP-relevant and never qualified: the prospect (or the public post that started this DM) plausibly fits a project in \$PROJECTS, AND \`qualification_status\` is still \`pending\`, AND every prior outbound was \`mode=rapport\` (we never pitched, never asked a qualifier). On (b), reply ONCE with one natural Mode A qualifier per Step 2.5 — anchor it on something concrete from the thread or the original post (their stack, what they're shipping, the pain they hinted at), one casual texting-voice sentence, no "btw I built", no link, no pitch. Run Step 2.4 first to assign \`target_project\` if NULL; if the matched project has empty \`qualifying_questions\` AND empty \`must_have\`, synthesize a single qualifier from the conversation's domain instead of auto-disqualifying. After sending, set \`qualification_status = asked\` and let their next answer (or 14-day silence) decide qualified / disqualified / stale — not the brush-off itself. The rescue fires AT MOST ONCE per thread; if \`qualification_status\` is anything other than \`pending\`, the brush-off SKIP applies normally.
|
|
694
|
+
- Their message is a one-word/emoji response with nothing to respond to
|
|
695
|
+
- The conversation has no natural continuation (no open question from them, no new direction, no implicit invitation to keep going)
|
|
696
|
+
- \`qualification_status\` is already \`disqualified\` (we closed on a prior turn; do not generate fresh rapport on later inbounds)
|
|
697
|
+
|
|
698
|
+
### Step 1: Should a HUMAN handle this? (with booking link exception)
|
|
699
|
+
|
|
700
|
+
**BOOKING LINK AUTO-SHARE (config-driven):**
|
|
701
|
+
Booking links are stored in \`config.json\` per project and injected into the \$PROJECTS context block above. A project is auto-share eligible ONLY if BOTH fields are present:
|
|
702
|
+
- \`booking_link\`: the URL to share
|
|
703
|
+
- \`booking_link_auto_share: true\`: the flag authorizing the bot to send it
|
|
704
|
+
|
|
705
|
+
The DM row carries \`target_project\` (set at outreach time, from post or topic match) and a \`qualification_status\` (pending / asked / answered / qualified / disqualified). Use \`target_project\` first; if it's NULL, fall back to \`project_name\`. Never substitute another project's booking link.
|
|
706
|
+
|
|
707
|
+
If the matched project has \`booking_link_auto_share: true\` AND they just asked outright for a call/meeting/demo/scheduled time AND \`qualification_status\` is already \`qualified\` (or the prospect's own message plus the conversation+prospect profile clearly satisfy the project's \`must_have\` list and don't trigger any \`disqualify\` item):
|
|
708
|
+
- Do NOT flag for human.
|
|
709
|
+
- Compose the reply with the project's raw \`booking_link\` URL pasted verbatim from \$PROJECTS. The send tool wraps it transparently into \`https://<website>/r/<code>\` before typing, so click + booking attribution is automatic. \`booking_link_sent_at\` auto-stamps on first wrap. Mint is idempotent — re-runs of the same DM + same URL reuse the existing code. (For LinkedIn DMs only, the wrap happens via an explicit pre-send step in Step 4; for Reddit/Twitter the browser tools wrap in-process.)
|
|
710
|
+
- Example shape: "yeah for sure, here's a link to grab a time: <booking_link from config>"
|
|
711
|
+
- After sending, lock the project and raise tier (booking_link_sent_at is already stamped by the wrap pipeline; explicit mark-booking-sent below is idempotent and serves as audit-trail belt-and-suspenders):
|
|
712
|
+
\`\`\`bash
|
|
713
|
+
python3 scripts/dm_conversation.py set-project --dm-id DM_ID --project "EXACT_PROJECT_NAME_FROM_CONFIG"
|
|
714
|
+
python3 scripts/dm_conversation.py set-target-project --dm-id DM_ID --project "EXACT_PROJECT_NAME_FROM_CONFIG"
|
|
715
|
+
python3 scripts/dm_conversation.py set-tier --dm-id DM_ID --tier 3
|
|
716
|
+
python3 scripts/dm_conversation.py set-qualification --dm-id DM_ID --status qualified --notes "ASKED_FOR_CALL"
|
|
717
|
+
python3 scripts/dm_conversation.py mark-booking-sent --dm-id DM_ID
|
|
718
|
+
\`\`\`
|
|
719
|
+
|
|
720
|
+
If they asked for a call/demo but \`qualification_status\` is still \`pending\` or \`asked\` AND the must_have/disqualify bar isn't clearly met yet, do NOT share the booking link yet — drop into Step 2.5 and qualify first, or compose a Mode A rapport reply that surfaces the qualifying question naturally.
|
|
721
|
+
|
|
722
|
+
If the conversation's matched project has a \`booking_link\` but \`booking_link_auto_share\` is false (or the field is missing), the bot must NOT share that link automatically — flag for human instead.
|
|
723
|
+
|
|
724
|
+
**Flag for human (do NOT auto-reply) if:**
|
|
725
|
+
- They asked for a call/meeting/demo BUT the matched project lacks \`booking_link_auto_share: true\` in config
|
|
726
|
+
- They asked for a call/meeting/demo AND the prospect clearly fails the project's \`disqualify\` list (e.g., competitor, wrong geography, wrong scale) — a human needs to decide whether to decline
|
|
727
|
+
- They invited us to a podcast, interview, or event
|
|
728
|
+
- They offered a collaboration or business proposal
|
|
729
|
+
- They asked to move to another platform (Telegram, email, etc.)
|
|
730
|
+
- They need a specific personal commitment ("when are you free?", "can you demo this?") that isn't a booking link scenario
|
|
731
|
+
- They asked about pricing or business terms (UNLESS config has pricing for that project — then answer from config)
|
|
732
|
+
- Their LATEST inbound message expresses distress about themselves (e.g., "I'm burned out", "nothing works for me"). Philosophical or polemical arguments do not qualify even if the surrounding thread title uses dark language. Subreddit titles, other users' comments, and the broader thread context never trigger this rule on their own.
|
|
733
|
+
- The conversation is 8+ messages deep and going really well (high-value relationship) AND isn't a booking link scenario
|
|
734
|
+
- You're not sure how to respond authentically
|
|
735
|
+
|
|
736
|
+
**NEVER flag for human (set interest to not_our_prospect and skip instead) if:**
|
|
737
|
+
- They are pitching US a product, service, offer, agency, or partnership deal
|
|
738
|
+
- They treat us as a potential buyer/customer for their thing
|
|
739
|
+
- They offered a call/demo/meeting but the call is about THEIR work, THEIR product, or THEIR workflow — not a buying interest in one of our products
|
|
740
|
+
- They work in an unrelated domain with no realistic fit to our project list
|
|
741
|
+
- Peer/colleague chatter with no buyer signal
|
|
742
|
+
- Philosophical, political, or polemical pushback about ideas (meditation, productivity, AI, religion, etc.). Human escalation is for actionable requests (calls, business proposals, personal decisions we have to make), not a prospect disagreeing with an idea. Classify under general_discussion or declined and let Step 2 compose a Mode A rapport reply, or skip if not our prospect.
|
|
743
|
+
These waste inbox attention. Set \`--interest not_our_prospect\`, skip the reply, and move on.
|
|
744
|
+
|
|
745
|
+
\`\`\`bash
|
|
746
|
+
cd ~/social-autoposter && python3 scripts/dm_conversation.py flag-human --dm-id DM_ID --reason "REASON"
|
|
747
|
+
\`\`\`
|
|
748
|
+
Then SKIP. Do NOT reply.
|
|
749
|
+
|
|
750
|
+
### Step 2: Compose the reply (rapport OR product mention + booking link)
|
|
751
|
+
|
|
752
|
+
Every reply is one of two modes. Pick the mode based on the timeline rule and Mode B triggers below; everything else in this step (2.4 through 2.8) then shapes the content.
|
|
753
|
+
|
|
754
|
+
- **Mode A (rapport)**: conversational/continuation reply. No product name, no GitHub link, no booking link.
|
|
755
|
+
- **Mode B (product pivot)**: the reply introduces the best-fit project from \$PROJECTS, with the booking link appended when Step 2.8 eligibility is met.
|
|
756
|
+
|
|
757
|
+
**TIMELINE RULE (overrides the default):**
|
|
758
|
+
- Total messages 1-3 in the thread: Mode A is the default. Switch to Mode B only if the Mode B triggers in Step 2.7 are already met.
|
|
759
|
+
- Total messages 4+: you MUST be in Mode B. Either mention the best-fit product naturally OR, if no project in \$PROJECTS plausibly fits this prospect, set \`qualification_status = disqualified\` per Step 2.5 with a one-line reason and stay in Mode A permanently.
|
|
760
|
+
|
|
761
|
+
**Auto-disqualify triggers — narrowed.** A "peer-shaped" or "technical-discussion" thread is NOT by itself a reason to disqualify. Engineers, hobbyist OSS contributors, teachers/coaches, and community runners can ALSO be buyers; we cannot tell from rapport alone. Before flipping to \`disqualified\`, you MUST have asked at least one qualifying question per Step 2.5 (one of the project's \`qualifying_questions\` suggestions, your own qualifier off a \`must_have\` item, or a synthesized qualifier from \`must_have\` if no list is configured) and seen their answer. Only flip to \`disqualified\` when one of these is unambiguously true on the *answered turn*:
|
|
762
|
+
- their reply explicitly confirms a config-level \`disqualify\` item (e.g. "I work at QA Wolf", "we ship native iOS only", "I'm a student with no production app");
|
|
763
|
+
- their reply explicitly disclaims the \`must_have\` (e.g. "no, I'm not actually shipping anything", "I just teach this, I don't build anymore");
|
|
764
|
+
- they're currently shipping a competing **commercial** product in the same space and asking for our contributions/roadmap input.
|
|
765
|
+
Otherwise: stay at \`asked\` or \`answered\` and either ask one short clarifier or stay in Mode A rapport. The default for ambiguous peer-pattern threads is **ask, don't dismiss**.
|
|
766
|
+
|
|
767
|
+
If target_project exists in \$PROJECTS and has no \`qualifying_questions\` AND no \`must_have\` (truly empty qualification block), only then may you set \`disqualified\` here with reason "no qualification config — cannot ask"; this should be rare.
|
|
768
|
+
|
|
769
|
+
Do NOT let a thread drift past 3 outbound turns of pure rapport without either pitching, asking the qualifier, or disqualifying *post-answer*. \`scripts/dm_conversation.py log-outbound\` enforces this with a TIMELINE BLOCKED error once msg_count >= 3 and both qualification_status=pending and icp_matches=[].
|
|
770
|
+
|
|
771
|
+
Total messages = inbound + outbound count in this thread (use conversation_history length). The message you're about to send counts, so by the time you send message 4 it must be a Mode B pivot (or a Mode A rapport reply after a disqualification has been recorded).
|
|
772
|
+
|
|
773
|
+
**SHARED STYLE RULES (both modes):**
|
|
774
|
+
- Be 1-3 sentences max. Texting style, not essay style.
|
|
775
|
+
- Reference something SPECIFIC from their message (proves you read it).
|
|
776
|
+
- Either share a relevant experience, ask a genuine follow-up question, or acknowledge what they said.
|
|
777
|
+
- Sound like a real person texting, not a customer success bot.
|
|
778
|
+
- No em dashes. Lowercase is fine. Casual.
|
|
779
|
+
- NEVER repeat a question or point you already made in a previous message.
|
|
780
|
+
|
|
781
|
+
COMMITMENT GUARDRAILS (never violate these in any reply):
|
|
782
|
+
- NEVER suggest, offer, or agree to calls, meetings, demos, or video chats UNLESS the matched project in \$PROJECTS has \`booking_link_auto_share: true\` AND \`qualification_status = qualified\` on the DM row. In that case append the config booking_link to the Mode B reply per Step 2.8. Otherwise keep it in the DM.
|
|
783
|
+
- NEVER agree to podcast appearances, X Spaces, interviews, or live events.
|
|
784
|
+
- NEVER offer to move to another platform (Telegram, Discord, email, etc.). Stay in this DM thread.
|
|
785
|
+
- NEVER promise to share specific links or resources you don't have right now in config.json projects.
|
|
786
|
+
- NEVER make time-bound commitments ("this week", "tomorrow", "Thursday").
|
|
787
|
+
- NEVER share location ("I'm in SF") or personal details not in config.json.
|
|
788
|
+
- If they push for any of the above, deflect naturally: "honestly easier to hash it out here" or ask a follow-up question to keep the convo going in the DM.
|
|
789
|
+
|
|
790
|
+
### Step 2.4: Rescore ICP against every project (and optionally switch target_project)
|
|
791
|
+
|
|
792
|
+
Before the qualification funnel, rescore this prospect against EVERY project listed in \$PROJECTS that has qualification criteria. A conversation can reveal new facts (role, company, domain), and the best-fit project may have changed since the initial scan.
|
|
793
|
+
|
|
794
|
+
For each project in \$PROJECTS with qualification criteria:
|
|
795
|
+
- Compare the prospect profile (headline, company, role, bio, recent_activity, notes) + conversation history against that project's \`must_have\` and \`disqualify\` lists.
|
|
796
|
+
- Pick one label: icp_match, icp_miss, disqualified, unknown.
|
|
797
|
+
- Upsert one entry per project:
|
|
798
|
+
\`\`\`bash
|
|
799
|
+
python3 scripts/dm_conversation.py set-icp-precheck \\
|
|
800
|
+
--dm-id DM_ID --project PROJECT_NAME --label LABEL --notes "SHORT_RATIONALE"
|
|
801
|
+
\`\`\`
|
|
802
|
+
Repeat for every project. Each call upserts one entry in dms.icp_matches (JSONB array) keyed by project.
|
|
803
|
+
|
|
804
|
+
Then decide whether to switch \`target_project\`:
|
|
805
|
+
- If a project OTHER than the current target_project scores \`icp_match\` AND the current target_project scores \`icp_miss\` or \`disqualified\`, switch target_project to that better-fit project.
|
|
806
|
+
- If nothing scores \`icp_match\`, leave target_project alone.
|
|
807
|
+
- If the current target_project is NULL and a project scores \`icp_match\`, set it to that project.
|
|
808
|
+
- When switching, log the change:
|
|
809
|
+
\`\`\`bash
|
|
810
|
+
python3 scripts/dm_conversation.py set-target-project --dm-id DM_ID --project NEW_PROJECT
|
|
811
|
+
python3 scripts/dm_conversation.py set-qualification --dm-id DM_ID --status \$CURRENT_STATUS --notes "target: OLD -> NEW (reason: SHORT_WHY)"
|
|
812
|
+
\`\`\`
|
|
813
|
+
|
|
814
|
+
The qualification funnel in Step 2.5 then runs against the (possibly updated) target_project.
|
|
815
|
+
|
|
816
|
+
### Step 2.5: Qualification funnel (runs whenever target_project has \`qualifying_questions\` OR a \`must_have\` block)
|
|
817
|
+
|
|
818
|
+
Goal: before we ever drop a booking link or pitch, OR before we disqualify a peer-shaped thread, know whether the prospect matches the project's must_have list and doesn't trigger the disqualify list. Do this as a natural conversational question, not a form.
|
|
819
|
+
|
|
820
|
+
Pull the matched project's \`qualifying_questions\` (list of 3 example questions, see below), \`must_have\`, and \`disqualify\` from the \$PROJECTS block. If the DM has no target_project (and no project_name) AND message_count < 4, skip this step entirely; Step 2 already produced a rapport reply. If message_count >= 4 and Step 2.4 couldn't assign a target_project, set \`qualification_status = disqualified\` here with a one-line reason like "no product fit after rescore" and send a short Mode A close in Step 2 — do NOT keep generating substantive rapport turn after turn.
|
|
821
|
+
|
|
822
|
+
**\`qualifying_questions\` are SUGGESTIONS, not mandates.** Each project ships 3 example questions hitting different angles (stack/setup, scale/scope, pain/intent). Your job is to:
|
|
823
|
+
1. Pick the ONE that fits this specific conversation best (e.g. they already mentioned their stack → skip the stack question and pick the scale or pain one).
|
|
824
|
+
2. Paraphrase it into your own texting voice — never paste verbatim.
|
|
825
|
+
3. OR, if none of the 3 fits naturally given what's been said, write a different one-sentence qualifier that probes a must_have item.
|
|
826
|
+
4. OR, if asking any qualifier right now would feel forced or interrogative (e.g. you have nothing concrete to anchor it to yet), DO NOT ask. Stay in Mode A rapport. Asking is encouraged but never required on a specific turn — the only hard rule is that you must have asked at least once before flipping to \`disqualified\`.
|
|
827
|
+
|
|
828
|
+
If the target project has \`must_have\` but the \`qualifying_questions\` list is missing or empty, synthesize one from the first \`must_have\` item:
|
|
829
|
+
- Assrt must_have "engineering team or solo founder shipping a web app with real users that needs end-to-end test coverage" → "you maintaining e2e tests on a web app that's already in front of real users, or skipping that layer right now?"
|
|
830
|
+
- Cyrano must_have "property manager or HOA board running multifamily" → "you running operations across multiple units, or just looking out for your own place?"
|
|
831
|
+
- Generic shape: take the first must_have, strip the role label, turn it into one casual yes/no-ish question.
|
|
832
|
+
|
|
833
|
+
Never paste anything verbatim. One sentence. One question. Texting voice.
|
|
834
|
+
|
|
835
|
+
Branch on \`qualification_status\` of the DM row:
|
|
836
|
+
|
|
837
|
+
1. \`pending\` → we have never asked yet.
|
|
838
|
+
- If fewer than 2 total messages exist, do NOT ask yet. Stay in Mode A rapport from Step 2.
|
|
839
|
+
- Otherwise look at the project's \`qualifying_questions\` list (3 suggestions). Pick the ONE that best fits the *current* conversation:
|
|
840
|
+
- If they've already revealed their stack/tools, skip the stack question and pick the scale or pain one.
|
|
841
|
+
- If they've already revealed scale, pick the pain or stack one.
|
|
842
|
+
- If they've revealed nothing concrete, the stack/setup question is usually the safest opener.
|
|
843
|
+
Paraphrase your pick into one casual texting-voice sentence. Never paste verbatim. Never stack two of them in the same reply.
|
|
844
|
+
- If none of the 3 suggestions fits naturally given what's been said, you may write your own one-sentence qualifier that probes a \`must_have\` item, OR (if no \`qualifying_questions\` list exists) synthesize one from the first \`must_have\` item per the Step 2.5 header.
|
|
845
|
+
- **Asking is optional on any given turn.** If asking would feel forced (no anchor in the inbound, conversation hasn't surfaced anything to attach to, you'd be jumping out of register), it's fine to stay in Mode A rapport this turn and try again next turn. Leave \`qualification_status = pending\` and don't log an ASKED note.
|
|
846
|
+
- **Peer-pattern guard**: if the conversation reads as substantive peer-to-peer technical discussion in the project's domain (engineering war stories, ops/testing/AI workflow discussion, sharing their own setup), DO NOT auto-disqualify. Peers are often buyers in disguise. Ask the qualifier first (or wait for a better anchor and ask later); flip to \`disqualified\` only after their *answer* makes the no-fit clear (per the narrowed triggers in Step 2's TIMELINE RULE).
|
|
847
|
+
- **TIMELINE backstop**: by the 4th total message you SHOULD have either asked one of the qualifiers, or — if nothing in \$PROJECTS plausibly fits AND no must_have can be turned into a qualifier — set \`qualification_status = disqualified\` with a one-line reason and stop pitching. If a fit *exists* but you've genuinely had no place to land the question yet, you may carry pending one more turn; don't disqualify just to satisfy the deadline.
|
|
848
|
+
- When you do ask, after sending, mark status as \`asked\`:
|
|
849
|
+
\`\`\`bash
|
|
850
|
+
python3 scripts/dm_conversation.py set-qualification --dm-id DM_ID --status asked --notes "ASKED: short paraphrase of what we asked"
|
|
851
|
+
\`\`\`
|
|
852
|
+
|
|
853
|
+
2. \`asked\` → we already asked on a prior turn. The inbound we're processing now is (usually) their answer. Evaluate:
|
|
854
|
+
- Read their latest inbound message plus the prospect profile fields (headline, bio, company, role, recent_activity, notes) that are attached to the DM row.
|
|
855
|
+
- Cross-check against the project's \`must_have\` and \`disqualify\` lists from \$PROJECTS.
|
|
856
|
+
- Set status to \`qualified\` ONLY if EITHER (A) OR (B) holds, AND no disqualify item is triggered:
|
|
857
|
+
|
|
858
|
+
**(A) Role/persona fit + soft engagement co-signal**
|
|
859
|
+
- must_have role/persona is satisfied: they ARE the target persona, OR they explicitly know/work with someone who is, OR their stated role plausibly maps to the use case.
|
|
860
|
+
- AND they are meaningfully engaged in the conversation: substantive replies, asking technical questions, sharing setup details, comparing approaches. NOT one-word acks ("cool", "thanks", "will check it out") or polite brush-offs.
|
|
861
|
+
|
|
862
|
+
**(B) Explicit try/buy intent (regardless of role)**
|
|
863
|
+
- "how do I install / get access / sign up"
|
|
864
|
+
- "what does it cost / pricing"
|
|
865
|
+
- "I want to try this" / "send me a link"
|
|
866
|
+
- "can you demo this on my stack"
|
|
867
|
+
|
|
868
|
+
Neither path is satisfied by interest signals alone ("looks cool", "starred", "dope stuff", "wanna take this to the DMs" while already in DMs, "I'll check it out"). Those are interest, not qualification. If the prospect fits role-wise but shows only soft interest without substantive engagement, leave status at \`asked\` and stay in rapport.
|
|
869
|
+
- If they trigger ANY disqualify item: set status to \`disqualified\` with the rationale.
|
|
870
|
+
- If the answer is ambiguous, vague, or off-topic: set status to \`answered\` and compose ONE follow-up clarifier in Step 2. Do not keep grinding; 1 follow-up max before letting it rest.
|
|
871
|
+
\`\`\`bash
|
|
872
|
+
python3 scripts/dm_conversation.py set-qualification --dm-id DM_ID --status qualified --notes "SHORT_REASON"
|
|
873
|
+
# or --status disqualified --notes "SHORT_REASON"
|
|
874
|
+
# or --status answered --notes "SHORT_REASON"
|
|
875
|
+
\`\`\`
|
|
876
|
+
|
|
877
|
+
3. \`answered\` → we already asked a clarifier on the prior turn. Evaluate now and land on \`qualified\` or \`disqualified\`; do not ask a third time.
|
|
878
|
+
|
|
879
|
+
4. \`qualified\` → proceed to Step 2.8 (auto-share the booking link if eligible).
|
|
880
|
+
|
|
881
|
+
5. \`disqualified\` → send a short, polite close (1-2 sentences, no new open question), never the booking link, never a pitch. Then in Step 5b set \`--interest not_our_prospect\` AND run \`set-status --status stale\` so later inbounds don't resurface the thread. If the row is ALREADY \`disqualified\` on entry to this step (we closed on a prior turn and they replied again), SKIP entirely in Step 0 rather than generating fresh rapport.
|
|
882
|
+
|
|
883
|
+
### Step 2.6: Use prospect profile context in the reply
|
|
884
|
+
|
|
885
|
+
The DM payload now includes prospect_headline, prospect_bio, prospect_company, prospect_role, prospect_recent_activity, and prospect_notes (best-effort; some may be NULL). If any of these are set, weave at most ONE specific detail into your reply so it sounds like we know who they are (e.g., referencing their company's stage, the role they hold, or a recent thing they shipped). Never dump the whole profile; never mention that we scraped it. If the fields are all NULL, don't apologize, just write the reply without.
|
|
886
|
+
|
|
887
|
+
### Step 2.7: Mode B triggers and product mention
|
|
888
|
+
|
|
889
|
+
You are in Mode B on this turn if EITHER of the following is true:
|
|
890
|
+
- **Organic trigger**: they described a problem a project in \$PROJECTS solves, OR they asked for tool recommendations.
|
|
891
|
+
- **Timeline trigger (Step 2)**: total messages in this thread are 4+. By this point Mode B is mandatory unless the DM is already \`qualification_status = disqualified\`, in which case stay in Mode A permanently.
|
|
892
|
+
|
|
893
|
+
AND ALL of the following are true (floor conditions, never pivot without them):
|
|
894
|
+
- There are 2+ total messages in the conversation (they have replied at least once). This mirrors HARD RULE 4.
|
|
895
|
+
- The mention fits naturally in the reply without any "btw" or topic change.
|
|
896
|
+
- You would genuinely recommend this tool to a friend in their situation.
|
|
897
|
+
- If target_project has \`qualifying_questions\` (or a \`must_have\`), \`qualification_status\` is NOT \`disqualified\`.
|
|
898
|
+
|
|
899
|
+
If none of these conditions hold AND the thread is 4+ messages, the timeline trigger still forces a decision: either find a plausible fit and pivot, or set \`qualification_status = disqualified\` via Step 2.5 and send a Mode A reply. Do NOT keep ping-ponging rapport forever.
|
|
900
|
+
|
|
901
|
+
Pick the best-fit project from \$PROJECTS using the (possibly updated) target_project from Step 2.4. If sharing a link, embed it in a natural sentence. Never make the link the point of the message.
|
|
902
|
+
Good: "yeah there's this tool terminator that does that, github.com/mediar-ai/terminator - the accessibility API approach avoids the screenshot reliability issues you mentioned"
|
|
903
|
+
Bad: "btw I built a tool for that, check out github.com/mediar-ai/terminator if you're curious"
|
|
904
|
+
|
|
905
|
+
**Universal link wrapping**: every URL you paste — booking links, GitHub repos, blog posts, our own website pages, third-party references — is auto-wrapped by the send tool into \`https://<website>/r/<code>\` for click attribution. You paste raw URLs in your reply (the GOOD/BAD examples above show raw URLs for readability); the tool replaces them with wrapped versions before typing. **Constraint**: if you mention a URL pointing at a project not currently in \`target_projects[]\`, the send tool refuses with \`link_wrap_failed\` / \`needed_project=X\`. Before retrying, append the project to the union:
|
|
906
|
+
\`\`\`bash
|
|
907
|
+
python3 scripts/dm_conversation.py set-target-project --dm-id DM_ID --append --project "X"
|
|
908
|
+
\`\`\`
|
|
909
|
+
This is by design: any link in a reply is, by definition, a target project for that thread. The union grows; we never remove projects.
|
|
910
|
+
|
|
911
|
+
When you pivot to Mode B (with or without a booking link), stamp project and tier:
|
|
912
|
+
\`\`\`bash
|
|
913
|
+
python3 scripts/dm_conversation.py set-project --dm-id DM_ID --project "EXACT_PROJECT_NAME_FROM_CONFIG"
|
|
914
|
+
python3 scripts/dm_conversation.py set-target-project --dm-id DM_ID --project "EXACT_PROJECT_NAME_FROM_CONFIG"
|
|
915
|
+
python3 scripts/dm_conversation.py set-tier --dm-id DM_ID --tier 2
|
|
916
|
+
\`\`\`
|
|
917
|
+
\`set-target-project\` always extends \`target_projects[]\` (deduped) so future link wraps for this project succeed without an extra \`--append\`. \`set-tier\` auto-stamps \`first_product_mention_at = NOW()\` on first transition to tier >= 2. Soft pivots (category named, product name deferred) stay at tier 1 until the next turn; see PIVOT EXAMPLES below.
|
|
918
|
+
|
|
919
|
+
### Step 2.8: Append the booking link (Mode B only, when eligible)
|
|
920
|
+
|
|
921
|
+
If all of the following are true, append the project's booking link to the Mode B reply you're about to send:
|
|
922
|
+
- \`qualification_status = qualified\` for this DM (set in Step 2.5 or earlier)
|
|
923
|
+
- The matched project has \`booking_link\` AND \`booking_link_auto_share: true\` in config
|
|
924
|
+
- \`booking_link_sent_at\` is NULL (we haven't already sent it)
|
|
925
|
+
- The conversation is at a natural place to propose a call (they've surfaced pain or asked for more; not just "cool"). You do NOT need them to have explicitly asked for a call; see the updated COMMITMENT GUARDRAILS in Step 2.
|
|
926
|
+
|
|
927
|
+
Paste the raw \`booking_link\` from \$PROJECTS verbatim. The send tool wraps it into \`https://<website>/r/<code>\` automatically for click + booking attribution. \`booking_link_sent_at\` auto-stamps on first wrap; the legacy \`mark-booking-sent\` call below is idempotent (audit-trail belt + suspenders).
|
|
928
|
+
|
|
929
|
+
Phrase it naturally, one sentence, link embedded:
|
|
930
|
+
"makes sense, if you want to see how it'd work on your setup, grab a time here: <booking_link>"
|
|
931
|
+
|
|
932
|
+
After sending, stamp project + tier 3 + mark booking sent (runs in addition to the Step 2.7 set-project/set-tier; set-tier 3 supersedes tier 2):
|
|
933
|
+
\`\`\`bash
|
|
934
|
+
python3 scripts/dm_conversation.py set-project --dm-id DM_ID --project "EXACT_PROJECT_NAME_FROM_CONFIG"
|
|
935
|
+
python3 scripts/dm_conversation.py set-target-project --dm-id DM_ID --project "EXACT_PROJECT_NAME_FROM_CONFIG"
|
|
936
|
+
python3 scripts/dm_conversation.py set-tier --dm-id DM_ID --tier 3
|
|
937
|
+
python3 scripts/dm_conversation.py mark-booking-sent --dm-id DM_ID
|
|
938
|
+
\`\`\`
|
|
939
|
+
|
|
940
|
+
Never send the booking link twice. If \`booking_link_sent_at\` is not NULL, Step 2.8 is a no-op; let Step 2.7 handle any tool mention normally. (The wrap pipeline is also idempotent — re-wrapping the same URL for the same DM returns the existing code rather than minting a new one.)
|
|
941
|
+
|
|
942
|
+
### Step 3.5: ACQUIRE the reddit-browser lease (REDDIT DMs ONLY)
|
|
943
|
+
|
|
944
|
+
**Reddit only.** Skip this step entirely for LinkedIn and Twitter DMs.
|
|
945
|
+
|
|
946
|
+
Before driving any reddit browser tool (mcp__reddit-harness__bh_run or the Python CDP scripts) for THIS DM, acquire the reddit-browser lease. The lease is held only around THIS DM's Step 4 + Step 5; the next DM acquires its own. Peer reddit pipelines (run-reddit-search post phase, engage-reddit, link-edit-reddit, dm-outreach-reddit) can use the browser between our DMs.
|
|
947
|
+
|
|
948
|
+
\`\`\`bash
|
|
949
|
+
LOCK_OUT=\$(python3 ~/social-autoposter/scripts/reddit_browser_lock.py acquire --timeout 600 --ttl 90 2>&1)
|
|
950
|
+
\`\`\`
|
|
951
|
+
|
|
952
|
+
- If \`LOCK_OUT\` starts with "OK", you hold the lease. Proceed to Step 4.
|
|
953
|
+
- If "BUSY", a peer reddit pipeline owns the browser and didn't release within 10 min. Treat as a TRANSIENT skip: log nothing, do NOT call Step 4, move on to the NEXT DM. The conversation stays pending and the next launchd cycle will retry. Do NOT attempt Step 4 without the lease; collisions on the same Chrome profile crash both runs.
|
|
954
|
+
|
|
955
|
+
\`scripts/reddit_browser.py\` heartbeats the lease on every CDP subprocess, so on the PRIMARY (Python CDP) path you do NOT need to manually heartbeat. The mcp__reddit-harness__bh_run fallback does NOT auto-heartbeat (the harness MCP has no lock-proxy wrapper, unlike the retired reddit-agent), so keep any bh_run fallback brief: a single compose + send completes well within the 90s TTL. Just acquire before Step 4's first browser call for the DM and release in Step 5.5 below.
|
|
956
|
+
|
|
957
|
+
### Step 4: Send the reply
|
|
958
|
+
|
|
959
|
+
Reddit dms split into two surfaces (pick by whether \`chat_url\` is set on the dms row):
|
|
960
|
+
|
|
961
|
+
**Reddit Chat** (chat_url set; true DM — try CDP first, fall back to mcp__reddit-harness__bh_run browser):
|
|
962
|
+
\`\`\`bash
|
|
963
|
+
cd ~/social-autoposter && python3 scripts/reddit_browser.py send-dm "CHAT_URL" "YOUR_REPLY_TEXT" DM_ID
|
|
964
|
+
\`\`\`
|
|
965
|
+
Pass the conversation's DM_ID as the third positional arg so the tool can self-log the outbound (some rows have empty chat_url which would otherwise miss). The tool may append a campaign suffix to the message before typing; trust its return — \`message_sent\` is what was actually delivered.
|
|
966
|
+
If the CDP script returns {ok:false} (Reddit Chat SPA may not render via CDP), fall back to using the mcp__reddit-harness__bh_run tool (CDP-driven real Chrome on port 9557; see lib/reddit-backend.sh BROWSER_INSTRUCTIONS for the Playwright -> bh_run translation table). **The CDP path wraps URLs into /r/<code> automatically; the MCP fallback bypasses that wrap, so any project URL in the reply must be wrapped here explicitly or click attribution is lost. A 7d audit found 41 real-DM messages mentioning project URLs and ZERO of them produced a minted short link, almost entirely traceable to this fallback gap.** **STDERR_FALLBACK_MARKER (REQUIRED FIRST STEP):** before doing anything else on the MCP fallback path, emit a single line to stderr exactly: \`[reddit-chat-mcp-fallback] dm_id=DM_ID reason="<short>"\`. This is the only stable trace we have for fallback frequency; cron logs are grepped for this marker.
|
|
967
|
+
|
|
968
|
+
0. **Pre-send URL wrap (REQUIRED if YOUR_REPLY_TEXT contains any URL, bare-domain shorthands like fazm.ai included).** Run the same wrap CLI the LinkedIn flow uses:
|
|
969
|
+
\`\`\`bash
|
|
970
|
+
WRAPPED_TEXT=\$(python3 scripts/dm_short_links.py wrap-text --dm-id DM_ID --text "YOUR_REPLY_TEXT" 2>/tmp/dm_wrap_err)
|
|
971
|
+
if [ \$? -ne 0 ]; then
|
|
972
|
+
cat /tmp/dm_wrap_err >&2
|
|
973
|
+
# Common failure: target_project_required. Append the project to the union and retry:
|
|
974
|
+
# python3 scripts/dm_conversation.py set-target-project --dm-id DM_ID --append --project "X"
|
|
975
|
+
# Then re-run the wrap-text command above.
|
|
976
|
+
exit 1
|
|
977
|
+
fi
|
|
978
|
+
\`\`\`
|
|
979
|
+
Use \$WRAPPED_TEXT (NOT YOUR_REPLY_TEXT) as the input to browser_type AND as the \`--content\` for the post-send log-outbound. If YOUR_REPLY_TEXT has no URLs, wrap-text returns it unchanged; running it is still cheap and harmless, so always run it.
|
|
980
|
+
1. Navigate to the chat room (use chat_url if available, or find via sidebar)
|
|
981
|
+
2. Type \$WRAPPED_TEXT in the message input
|
|
982
|
+
3. Press Enter to send
|
|
983
|
+
4. **Campaign suffix on the MCP fallback:** if $REDDIT_CAMPAIGN_SUFFIX_LITERAL is non-empty (the shell precomputes it from the active reddit campaign), flip a coin AT $REDDIT_CAMPAIGN_SAMPLE_RATE and, on heads, type the suffix verbatim at the end of the message before pressing Enter. The CDP path does this automatically; the MCP fallback must do it by hand or the campaign attribution is lost.
|
|
984
|
+
|
|
985
|
+
**Reddit comment reply** (chat_url empty / null; the inbound is a public comment, not a chat message — use the CDP path so suffix injection runs at the tool layer):
|
|
986
|
+
\`\`\`bash
|
|
987
|
+
cd ~/social-autoposter && python3 scripts/reddit_browser.py reply "COMMENT_PERMALINK" "YOUR_REPLY_TEXT" DM_ID
|
|
988
|
+
\`\`\`
|
|
989
|
+
Pass DM_ID as the third positional arg so the tool logs to dm_messages with auto-attribution. The tool injects the active campaign suffix at \`sample_rate\`; \`reply_text\` in the JSON return is what was actually posted. \`COMMENT_PERMALINK\` is the inbound comment URL on reddit.com (the tool normalizes to old.reddit.com internally).
|
|
990
|
+
If CDP returns {ok:false, error:"subreddit_blocked"}, the comment is in a sub on \`subreddit_bans.comment_blocked\` and the tool has already auto-closed the DM (when dm_id was passed). Treat this as a clean SKIP — do NOT fall back to MCP, do NOT flag-human, do NOT retry. Move on to the next conversation.
|
|
991
|
+
If CDP returns {ok:false} with any other non-recoverable error, fall back to the mcp__reddit-harness__bh_run tool (CDP-driven real Chrome on port 9557; see lib/reddit-backend.sh BROWSER_INSTRUCTIONS for the Playwright -> bh_run translation table) to type the reply on the post page. On the MCP fallback path, the same Step-4 suffix rule applies — if $REDDIT_CAMPAIGN_SUFFIX_LITERAL is set, append it verbatim at $REDDIT_CAMPAIGN_SAMPLE_RATE before submitting; if $REDDIT_CAMPAIGN_SUFFIX_LITERAL is empty, do nothing extra. **Also: emit \`[reddit-comment-mcp-fallback] dm_id=DM_ID reason="<short>"\` to stderr first, then run the same Pre-send URL wrap step (\`python3 scripts/dm_short_links.py wrap-text --dm-id DM_ID --text "YOUR_REPLY_TEXT"\`) and use \$WRAPPED_TEXT as the input to browser_type. The wrap is cheap and harmless on URL-free replies; required when any URL is present so click attribution survives.**
|
|
992
|
+
|
|
993
|
+
**LinkedIn Messages** (mcp__linkedin-harness__bh_run tool ONLY, no raw Python CDP, no /voyager/api/. See lib/linkedin-backend.sh BROWSER_INSTRUCTIONS for the Playwright -> bh_run translation table):
|
|
994
|
+
|
|
995
|
+
0. **Pre-send URL wrap (REQUIRED if YOUR_REPLY_TEXT contains any URL).** LinkedIn types via the harness without a Python pre-pass, so the wrap step that reddit/twitter handle in-process must happen explicitly here:
|
|
996
|
+
\`\`\`bash
|
|
997
|
+
WRAPPED_TEXT=\$(python3 scripts/dm_short_links.py wrap-text --dm-id DM_ID --text "YOUR_REPLY_TEXT" 2>/tmp/dm_wrap_err)
|
|
998
|
+
if [ \$? -ne 0 ]; then
|
|
999
|
+
cat /tmp/dm_wrap_err >&2
|
|
1000
|
+
# Common failure: target_project_required. Append the project to the union and retry:
|
|
1001
|
+
# python3 scripts/dm_conversation.py set-target-project --dm-id DM_ID --append --project "X"
|
|
1002
|
+
# Then re-run the wrap-text command above.
|
|
1003
|
+
exit 1
|
|
1004
|
+
fi
|
|
1005
|
+
\`\`\`
|
|
1006
|
+
Use \$WRAPPED_TEXT (NOT YOUR_REPLY_TEXT) as the text you type via type_text AND as the \`--content\` for the post-send log-outbound. If YOUR_REPLY_TEXT has no URLs, wrap-text returns it unchanged; running it is still cheap and harmless, so always run it.
|
|
1007
|
+
1. Navigate to THREAD_URL: bh_run('goto_url("THREAD_URL"); wait_for_load()').
|
|
1008
|
+
2. Capture a screenshot (bh_run('print(capture_screenshot())')) and Read it. If you see login, captcha, or checkpoint, STOP and print SESSION_INVALID. Do not attempt to re-login.
|
|
1009
|
+
3. Find the message input (contenteditable, aria-label typically "Write a message"). Compute its center from getBoundingClientRect via js(), click_at_xy(X, Y) to focus it, then bh_run('type_text(...)') with \$WRAPPED_TEXT.
|
|
1010
|
+
4. Click the Send button (aria-label "Send", role=button): compute its center via js() and bh_run('click_at_xy(X, Y)'). Do NOT press Enter to send (Enter inserts a newline in LinkedIn's contenteditable).
|
|
1011
|
+
5. Capture a screenshot and confirm the message appears in the thread as the newest outbound bubble. If not visible, mark this convo as failed (do not retry more than once per run).
|
|
1012
|
+
|
|
1013
|
+
**X/Twitter DMs** (Python CDP script, no browser MCP needed):
|
|
1014
|
+
\`\`\`bash
|
|
1015
|
+
cd ~/social-autoposter && python3 scripts/twitter_browser.py send-dm "THREAD_URL" "YOUR_REPLY_TEXT" DM_ID
|
|
1016
|
+
\`\`\`
|
|
1017
|
+
Pass the conversation's DM_ID as the third positional arg so the tool can self-log the outbound and auto-attribute to any active Twitter campaign whose suffix it appended. Returns JSON with {ok: true, thread_url, verified, applied_campaigns, message_sent} on success. \`message_sent\` is the literal text that landed (which may include the campaign suffix appended at \`sample_rate\`). Handles the encrypted DM passcode automatically.
|
|
1018
|
+
On {ok:false}, treat these errors as TERMINAL for this run: \`rate_limited\`, \`conversation_not_found_in_sidebar\`, \`message_box_not_found\`, \`tweet_not_found\`. They mean platform-level state we can't fix mid-cycle. SKIP the conversation, do NOT retry, do NOT flag-human, do NOT log-outbound. The next launchd cycle handles its own backoff. The generic "retry up to 3 times" rule does NOT apply to these errors — retrying a rate_limited burns more X-side budget.
|
|
1019
|
+
|
|
1020
|
+
### Step 5: Log the reply
|
|
1021
|
+
\`\`\`bash
|
|
1022
|
+
cd ~/social-autoposter && python3 scripts/dm_conversation.py log-outbound --dm-id DM_ID --content "MESSAGE_SENT_FROM_TOOL_JSON" --verified
|
|
1023
|
+
\`\`\`
|
|
1024
|
+
Use \`message_sent\` from the send-dm JSON as the content (NOT YOUR_REPLY_TEXT) so any campaign suffix the tool appended is captured, enabling auto-attribution. For Twitter, the send-dm helper has already self-logged when DM_ID was passed; this call is dedup-protected and acts as a no-op confirmation. Pass --verified ONLY when the browser tool returned verified=true (or you visually confirmed the message in the thread). The flag is a hard gate: log-outbound refuses to insert without it. If verification failed, log nothing and let the next cycle retry; never pass --verified speculatively. The log-outbound command also has a dedup guard. If it says "DEDUP BLOCKED" or "VERIFY BLOCKED", the message was NOT logged. Do not retry.
|
|
1025
|
+
|
|
1026
|
+
### Step 5.5: RELEASE the reddit-browser lease (REDDIT DMs ONLY)
|
|
1027
|
+
|
|
1028
|
+
**Reddit only.** Skip this step entirely for LinkedIn and Twitter DMs.
|
|
1029
|
+
|
|
1030
|
+
RELEASE the reddit-browser lease IMMEDIATELY after the DM result is logged (success, error, or skip). This is mandatory; failing to release blocks every other reddit pipeline for up to 90s of TTL idle decay:
|
|
1031
|
+
|
|
1032
|
+
\`\`\`bash
|
|
1033
|
+
python3 ~/social-autoposter/scripts/reddit_browser_lock.py release
|
|
1034
|
+
\`\`\`
|
|
1035
|
+
|
|
1036
|
+
Run this even if Step 4 raised, the send threw, or Step 5 failed. Wrap the per-DM browser block (Step 4 + Step 5) in a way that Step 5.5 ALWAYS executes (mental try/finally). The release is idempotent and safe to call multiple times. Move to the NEXT DM only after the release. Step 5b classify/log work below is DB-only (no browser) and runs unlocked, so peer pipelines can use the browser during it.
|
|
1037
|
+
|
|
1038
|
+
### Step 5b: Classify interest level AND mode (REQUIRED on every reply)
|
|
1039
|
+
|
|
1040
|
+
After replying (or deciding to SKIP/flag/stale), classify two things and write BOTH. These are separate commands, one call each, every turn, no exceptions.
|
|
1041
|
+
|
|
1042
|
+
**(i) interest_level**: the prospect's signal, based on their LATEST inbound plus the full arc. Can go up or down as the conversation evolves.
|
|
1043
|
+
|
|
1044
|
+
\`\`\`bash
|
|
1045
|
+
python3 scripts/dm_conversation.py set-interest --dm-id DM_ID --interest LEVEL
|
|
1046
|
+
\`\`\`
|
|
1047
|
+
|
|
1048
|
+
LEVEL is one of (pick the single best fit; the ladder roughly goes no_response → general_discussion → warm → hot, with cold / not_our_prospect / declined as off-ramps):
|
|
1049
|
+
- **no_response** — we messaged them and they have never replied. This flow only runs on threads with an inbound message, so you will rarely pick this; it is set upstream by the classifier/DB for untouched outreach. Do not pick it once there is any inbound content.
|
|
1050
|
+
- **general_discussion** — default baseline AFTER they have replied but BEFORE any product-relevant signal has appeared. Use this for early-stage threads where the topic hasn't yet touched anything our products solve, no product has been mentioned by either side, and you're still getting to know each other. This is what most tier-1 threads should be until they surface a real pain point.
|
|
1051
|
+
- **hot** — explicit buying or trial signals DIRECTED AT ONE OF OUR PRODUCTS (Terminator, Fazm, PieLine, Cyrano, vipassana.cool, Octolens): asked for the link/demo/trial/pricing for our product, said "tell me more" about our product, said they already use or want to use our product, booked a call to discuss our product, gave us an email for follow-up about our product. A call offer that is about THEIR workflow, THEIR product, or an adjacent topic is NOT hot — it's warm or not_our_prospect depending on fit. The buy signal must point at something we sell.
|
|
1052
|
+
- **warm** — engaged and problem-aware: asking substantive follow-up questions, describing their exact pain in detail, comparing tools, acknowledging the use case, multi-turn back-and-forth where they keep the thread alive AND the thread is in a domain one of our products could serve. Not yet a direct ask, but the conversation has real traction.
|
|
1053
|
+
- **cold** — polite but shallow AFTER the conversation already touched a relevant topic: one-liners ("cool", "thanks", "will check it out", "interesting"), they disengaged from a thread that had product relevance, conversational small talk that used to have a product angle and no longer does. (If the thread never had a product angle, use general_discussion instead.)
|
|
1054
|
+
- **not_our_prospect** — engaged but in the wrong direction: they're pitching US (offering services, leads, a sale), they treat us as a potential customer/buyer, they work in an unrelated domain, or it's a peer/colleague exchange with no realistic buyer fit. The conversation may be friendly and ongoing, but they are not a candidate for our products.
|
|
1055
|
+
- **declined** — explicit negative: "not interested", "stop messaging", "this isn't for me", confrontational tone, accused us of being a bot/spam, asked us to leave them alone.
|
|
1056
|
+
|
|
1057
|
+
Rules:
|
|
1058
|
+
- Base the label on THEIR latest message plus the full history, not on how OUR reply landed.
|
|
1059
|
+
- If you already set a level on a prior turn and the new message doesn't change the signal, re-set the same level anyway (confirms it's current).
|
|
1060
|
+
- Never mark hot unless there's a concrete buying/trial/demo signal from them. Don't inflate.
|
|
1061
|
+
- Default new replied-to threads to general_discussion, not cold. Cold means "engagement faded after product relevance appeared"; general_discussion means "product relevance hasn't appeared yet." (no_response is reserved for threads where they have not replied at all, which this flow does not process.)
|
|
1062
|
+
- Move to not_our_prospect as soon as it's clear they're pitching us or have no buyer fit — don't let those sit as warm.
|
|
1063
|
+
- If they move from warm → declined (e.g., shut the conversation down), update to declined.
|
|
1064
|
+
|
|
1065
|
+
**(ii) mode**: the posture of the OUTBOUND reply we just sent. This describes what we said on this turn, not what the prospect feels. Reversible; can flip back and forth as the thread evolves.
|
|
1066
|
+
|
|
1067
|
+
\`\`\`bash
|
|
1068
|
+
python3 scripts/dm_conversation.py set-mode --dm-id DM_ID --mode MODE
|
|
1069
|
+
\`\`\`
|
|
1070
|
+
|
|
1071
|
+
MODE is exactly one of:
|
|
1072
|
+
- **rapport**: our reply contained no product name, no GitHub link, no booking link. Conversational continuation, qualifying question folded into rapport, or polite brush-off. This is Mode A from Step 2.
|
|
1073
|
+
- **pitch**: our reply named a project from \$PROJECTS, shared a project link, or appended the booking_link. This is Mode B from Step 2 (with or without Step 2.8's booking link).
|
|
1074
|
+
|
|
1075
|
+
Rules:
|
|
1076
|
+
- Base the label on what WE sent on THIS turn, not on the thread's overall direction.
|
|
1077
|
+
- \`mode\` is INDEPENDENT of \`tier\` and \`first_product_mention_at\`. A thread that pitched on turn 2 (tier=2, first_product_mention_at stamped) can still have \`mode='rapport'\` on turn 5 if we stepped back to a casual reply.
|
|
1078
|
+
- If you SKIPPED (no reply sent) or flagged for human, do NOT call set-mode; mode only updates on turns where we actually sent an outbound message.
|
|
1079
|
+
- Re-setting the same mode as the prior turn is fine and expected (e.g. three rapport turns in a row all re-stamp \`rapport\`).
|
|
1080
|
+
- \`mode\` is included in the PENDING_CONVOS payload so you can see what the previous outbound was labeled.
|
|
1081
|
+
|
|
1082
|
+
### Step 6: Let go when it's time
|
|
1083
|
+
Mark as stale ONLY if ALL of these are true (be conservative — when in doubt, SKIP without marking stale):
|
|
1084
|
+
- Their last message contains NO new question, NO new topic, NO open thought, NO implicit invitation to continue
|
|
1085
|
+
- AND one of: pure ending ("thanks", "bye", "good luck", "will check it out") with nothing else; OR no reply from them in 7+ days after a surface-level exchange; OR 2+ consecutive outbound messages with no reply (something went wrong previously)
|
|
1086
|
+
- "Thanks + new question" or "thanks + new musing" is NEVER a stale trigger. Reply, do not mark stale. Warm + ICP-matched threads especially: if they are still asking or musing, the thread is alive.
|
|
1087
|
+
- **ICP rescue overrides stale**: NEVER auto-stale a brush-off when the thread is ICP-relevant (target_project assignable in \$PROJECTS or already assigned) AND \`qualification_status = pending\` AND every prior outbound was \`mode=rapport\` (we never pitched, never asked a qualifier). Per Step 0 exception (b), the correct move on this turn is to send ONE last natural qualifier and set \`qualification_status = asked\`; THEIR answer (or 14-day silence) is what tells us to stale, not their polite close.
|
|
1088
|
+
\`\`\`bash
|
|
1089
|
+
python3 scripts/dm_conversation.py set-status --dm-id DM_ID --status stale
|
|
1090
|
+
\`\`\`
|
|
1091
|
+
|
|
1092
|
+
## REPLY EXAMPLES BY INBOUND TYPE
|
|
1093
|
+
|
|
1094
|
+
Use these as tone/shape references, not templates. Every reply must reference a specific detail from the inbound. Adapt to the conversation, don't copy the wording.
|
|
1095
|
+
|
|
1096
|
+
### Type 1: Simple positive ("yeah", "sure", "cool, tell me more")
|
|
1097
|
+
|
|
1098
|
+
Short acknowledgment plus ONE substantive continuation. No product name unless 2+ messages in. No link unless qualified.
|
|
1099
|
+
|
|
1100
|
+
GOOD: "nice, what's the stack you're running it on? curious how it handles the auth flow"
|
|
1101
|
+
GOOD: "yeah same, we spent a week last month on a bug where the action completed but the state never flushed"
|
|
1102
|
+
BAD: "Great to hear! I'd love to tell you more about our solution. Here's a link: ..." (product-first, formal register, no hook)
|
|
1103
|
+
BAD: "btw I built something for that" (self-promo, banned phrase per HARD RULES)
|
|
1104
|
+
|
|
1105
|
+
### Type 2: Engaged with detail (they describe their setup or ask a specific question)
|
|
1106
|
+
|
|
1107
|
+
Answer the specific thing first, then ONE follow-up. Reference a concrete detail they mentioned.
|
|
1108
|
+
|
|
1109
|
+
GOOD: "the parallel agents thing is what got us too, ended up scoping each one to its own worktree so they can't step on each other. how many do you run at once?"
|
|
1110
|
+
GOOD: "yeah 30s polling is rough, we moved to event-driven with a tiny socket listener and the cost dropped like 80%"
|
|
1111
|
+
BAD: "Thanks for sharing! That sounds really interesting. Would you like to hop on a call to discuss further?" (generic, unearned call offer, violates COMMITMENT GUARDRAILS)
|
|
1112
|
+
BAD: "Your question about X is really good. Here's what we do: [paragraph of marketing copy]" (essay-style, not texting register)
|
|
1113
|
+
|
|
1114
|
+
### Type 2b: Folding ONE qualifying question into a rapport reply (Step 2.5 \`pending\` -> \`asked\`)
|
|
1115
|
+
|
|
1116
|
+
When the thread has 2+ messages AND a relevant topic has surfaced AND \`qualification_status = pending\`, you MAY pick ONE question from the matched project's \`qualifying_questions\` list (3 suggestions: stack, scale, pain) and paraphrase it as a natural sentence inside your normal rapport reply. Pick the one that fits the conversation best — skip the stack one if they already named their stack, skip the scale one if they already gave numbers, etc. If none of the 3 fits naturally, write your own one-sentence qualifier off a \`must_have\` item, OR skip asking this turn entirely and try again next turn. Asking is encouraged but never required on a specific turn.
|
|
1117
|
+
|
|
1118
|
+
Never paste any suggestion verbatim. Never stack two questions in one reply. Never make it feel like a form.
|
|
1119
|
+
|
|
1120
|
+
GOOD (suggestions for Terminator included "what are you trying to automate?" + "running it for clients or in-house?" — they already mentioned a client project, so we pick the in-house/clients angle): "the orchestration drift thing killed us too, ended up with one worktree per agent just to stop them fighting. is this for an internal tool or something you're shipping to customers?"
|
|
1121
|
+
GOOD (suggestions for fde10x included "what's the agent/eval problem?" — they just described the eval pain, so we pick the timeline/owner angle): "yeah the CAC math on cold outbound is brutal right now. who on your side is the technical owner if you decided to ship this in the next month?"
|
|
1122
|
+
BAD: "What does your E2E testing stack look like today, mostly manual, Playwright, Cypress, or nothing yet?" (verbatim paste of the suggestion, interrogation register)
|
|
1123
|
+
BAD: "Quick question: what's your team size, industry, and current tooling?" (multiple questions stacked, form-style)
|
|
1124
|
+
|
|
1125
|
+
After sending, mark status as \`asked\` with a one-line paraphrase of what we asked (see Step 2.5 item 1).
|
|
1126
|
+
|
|
1127
|
+
### Type 3: Direct question ("what tool do you use for X?", "how do you handle Y?")
|
|
1128
|
+
|
|
1129
|
+
Answer the question directly. Only name a product if it's genuinely relevant AND the thread is 2+ messages in. Embed any link in a natural sentence, never lead with it.
|
|
1130
|
+
|
|
1131
|
+
GOOD: "we use terminator for the desktop automation side, github.com/mediar-ai/terminator. went with the accessibility API approach after screenshot-based kept flaking on retina displays"
|
|
1132
|
+
GOOD: "honestly still figuring it out, ended up with a cron that fires every 4h and posts to a slack channel, not pretty but it works"
|
|
1133
|
+
BAD: "Check out our amazing product at [link]! It does exactly what you need." (sales register)
|
|
1134
|
+
BAD: "Great question! Before I answer, can I ask what your use case is?" (dodge, no answer given)
|
|
1135
|
+
|
|
1136
|
+
### Type 4: Hesitant or skeptical ("not sure", "we tried something like this before", "probably won't work for us")
|
|
1137
|
+
|
|
1138
|
+
Validate the hesitation, don't argue. Low-pressure continuation. No link, no product name.
|
|
1139
|
+
|
|
1140
|
+
GOOD: "yeah same reason we kept putting it off, the last tool we tried ate our logs and we spent two weeks recovering. what happened when you tried it before?"
|
|
1141
|
+
GOOD: "makes sense, the ROI math only works past a certain team size. what's your setup now?"
|
|
1142
|
+
BAD: "But our solution is different! Here's why you should reconsider: ..." (defensive, pushy)
|
|
1143
|
+
BAD: "No worries, if you change your mind, here's my calendar: ..." (premature calendar share, they didn't ask)
|
|
1144
|
+
|
|
1145
|
+
### Type 4b: One-shot clarifier after an ambiguous answer (Step 2.5 \`asked\` -> \`answered\`)
|
|
1146
|
+
|
|
1147
|
+
We already folded one of the qualifying_questions (or a synthesized qualifier) into a prior turn. Their latest reply is vague, partial, or off-topic. Compose ONE narrow clarifier that references their actual words. Do NOT ask a second distinct question. Do NOT press a third time on a later turn; if it's still ambiguous after this, let it rest and evaluate based on what you have.
|
|
1148
|
+
|
|
1149
|
+
GOOD (they said "yeah we use some automation stuff"): "got it, when you say automation is that mostly CI scripts or also stuff that drives the desktop/browser UI?"
|
|
1150
|
+
GOOD (they said "kinda both I guess"): "fair, is the team already paying for something for this or still stitching it together in-house?"
|
|
1151
|
+
BAD: "Can you clarify? Also, what's your budget, team size, and timeline?" (stacked questions, interrogation)
|
|
1152
|
+
BAD: "To qualify you properly I need to know X, Y, Z" (form register, exposes the sales machinery)
|
|
1153
|
+
|
|
1154
|
+
After sending, mark status as \`answered\` with a one-line rationale (see Step 2.5 item 2). On the next turn, land on \`qualified\` or \`disqualified\` based on the full picture; do not ask a third time.
|
|
1155
|
+
|
|
1156
|
+
### Type 5: They asked for a call, demo, or meeting
|
|
1157
|
+
|
|
1158
|
+
BOOKING LINK LOGIC: only share a link if (a) matched project has booking_link_auto_share: true in config.json, (b) qualification_status is already qualified, (c) booking_link_sent_at is NULL. Otherwise either qualify first (Step 2.5) or flag for human. ALWAYS mint via \`python3 scripts/dm_short_links.py mint --dm-id DM_ID\` and paste the printed short URL — never the raw cal.com URL from \$PROJECTS.
|
|
1159
|
+
|
|
1160
|
+
GOOD (qualified, config allows auto-share): "yeah for sure, grab a time here: <minted short URL from dm_short_links.py>"
|
|
1161
|
+
GOOD (not yet qualified, folding in one of the project's qualifying_questions naturally — pick the one that fits the inbound): "happy to dig in, what's the team size you're running this across?"
|
|
1162
|
+
GOOD (project has no auto-share, or they fail disqualify list): flag for human, do NOT reply in this run.
|
|
1163
|
+
BAD: "Absolutely! Here's my calendar: calendly.com/my-made-up-link" (fabricated link, never invent one)
|
|
1164
|
+
BAD: "Let's do Thursday at 2pm!" (time-bound commitment, violates COMMITMENT GUARDRAILS)
|
|
1165
|
+
|
|
1166
|
+
### Type 6: They're pitching US (agency, service, their product, their workflow)
|
|
1167
|
+
|
|
1168
|
+
Set interest to not_our_prospect. Short polite reply or skip. Do NOT flag for human. Do NOT pitch back.
|
|
1169
|
+
|
|
1170
|
+
GOOD: "appreciate it, not a fit right now but good luck with it"
|
|
1171
|
+
GOOD: skip entirely (if their message doesn't warrant a reply)
|
|
1172
|
+
BAD: "Thanks for reaching out! Here's what WE do: ..." (turning their pitch into ours)
|
|
1173
|
+
BAD: flagging for human (wastes inbox attention for non-buyer)
|
|
1174
|
+
|
|
1175
|
+
### Type 7: Philosophical disagreement or polemic (meditation, AI doomerism, productivity takes)
|
|
1176
|
+
|
|
1177
|
+
Rapport reply, no product, no booking link, no human flag. Keep it conversational.
|
|
1178
|
+
|
|
1179
|
+
GOOD: "yeah the framing where everyone gets enlightened in 10 days feels like a marketing thing to me too. 7 courses in and the gains are subtle, mostly in how i notice reactivity before it flares"
|
|
1180
|
+
BAD: "Would you like to try vipassana.cool? It's designed for people like you." (forcing product into an unrelated philosophical thread)
|
|
1181
|
+
BAD: flagging for human (escalation is for actionable requests, not disagreements about ideas)
|
|
1182
|
+
|
|
1183
|
+
## PIVOT EXAMPLES (Tier 1 -> Tier 2): general chat -> product mention
|
|
1184
|
+
|
|
1185
|
+
The single most consequential move in a thread. Before composing any Mode B pivot, verify ALL four Step 2.7 conditions: 2+ total messages, the Mode B trigger is satisfied (they surfaced a problem a project solves, OR they asked for tools, OR the thread has reached 4+ messages and the timeline rule forces a decision), the product fits naturally in the reply with no "btw" register, and you'd genuinely recommend it to a friend in their situation. If the thread is still under 4 messages and none of the organic triggers hit, stay in Tier 1 rapport.
|
|
1186
|
+
|
|
1187
|
+
At the pivot turn, fire these writes together (alongside Step 5b's \`set-interest\`):
|
|
1188
|
+
\`\`\`bash
|
|
1189
|
+
python3 scripts/dm_conversation.py set-project --dm-id ID --project "PROJECT_NAME_FROM_CONFIG"
|
|
1190
|
+
python3 scripts/dm_conversation.py set-tier --dm-id ID --tier 2
|
|
1191
|
+
python3 scripts/dm_conversation.py set-interest --dm-id ID --interest warm
|
|
1192
|
+
\`\`\`
|
|
1193
|
+
\`set-tier\` automatically stamps \`first_product_mention_at = NOW()\` on the first transition to tier >= 2. Do not set that column by hand.
|
|
1194
|
+
|
|
1195
|
+
### Trigger A: they explicitly asked for a tool
|
|
1196
|
+
|
|
1197
|
+
GOOD (Terminator, asked about desktop automation): "we use terminator for the desktop automation side, github.com/mediar-ai/terminator. went with the accessibility API approach after screenshot-based kept flaking on retina displays"
|
|
1198
|
+
GOOD (Octolens, asked about mention tracking): "honestly octolens has been the one that stuck, octolens.com. picks up reddit/x/youtube/hn mentions in one feed so i stopped running manual searches"
|
|
1199
|
+
|
|
1200
|
+
### Trigger B: they described a pain a project solves (no explicit tool ask)
|
|
1201
|
+
|
|
1202
|
+
GOOD (Terminator, they described retina/screenshot flake): "yeah the retina flake was our whole week last month. ended up on terminator, github.com/mediar-ai/terminator, since it drives the accessibility tree directly, killed the flake in one afternoon"
|
|
1203
|
+
GOOD (Octolens, they described manual search pain): "that's the exact reason we stopped doing it manually. octolens.com catches reddit/x/youtube/hn mentions in one feed, daily email is enough that i barely check the dashboard"
|
|
1204
|
+
|
|
1205
|
+
### Soft pivot: category first, name next turn if they bite
|
|
1206
|
+
|
|
1207
|
+
GOOD (desktop automation, no product name yet): "yeah the retina flake was our whole week last month. ended up moving to an accessibility-API runner instead of screenshots, solved it immediately"
|
|
1208
|
+
|
|
1209
|
+
For the soft-pivot turn, do NOT set tier to 2 yet. Keep tier at 1 and do NOT call \`set-project\`. If they reply asking "what tool?", the NEXT turn becomes Type 3 and completes the pivot with \`set-tier 2\` + \`set-project\` + \`set-interest warm\` together.
|
|
1210
|
+
|
|
1211
|
+
### BAD examples
|
|
1212
|
+
|
|
1213
|
+
BAD: "btw I built a tool for that, check out github.com/mediar-ai/terminator if you're curious" (HARD RULE 3, "btw I built" is banned self-promo)
|
|
1214
|
+
BAD: "What you're describing is exactly what Terminator solves. Would you like to try it? Here's the link: ..." (sales register, product-first, unearned pivot)
|
|
1215
|
+
BAD: pivoting on the very first outbound (HARD RULE 4, need 2+ total messages in the thread)
|
|
1216
|
+
BAD: pivoting to Terminator when the pain is about brand mentions (HARD RULE 5, product must fit the actual pain)
|
|
1217
|
+
|
|
1218
|
+
## ANTI-PATTERNS TO AVOID (learned from past mistakes)
|
|
1219
|
+
- Sending two messages before getting a reply (got us called out as AI)
|
|
1220
|
+
- Dropping a GitHub link in the second message of a conversation
|
|
1221
|
+
- Pivoting from their topic to desktop automation/accessibility APIs when it's irrelevant
|
|
1222
|
+
- Using the same opener pattern ("honestly still juggling...", "that's basically the bet I'm making...")
|
|
1223
|
+
- Asking a question you already asked in a previous message
|
|
1224
|
+
- Pitching vipassana.cool to someone who just mentioned meditation casually
|
|
1225
|
+
- Saying "cool I'll hit you up on [platform]" when you can't actually do that
|
|
1226
|
+
|
|
1227
|
+
After processing all conversations, print a summary:
|
|
1228
|
+
- How many human replies delivered (Phase 0)
|
|
1229
|
+
- How many new inbound messages found per platform
|
|
1230
|
+
- How many replies sent
|
|
1231
|
+
- How many flagged for human attention (list each with reason)
|
|
1232
|
+
- How many left alone (no reply needed)
|
|
1233
|
+
- How many marked stale
|
|
1234
|
+
PROMPT_EOF
|
|
1235
|
+
|
|
1236
|
+
# Select MCP config based on platform
|
|
1237
|
+
DM_MCP_CONFIG="$HOME/.claude/browser-agent-configs/all-agents-mcp.json"
|
|
1238
|
+
if [ -n "$PLATFORM" ]; then
|
|
1239
|
+
case "$PLATFORM" in
|
|
1240
|
+
reddit) DM_MCP_CONFIG="$HOME/.claude/browser-agent-configs/reddit-harness-mcp.json" ;;
|
|
1241
|
+
linkedin) DM_MCP_CONFIG="$HOME/.claude/browser-agent-configs/linkedin-harness-mcp.json" ;;
|
|
1242
|
+
twitter|x) DM_MCP_CONFIG="$HOME/.claude/browser-agent-configs/twitter-harness-mcp.json" ;;
|
|
1243
|
+
esac
|
|
1244
|
+
fi
|
|
1245
|
+
|
|
1246
|
+
# Acquire platform-browser lock(s) now, immediately before the Claude/MCP
|
|
1247
|
+
# step. Alphabetical order in the multi-platform branch prevents deadlock
|
|
1248
|
+
# with other pipelines that also acquire multiple browser locks.
|
|
1249
|
+
#
|
|
1250
|
+
# Reddit lock strategy (migrated 2026-05-13): the reddit-browser lease is
|
|
1251
|
+
# acquired/released PER DM inside the Claude prompt (Step 4-pre / Step 5-post),
|
|
1252
|
+
# mirroring the link-edit-reddit.sh and dm-outreach-reddit.sh pattern. Holding
|
|
1253
|
+
# the lease around the whole 5400s Claude turn monopolised the browser for
|
|
1254
|
+
# the full DM batch (~5-15 min) while peer reddit pipelines sat blocked during
|
|
1255
|
+
# Claude's compose + classify phases. The brief pre-flight below runs the
|
|
1256
|
+
# one-shot ensure_browser_healthy / Singleton-lock clear under a 30s lease so
|
|
1257
|
+
# the cycle starts on a clean profile; the prompt then acquires per-DM.
|
|
1258
|
+
#
|
|
1259
|
+
# LinkedIn/Twitter still use the long bash lock because they don't have the
|
|
1260
|
+
# per-MCP-call heartbeat proxy wired through their MCP configs yet.
|
|
1261
|
+
log "Acquiring platform-browser lock(s) for Claude/MCP step..."
|
|
1262
|
+
case "${PLATFORM:-all}" in
|
|
1263
|
+
linkedin) acquire_lock "linkedin-browser" 3600; ( source "$(dirname "$0")/lib/linkedin-backend.sh"; ensure_linkedin_browser_for_backend ) ;;
|
|
1264
|
+
reddit)
|
|
1265
|
+
# Reddit: brief pre-flight acquire+ensure+release ONLY. Per-DM
|
|
1266
|
+
# acquire/release happens inside the Claude prompt.
|
|
1267
|
+
log "Reddit pre-flight: brief acquire + ensure_reddit_browser_for_backend (harness 9557) + release..."
|
|
1268
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" acquire --timeout 60 --ttl 30 2>&1 | tee -a "$LOG_FILE" || \
|
|
1269
|
+
log "WARNING: reddit pre-flight acquire BUSY; ensure_reddit_browser_for_backend will run anyway; per-DM acquires inside the prompt will retry."
|
|
1270
|
+
ensure_reddit_browser_for_backend
|
|
1271
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
|
|
1272
|
+
;;
|
|
1273
|
+
twitter|x) acquire_lock "twitter-browser" 3600; ensure_twitter_browser_for_backend ;;
|
|
1274
|
+
all)
|
|
1275
|
+
acquire_lock "linkedin-browser" 3600
|
|
1276
|
+
( source "$(dirname "$0")/lib/linkedin-backend.sh"; ensure_linkedin_browser_for_backend )
|
|
1277
|
+
# Reddit: brief pre-flight only (same as the `reddit` branch above).
|
|
1278
|
+
log "Reddit pre-flight: brief acquire + ensure_reddit_browser_for_backend (harness 9557) + release..."
|
|
1279
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" acquire --timeout 60 --ttl 30 2>&1 | tee -a "$LOG_FILE" || \
|
|
1280
|
+
log "WARNING: reddit pre-flight acquire BUSY; per-DM acquires inside the prompt will retry."
|
|
1281
|
+
ensure_reddit_browser_for_backend
|
|
1282
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
|
|
1283
|
+
acquire_lock "twitter-browser" 3600
|
|
1284
|
+
ensure_twitter_browser_for_backend
|
|
1285
|
+
;;
|
|
1286
|
+
esac
|
|
1287
|
+
|
|
1288
|
+
# ============================================================================
|
|
1289
|
+
# EARLY-EXIT GATE (added 2026-04-29 to skip Claude on empty cycles)
|
|
1290
|
+
# ----------------------------------------------------------------------------
|
|
1291
|
+
# Before invoking Claude (~$5-20 per run on Opus, mostly burned reading
|
|
1292
|
+
# huge LinkedIn DOM snapshots), check whether there's *anything* to do:
|
|
1293
|
+
#
|
|
1294
|
+
# 1. DB-side: any active DM where the most recent message is inbound?
|
|
1295
|
+
# (Cheap: ~50ms SQL.) Catches every conversation we're already
|
|
1296
|
+
# tracking that needs a reply.
|
|
1297
|
+
#
|
|
1298
|
+
# 2. Live-side, LinkedIn only: scrape /messaging/ sidebar via the
|
|
1299
|
+
# read-only helper (scripts/linkedin_browser.py unread-dms) and
|
|
1300
|
+
# count threads with the unread badge. Catches brand-new inbound
|
|
1301
|
+
# from prospects we haven't logged a DM row for yet.
|
|
1302
|
+
#
|
|
1303
|
+
# If both signals say "nothing", we log run with cost=0 and exit cleanly.
|
|
1304
|
+
# ============================================================================
|
|
1305
|
+
NEEDS_CLAUDE=false
|
|
1306
|
+
GATE_REASON=""
|
|
1307
|
+
|
|
1308
|
+
# Helper: count DMs where last message is inbound, per platform. Includes
|
|
1309
|
+
# both 'active' and 'needs_reply' because Phase A.0 ingest flips status to
|
|
1310
|
+
# 'needs_reply' the moment a fresh inbound lands; gating on 'active' alone
|
|
1311
|
+
# silently skipped every fresh inbound (bug surfaced 2026-04-30 with 4 warm
|
|
1312
|
+
# leads stuck unanswered for hours/days).
|
|
1313
|
+
needs_reply_count_for() {
|
|
1314
|
+
# HTTP-only via /api/v1/dms/engage?mode=needs_reply. The endpoint folds
|
|
1315
|
+
# 'x'/'twitter' into one platform server-side (matches the legacy
|
|
1316
|
+
# PLATFORM_SQL_FILTER), so passing either is safe. Prints an integer, or
|
|
1317
|
+
# '?' on transport failure (the gate treats '?' as "don't trust, wake
|
|
1318
|
+
# Claude" just like the old psql-error path did).
|
|
1319
|
+
local plat="$1"
|
|
1320
|
+
python3 "$REPO_DIR/scripts/dm_engage_helper.py" needs-reply --platform "$plat" 2>/dev/null | tr -d ' \n' || echo "?"
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
# DB-side check across in-scope platforms.
|
|
1324
|
+
for plat_check in ${PLATFORM:-reddit linkedin twitter}; do
|
|
1325
|
+
case "$plat_check" in
|
|
1326
|
+
x) plat_check="twitter" ;; # canonical display name; query covers both
|
|
1327
|
+
esac
|
|
1328
|
+
NR=$(needs_reply_count_for "$plat_check")
|
|
1329
|
+
if [ "$NR" != "0" ] && [ "$NR" != "?" ] && [ -n "$NR" ]; then
|
|
1330
|
+
NEEDS_CLAUDE=true
|
|
1331
|
+
GATE_REASON="db: ${plat_check} has ${NR} convos with inbound>outbound"
|
|
1332
|
+
log "[gate] ${GATE_REASON}"
|
|
1333
|
+
break
|
|
1334
|
+
fi
|
|
1335
|
+
done
|
|
1336
|
+
|
|
1337
|
+
# Live-side LinkedIn pre-check (only when DB said nothing AND LinkedIn is
|
|
1338
|
+
# in scope). Read-only sidebar scrape via headed Chromium, ~5s, $0.
|
|
1339
|
+
#
|
|
1340
|
+
# We capture stderr (don't /dev/null it) so a Playwright traceback is
|
|
1341
|
+
# auditable in the run log. We also surface total_threads and the first
|
|
1342
|
+
# partner name so post-hoc you can tell "really empty inbox" from "session
|
|
1343
|
+
# bad / DOM didn't render" — both of which would otherwise look identical
|
|
1344
|
+
# (ok=true, unread_count=0). total_threads==0 is treated as a soft failure
|
|
1345
|
+
# because our account always has 10+ sidebar threads.
|
|
1346
|
+
if ! $NEEDS_CLAUDE && { [ -z "$PLATFORM" ] || [ "$PLATFORM" = "linkedin" ]; }; then
|
|
1347
|
+
log "[gate] DB says nothing pending; running LinkedIn live sidebar pre-check..."
|
|
1348
|
+
LI_PRECHECK_STDERR="/tmp/li_precheck_stderr.$$"
|
|
1349
|
+
LI_PRECHECK_PARSED="/tmp/li_precheck_parsed.$$"
|
|
1350
|
+
# `set -e` + `set -o pipefail` is on. Three things matter:
|
|
1351
|
+
# 1. Caller env var: the helper now requires
|
|
1352
|
+
# SOCIAL_AUTOPOSTER_LINKEDIN_PRECHECK=1 (added to stop wandering
|
|
1353
|
+
# Claude planners from smoke-testing it and racing the profile).
|
|
1354
|
+
# 2. Capture helper exit code via `&& a || b` so a non-zero exit
|
|
1355
|
+
# doesn't trip set -e.
|
|
1356
|
+
# 3. Parse the JSON in a SINGLE python invocation that tolerates
|
|
1357
|
+
# empty/whitespace input (json.loads on `\n` from echo crashes,
|
|
1358
|
+
# tripping pipefail+set -e and silently killing the script — the
|
|
1359
|
+
# bug that hid behind every prior "log frozen at precheck" run).
|
|
1360
|
+
LI_PRECHECK=$(SOCIAL_AUTOPOSTER_LINKEDIN_PRECHECK=1 \
|
|
1361
|
+
LINKEDIN_CDP_URL="${LINKEDIN_CDP_URL:-http://127.0.0.1:9556}" \
|
|
1362
|
+
PYTHONPATH="$HOME/Library/Python/3.9/lib/python/site-packages" \
|
|
1363
|
+
/usr/bin/python3 "$REPO_DIR/scripts/linkedin_browser.py" unread-dms 2>"$LI_PRECHECK_STDERR") \
|
|
1364
|
+
&& LI_EXIT=0 || LI_EXIT=$?
|
|
1365
|
+
LI_OK="" LI_UNREAD="" LI_TOTAL="" LI_FIRST_PARTNER="" LI_ERROR=""
|
|
1366
|
+
/usr/bin/python3 - "$LI_PRECHECK_PARSED" "$LI_PRECHECK" << 'PYEOF' || true
|
|
1367
|
+
import json, shlex, sys
|
|
1368
|
+
out_path = sys.argv[1]
|
|
1369
|
+
raw = (sys.argv[2] if len(sys.argv) > 2 else "").strip()
|
|
1370
|
+
try:
|
|
1371
|
+
d = json.loads(raw) if raw else {}
|
|
1372
|
+
except Exception:
|
|
1373
|
+
d = {}
|
|
1374
|
+
threads = d.get("threads") or []
|
|
1375
|
+
first = (threads[0] if threads else {}) or {}
|
|
1376
|
+
fields = {
|
|
1377
|
+
"LI_OK": d.get("ok"),
|
|
1378
|
+
"LI_UNREAD": d.get("unread_count", 0),
|
|
1379
|
+
"LI_TOTAL": d.get("total_threads", 0),
|
|
1380
|
+
"LI_FIRST_PARTNER": first.get("partner", ""),
|
|
1381
|
+
"LI_ERROR": d.get("error", ""),
|
|
1382
|
+
}
|
|
1383
|
+
with open(out_path, "w") as f:
|
|
1384
|
+
for k, v in fields.items():
|
|
1385
|
+
f.write(f"{k}={shlex.quote(str(v) if v is not None else '')}\n")
|
|
1386
|
+
PYEOF
|
|
1387
|
+
if [ -s "$LI_PRECHECK_PARSED" ]; then
|
|
1388
|
+
# shellcheck disable=SC1090
|
|
1389
|
+
source "$LI_PRECHECK_PARSED"
|
|
1390
|
+
fi
|
|
1391
|
+
rm -f "$LI_PRECHECK_PARSED"
|
|
1392
|
+
log "[gate] linkedin precheck: exit=$LI_EXIT ok=$LI_OK total=$LI_TOTAL unread=$LI_UNREAD first_partner='${LI_FIRST_PARTNER}' error='${LI_ERROR}'"
|
|
1393
|
+
if [ -s "$LI_PRECHECK_STDERR" ]; then
|
|
1394
|
+
log "[gate] linkedin precheck stderr (first 20 lines):"
|
|
1395
|
+
head -20 "$LI_PRECHECK_STDERR" | sed 's/^/[gate] /' | tee -a "$LOG_FILE" >/dev/null
|
|
1396
|
+
fi
|
|
1397
|
+
rm -f "$LI_PRECHECK_STDERR"
|
|
1398
|
+
if [ "$LI_OK" = "True" ] && [ "$LI_TOTAL" != "0" ] && [ -n "$LI_TOTAL" ] && [ "$LI_UNREAD" = "0" ]; then
|
|
1399
|
+
log "[gate] LinkedIn sidebar pre-check: 0 unread of $LI_TOTAL threads"
|
|
1400
|
+
elif [ "$LI_OK" = "True" ] && [ "$LI_TOTAL" != "0" ] && [ -n "$LI_TOTAL" ]; then
|
|
1401
|
+
NEEDS_CLAUDE=true
|
|
1402
|
+
GATE_REASON="linkedin live: ${LI_UNREAD} unread of ${LI_TOTAL} threads in sidebar"
|
|
1403
|
+
log "[gate] ${GATE_REASON}"
|
|
1404
|
+
elif [ "$LI_OK" = "True" ]; then
|
|
1405
|
+
# ok=true but total_threads=0 is suspicious. Could be: session
|
|
1406
|
+
# silently expired (no /login redirect), DOM markup changed, or
|
|
1407
|
+
# a TargetClosedError mid-scan that the helper didn't catch.
|
|
1408
|
+
# Don't drop work; fall through to Claude.
|
|
1409
|
+
NEEDS_CLAUDE=true
|
|
1410
|
+
GATE_REASON="linkedin pre-check returned 0 total threads (suspect bad session or DOM); falling through to Claude"
|
|
1411
|
+
log "[gate] ${GATE_REASON}"
|
|
1412
|
+
else
|
|
1413
|
+
# Helper failed (session_invalid, profile_locked, crash, etc).
|
|
1414
|
+
NEEDS_CLAUDE=true
|
|
1415
|
+
GATE_REASON="linkedin pre-check failed (ok=$LI_OK error=$LI_ERROR); falling through to Claude"
|
|
1416
|
+
log "[gate] ${GATE_REASON}"
|
|
1417
|
+
fi
|
|
1418
|
+
fi
|
|
1419
|
+
|
|
1420
|
+
# Live-side Twitter pre-check (mirror of the LinkedIn one above). Required
|
|
1421
|
+
# because Twitter inbound DMs are only ingested into the dms/dm_messages tables
|
|
1422
|
+
# when Claude runs Phase B of this script — but the DB-side gate above would
|
|
1423
|
+
# never see them and skip Claude entirely. That's bug B in the 2026-05-01..
|
|
1424
|
+
# 05-13 inbound-DM cliff: 0 X DMs ingested for 13 days after the gate landed.
|
|
1425
|
+
#
|
|
1426
|
+
# Read-only sidebar scrape via headed Chromium (~5s, $0). Identical safety
|
|
1427
|
+
# envelope to the LinkedIn one: capture stderr for auditability, parse JSON
|
|
1428
|
+
# defensively, fall through to Claude on any failure rather than dropping work.
|
|
1429
|
+
#
|
|
1430
|
+
# Disable with SOCIAL_AUTOPOSTER_DISABLE_TWITTER_PRECHECK=1 if it starts
|
|
1431
|
+
# misbehaving; the cycle will then run Claude on every fire (high cost, but
|
|
1432
|
+
# never silently drops inbound).
|
|
1433
|
+
if ! $NEEDS_CLAUDE \
|
|
1434
|
+
&& { [ -z "$PLATFORM" ] || [ "$PLATFORM" = "twitter" ] || [ "$PLATFORM" = "x" ]; } \
|
|
1435
|
+
&& [ "${SOCIAL_AUTOPOSTER_DISABLE_TWITTER_PRECHECK:-0}" != "1" ]; then
|
|
1436
|
+
log "[gate] DB says nothing pending; running Twitter live sidebar pre-check..."
|
|
1437
|
+
TW_PRECHECK_STDERR="/tmp/tw_precheck_stderr.$$"
|
|
1438
|
+
TW_PRECHECK_PARSED="/tmp/tw_precheck_parsed.$$"
|
|
1439
|
+
TW_PRECHECK=$(/usr/bin/python3 "$REPO_DIR/scripts/twitter_browser.py" unread-dms 2>"$TW_PRECHECK_STDERR") \
|
|
1440
|
+
&& TW_EXIT=0 || TW_EXIT=$?
|
|
1441
|
+
TW_OK="" TW_UNREAD="" TW_TOTAL="" TW_FIRST_PARTNER="" TW_ERROR=""
|
|
1442
|
+
/usr/bin/python3 - "$TW_PRECHECK_PARSED" "$TW_PRECHECK" << 'PYEOF' || true
|
|
1443
|
+
import json, shlex, sys
|
|
1444
|
+
out_path = sys.argv[1]
|
|
1445
|
+
raw = (sys.argv[2] if len(sys.argv) > 2 else "").strip()
|
|
1446
|
+
try:
|
|
1447
|
+
d = json.loads(raw) if raw else None
|
|
1448
|
+
except Exception:
|
|
1449
|
+
d = None
|
|
1450
|
+
|
|
1451
|
+
# twitter_browser.unread_dms returns either:
|
|
1452
|
+
# - a LIST of {has_unread, author, thread_url, ...} on success
|
|
1453
|
+
# - a DICT {"ok": False, "error": "..."} on rate limit / not_on_dm_page
|
|
1454
|
+
# - None / unparseable on script crash
|
|
1455
|
+
total = 0
|
|
1456
|
+
unread = 0
|
|
1457
|
+
first_partner = ""
|
|
1458
|
+
ok = False
|
|
1459
|
+
err = ""
|
|
1460
|
+
if isinstance(d, list):
|
|
1461
|
+
ok = True
|
|
1462
|
+
total = len(d)
|
|
1463
|
+
for c in d:
|
|
1464
|
+
if c.get("has_unread"):
|
|
1465
|
+
unread += 1
|
|
1466
|
+
if not first_partner:
|
|
1467
|
+
first_partner = c.get("author") or c.get("handle") or ""
|
|
1468
|
+
elif isinstance(d, dict):
|
|
1469
|
+
ok = bool(d.get("ok"))
|
|
1470
|
+
err = d.get("error", "") or ""
|
|
1471
|
+
else:
|
|
1472
|
+
err = "unparseable_json"
|
|
1473
|
+
|
|
1474
|
+
fields = {
|
|
1475
|
+
"TW_OK": "True" if ok else "False",
|
|
1476
|
+
"TW_UNREAD": unread,
|
|
1477
|
+
"TW_TOTAL": total,
|
|
1478
|
+
"TW_FIRST_PARTNER": first_partner,
|
|
1479
|
+
"TW_ERROR": err,
|
|
1480
|
+
}
|
|
1481
|
+
with open(out_path, "w") as f:
|
|
1482
|
+
for k, v in fields.items():
|
|
1483
|
+
f.write(f"{k}={shlex.quote(str(v) if v is not None else '')}\n")
|
|
1484
|
+
PYEOF
|
|
1485
|
+
if [ -s "$TW_PRECHECK_PARSED" ]; then
|
|
1486
|
+
# shellcheck disable=SC1090
|
|
1487
|
+
source "$TW_PRECHECK_PARSED"
|
|
1488
|
+
fi
|
|
1489
|
+
rm -f "$TW_PRECHECK_PARSED"
|
|
1490
|
+
log "[gate] twitter precheck: exit=$TW_EXIT ok=$TW_OK total=$TW_TOTAL unread=$TW_UNREAD first_partner='${TW_FIRST_PARTNER}' error='${TW_ERROR}'"
|
|
1491
|
+
if [ -s "$TW_PRECHECK_STDERR" ]; then
|
|
1492
|
+
log "[gate] twitter precheck stderr (first 20 lines):"
|
|
1493
|
+
head -20 "$TW_PRECHECK_STDERR" | sed 's/^/[gate] /' | tee -a "$LOG_FILE" >/dev/null
|
|
1494
|
+
fi
|
|
1495
|
+
rm -f "$TW_PRECHECK_STDERR"
|
|
1496
|
+
if [ "$TW_OK" = "True" ] && [ "$TW_TOTAL" != "0" ] && [ -n "$TW_TOTAL" ] && [ "$TW_UNREAD" = "0" ]; then
|
|
1497
|
+
log "[gate] Twitter sidebar pre-check: 0 unread of $TW_TOTAL threads"
|
|
1498
|
+
elif [ "$TW_OK" = "True" ] && [ "$TW_TOTAL" != "0" ] && [ -n "$TW_TOTAL" ]; then
|
|
1499
|
+
NEEDS_CLAUDE=true
|
|
1500
|
+
GATE_REASON="twitter live: ${TW_UNREAD} unread of ${TW_TOTAL} threads in sidebar"
|
|
1501
|
+
log "[gate] ${GATE_REASON}"
|
|
1502
|
+
elif [ "$TW_OK" = "True" ]; then
|
|
1503
|
+
# ok=true but total_threads=0 means the helper returned an empty list.
|
|
1504
|
+
# Could be: encryption passcode prompt, session expired, DOM changed,
|
|
1505
|
+
# or a real empty inbox. Don't drop work; fall through to Claude.
|
|
1506
|
+
NEEDS_CLAUDE=true
|
|
1507
|
+
GATE_REASON="twitter pre-check returned 0 total threads (suspect bad session or passcode); falling through to Claude"
|
|
1508
|
+
log "[gate] ${GATE_REASON}"
|
|
1509
|
+
else
|
|
1510
|
+
# Helper failed (rate limit, not_on_dm_page, crash, etc).
|
|
1511
|
+
NEEDS_CLAUDE=true
|
|
1512
|
+
GATE_REASON="twitter pre-check failed (ok=$TW_OK error=$TW_ERROR); falling through to Claude"
|
|
1513
|
+
log "[gate] ${GATE_REASON}"
|
|
1514
|
+
fi
|
|
1515
|
+
fi
|
|
1516
|
+
|
|
1517
|
+
if ! $NEEDS_CLAUDE; then
|
|
1518
|
+
log "[gate] All signals say nothing to do; skipping Claude invocation."
|
|
1519
|
+
rm -f "$PHASE_A_PROMPT"
|
|
1520
|
+
RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
|
|
1521
|
+
# Log a zero-cost run so the dashboard shows the cycle fired.
|
|
1522
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "reddit" ]; then
|
|
1523
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "dm_replies_reddit" --posted 0 --skipped 0 --failed 0 --cost "0.0" --elapsed "$RUN_ELAPSED" 2>/dev/null || true
|
|
1524
|
+
fi
|
|
1525
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "linkedin" ]; then
|
|
1526
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "dm_replies_linkedin" --posted 0 --skipped 0 --failed 0 --cost "0.0" --elapsed "$RUN_ELAPSED" 2>/dev/null || true
|
|
1527
|
+
fi
|
|
1528
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "twitter" ] || [ "$PLATFORM" = "x" ]; then
|
|
1529
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "dm_replies_twitter" --posted 0 --skipped 0 --failed 0 --cost "0.0" --elapsed "$RUN_ELAPSED" 2>/dev/null || true
|
|
1530
|
+
fi
|
|
1531
|
+
log "=== DM reply engagement complete (gated, cost=\$0): $(date) ==="
|
|
1532
|
+
exit 0
|
|
1533
|
+
fi
|
|
1534
|
+
# ============================================================================
|
|
1535
|
+
# END EARLY-EXIT GATE
|
|
1536
|
+
# ============================================================================
|
|
1537
|
+
|
|
1538
|
+
gtimeout 5400 "$REPO_DIR/scripts/run_claude.sh" "engage-dm-replies" --strict-mcp-config --mcp-config "$DM_MCP_CONFIG" --output-format stream-json --verbose -p "$(cat "$PHASE_A_PROMPT")" 2>&1 | tee -a "$LOG_FILE" || log "WARNING: DM reply claude exited with code $?"
|
|
1539
|
+
rm -f "$PHASE_A_PROMPT"
|
|
1540
|
+
|
|
1541
|
+
# Belt-and-suspenders: free the reddit-browser lease if it's still held.
|
|
1542
|
+
# Idempotent — release prints OK / NOT_HELD / HELD_BY_OTHER. Mirrors
|
|
1543
|
+
# link-edit-reddit.sh:185. Only fires when reddit was in scope this run.
|
|
1544
|
+
case "${PLATFORM:-all}" in
|
|
1545
|
+
reddit|all)
|
|
1546
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
|
|
1547
|
+
;;
|
|
1548
|
+
esac
|
|
1549
|
+
|
|
1550
|
+
# ═══════════════════════════════════════════════════════
|
|
1551
|
+
# Cleanup
|
|
1552
|
+
# ═══════════════════════════════════════════════════════
|
|
1553
|
+
DM_SUMMARY=$(python3 "$REPO_DIR/scripts/dm_engage_helper.py" summary 2>/dev/null || echo "{}")
|
|
1554
|
+
|
|
1555
|
+
log "DM pipeline summary: $DM_SUMMARY"
|
|
1556
|
+
|
|
1557
|
+
# Log run to persistent monitor per platform.
|
|
1558
|
+
# posted = DM replies actually sent during this run's window (per-platform, per-window)
|
|
1559
|
+
# skipped = conversations currently marked stale (per-platform, cumulative snapshot)
|
|
1560
|
+
dm_counts_for() {
|
|
1561
|
+
local plat="$1"
|
|
1562
|
+
# HTTP-only via /api/v1/dms/engage?mode=run_counts. The endpoint folds
|
|
1563
|
+
# 'x'/'twitter' server-side (the dms table stores X rows as platform='x')
|
|
1564
|
+
# and counts outbound messages since RUN_START + current stale convos. The
|
|
1565
|
+
# helper prints 'POSTED STALE' (space-separated) so the `read -r` below is
|
|
1566
|
+
# unchanged.
|
|
1567
|
+
python3 "$REPO_DIR/scripts/dm_engage_helper.py" run-counts --platform "$plat" --since "$RUN_START" 2>/dev/null
|
|
1568
|
+
}
|
|
1569
|
+
RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
|
|
1570
|
+
_COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START" --scripts "engage-dm-replies" 2>/dev/null || echo "0.0000")
|
|
1571
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "reddit" ]; then
|
|
1572
|
+
read -r R_POSTED R_STALE <<< "$(dm_counts_for reddit)"
|
|
1573
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "dm_replies_reddit" --posted "${R_POSTED:-0}" --skipped "${R_STALE:-0}" --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED"
|
|
1574
|
+
fi
|
|
1575
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "linkedin" ]; then
|
|
1576
|
+
read -r L_POSTED L_STALE <<< "$(dm_counts_for linkedin)"
|
|
1577
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "dm_replies_linkedin" --posted "${L_POSTED:-0}" --skipped "${L_STALE:-0}" --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED"
|
|
1578
|
+
fi
|
|
1579
|
+
if [ -z "$PLATFORM" ] || [ "$PLATFORM" = "twitter" ] || [ "$PLATFORM" = "x" ]; then
|
|
1580
|
+
read -r T_POSTED T_STALE <<< "$(dm_counts_for twitter)"
|
|
1581
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script "dm_replies_twitter" --posted "${T_POSTED:-0}" --skipped "${T_STALE:-0}" --failed 0 --cost "$_COST" --elapsed "$RUN_ELAPSED"
|
|
1582
|
+
fi
|
|
1583
|
+
|
|
1584
|
+
# Report flagged conversations needing human attention (emails already sent per-DM during flagging)
|
|
1585
|
+
FLAGGED_COUNT=$(python3 "$REPO_DIR/scripts/dm_engage_helper.py" flagged-count 2>/dev/null || echo "0")
|
|
1586
|
+
|
|
1587
|
+
if [ "$FLAGGED_COUNT" -gt 0 ] 2>/dev/null; then
|
|
1588
|
+
log "ACTION REQUIRED: $FLAGGED_COUNT conversations flagged for human attention (escalation emails already sent per-DM)"
|
|
1589
|
+
log "Run: python3 ~/social-autoposter/scripts/dm_conversation.py show-flagged"
|
|
1590
|
+
|
|
1591
|
+
platform_notify "Social DM Escalation" "$FLAGGED_COUNT DM conversations need your attention"
|
|
1592
|
+
fi
|
|
1593
|
+
|
|
1594
|
+
# Delete old logs
|
|
1595
|
+
find "$LOG_DIR" -name "engage-dm-replies-*.log" -mtime +7 -delete 2>/dev/null || true
|
|
1596
|
+
|
|
1597
|
+
log "=== DM reply engagement complete: $(date) ==="
|