@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,320 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Moltbook post/comment helper with automatic verification.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 scripts/moltbook_post.py post --title "..." --content "..." [--submolt technology]
|
|
7
|
+
python3 scripts/moltbook_post.py comment --post-id UUID --content "..."
|
|
8
|
+
|
|
9
|
+
Handles the obfuscated lobster math CAPTCHA automatically.
|
|
10
|
+
"""
|
|
11
|
+
import requests, json, re, sys, os, argparse, time
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
+
try:
|
|
15
|
+
from moltbook_tools import note_rate_limited as _note_rate_limited
|
|
16
|
+
except Exception:
|
|
17
|
+
def _note_rate_limited(_retry):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_api_key():
|
|
22
|
+
key = os.environ.get("MOLTBOOK_API_KEY")
|
|
23
|
+
if not key:
|
|
24
|
+
env_file = os.path.expanduser("~/social-autoposter/.env")
|
|
25
|
+
if os.path.exists(env_file):
|
|
26
|
+
with open(env_file) as f:
|
|
27
|
+
for line in f:
|
|
28
|
+
if line.startswith("MOLTBOOK_API_KEY="):
|
|
29
|
+
key = line.strip().split("=", 1)[1]
|
|
30
|
+
break
|
|
31
|
+
if not key:
|
|
32
|
+
print("ERROR: MOLTBOOK_API_KEY not found", file=sys.stderr)
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
return key
|
|
35
|
+
|
|
36
|
+
BASE = "https://www.moltbook.com/api/v1"
|
|
37
|
+
|
|
38
|
+
NUMBER_WORDS = {
|
|
39
|
+
'zero':0,'one':1,'two':2,'three':3,'four':4,'five':5,'six':6,'seven':7,
|
|
40
|
+
'eight':8,'nine':9,'ten':10,'eleven':11,'twelve':12,'thirteen':13,
|
|
41
|
+
'fourteen':14,'fifteen':15,'sixteen':16,'seventeen':17,'eighteen':18,
|
|
42
|
+
'nineteen':19,'twenty':20,'thirty':30,'forty':40,'fifty':50,'sixty':60,
|
|
43
|
+
'seventy':70,'eighty':80,'ninety':90
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def solve_challenge(challenge_text):
|
|
47
|
+
"""Solve Moltbook's obfuscated lobster math CAPTCHA.
|
|
48
|
+
|
|
49
|
+
Strategy: strip ALL non-alpha chars (handles fragmented words like "tH iR tY"),
|
|
50
|
+
scan for number words using greedy longest-first matching, detect operation,
|
|
51
|
+
try all number pairs with all operations via brute force if needed.
|
|
52
|
+
"""
|
|
53
|
+
# Strip non-alpha, join everything
|
|
54
|
+
nospace = re.sub(r'[^a-zA-Z]', '', challenge_text).lower()
|
|
55
|
+
|
|
56
|
+
# Build regex patterns that match each number word with optional repeated chars
|
|
57
|
+
# e.g., "three" -> "t+h+r+e+e+" matches "tthhrreeee"
|
|
58
|
+
def make_fuzzy_pattern(word):
|
|
59
|
+
return ''.join(c + '+' for c in word)
|
|
60
|
+
|
|
61
|
+
sorted_words = sorted(NUMBER_WORDS.keys(), key=len, reverse=True)
|
|
62
|
+
fuzzy_patterns = [(w, re.compile(make_fuzzy_pattern(w))) for w in sorted_words]
|
|
63
|
+
|
|
64
|
+
# Scan for number words using fuzzy matching on the raw stripped text
|
|
65
|
+
nums_raw = []
|
|
66
|
+
remaining = nospace
|
|
67
|
+
while remaining:
|
|
68
|
+
found = False
|
|
69
|
+
for word, pattern in fuzzy_patterns:
|
|
70
|
+
m = pattern.match(remaining)
|
|
71
|
+
if m:
|
|
72
|
+
nums_raw.append(NUMBER_WORDS[word])
|
|
73
|
+
remaining = remaining[m.end():]
|
|
74
|
+
found = True
|
|
75
|
+
break
|
|
76
|
+
if not found:
|
|
77
|
+
remaining = remaining[1:]
|
|
78
|
+
|
|
79
|
+
# Combine tens+ones (e.g., twenty + three = 23)
|
|
80
|
+
nums = []
|
|
81
|
+
i = 0
|
|
82
|
+
while i < len(nums_raw):
|
|
83
|
+
val = nums_raw[i]
|
|
84
|
+
if val >= 20 and val < 100 and i+1 < len(nums_raw) and nums_raw[i+1] < 10:
|
|
85
|
+
nums.append(val + nums_raw[i+1])
|
|
86
|
+
i += 2
|
|
87
|
+
else:
|
|
88
|
+
nums.append(val)
|
|
89
|
+
i += 1
|
|
90
|
+
|
|
91
|
+
# Filter to reasonable candidates (5-999)
|
|
92
|
+
candidates = [n for n in nums if 5 <= n <= 999]
|
|
93
|
+
if len(candidates) < 2:
|
|
94
|
+
candidates = [n for n in nums if n > 0]
|
|
95
|
+
|
|
96
|
+
# Detect primary operation (check raw, stripped, and deduped text)
|
|
97
|
+
lower = challenge_text.lower()
|
|
98
|
+
stripped_lower = nospace # already lowercase stripped
|
|
99
|
+
deduped_lower = re.sub(r'(.)\1+', r'\1', stripped_lower)
|
|
100
|
+
check_texts = [lower, stripped_lower, deduped_lower]
|
|
101
|
+
if any(any(w in t for t in check_texts) for w in ['multipl', 'product', 'times', 'triple', 'double']) or '*' in challenge_text:
|
|
102
|
+
primary = 'mul'
|
|
103
|
+
elif any(any(w in t for t in check_texts) for w in ['differ', 'subtract', 'less', 'minus', 'remain', 'reduc', 'loses', 'lose', 'lost', 'slow']):
|
|
104
|
+
primary = 'sub'
|
|
105
|
+
else:
|
|
106
|
+
primary = 'add'
|
|
107
|
+
|
|
108
|
+
return candidates, primary
|
|
109
|
+
|
|
110
|
+
def verify_with_brute_force(candidates, primary_op, verification_code, headers):
|
|
111
|
+
"""Try all number pair + operation combinations to verify."""
|
|
112
|
+
ops = {
|
|
113
|
+
'add': lambda a, b: a + b,
|
|
114
|
+
'sub': lambda a, b: abs(a - b),
|
|
115
|
+
'mul': lambda a, b: a * b,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Try primary op first with last two candidates
|
|
119
|
+
op_order = [primary_op] + [o for o in ['add', 'sub', 'mul'] if o != primary_op]
|
|
120
|
+
|
|
121
|
+
for op_name in op_order:
|
|
122
|
+
for i in range(len(candidates)):
|
|
123
|
+
for j in range(len(candidates)):
|
|
124
|
+
if i == j:
|
|
125
|
+
continue
|
|
126
|
+
a, b = candidates[i], candidates[j]
|
|
127
|
+
answer = f"{ops[op_name](a, b):.2f}"
|
|
128
|
+
try:
|
|
129
|
+
r = requests.post(
|
|
130
|
+
f"{BASE}/verify",
|
|
131
|
+
headers=headers,
|
|
132
|
+
json={"answer": answer, "verification_code": verification_code},
|
|
133
|
+
timeout=15,
|
|
134
|
+
)
|
|
135
|
+
if r.json().get("success"):
|
|
136
|
+
return True, answer, f"{op_name}({a},{b})"
|
|
137
|
+
except Exception:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
return False, None, None
|
|
141
|
+
|
|
142
|
+
def create_post(title, content, submolt, api_key):
|
|
143
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
144
|
+
|
|
145
|
+
r = requests.post(
|
|
146
|
+
f"{BASE}/posts",
|
|
147
|
+
headers=headers,
|
|
148
|
+
json={"title": title, "content": content, "type": "text", "submolt_name": submolt},
|
|
149
|
+
timeout=30,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if r.status_code == 429:
|
|
153
|
+
retry = r.json().get("retry_after_seconds", 160)
|
|
154
|
+
_note_rate_limited(retry)
|
|
155
|
+
print(f"Rate limited. Retry after {retry}s", file=sys.stderr)
|
|
156
|
+
sys.exit(2)
|
|
157
|
+
|
|
158
|
+
d = r.json()
|
|
159
|
+
if not d.get("success"):
|
|
160
|
+
print(f"Create failed: {d.get('message', '')}", file=sys.stderr)
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
post = d["post"]
|
|
164
|
+
post_id = post["id"]
|
|
165
|
+
verification = post.get("verification", {})
|
|
166
|
+
challenge = verification.get("challenge_text", "")
|
|
167
|
+
code = verification.get("verification_code", "")
|
|
168
|
+
|
|
169
|
+
print(f"Post created: {post_id}")
|
|
170
|
+
|
|
171
|
+
if not challenge or not code:
|
|
172
|
+
print("No verification challenge (unexpected)")
|
|
173
|
+
return post_id, False
|
|
174
|
+
|
|
175
|
+
print(f"Challenge: {challenge}")
|
|
176
|
+
|
|
177
|
+
candidates, primary_op = solve_challenge(challenge)
|
|
178
|
+
print(f"Numbers found: {candidates}, primary op: {primary_op}")
|
|
179
|
+
|
|
180
|
+
if len(candidates) < 2:
|
|
181
|
+
print("ERROR: Could not find enough numbers in challenge", file=sys.stderr)
|
|
182
|
+
return post_id, False
|
|
183
|
+
|
|
184
|
+
ok, answer, expr = verify_with_brute_force(candidates, primary_op, code, headers)
|
|
185
|
+
if ok:
|
|
186
|
+
print(f"VERIFIED: {expr} = {answer}")
|
|
187
|
+
return post_id, True
|
|
188
|
+
else:
|
|
189
|
+
print("VERIFICATION FAILED - delete and retry", file=sys.stderr)
|
|
190
|
+
return post_id, False
|
|
191
|
+
|
|
192
|
+
def create_comment(post_id, content, api_key, parent_id=None):
|
|
193
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
194
|
+
|
|
195
|
+
body = {"content": content}
|
|
196
|
+
if parent_id:
|
|
197
|
+
body["parent_id"] = parent_id
|
|
198
|
+
|
|
199
|
+
r = requests.post(
|
|
200
|
+
f"{BASE}/posts/{post_id}/comments",
|
|
201
|
+
headers=headers,
|
|
202
|
+
json=body,
|
|
203
|
+
timeout=30,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if r.status_code == 429:
|
|
207
|
+
retry = r.json().get("retry_after_seconds", 160)
|
|
208
|
+
_note_rate_limited(retry)
|
|
209
|
+
print(f"Rate limited. Retry after {retry}s", file=sys.stderr)
|
|
210
|
+
sys.exit(2)
|
|
211
|
+
|
|
212
|
+
d = r.json()
|
|
213
|
+
if not d.get("success"):
|
|
214
|
+
msg_raw = d.get("message", "")
|
|
215
|
+
msg = " ".join(msg_raw) if isinstance(msg_raw, list) else str(msg_raw)
|
|
216
|
+
if "suspend" in msg.lower():
|
|
217
|
+
print(f"SUSPENDED: {msg}", file=sys.stderr)
|
|
218
|
+
sys.exit(3)
|
|
219
|
+
print(f"Comment failed: {msg}", file=sys.stderr)
|
|
220
|
+
sys.exit(1)
|
|
221
|
+
|
|
222
|
+
comment = d.get("comment", d)
|
|
223
|
+
comment_id = comment.get("id", "?")
|
|
224
|
+
print(f"Comment created: {comment_id}")
|
|
225
|
+
|
|
226
|
+
# Comments require verification
|
|
227
|
+
verification = comment.get("verification", d.get("verification", {}))
|
|
228
|
+
if verification.get("challenge_text") and verification.get("verification_code"):
|
|
229
|
+
challenge_text = verification["challenge_text"]
|
|
230
|
+
ver_code = verification["verification_code"]
|
|
231
|
+
print(f"Challenge: {challenge_text}")
|
|
232
|
+
candidates, primary_op = solve_challenge(challenge_text)
|
|
233
|
+
print(f"Numbers found: {candidates}, primary op: {primary_op}")
|
|
234
|
+
if len(candidates) < 2:
|
|
235
|
+
print("ERROR: Not enough numbers found", file=sys.stderr)
|
|
236
|
+
return comment_id, False
|
|
237
|
+
ok, answer, expr = verify_with_brute_force(
|
|
238
|
+
candidates, primary_op, ver_code, headers
|
|
239
|
+
)
|
|
240
|
+
if ok:
|
|
241
|
+
print(f"VERIFIED: {expr} = {answer}")
|
|
242
|
+
else:
|
|
243
|
+
print("VERIFICATION FAILED", file=sys.stderr)
|
|
244
|
+
return comment_id, False
|
|
245
|
+
|
|
246
|
+
return comment_id, True
|
|
247
|
+
|
|
248
|
+
def self_upvote(item_type, item_id, api_key):
|
|
249
|
+
"""Self-upvote a post or comment after verification."""
|
|
250
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
251
|
+
if item_type == "post":
|
|
252
|
+
url = f"{BASE}/posts/{item_id}/upvote"
|
|
253
|
+
else:
|
|
254
|
+
url = f"{BASE}/comments/{item_id}/upvote"
|
|
255
|
+
try:
|
|
256
|
+
r = requests.post(url, headers=headers, timeout=15)
|
|
257
|
+
d = r.json()
|
|
258
|
+
if d.get("success") or d.get("upvoted"):
|
|
259
|
+
print(f"Self-upvoted {item_type} {item_id[:12]}")
|
|
260
|
+
return True
|
|
261
|
+
retry = d.get("retry_after_seconds")
|
|
262
|
+
if retry:
|
|
263
|
+
_note_rate_limited(retry)
|
|
264
|
+
time.sleep(retry + 1)
|
|
265
|
+
r = requests.post(url, headers=headers, timeout=15)
|
|
266
|
+
if r.json().get("success") or r.json().get("upvoted"):
|
|
267
|
+
print(f"Self-upvoted {item_type} {item_id[:12]} (retry)")
|
|
268
|
+
return True
|
|
269
|
+
except Exception as e:
|
|
270
|
+
print(f"Upvote failed: {e}", file=sys.stderr)
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def main():
|
|
275
|
+
parser = argparse.ArgumentParser(description="Moltbook post/comment with auto-verification")
|
|
276
|
+
sub = parser.add_subparsers(dest="action")
|
|
277
|
+
|
|
278
|
+
post_p = sub.add_parser("post")
|
|
279
|
+
post_p.add_argument("--title", required=True)
|
|
280
|
+
post_p.add_argument("--content", required=True)
|
|
281
|
+
post_p.add_argument("--submolt", default="general")
|
|
282
|
+
post_p.add_argument("--no-upvote", action="store_true", help="Skip self-upvote")
|
|
283
|
+
|
|
284
|
+
comment_p = sub.add_parser("comment")
|
|
285
|
+
comment_p.add_argument("--post-id", required=True)
|
|
286
|
+
comment_p.add_argument("--content", required=True)
|
|
287
|
+
comment_p.add_argument("--parent-id", default=None,
|
|
288
|
+
help="UUID of comment to reply to (for threaded replies)")
|
|
289
|
+
comment_p.add_argument("--no-upvote", action="store_true", help="Skip self-upvote")
|
|
290
|
+
|
|
291
|
+
args = parser.parse_args()
|
|
292
|
+
api_key = get_api_key()
|
|
293
|
+
|
|
294
|
+
if args.action == "post":
|
|
295
|
+
post_id, verified = create_post(args.title, args.content, args.submolt, api_key)
|
|
296
|
+
if not verified:
|
|
297
|
+
print(f"Deleting unverified post {post_id}...")
|
|
298
|
+
requests.delete(
|
|
299
|
+
f"{BASE}/posts/{post_id}",
|
|
300
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
301
|
+
)
|
|
302
|
+
sys.exit(1)
|
|
303
|
+
if not args.no_upvote:
|
|
304
|
+
self_upvote("post", post_id, api_key)
|
|
305
|
+
url = f"https://www.moltbook.com/post/{post_id}"
|
|
306
|
+
print(json.dumps({"post_id": post_id, "verified": True, "url": url}))
|
|
307
|
+
elif args.action == "comment":
|
|
308
|
+
comment_id, ok = create_comment(args.post_id, args.content, api_key,
|
|
309
|
+
parent_id=args.parent_id)
|
|
310
|
+
if ok and not args.no_upvote:
|
|
311
|
+
self_upvote("comment", str(comment_id), api_key)
|
|
312
|
+
url = f"https://www.moltbook.com/post/{args.post_id}#{comment_id}"
|
|
313
|
+
print(json.dumps({"ok": bool(ok), "comment_id": str(comment_id),
|
|
314
|
+
"verified": ok, "url": url}))
|
|
315
|
+
else:
|
|
316
|
+
parser.print_help()
|
|
317
|
+
sys.exit(1)
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
main()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared Moltbook API helpers with cooperative cross-process rate-limit state.
|
|
3
|
+
|
|
4
|
+
Mirrors the pattern in scripts/reddit_tools.py so multiple concurrent Moltbook
|
|
5
|
+
callers (scan_moltbook_replies.py, stats.py, find_threads.py,
|
|
6
|
+
moltbook_post.py) back off together when any one of them hits a 429.
|
|
7
|
+
|
|
8
|
+
State file: /tmp/moltbook_ratelimit.json
|
|
9
|
+
{"remaining": int, "reset_at": epoch_seconds}
|
|
10
|
+
|
|
11
|
+
On 429, the Moltbook API returns a JSON body with `retry_after_seconds`.
|
|
12
|
+
We persist that reset into the shared file so the next caller (in any process)
|
|
13
|
+
can decide to wait inline or raise MoltbookRateLimitedError to exit early.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
RATELIMIT_FILE = "/tmp/moltbook_ratelimit.json"
|
|
25
|
+
|
|
26
|
+
# Same threshold as Reddit: resets under 90s are absorbed inline, longer resets
|
|
27
|
+
# raise MoltbookRateLimitedError so the caller exits rather than blocking a slot.
|
|
28
|
+
MAX_INLINE_WAIT_SECONDS = 90
|
|
29
|
+
|
|
30
|
+
# Default 429 retry when the server does not return retry_after_seconds.
|
|
31
|
+
DEFAULT_RETRY_SECONDS = 160
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MoltbookRateLimitedError(Exception):
|
|
35
|
+
"""Raised when Moltbook returns 429 and reset is longer than MAX_INLINE_WAIT_SECONDS."""
|
|
36
|
+
def __init__(self, reset_seconds):
|
|
37
|
+
self.reset_seconds = reset_seconds
|
|
38
|
+
super().__init__(f"moltbook_rate_limited_wait_{int(reset_seconds)}s")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HttpNotFoundError(Exception):
|
|
42
|
+
"""Raised when a Moltbook GET returns HTTP 404."""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_ratelimit():
|
|
47
|
+
try:
|
|
48
|
+
with open(RATELIMIT_FILE) as f:
|
|
49
|
+
return json.load(f)
|
|
50
|
+
except Exception:
|
|
51
|
+
return {"remaining": 100, "reset_at": 0}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_ratelimit(remaining, reset_seconds):
|
|
55
|
+
reset_at = time.time() + reset_seconds
|
|
56
|
+
try:
|
|
57
|
+
with open(RATELIMIT_FILE, "w") as f:
|
|
58
|
+
json.dump({"remaining": remaining, "reset_at": reset_at}, f)
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _wait_if_needed():
|
|
64
|
+
"""Block or raise before making a Moltbook request if a prior 429 is still pending."""
|
|
65
|
+
rl = _read_ratelimit()
|
|
66
|
+
if rl.get("remaining", 100) <= 2 and rl.get("reset_at", 0) > time.time():
|
|
67
|
+
wait = int(rl["reset_at"] - time.time()) + 2
|
|
68
|
+
if wait <= 0:
|
|
69
|
+
return
|
|
70
|
+
if wait > MAX_INLINE_WAIT_SECONDS:
|
|
71
|
+
raise MoltbookRateLimitedError(wait)
|
|
72
|
+
print(f"Moltbook rate limit cooling down, waiting {wait}s...", file=sys.stderr)
|
|
73
|
+
time.sleep(wait)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_retry_seconds(body_bytes):
|
|
77
|
+
"""Parse retry_after_seconds from a 429 response body."""
|
|
78
|
+
try:
|
|
79
|
+
payload = json.loads(body_bytes.decode("utf-8", errors="replace"))
|
|
80
|
+
retry = payload.get("retry_after_seconds")
|
|
81
|
+
if isinstance(retry, (int, float)) and retry > 0:
|
|
82
|
+
return float(retry)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
return float(DEFAULT_RETRY_SECONDS)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def note_rate_limited(retry_seconds):
|
|
89
|
+
"""Public: record a rate-limit event so other processes back off.
|
|
90
|
+
|
|
91
|
+
Used by moltbook_post.py (which uses the `requests` library) to feed
|
|
92
|
+
the shared state without rewriting its HTTP layer.
|
|
93
|
+
"""
|
|
94
|
+
_write_ratelimit(0, float(retry_seconds))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fetch_moltbook_json(url, api_key=None, headers=None,
|
|
98
|
+
user_agent="social-autoposter/1.0", timeout=15):
|
|
99
|
+
"""GET a Moltbook JSON endpoint with cooperative rate-limit handling.
|
|
100
|
+
|
|
101
|
+
- Waits or raises MoltbookRateLimitedError based on shared state before firing.
|
|
102
|
+
- On 200: clears the shared "near zero" signal.
|
|
103
|
+
- On 404: raises HttpNotFoundError (callers typically use this for deletion detection).
|
|
104
|
+
- On 429: persists retry_after_seconds. If <= MAX_INLINE_WAIT_SECONDS, sleeps
|
|
105
|
+
once and retries; otherwise raises MoltbookRateLimitedError.
|
|
106
|
+
- On other HTTPError / network error: prints and returns None (preserves the
|
|
107
|
+
existing callers' "return None on error" contract).
|
|
108
|
+
"""
|
|
109
|
+
_wait_if_needed()
|
|
110
|
+
|
|
111
|
+
hdrs = {"User-Agent": user_agent}
|
|
112
|
+
if api_key:
|
|
113
|
+
hdrs["Authorization"] = f"Bearer {api_key}"
|
|
114
|
+
if headers:
|
|
115
|
+
hdrs.update(headers)
|
|
116
|
+
|
|
117
|
+
req = urllib.request.Request(url, headers=hdrs)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
121
|
+
# Success: clear any lingering "near zero" from a stale 429.
|
|
122
|
+
_write_ratelimit(100, 0)
|
|
123
|
+
return json.loads(resp.read())
|
|
124
|
+
except urllib.error.HTTPError as e:
|
|
125
|
+
if e.code == 404:
|
|
126
|
+
raise HttpNotFoundError(url)
|
|
127
|
+
if e.code == 429:
|
|
128
|
+
body = e.read() if hasattr(e, "read") else b""
|
|
129
|
+
retry = _parse_retry_seconds(body)
|
|
130
|
+
_write_ratelimit(0, retry)
|
|
131
|
+
if retry > MAX_INLINE_WAIT_SECONDS:
|
|
132
|
+
raise MoltbookRateLimitedError(retry)
|
|
133
|
+
print(f"Moltbook 429, waiting {int(retry)+2}s... ({url})", file=sys.stderr)
|
|
134
|
+
time.sleep(int(retry) + 2)
|
|
135
|
+
# Single retry, propagate any errors from the retry.
|
|
136
|
+
try:
|
|
137
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
138
|
+
_write_ratelimit(100, 0)
|
|
139
|
+
return json.loads(resp.read())
|
|
140
|
+
except urllib.error.HTTPError as e2:
|
|
141
|
+
if e2.code == 404:
|
|
142
|
+
raise HttpNotFoundError(url)
|
|
143
|
+
if e2.code == 429:
|
|
144
|
+
body2 = e2.read() if hasattr(e2, "read") else b""
|
|
145
|
+
retry2 = _parse_retry_seconds(body2)
|
|
146
|
+
_write_ratelimit(0, retry2)
|
|
147
|
+
raise MoltbookRateLimitedError(retry2)
|
|
148
|
+
print(f" ERROR fetching {url}: {e2}", file=sys.stderr)
|
|
149
|
+
return None
|
|
150
|
+
except Exception as ex:
|
|
151
|
+
print(f" ERROR fetching {url}: {ex}", file=sys.stderr)
|
|
152
|
+
return None
|
|
153
|
+
print(f" ERROR fetching {url}: {e}", file=sys.stderr)
|
|
154
|
+
return None
|
|
155
|
+
except HttpNotFoundError:
|
|
156
|
+
raise
|
|
157
|
+
except Exception as e:
|
|
158
|
+
print(f" ERROR fetching {url}: {e}", file=sys.stderr)
|
|
159
|
+
return None
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
pending_threads.py — persistence layer for thread drafts that may need retry.
|
|
4
|
+
|
|
5
|
+
Why this exists:
|
|
6
|
+
Pre-2026-05-01 the run-reddit-threads pipeline drafted a post inside one
|
|
7
|
+
Claude session and submitted it via the reddit-agent MCP within the same
|
|
8
|
+
session. If the MCP child process died mid-flow (e.g. flair-click step on
|
|
9
|
+
r/AutoHotkey, 2026-05-01), the entire $4-24 of work was lost: the title +
|
|
10
|
+
body lived only in the Claude transcript JSON, not in the DB. Subsequent
|
|
11
|
+
pipeline runs regenerated everything from scratch.
|
|
12
|
+
|
|
13
|
+
pending_threads is a durable holding pen. The shell wrapper writes a row
|
|
14
|
+
here BEFORE attempting to submit, and the row's `status` tracks lifecycle:
|
|
15
|
+
|
|
16
|
+
pending - drafted, not yet submitted (or submit aborted before permalink)
|
|
17
|
+
posted - submit succeeded, posted_post_id + posted_permalink filled
|
|
18
|
+
abandoned - too many failed retries, or permanent_block on the sub
|
|
19
|
+
|
|
20
|
+
Recovery flow (next pipeline run): pick the oldest pending row for the
|
|
21
|
+
project before generating a fresh draft.
|
|
22
|
+
|
|
23
|
+
Sub-commands (called from shell pipelines):
|
|
24
|
+
create Insert a draft row, print id
|
|
25
|
+
mark-posted status=posted, fill posted_post_id + posted_permalink
|
|
26
|
+
mark-aborted bump attempts, fill abort_reason / abort_stage; keep pending
|
|
27
|
+
abandon status=abandoned (e.g. sub got permanent_block)
|
|
28
|
+
list-pending print all pending rows for a project (or all if no project)
|
|
29
|
+
|
|
30
|
+
HTTP-only lane (2026-06-01): every read/write routes through the s4l.ai API
|
|
31
|
+
(/api/v1/pending-threads). No DATABASE_URL, no psql, no db.get_conn(), no
|
|
32
|
+
fallback. The function signatures + CLI shapes are unchanged so callers
|
|
33
|
+
(run-reddit-threads.sh) need no edits beyond the DB-insert swap.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
from typing import Any, Optional
|
|
42
|
+
|
|
43
|
+
# scripts/ is on sys.path when called from skill/*.sh; ensure it works
|
|
44
|
+
# standalone too.
|
|
45
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
46
|
+
from http_api import api_get, api_post, api_patch # noqa: E402
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create(
|
|
50
|
+
*,
|
|
51
|
+
project: str,
|
|
52
|
+
subreddit: str,
|
|
53
|
+
account: str,
|
|
54
|
+
title: str,
|
|
55
|
+
body: str,
|
|
56
|
+
flair_target: Optional[str] = None,
|
|
57
|
+
engagement_style: Optional[str] = None,
|
|
58
|
+
topic_angle: Optional[str] = None,
|
|
59
|
+
source_summary: Optional[str] = None,
|
|
60
|
+
claude_session_id: Optional[str] = None,
|
|
61
|
+
cost_usd: Optional[float] = None,
|
|
62
|
+
) -> int:
|
|
63
|
+
resp = api_post("/api/v1/pending-threads", {
|
|
64
|
+
"project": project,
|
|
65
|
+
"subreddit": subreddit,
|
|
66
|
+
"account": account,
|
|
67
|
+
"title": title,
|
|
68
|
+
"body": body,
|
|
69
|
+
"flair_target": flair_target,
|
|
70
|
+
"engagement_style": engagement_style,
|
|
71
|
+
"topic_angle": topic_angle,
|
|
72
|
+
"source_summary": source_summary,
|
|
73
|
+
"claude_session_id": claude_session_id,
|
|
74
|
+
"cost_usd": cost_usd,
|
|
75
|
+
})
|
|
76
|
+
return int((resp.get("data") or {}).get("id"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def mark_posted(*, pending_id: int, post_id: int, permalink: str) -> None:
|
|
80
|
+
api_patch(f"/api/v1/pending-threads/{pending_id}", {
|
|
81
|
+
"action": "mark_posted",
|
|
82
|
+
"post_id": post_id,
|
|
83
|
+
"permalink": permalink,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def mark_aborted(*, pending_id: int, abort_reason: str, abort_stage: Optional[str] = None) -> None:
|
|
88
|
+
api_patch(f"/api/v1/pending-threads/{pending_id}", {
|
|
89
|
+
"action": "mark_aborted",
|
|
90
|
+
"abort_reason": abort_reason,
|
|
91
|
+
"abort_stage": abort_stage,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def abandon(*, pending_id: int, reason: str) -> None:
|
|
96
|
+
api_patch(f"/api/v1/pending-threads/{pending_id}", {
|
|
97
|
+
"action": "abandon",
|
|
98
|
+
"reason": reason,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def list_pending(project: Optional[str] = None) -> list[dict[str, Any]]:
|
|
103
|
+
resp = api_get("/api/v1/pending-threads",
|
|
104
|
+
query={"project": project} if project else None)
|
|
105
|
+
return (resp.get("data") or {}).get("pending_threads") or []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get(pending_id: int) -> Optional[dict[str, Any]]:
|
|
109
|
+
resp = api_get(f"/api/v1/pending-threads/{pending_id}", ok_on_404=True)
|
|
110
|
+
if resp.get("_not_found"):
|
|
111
|
+
return None
|
|
112
|
+
return (resp.get("data") or {}).get("pending_thread")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main() -> int:
|
|
116
|
+
p = argparse.ArgumentParser(description="pending_threads helper")
|
|
117
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
118
|
+
|
|
119
|
+
pc = sub.add_parser("create")
|
|
120
|
+
pc.add_argument("--project", required=True)
|
|
121
|
+
pc.add_argument("--subreddit", required=True)
|
|
122
|
+
pc.add_argument("--account", required=True)
|
|
123
|
+
pc.add_argument("--title", required=True)
|
|
124
|
+
pc.add_argument("--body", required=True)
|
|
125
|
+
pc.add_argument("--flair-target")
|
|
126
|
+
pc.add_argument("--engagement-style")
|
|
127
|
+
pc.add_argument("--topic-angle")
|
|
128
|
+
pc.add_argument("--source-summary")
|
|
129
|
+
pc.add_argument("--claude-session-id")
|
|
130
|
+
pc.add_argument("--cost-usd", type=float)
|
|
131
|
+
|
|
132
|
+
pp = sub.add_parser("mark-posted")
|
|
133
|
+
pp.add_argument("--id", required=True, type=int)
|
|
134
|
+
pp.add_argument("--post-id", required=True, type=int)
|
|
135
|
+
pp.add_argument("--permalink", required=True)
|
|
136
|
+
|
|
137
|
+
pa = sub.add_parser("mark-aborted")
|
|
138
|
+
pa.add_argument("--id", required=True, type=int)
|
|
139
|
+
pa.add_argument("--abort-reason", required=True)
|
|
140
|
+
pa.add_argument("--abort-stage")
|
|
141
|
+
|
|
142
|
+
pab = sub.add_parser("abandon")
|
|
143
|
+
pab.add_argument("--id", required=True, type=int)
|
|
144
|
+
pab.add_argument("--reason", required=True)
|
|
145
|
+
|
|
146
|
+
pl = sub.add_parser("list-pending")
|
|
147
|
+
pl.add_argument("--project")
|
|
148
|
+
|
|
149
|
+
pg = sub.add_parser("get")
|
|
150
|
+
pg.add_argument("--id", required=True, type=int)
|
|
151
|
+
|
|
152
|
+
args = p.parse_args()
|
|
153
|
+
|
|
154
|
+
if args.cmd == "create":
|
|
155
|
+
i = create(
|
|
156
|
+
project=args.project,
|
|
157
|
+
subreddit=args.subreddit,
|
|
158
|
+
account=args.account,
|
|
159
|
+
title=args.title,
|
|
160
|
+
body=args.body,
|
|
161
|
+
flair_target=args.flair_target,
|
|
162
|
+
engagement_style=args.engagement_style,
|
|
163
|
+
topic_angle=args.topic_angle,
|
|
164
|
+
source_summary=args.source_summary,
|
|
165
|
+
claude_session_id=args.claude_session_id,
|
|
166
|
+
cost_usd=args.cost_usd,
|
|
167
|
+
)
|
|
168
|
+
print(json.dumps({"ok": True, "id": i}))
|
|
169
|
+
elif args.cmd == "mark-posted":
|
|
170
|
+
mark_posted(pending_id=args.id, post_id=args.post_id, permalink=args.permalink)
|
|
171
|
+
print(json.dumps({"ok": True}))
|
|
172
|
+
elif args.cmd == "mark-aborted":
|
|
173
|
+
mark_aborted(pending_id=args.id, abort_reason=args.abort_reason, abort_stage=args.abort_stage)
|
|
174
|
+
print(json.dumps({"ok": True}))
|
|
175
|
+
elif args.cmd == "abandon":
|
|
176
|
+
abandon(pending_id=args.id, reason=args.reason)
|
|
177
|
+
print(json.dumps({"ok": True}))
|
|
178
|
+
elif args.cmd == "list-pending":
|
|
179
|
+
rows = list_pending(args.project)
|
|
180
|
+
print(json.dumps(rows, indent=2))
|
|
181
|
+
elif args.cmd == "get":
|
|
182
|
+
rec = get(args.id)
|
|
183
|
+
print(json.dumps(rec, indent=2))
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
sys.exit(main())
|