@m13v/s4l 1.6.197-rc.10
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 +1336 -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 +513 -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,682 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""DM conversation tracker - log messages, query history, update state.
|
|
3
|
+
|
|
4
|
+
This is the central module for all DM conversation tracking. Every DM
|
|
5
|
+
interaction (outbound or inbound) should go through here.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
# Log an outbound message we sent
|
|
9
|
+
python3 scripts/dm_conversation.py log-outbound --dm-id 5 --content "hey, what stack..."
|
|
10
|
+
|
|
11
|
+
# Log an inbound message we received
|
|
12
|
+
python3 scripts/dm_conversation.py log-inbound --dm-id 5 --author tolley --content "I use React..."
|
|
13
|
+
|
|
14
|
+
# Show full conversation history for a DM
|
|
15
|
+
python3 scripts/dm_conversation.py history --dm-id 5
|
|
16
|
+
|
|
17
|
+
# Show all conversations with pending inbound (needs reply)
|
|
18
|
+
python3 scripts/dm_conversation.py pending
|
|
19
|
+
|
|
20
|
+
# Set chat URL for a conversation
|
|
21
|
+
python3 scripts/dm_conversation.py set-url --dm-id 5 --url "https://www.reddit.com/chat/room/..."
|
|
22
|
+
|
|
23
|
+
# Update conversation tier
|
|
24
|
+
python3 scripts/dm_conversation.py set-tier --dm-id 5 --tier 2
|
|
25
|
+
|
|
26
|
+
# Mark conversation status
|
|
27
|
+
python3 scripts/dm_conversation.py set-status --dm-id 5 --status converted
|
|
28
|
+
|
|
29
|
+
# Find DM by author name (fuzzy)
|
|
30
|
+
python3 scripts/dm_conversation.py find --author tolley
|
|
31
|
+
|
|
32
|
+
# Summary of all active conversations
|
|
33
|
+
python3 scripts/dm_conversation.py summary
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import sys
|
|
41
|
+
|
|
42
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
43
|
+
import db as dbmod
|
|
44
|
+
|
|
45
|
+
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _valid_chat_url(platform, url):
|
|
49
|
+
"""Return a cleaned chat_url or None.
|
|
50
|
+
|
|
51
|
+
The dashboard only treats it as an "open chat" link when it looks like a
|
|
52
|
+
real DM thread URL. Post URLs / profile URLs silently leak in when the
|
|
53
|
+
prompt passes the wrong variable, so we reject anything that is not the
|
|
54
|
+
per-platform DM-thread shape.
|
|
55
|
+
"""
|
|
56
|
+
if not url:
|
|
57
|
+
return None
|
|
58
|
+
u = url.strip()
|
|
59
|
+
if not u:
|
|
60
|
+
return None
|
|
61
|
+
p = (platform or "").lower()
|
|
62
|
+
if p == "reddit":
|
|
63
|
+
if "/chat/room/" in u or "/message/messages/" in u:
|
|
64
|
+
return u
|
|
65
|
+
if "/room/!" in u and "/chat/room/!" not in u:
|
|
66
|
+
return u.replace("/room/!", "/chat/room/!", 1)
|
|
67
|
+
return None
|
|
68
|
+
if p in ("twitter", "x"):
|
|
69
|
+
if "/i/chat/" in u or "/messages/" in u:
|
|
70
|
+
return u
|
|
71
|
+
return None
|
|
72
|
+
if p == "linkedin":
|
|
73
|
+
if "/messaging/thread/" in u:
|
|
74
|
+
return u
|
|
75
|
+
return None
|
|
76
|
+
return u
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_config():
|
|
80
|
+
if os.path.exists(CONFIG_PATH):
|
|
81
|
+
with open(CONFIG_PATH) as f:
|
|
82
|
+
return json.load(f)
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_our_account(config, platform):
|
|
87
|
+
accounts = config.get("accounts", {})
|
|
88
|
+
if platform == "reddit":
|
|
89
|
+
return accounts.get("reddit", {}).get("username", "Deep_Ad1959")
|
|
90
|
+
elif platform == "linkedin":
|
|
91
|
+
return accounts.get("linkedin", {}).get("name", "Matthew Diakonov")
|
|
92
|
+
elif platform == "x":
|
|
93
|
+
# No hardcoded fallback: stamping a default handle on an outbound DM
|
|
94
|
+
# silently mis-attributes it to the repo owner. Resolve from config / env
|
|
95
|
+
# and fail loud if absent.
|
|
96
|
+
from account_resolver import resolve as _resolve_account
|
|
97
|
+
h = _resolve_account("twitter")
|
|
98
|
+
if not h:
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
"no Twitter handle configured (accounts.twitter.handle / "
|
|
101
|
+
"AUTOPOSTER_TWITTER_HANDLE); refusing to stamp a fallback account "
|
|
102
|
+
"on an outbound DM to avoid wrong-attribution. Run connect_x first.")
|
|
103
|
+
return h.lstrip("@")
|
|
104
|
+
return "unknown"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _http_link_wrap_guard(dm_id, content):
|
|
108
|
+
"""Pure-Python (no DB) link-wrap guard, mirroring log_outbound's pre-pass.
|
|
109
|
+
|
|
110
|
+
Returns True if an unwrapped project URL is present (caller must abort the
|
|
111
|
+
log and print already happened here). Returns False when content is clean
|
|
112
|
+
or the classifier could not load.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
from dm_short_links import _classify_url, _load_projects, _URL_RE, _TRAILING_PUNCT
|
|
116
|
+
_wrap_check_projects = _load_projects()
|
|
117
|
+
for m in _URL_RE.finditer(content or ""):
|
|
118
|
+
raw_url = m.group(0).rstrip(_TRAILING_PUNCT)
|
|
119
|
+
if re.search(r'/r/[a-z0-9]{4,32}(?:[/?#]|$)', raw_url, re.IGNORECASE):
|
|
120
|
+
continue
|
|
121
|
+
kind, matched = _classify_url(raw_url, _wrap_check_projects)
|
|
122
|
+
if kind != 'other':
|
|
123
|
+
print(f" LINK BLOCKED: DM #{dm_id} content contains unwrapped {kind} URL "
|
|
124
|
+
f"({raw_url[:80]}) for project {matched!r}. Re-send via the wrap-text "
|
|
125
|
+
f"helper (python3 scripts/dm_short_links.py wrap-text --dm-id {dm_id} "
|
|
126
|
+
f"--text '...').")
|
|
127
|
+
return True
|
|
128
|
+
except Exception as _wrap_err:
|
|
129
|
+
print(f" WARNING: link-wrap guard skipped due to error: {_wrap_err}", file=sys.stderr)
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _http_log_outbound(args):
|
|
134
|
+
"""DB-free log-outbound over the s4l.ai API.
|
|
135
|
+
|
|
136
|
+
Preserves log_outbound's behaviour for the LinkedIn/HTTP lane: --verified
|
|
137
|
+
gate, link-wrap guard, timeline gate, dedup guard, message insert,
|
|
138
|
+
conversation_status='active'. The reddit-only campaign suffix attribution
|
|
139
|
+
and dm_links.message_id backfill (driven by WRAP_MINTED_CODES, set only by
|
|
140
|
+
reddit_browser/twitter_browser) are no-ops on this lane because LinkedIn
|
|
141
|
+
never mints codes through a Python pre-pass; those rails run on the
|
|
142
|
+
DB-equipped machine. Returns the process exit behaviour via sys.exit on
|
|
143
|
+
block (matching the DB path's sys.exit(3))."""
|
|
144
|
+
import http_api
|
|
145
|
+
|
|
146
|
+
dm_id = args.dm_id
|
|
147
|
+
content = args.content
|
|
148
|
+
|
|
149
|
+
if not args.verified:
|
|
150
|
+
print(f" VERIFY BLOCKED: refusing to log outbound for DM #{dm_id} without "
|
|
151
|
+
f"--verified. Pass it only when the browser send tool returned verified=true.")
|
|
152
|
+
sys.exit(3)
|
|
153
|
+
|
|
154
|
+
if _http_link_wrap_guard(dm_id, content):
|
|
155
|
+
sys.exit(3)
|
|
156
|
+
|
|
157
|
+
resp = http_api.api_get(f"/api/v1/dms/{dm_id}", ok_on_404=True)
|
|
158
|
+
if resp.get("_not_found"):
|
|
159
|
+
print(f" ERROR: DM #{dm_id} not found")
|
|
160
|
+
sys.exit(3)
|
|
161
|
+
row = (resp.get("data") or {}).get("dm") or {}
|
|
162
|
+
|
|
163
|
+
# Timeline gate (mirror log_outbound:194-201).
|
|
164
|
+
cur_count = row.get("message_count") or 0
|
|
165
|
+
qual_status = row.get("qualification_status") or "pending"
|
|
166
|
+
icp_list = row.get("icp_matches") or []
|
|
167
|
+
if cur_count >= 3 and qual_status == "pending" and not icp_list:
|
|
168
|
+
print(f" TIMELINE BLOCKED: DM #{dm_id} is at msg {cur_count} with qualification_status=pending and empty icp_matches.")
|
|
169
|
+
print(f" Run Step 2.4 (set-icp-precheck for every project in $PROJECTS) before logging this outbound.")
|
|
170
|
+
print(f" If nothing in $PROJECTS plausibly fits this prospect, call set-qualification --status disqualified --notes 'reason' and retry.")
|
|
171
|
+
sys.exit(3)
|
|
172
|
+
|
|
173
|
+
# Dedup guard: block if the last message is already outbound.
|
|
174
|
+
msgs = (http_api.api_get(f"/api/v1/dms/{dm_id}/messages", {"limit": 1000}).get("data") or {}).get("messages") or []
|
|
175
|
+
if msgs and msgs[-1].get("direction") == "outbound":
|
|
176
|
+
print(f" DEDUP BLOCKED: Last message to {row.get('their_author')} (DM #{dm_id}) was already outbound. Skipping.")
|
|
177
|
+
sys.exit(3)
|
|
178
|
+
|
|
179
|
+
config = load_config()
|
|
180
|
+
author = args.author or get_our_account(config, row.get("platform"))
|
|
181
|
+
claude_session_id = os.environ.get("CLAUDE_SESSION_ID") or None
|
|
182
|
+
|
|
183
|
+
http_api.api_post(
|
|
184
|
+
f"/api/v1/dms/{dm_id}/messages",
|
|
185
|
+
{
|
|
186
|
+
"direction": "outbound",
|
|
187
|
+
"author": author,
|
|
188
|
+
"content": content,
|
|
189
|
+
"claude_session_id": claude_session_id,
|
|
190
|
+
"bump_to_needs_reply": False,
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
# log_outbound sets conversation_status='active' on every outbound.
|
|
194
|
+
http_api.api_patch(f"/api/v1/dms/{dm_id}", {"conversation_status": "active"})
|
|
195
|
+
|
|
196
|
+
print(f" Logged outbound to {row.get('their_author')} (DM #{dm_id})")
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _http_dispatch(args):
|
|
201
|
+
"""Route DB-free commands through the s4l.ai HTTP API.
|
|
202
|
+
|
|
203
|
+
Returns True when the command was handled (caller should return), False
|
|
204
|
+
when the command is not yet wired for the no-DATABASE_URL lane (caller
|
|
205
|
+
falls through to the clear "needs DB" error). Each branch prints the exact
|
|
206
|
+
same stdout the DB path emits so downstream shell parsing is unchanged.
|
|
207
|
+
"""
|
|
208
|
+
import http_api
|
|
209
|
+
|
|
210
|
+
cmd = args.command
|
|
211
|
+
dm_id = getattr(args, "dm_id", None)
|
|
212
|
+
|
|
213
|
+
if cmd == "mark-skipped":
|
|
214
|
+
http_api.api_patch(
|
|
215
|
+
f"/api/v1/dms/{dm_id}",
|
|
216
|
+
{"status": "skipped", "only_if_status": "pending", "skip_reason": args.reason},
|
|
217
|
+
)
|
|
218
|
+
print(f" Set status=skipped (reason: {args.reason}) for DM #{dm_id}")
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
if cmd == "set-icp-precheck":
|
|
222
|
+
body = {"project": args.project, "label": args.label}
|
|
223
|
+
if args.notes is not None:
|
|
224
|
+
body["notes"] = args.notes
|
|
225
|
+
http_api.api_post(f"/api/v1/dms/{dm_id}/icp-precheck", body)
|
|
226
|
+
suffix = f" (notes: {args.notes[:60]}...)" if args.notes else ""
|
|
227
|
+
print(f" Upserted icp_matches[{args.project}]={args.label} for DM #{dm_id}{suffix}")
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
if cmd == "set-tier":
|
|
231
|
+
http_api.api_patch(f"/api/v1/dms/{dm_id}", {"tier": args.tier})
|
|
232
|
+
print(f" Set tier={args.tier} for DM #{dm_id}")
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
if cmd == "set-status":
|
|
236
|
+
http_api.api_patch(f"/api/v1/dms/{dm_id}", {"conversation_status": args.status})
|
|
237
|
+
print(f" Set conversation_status={args.status} for DM #{dm_id}")
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
if cmd == "set-interest":
|
|
241
|
+
http_api.api_patch(f"/api/v1/dms/{dm_id}", {"interest_level": args.interest})
|
|
242
|
+
print(f" Set interest_level={args.interest} for DM #{dm_id}")
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
if cmd == "set-mode":
|
|
246
|
+
http_api.api_patch(f"/api/v1/dms/{dm_id}", {"mode": args.mode})
|
|
247
|
+
print(f" Set mode={args.mode} for DM #{dm_id}")
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
if cmd == "set-project":
|
|
251
|
+
body = {"project_name": args.project}
|
|
252
|
+
if getattr(args, "append", False):
|
|
253
|
+
body["target_projects_add"] = args.project
|
|
254
|
+
http_api.api_patch(f"/api/v1/dms/{dm_id}", body)
|
|
255
|
+
extra = " (appended to target_projects)" if getattr(args, "append", False) else ""
|
|
256
|
+
print(f" Set project_name={args.project} for DM #{dm_id}{extra}")
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
if cmd == "set-target-project":
|
|
260
|
+
http_api.api_patch(
|
|
261
|
+
f"/api/v1/dms/{dm_id}",
|
|
262
|
+
{"target_project": args.project, "target_projects_add": args.project},
|
|
263
|
+
)
|
|
264
|
+
print(f" Set target_project={args.project} for DM #{dm_id} (target_projects union extended)")
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
if cmd == "set-qualification":
|
|
268
|
+
body = {"qualification_status": args.status}
|
|
269
|
+
if args.notes is not None:
|
|
270
|
+
body["qualification_notes"] = args.notes
|
|
271
|
+
http_api.api_patch(f"/api/v1/dms/{dm_id}", body)
|
|
272
|
+
suffix = f" (notes: {args.notes[:60]}...)" if args.notes else ""
|
|
273
|
+
print(f" Set qualification_status={args.status} for DM #{dm_id}{suffix}")
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
if cmd == "mark-booking-sent":
|
|
277
|
+
http_api.api_patch(f"/api/v1/dms/{dm_id}", {"booking_link_sent_at_now": True})
|
|
278
|
+
print(f" Set booking_link_sent_at=NOW() for DM #{dm_id}")
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
if cmd == "mark-inspected":
|
|
282
|
+
http_api.api_patch(f"/api/v1/dms/{dm_id}", {"last_inspected_at_now": True})
|
|
283
|
+
print(f" Marked DM #{dm_id} inspected at NOW()")
|
|
284
|
+
return True
|
|
285
|
+
|
|
286
|
+
if cmd == "set-url":
|
|
287
|
+
resp = http_api.api_get(f"/api/v1/dms/{dm_id}", ok_on_404=True)
|
|
288
|
+
if resp.get("_not_found"):
|
|
289
|
+
print(f" ERROR: DM #{dm_id} not found")
|
|
290
|
+
return True
|
|
291
|
+
platform = ((resp.get("data") or {}).get("dm") or {}).get("platform")
|
|
292
|
+
clean = _valid_chat_url(platform, args.url)
|
|
293
|
+
if args.url and not clean:
|
|
294
|
+
print(f" ERROR: '{args.url[:120]}' is not a valid {platform} DM-thread URL; refusing to save.")
|
|
295
|
+
print(f" Expected shapes: reddit=/chat/room/!..., x=/i/chat/..., linkedin=/messaging/thread/...")
|
|
296
|
+
sys.exit(2)
|
|
297
|
+
http_api.api_patch(f"/api/v1/dms/{dm_id}", {"chat_url": clean})
|
|
298
|
+
print(f" Set chat_url for DM #{dm_id}")
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
if cmd == "log-inbound":
|
|
302
|
+
body = {"direction": "inbound", "author": args.author, "content": args.content}
|
|
303
|
+
if getattr(args, "message_at", None):
|
|
304
|
+
body["message_at"] = args.message_at
|
|
305
|
+
if getattr(args, "event_id", None):
|
|
306
|
+
body["event_id"] = args.event_id
|
|
307
|
+
http_api.api_post(f"/api/v1/dms/{dm_id}/messages", body)
|
|
308
|
+
print(f" Logged inbound from {args.author} (DM #{dm_id})")
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
if cmd == "log-outbound":
|
|
312
|
+
return _http_log_outbound(args)
|
|
313
|
+
|
|
314
|
+
if cmd == "ensure-dm":
|
|
315
|
+
body = {"platform": args.platform, "author": args.author}
|
|
316
|
+
if getattr(args, "chat_url", None):
|
|
317
|
+
body["chat_url"] = args.chat_url
|
|
318
|
+
if getattr(args, "lookback_hours", None) is not None:
|
|
319
|
+
body["lookback_hours"] = args.lookback_hours
|
|
320
|
+
resp = http_api.api_post("/api/v1/dms/ensure", body)
|
|
321
|
+
data = resp.get("data") or {}
|
|
322
|
+
new_id = data.get("dm_id")
|
|
323
|
+
print(f"DM_ID={new_id}")
|
|
324
|
+
if data.get("created"):
|
|
325
|
+
linked = data.get("linked_reply_id")
|
|
326
|
+
if linked:
|
|
327
|
+
print(f" created (linked to replies.id={linked})")
|
|
328
|
+
else:
|
|
329
|
+
print(" created (no matching replies row within lookback, reply_id/post_id NULL)")
|
|
330
|
+
else:
|
|
331
|
+
print(" existing")
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
if cmd == "history":
|
|
335
|
+
resp = http_api.api_get(f"/api/v1/dms/{dm_id}", ok_on_404=True)
|
|
336
|
+
if resp.get("_not_found"):
|
|
337
|
+
print(f"DM #{dm_id} not found")
|
|
338
|
+
return True
|
|
339
|
+
dm = (resp.get("data") or {}).get("dm") or {}
|
|
340
|
+
print(f"=== DM #{dm.get('id')} with {dm.get('their_author')} [{dm.get('platform')}] ===")
|
|
341
|
+
print(f"Status: {dm.get('conversation_status')} Tier: {dm.get('tier')} Messages: {dm.get('message_count')}")
|
|
342
|
+
if dm.get("chat_url"):
|
|
343
|
+
print(f"Chat URL: {dm['chat_url']}")
|
|
344
|
+
if dm.get("comment_context"):
|
|
345
|
+
print(f"Original context: {dm['comment_context'][:200]}...")
|
|
346
|
+
print()
|
|
347
|
+
msgs_resp = http_api.api_get(f"/api/v1/dms/{dm_id}/messages")
|
|
348
|
+
msgs = (msgs_resp.get("data") or {}).get("messages") or []
|
|
349
|
+
for m in msgs:
|
|
350
|
+
arrow = ">>" if m.get("direction") == "outbound" else "<<"
|
|
351
|
+
ma = m.get("message_at") or ""
|
|
352
|
+
ts = str(ma)[:16].replace("T", " ") if ma else "?"
|
|
353
|
+
print(f" {arrow} [{ts}] {m.get('author')}: {m.get('content')}")
|
|
354
|
+
print()
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
if cmd == "pending":
|
|
358
|
+
resp = http_api.api_get("/api/v1/dms/pending")
|
|
359
|
+
rows = (resp.get("data") or {}).get("pending") or []
|
|
360
|
+
if not rows:
|
|
361
|
+
print("No conversations needing reply.")
|
|
362
|
+
return True
|
|
363
|
+
print(f"=== {len(rows)} conversations need reply ===\n")
|
|
364
|
+
for r in rows:
|
|
365
|
+
tier_label = f"T{r['tier']}" if r.get("tier") else "T1"
|
|
366
|
+
ma = r.get("last_message_at") or ""
|
|
367
|
+
ts = str(ma)[5:16].replace("T", " ") if ma else "?"
|
|
368
|
+
last = (r.get("last_msg") or "")[:100]
|
|
369
|
+
print(f" DM #{r['id']} [{r.get('platform')}] {r.get('their_author')} ({tier_label}, {r.get('message_count')} msgs, last: {ts})")
|
|
370
|
+
print(f" Last: {last}")
|
|
371
|
+
if r.get("chat_url"):
|
|
372
|
+
print(f" URL: {r['chat_url']}")
|
|
373
|
+
print()
|
|
374
|
+
return True
|
|
375
|
+
|
|
376
|
+
if cmd == "show-flagged":
|
|
377
|
+
resp = http_api.api_get("/api/v1/dms/flagged")
|
|
378
|
+
rows = (resp.get("data") or {}).get("flagged") or []
|
|
379
|
+
if not rows:
|
|
380
|
+
print("No conversations flagged for human attention.")
|
|
381
|
+
return True
|
|
382
|
+
print(f"=== {len(rows)} conversations need HUMAN attention ===\n")
|
|
383
|
+
for r in rows:
|
|
384
|
+
fa = r.get("flagged_at") or ""
|
|
385
|
+
ts = str(fa)[5:16].replace("T", " ") if fa else "?"
|
|
386
|
+
last = (r.get("last_msg") or "")[:150]
|
|
387
|
+
print(f" DM #{r['id']} [{r.get('platform')}] {r.get('their_author')} (T{r.get('tier') or 1}, {r.get('message_count')} msgs)")
|
|
388
|
+
print(f" REASON: {r.get('human_reason')}")
|
|
389
|
+
print(f" Flagged: {ts}")
|
|
390
|
+
print(f" Last msg ({r.get('last_dir')}): {last}")
|
|
391
|
+
if r.get("chat_url"):
|
|
392
|
+
print(f" URL: {r['chat_url']}")
|
|
393
|
+
print()
|
|
394
|
+
return True
|
|
395
|
+
|
|
396
|
+
if cmd == "flag-human":
|
|
397
|
+
resp = http_api.api_post(
|
|
398
|
+
f"/api/v1/dms/{dm_id}/flag-human", {"reason": args.reason}, ok_on_conflict=True
|
|
399
|
+
)
|
|
400
|
+
data = resp.get("data") or {}
|
|
401
|
+
if data.get("skipped"):
|
|
402
|
+
print(f" SKIP flag-human: DM #{dm_id} last message is OUTBOUND. We already replied; ball is in their court. Reason was: {args.reason}")
|
|
403
|
+
return True
|
|
404
|
+
print(f" FLAGGED DM #{dm_id} for human attention: {args.reason}")
|
|
405
|
+
if data.get("email_sent"):
|
|
406
|
+
print(f" Escalation email sent for DM #{dm_id}")
|
|
407
|
+
else:
|
|
408
|
+
print(f" WARNING: escalation email not sent for DM #{dm_id} (no RESEND_API_KEY on server, or send failed)")
|
|
409
|
+
return True
|
|
410
|
+
|
|
411
|
+
if cmd == "backfill-urls":
|
|
412
|
+
records = _load_records_arg(args)
|
|
413
|
+
resp = http_api.api_post(
|
|
414
|
+
"/api/v1/dms/backfill-urls",
|
|
415
|
+
{"platform": args.platform, "records": records},
|
|
416
|
+
)
|
|
417
|
+
stats = (resp.get("data") or {}).get("stats") or {}
|
|
418
|
+
print(f" backfill-urls [{args.platform}]: updated={stats.get('updated', 0)} "
|
|
419
|
+
f"already_set={stats.get('skipped_already_set', 0)} no_match={stats.get('no_match', 0)} "
|
|
420
|
+
f"invalid={stats.get('skipped_invalid', 0)} ambiguous={stats.get('ambiguous', 0)}")
|
|
421
|
+
return True
|
|
422
|
+
|
|
423
|
+
if cmd == "filter-inbox":
|
|
424
|
+
records = _load_records_arg(args)
|
|
425
|
+
resp = http_api.api_post(
|
|
426
|
+
"/api/v1/dms/filter-inbox",
|
|
427
|
+
{"platform": args.platform, "records": records},
|
|
428
|
+
)
|
|
429
|
+
data = resp.get("data") or {}
|
|
430
|
+
keep = data.get("keep") or []
|
|
431
|
+
counters = data.get("counters") or {}
|
|
432
|
+
norm = "x" if args.platform in ("twitter", "x") else args.platform
|
|
433
|
+
total_in = data.get("in", len(records))
|
|
434
|
+
total_keep = data.get("kept", len(keep))
|
|
435
|
+
print(
|
|
436
|
+
f" filter-inbox [{norm}]: in={total_in} kept={total_keep} "
|
|
437
|
+
f"(unread={counters.get('kept_unread', 0)}, "
|
|
438
|
+
f"no_db_row={counters.get('kept_no_db_row', 0)}, "
|
|
439
|
+
f"ambiguous={counters.get('kept_ambiguous', 0)}) "
|
|
440
|
+
f"skipped={total_in - total_keep} "
|
|
441
|
+
f"(is_from_us={counters.get('skip_is_from_us', 0)}, "
|
|
442
|
+
f"we_replied_after={counters.get('skip_we_replied_after', 0)}, "
|
|
443
|
+
f"recently_inspected={counters.get('skip_recently_inspected', 0)}, "
|
|
444
|
+
f"needs_human={counters.get('skip_needs_human', 0)}, "
|
|
445
|
+
f"closed={counters.get('skip_closed', 0)}, "
|
|
446
|
+
f"invalid_url={counters.get('skip_invalid_url', 0)})",
|
|
447
|
+
file=sys.stderr,
|
|
448
|
+
)
|
|
449
|
+
print(json.dumps(keep, default=str))
|
|
450
|
+
return True
|
|
451
|
+
|
|
452
|
+
if cmd == "find":
|
|
453
|
+
resp = http_api.api_get("/api/v1/dms/find", query={"author": args.author})
|
|
454
|
+
rows = (resp.get("data") or {}).get("matches") or []
|
|
455
|
+
if not rows:
|
|
456
|
+
print(f"No DMs found matching '{args.author}'")
|
|
457
|
+
return True
|
|
458
|
+
for r in rows:
|
|
459
|
+
ma = r.get("last_message_at") or ""
|
|
460
|
+
# API returns ISO 'YYYY-MM-DDTHH:MM:...'; DB path printed '%m/%d %H:%M'.
|
|
461
|
+
ts = (str(ma)[5:16].replace("T", " ").replace("-", "/")) if ma else "never"
|
|
462
|
+
print(f" DM #{r['id']} [{r.get('platform')}] {r.get('their_author')} - "
|
|
463
|
+
f"{r.get('status')}/{r.get('conversation_status')} T{r.get('tier') or 1} "
|
|
464
|
+
f"({r.get('message_count')} msgs, last: {ts})")
|
|
465
|
+
if r.get("chat_url"):
|
|
466
|
+
print(f" URL: {r['chat_url']}")
|
|
467
|
+
return True
|
|
468
|
+
|
|
469
|
+
if cmd == "summary":
|
|
470
|
+
resp = http_api.api_get("/api/v1/dms/summary")
|
|
471
|
+
s = (resp.get("data") or {}).get("summary") or {}
|
|
472
|
+
print("=== DM Pipeline Summary ===")
|
|
473
|
+
print(f" Conversations: {s.get('total', 0)} total ({s.get('sent', 0)} sent, {s.get('skipped', 0)} skipped)")
|
|
474
|
+
print(f" Unique authors: {s.get('unique_authors', 0)}")
|
|
475
|
+
print(f" Status: {s.get('needs_reply', 0)} needs_reply, {s.get('active', 0)} active, "
|
|
476
|
+
f"{s.get('converted', 0)} converted, {s.get('stale', 0)} stale")
|
|
477
|
+
print(f" Tiers: {s.get('tier2', 0)} at T2, {s.get('tier3', 0)} at T3")
|
|
478
|
+
print(f" Messages: {s.get('total_messages', 0)} total ({s.get('outbound', 0)} outbound, "
|
|
479
|
+
f"{s.get('inbound', 0)} inbound)")
|
|
480
|
+
print(f" Reply rate: {s.get('conversations_with_replies', 0)}/{s.get('sent', 0)} "
|
|
481
|
+
f"conversations have inbound replies")
|
|
482
|
+
print()
|
|
483
|
+
return True
|
|
484
|
+
|
|
485
|
+
if cmd == "send-escalation-email":
|
|
486
|
+
resp = http_api.api_post(
|
|
487
|
+
f"/api/v1/dms/{dm_id}/send-escalation-email", {}, ok_on_404=True
|
|
488
|
+
)
|
|
489
|
+
if resp.get("_not_found"):
|
|
490
|
+
print(f"ERROR: DM #{dm_id} not found")
|
|
491
|
+
return True
|
|
492
|
+
data = resp.get("data") or {}
|
|
493
|
+
if data.get("status_warning"):
|
|
494
|
+
print(f"WARNING: DM #{dm_id} is '{data.get('conversation_status')}', not 'needs_human'. Sending anyway.")
|
|
495
|
+
if data.get("email_sent"):
|
|
496
|
+
print(f" Escalation email sent for DM #{dm_id}")
|
|
497
|
+
else:
|
|
498
|
+
print(f" WARNING: escalation email not sent for DM #{dm_id} (no RESEND_API_KEY on server, or send failed)")
|
|
499
|
+
return True
|
|
500
|
+
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _load_records_arg(args):
|
|
505
|
+
"""Read a JSON array of records from --file or stdin for the bulk commands
|
|
506
|
+
(backfill-urls, filter-inbox), matching the DB-path's input handling so the
|
|
507
|
+
HTTP lane accepts the exact same scanner dumps. Returns a list (possibly
|
|
508
|
+
empty); exits 2 on unparseable input."""
|
|
509
|
+
raw = open(args.file).read() if getattr(args, "file", None) else sys.stdin.read()
|
|
510
|
+
try:
|
|
511
|
+
records = json.loads(raw)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
print(f"ERROR: could not parse JSON input: {e}", file=sys.stderr)
|
|
514
|
+
sys.exit(2)
|
|
515
|
+
if isinstance(records, dict):
|
|
516
|
+
for k in ("conversations", "threads", "dms", "items"):
|
|
517
|
+
if k in records and isinstance(records[k], list):
|
|
518
|
+
return records[k]
|
|
519
|
+
if records.get("ok") is False:
|
|
520
|
+
return []
|
|
521
|
+
if not isinstance(records, list):
|
|
522
|
+
print("ERROR: expected a JSON array of records", file=sys.stderr)
|
|
523
|
+
sys.exit(2)
|
|
524
|
+
return records
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def main():
|
|
528
|
+
parser = argparse.ArgumentParser(description="DM conversation tracker")
|
|
529
|
+
sub = parser.add_subparsers(dest="command")
|
|
530
|
+
|
|
531
|
+
p_out = sub.add_parser("log-outbound", help="Log outbound message")
|
|
532
|
+
p_out.add_argument("--dm-id", type=int, required=True)
|
|
533
|
+
p_out.add_argument("--content", required=True)
|
|
534
|
+
p_out.add_argument("--author")
|
|
535
|
+
p_out.add_argument(
|
|
536
|
+
"--verified",
|
|
537
|
+
action="store_true",
|
|
538
|
+
help="REQUIRED. Confirms the browser send_dm/compose_dm tool returned verified=true.",
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
p_ensure = sub.add_parser("ensure-dm",
|
|
542
|
+
help="Return dm_id for (platform, author), creating the row and auto-linking reply_id/post_id from the most recent matching replies row. Prints DM_ID=<n> on stdout.")
|
|
543
|
+
p_ensure.add_argument("--platform", required=True, choices=["reddit", "linkedin", "x", "twitter"])
|
|
544
|
+
p_ensure.add_argument("--author", required=True)
|
|
545
|
+
p_ensure.add_argument("--chat-url", default=None,
|
|
546
|
+
help="Optional chat URL to stamp on the DM row (set only if currently NULL).")
|
|
547
|
+
p_ensure.add_argument("--lookback-hours", type=int, default=720,
|
|
548
|
+
help="How far back to search for a matching replies row when auto-linking (default 720h = 30d).")
|
|
549
|
+
|
|
550
|
+
p_in = sub.add_parser("log-inbound", help="Log inbound message")
|
|
551
|
+
p_in.add_argument("--dm-id", type=int, required=True)
|
|
552
|
+
p_in.add_argument("--author", required=True)
|
|
553
|
+
p_in.add_argument("--content", required=True)
|
|
554
|
+
p_in.add_argument("--message-at", help="ISO timestamp (platform-provided); falls back to NOW() if omitted.")
|
|
555
|
+
p_in.add_argument("--event-id", help="Platform-native unique message id (e.g., Matrix $... event_id). When supplied, dedup is by event_id instead of content match.")
|
|
556
|
+
|
|
557
|
+
p_hist = sub.add_parser("history", help="Show conversation history")
|
|
558
|
+
p_hist.add_argument("--dm-id", type=int, required=True)
|
|
559
|
+
|
|
560
|
+
sub.add_parser("pending", help="Show conversations needing reply")
|
|
561
|
+
|
|
562
|
+
p_find = sub.add_parser("find", help="Find DM by author")
|
|
563
|
+
p_find.add_argument("--author", required=True)
|
|
564
|
+
|
|
565
|
+
sub.add_parser("summary", help="Pipeline summary")
|
|
566
|
+
|
|
567
|
+
p_url = sub.add_parser("set-url", help="Set chat URL")
|
|
568
|
+
p_url.add_argument("--dm-id", type=int, required=True)
|
|
569
|
+
p_url.add_argument("--url", required=True)
|
|
570
|
+
|
|
571
|
+
p_backfill = sub.add_parser("backfill-urls",
|
|
572
|
+
help=("Bulk-stamp chat_url onto orphan dms rows from a scanner JSON dump. "
|
|
573
|
+
"Input: a JSON array of {author|handle, chat_url|thread_url} on stdin or --file."))
|
|
574
|
+
p_backfill.add_argument("--platform", required=True, choices=["reddit", "linkedin", "x", "twitter"])
|
|
575
|
+
p_backfill.add_argument("--file", default=None,
|
|
576
|
+
help="Path to JSON file. If omitted, reads from stdin.")
|
|
577
|
+
|
|
578
|
+
p_filter = sub.add_parser("filter-inbox",
|
|
579
|
+
help=("Filter a sidebar scan dump down to threads that need inspection. "
|
|
580
|
+
"Combines sidebar signals (is_from_us, has_unread, time) with the "
|
|
581
|
+
"DB's last outbound message_at to drop threads where we already "
|
|
582
|
+
"sent the most recent message. "
|
|
583
|
+
"Input: JSON array on stdin or --file. "
|
|
584
|
+
"Output: filtered JSON array on stdout, summary on stderr."))
|
|
585
|
+
p_filter.add_argument("--platform", required=True, choices=["reddit", "linkedin", "x", "twitter"])
|
|
586
|
+
p_filter.add_argument("--file", default=None,
|
|
587
|
+
help="Path to JSON file. If omitted, reads from stdin.")
|
|
588
|
+
|
|
589
|
+
p_inspect = sub.add_parser("mark-inspected",
|
|
590
|
+
help=("Stamp NOW() onto dms.last_inspected_at after a read-conversation "
|
|
591
|
+
"call confirmed there is no new content to log. The next "
|
|
592
|
+
"filter-inbox run will skip this thread for 24h unless a fresh "
|
|
593
|
+
"outbound or inbound is logged in the meantime."))
|
|
594
|
+
p_inspect.add_argument("--dm-id", type=int, required=True)
|
|
595
|
+
|
|
596
|
+
p_tier = sub.add_parser("set-tier", help="Set conversation tier")
|
|
597
|
+
p_tier.add_argument("--dm-id", type=int, required=True)
|
|
598
|
+
p_tier.add_argument("--tier", type=int, required=True, choices=[1, 2, 3])
|
|
599
|
+
|
|
600
|
+
p_status = sub.add_parser("set-status", help="Set conversation status")
|
|
601
|
+
p_status.add_argument("--dm-id", type=int, required=True)
|
|
602
|
+
p_status.add_argument("--status", required=True,
|
|
603
|
+
choices=["active", "needs_reply", "stale", "converted", "closed", "needs_human"])
|
|
604
|
+
|
|
605
|
+
p_interest = sub.add_parser("set-interest", help="Set prospect interest level for product/topic")
|
|
606
|
+
p_interest.add_argument("--dm-id", type=int, required=True)
|
|
607
|
+
p_interest.add_argument("--interest", required=True,
|
|
608
|
+
choices=["no_response", "general_discussion", "cold", "warm", "hot", "declined", "not_our_prospect"])
|
|
609
|
+
|
|
610
|
+
p_mode = sub.add_parser("set-mode", help="Set per-turn conversational posture (rapport vs pitch). Reversible.")
|
|
611
|
+
p_mode.add_argument("--dm-id", type=int, required=True)
|
|
612
|
+
p_mode.add_argument("--mode", required=True, choices=["rapport", "pitch"])
|
|
613
|
+
|
|
614
|
+
p_flag = sub.add_parser("flag-human", help="Flag conversation for human attention")
|
|
615
|
+
p_flag.add_argument("--dm-id", type=int, required=True)
|
|
616
|
+
p_flag.add_argument("--reason", required=True)
|
|
617
|
+
|
|
618
|
+
sub.add_parser("show-flagged", help="Show conversations needing human attention")
|
|
619
|
+
|
|
620
|
+
p_resend = sub.add_parser("send-escalation-email",
|
|
621
|
+
help="Re-send the escalation email for an already-flagged DM (for testing / manual retry)")
|
|
622
|
+
p_resend.add_argument("--dm-id", type=int, required=True)
|
|
623
|
+
|
|
624
|
+
p_proj = sub.add_parser("set-project", help="Set project_name (project we recommended)")
|
|
625
|
+
p_proj.add_argument("--dm-id", type=int, required=True)
|
|
626
|
+
p_proj.add_argument("--project", required=True)
|
|
627
|
+
p_proj.add_argument("--append", action="store_true",
|
|
628
|
+
help="Also add to target_projects[] (the union of pursued projects)")
|
|
629
|
+
|
|
630
|
+
p_tproj = sub.add_parser("set-target-project",
|
|
631
|
+
help="Set primary target_project AND extend target_projects[] (always)")
|
|
632
|
+
p_tproj.add_argument("--dm-id", type=int, required=True)
|
|
633
|
+
p_tproj.add_argument("--project", required=True)
|
|
634
|
+
p_tproj.add_argument("--append", action="store_true",
|
|
635
|
+
help="Explicit caller intent (semantic no-op: union always grows)")
|
|
636
|
+
|
|
637
|
+
p_qual = sub.add_parser("set-qualification", help="Set qualification_status and optional notes")
|
|
638
|
+
p_qual.add_argument("--dm-id", type=int, required=True)
|
|
639
|
+
p_qual.add_argument("--status", required=True,
|
|
640
|
+
choices=["pending", "asked", "answered", "qualified", "disqualified"])
|
|
641
|
+
p_qual.add_argument("--notes", default=None)
|
|
642
|
+
|
|
643
|
+
p_book = sub.add_parser("mark-booking-sent", help="Record that a booking link was shared")
|
|
644
|
+
p_book.add_argument("--dm-id", type=int, required=True)
|
|
645
|
+
|
|
646
|
+
p_skip = sub.add_parser("mark-skipped", help="Skip a pending outreach DM (sets status=skipped). No-op on non-pending rows.")
|
|
647
|
+
p_skip.add_argument("--dm-id", type=int, required=True)
|
|
648
|
+
p_skip.add_argument("--reason", required=True)
|
|
649
|
+
|
|
650
|
+
p_icp = sub.add_parser("set-icp-precheck", help="Upsert per-project ICP verdict into icp_matches array (no filter)")
|
|
651
|
+
p_icp.add_argument("--dm-id", type=int, required=True)
|
|
652
|
+
p_icp.add_argument("--label", required=True,
|
|
653
|
+
choices=["icp_match", "icp_miss", "disqualified", "unknown"])
|
|
654
|
+
p_icp.add_argument("--project", required=True,
|
|
655
|
+
help="Project name from config.json (e.g., 'mk0r', 'Assrt')")
|
|
656
|
+
p_icp.add_argument("--notes", default=None)
|
|
657
|
+
|
|
658
|
+
args = parser.parse_args()
|
|
659
|
+
|
|
660
|
+
if not args.command:
|
|
661
|
+
parser.print_help()
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
dbmod.load_env()
|
|
665
|
+
|
|
666
|
+
# HTTP-only lane: every command routes through the s4l.ai HTTP API. The
|
|
667
|
+
# direct-Postgres lane was removed 2026-06-01 — there is NO database-driven
|
|
668
|
+
# path any more, not as primary, not as fallback. DATABASE_URL, if present
|
|
669
|
+
# in the environment, is deliberately ignored; all reads/writes go through
|
|
670
|
+
# _http_dispatch against /api/v1/*.
|
|
671
|
+
if _http_dispatch(args):
|
|
672
|
+
return
|
|
673
|
+
print(
|
|
674
|
+
f"ERROR: '{args.command}' is not wired for the HTTP API lane. "
|
|
675
|
+
f"Extend _http_dispatch in dm_conversation.py — there is no DB fallback.",
|
|
676
|
+
file=sys.stderr,
|
|
677
|
+
)
|
|
678
|
+
sys.exit(1)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
if __name__ == "__main__":
|
|
682
|
+
main()
|