@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,206 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared HTTP helper for the s4l.ai API endpoints.
|
|
3
|
+
|
|
4
|
+
All Reddit-pipeline (and friends) writes/reads route through here, carrying
|
|
5
|
+
either:
|
|
6
|
+
- X-Installation header (default lane, open-source identity), or
|
|
7
|
+
- Authorization: Bearer $AUTOPOSTER_API_KEY when the key is set in env
|
|
8
|
+
(server-internal callers / endpoints that still use requireApiKey).
|
|
9
|
+
|
|
10
|
+
Both headers are sent on every request when both are available, so a route
|
|
11
|
+
that uses resolveAuth picks the install lane while a route that uses
|
|
12
|
+
requireApiKey picks the bearer lane. No caller-side branching needed.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import ssl
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
|
|
27
|
+
# Best-effort Sentry init (no-op if sentry-sdk missing or DSN unset). http_api
|
|
28
|
+
# is the central HTTP-lane client (~100 pipeline scripts import it), so this one
|
|
29
|
+
# hook gives the whole Python pipeline error capture. Mirrors mcp/src/telemetry.ts.
|
|
30
|
+
try:
|
|
31
|
+
import sentry_init as _sentry_init
|
|
32
|
+
|
|
33
|
+
_sentry_init.init()
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_ssl_context() -> ssl.SSLContext:
|
|
39
|
+
"""Pin a known-good trust store, immune to a bad inherited SSL_CERT_FILE.
|
|
40
|
+
|
|
41
|
+
The MCP/host app can inject SSL_CERT_FILE / SSL_CERT_DIR pointing at a
|
|
42
|
+
bundle that lacks the right roots, which makes urllib raise
|
|
43
|
+
CERTIFICATE_VERIFY_FAILED only inside the spawned subprocess (TLS works
|
|
44
|
+
fine in a normal shell). We resolve the trust store deliberately here
|
|
45
|
+
instead of trusting whatever env we inherit:
|
|
46
|
+
|
|
47
|
+
1. inherited SSL_CERT_FILE, but only if the path exists AND yields a
|
|
48
|
+
context with at least one trusted root;
|
|
49
|
+
2. the platform default store;
|
|
50
|
+
3. certifi, if installed.
|
|
51
|
+
|
|
52
|
+
The get_ca_certs() check is what rejects a bad inherited path: an empty
|
|
53
|
+
trust store silently falls through to the next candidate.
|
|
54
|
+
"""
|
|
55
|
+
candidates = []
|
|
56
|
+
env_file = os.environ.get("SSL_CERT_FILE")
|
|
57
|
+
if env_file and os.path.exists(env_file):
|
|
58
|
+
candidates.append(env_file)
|
|
59
|
+
candidates.append(None) # platform default
|
|
60
|
+
for cafile in candidates:
|
|
61
|
+
try:
|
|
62
|
+
ctx = ssl.create_default_context(cafile=cafile)
|
|
63
|
+
if ctx.get_ca_certs():
|
|
64
|
+
return ctx
|
|
65
|
+
except Exception:
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
import certifi
|
|
69
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
70
|
+
except Exception:
|
|
71
|
+
return ssl.create_default_context()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_SSL_CONTEXT = _build_ssl_context()
|
|
75
|
+
|
|
76
|
+
ENV_PATH = os.path.expanduser("~/social-autoposter/.env")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_env():
|
|
80
|
+
"""Load ~/social-autoposter/.env into os.environ (setdefault, never clobber).
|
|
81
|
+
|
|
82
|
+
Generic dotenv loader, not DB-specific: callers need it for keys like
|
|
83
|
+
MOLTBOOK_API_KEY / AUTOPOSTER_API_KEY / AUTOPOSTER_API_BASE. Lives here
|
|
84
|
+
(rather than the now-removed db.py) so HTTP-only scripts have one import.
|
|
85
|
+
"""
|
|
86
|
+
if os.path.exists(ENV_PATH):
|
|
87
|
+
with open(ENV_PATH) as f:
|
|
88
|
+
for line in f:
|
|
89
|
+
line = line.strip()
|
|
90
|
+
if line and not line.startswith("#") and "=" in line:
|
|
91
|
+
k, v = line.split("=", 1)
|
|
92
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _base_url():
|
|
96
|
+
return os.environ.get("AUTOPOSTER_API_BASE", "https://s4l.ai").rstrip("/")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _headers():
|
|
100
|
+
from identity import get_identity_header
|
|
101
|
+
headers = {
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
"X-Installation": get_identity_header(),
|
|
104
|
+
}
|
|
105
|
+
bearer = (os.environ.get("AUTOPOSTER_API_KEY") or "").strip()
|
|
106
|
+
if bearer:
|
|
107
|
+
headers["Authorization"] = f"Bearer {bearer}"
|
|
108
|
+
headers["x-api-key"] = bearer
|
|
109
|
+
return headers
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _request(method: str, path: str, body: dict | None = None,
|
|
113
|
+
query: dict | None = None, ok_on_conflict: bool = False,
|
|
114
|
+
ok_on_404: bool = False):
|
|
115
|
+
"""Generic request runner with retries.
|
|
116
|
+
|
|
117
|
+
Returns parsed JSON. Raises SystemExit on terminal failure.
|
|
118
|
+
|
|
119
|
+
method: GET | POST | PATCH | DELETE
|
|
120
|
+
body: JSON body for write methods
|
|
121
|
+
query: optional dict for ?k=v query-string (GET / DELETE)
|
|
122
|
+
ok_on_conflict: when True, a 409 body is returned (not raised)
|
|
123
|
+
ok_on_404: when True, a 404 returns {"_not_found": True}
|
|
124
|
+
"""
|
|
125
|
+
url = f"{_base_url()}{path}"
|
|
126
|
+
if query:
|
|
127
|
+
# Drop None values so we don't send 'key=None' string.
|
|
128
|
+
qs = urllib.parse.urlencode(
|
|
129
|
+
{k: v for k, v in query.items() if v is not None},
|
|
130
|
+
doseq=True,
|
|
131
|
+
)
|
|
132
|
+
if qs:
|
|
133
|
+
sep = "&" if "?" in url else "?"
|
|
134
|
+
url = f"{url}{sep}{qs}"
|
|
135
|
+
|
|
136
|
+
data = None
|
|
137
|
+
if body is not None:
|
|
138
|
+
data = json.dumps(body).encode()
|
|
139
|
+
|
|
140
|
+
delays = [1, 3, 9]
|
|
141
|
+
last_err = None
|
|
142
|
+
for attempt, delay in enumerate(delays, 1):
|
|
143
|
+
try:
|
|
144
|
+
req = urllib.request.Request(
|
|
145
|
+
url, data=data, headers=_headers(), method=method,
|
|
146
|
+
)
|
|
147
|
+
with urllib.request.urlopen(
|
|
148
|
+
req, timeout=30, context=_SSL_CONTEXT
|
|
149
|
+
) as resp:
|
|
150
|
+
raw = resp.read()
|
|
151
|
+
if not raw:
|
|
152
|
+
return {}
|
|
153
|
+
return json.loads(raw)
|
|
154
|
+
except urllib.error.HTTPError as e:
|
|
155
|
+
body_txt = e.read().decode(errors="replace")
|
|
156
|
+
if e.code == 409 and ok_on_conflict:
|
|
157
|
+
try:
|
|
158
|
+
return json.loads(body_txt)
|
|
159
|
+
except Exception:
|
|
160
|
+
return {"error": "conflict"}
|
|
161
|
+
if e.code == 404 and ok_on_404:
|
|
162
|
+
return {"_not_found": True}
|
|
163
|
+
if 400 <= e.code < 500:
|
|
164
|
+
raise SystemExit(
|
|
165
|
+
f"[http_api] {method} {path} HTTP {e.code}: {body_txt}"
|
|
166
|
+
)
|
|
167
|
+
last_err = e
|
|
168
|
+
print(
|
|
169
|
+
f"[http_api] {method} {path} HTTP {e.code} attempt {attempt}: "
|
|
170
|
+
f"{body_txt[:120]}",
|
|
171
|
+
file=sys.stderr,
|
|
172
|
+
)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
last_err = e
|
|
175
|
+
print(
|
|
176
|
+
f"[http_api] {method} {path} attempt {attempt}: {e}",
|
|
177
|
+
file=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
if attempt < len(delays):
|
|
180
|
+
time.sleep(delay)
|
|
181
|
+
raise SystemExit(
|
|
182
|
+
f"[http_api] {method} {path} failed after {len(delays)} attempts: "
|
|
183
|
+
f"{last_err}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def api_get(path: str, query: dict | None = None, ok_on_404: bool = False):
|
|
188
|
+
"""GET path with optional query dict. Returns parsed JSON."""
|
|
189
|
+
return _request("GET", path, query=query, ok_on_404=ok_on_404)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def api_post(path: str, body: dict, ok_on_conflict: bool = False, ok_on_404: bool = False):
|
|
193
|
+
"""POST body to path. ok_on_conflict=True returns the 409 body;
|
|
194
|
+
ok_on_404=True returns {_not_found: True} on 404 instead of raising."""
|
|
195
|
+
return _request("POST", path, body=body, ok_on_conflict=ok_on_conflict, ok_on_404=ok_on_404)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def api_patch(path: str, body: dict, ok_on_conflict: bool = False, ok_on_404: bool = False):
|
|
199
|
+
"""PATCH body to path. ok_on_conflict=True returns the 409 body;
|
|
200
|
+
ok_on_404=True returns {_not_found: True} on 404 instead of raising."""
|
|
201
|
+
return _request("PATCH", path, body=body, ok_on_conflict=ok_on_conflict, ok_on_404=ok_on_404)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def api_delete(path: str, query: dict | None = None):
|
|
205
|
+
"""DELETE path. Optional query string."""
|
|
206
|
+
return _request("DELETE", path, query=query)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""human_dm_replies_helper.py — shell-friendly entrypoints for Phase 0 of
|
|
3
|
+
engage-dm-replies.sh, replacing the inline `psql "$DATABASE_URL"` blocks that
|
|
4
|
+
read and mutate the `human_dm_replies` queue.
|
|
5
|
+
|
|
6
|
+
Everything routes through /api/v1/human-dm-replies* on the website. No
|
|
7
|
+
psycopg2, no DATABASE_URL — there is intentionally NO direct-DB fallback.
|
|
8
|
+
|
|
9
|
+
Subcommands:
|
|
10
|
+
|
|
11
|
+
pending [--platform reddit|twitter|x|linkedin]
|
|
12
|
+
GET /api/v1/human-dm-replies?mode=pending[&platform=...]
|
|
13
|
+
-> prints the pending+retry queue as a JSON array (the same shape the
|
|
14
|
+
legacy `SELECT json_agg(q) FROM (... human_dm_replies h JOIN dms ...)`
|
|
15
|
+
produced). Prints NOTHING (empty output) when zero rows, so the bash
|
|
16
|
+
`[ -n "$HUMAN_REPLIES" ]` guard falls through to the "no replies"
|
|
17
|
+
branch exactly like psql's NULL -> empty string did.
|
|
18
|
+
|
|
19
|
+
kb [--limit 20]
|
|
20
|
+
GET /api/v1/human-dm-replies?mode=kb&limit=N
|
|
21
|
+
-> prints the last N SENT instructions as a JSON array (the Human Reply
|
|
22
|
+
Knowledge Base). Empty -> empty output (same as the legacy query).
|
|
23
|
+
|
|
24
|
+
patch --id N [--status S] [--last-error E] [--public-reply-id M]
|
|
25
|
+
[--increment-attempts] [--stamp-sent]
|
|
26
|
+
PATCH /api/v1/human-dm-replies/N
|
|
27
|
+
-> the four Phase 0 status transitions:
|
|
28
|
+
cancelled : --status cancelled --last-error "human reclassified: ..."
|
|
29
|
+
paired : --public-reply-id M
|
|
30
|
+
sent : --status sent
|
|
31
|
+
failed : --status failed --increment-attempts --last-error "..."
|
|
32
|
+
(sent_at auto-stamps server-side on sent/cancelled; --stamp-sent forces
|
|
33
|
+
it otherwise.)
|
|
34
|
+
|
|
35
|
+
insert-public-reply --post-id N|--no-post-id --platform P --comment-id C
|
|
36
|
+
--author A --comment-url U --our-content TEXT --our-url URL [--depth 2]
|
|
37
|
+
POST /api/v1/replies
|
|
38
|
+
-> inserts the public reply row the delivery bot just posted and prints
|
|
39
|
+
the new replies.id to stdout (so it can be paired back via
|
|
40
|
+
`patch --public-reply-id`). 409 (duplicate their_comment_id) returns the
|
|
41
|
+
existing row's id, never an error.
|
|
42
|
+
"""
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import sys
|
|
47
|
+
|
|
48
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
49
|
+
from http_api import api_get, api_patch, api_post
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _print_rows_or_empty(rows):
|
|
53
|
+
"""Mirror psql `json_agg` -t -A output: a JSON array when rows exist,
|
|
54
|
+
an empty string (nothing) when there are none."""
|
|
55
|
+
if not rows:
|
|
56
|
+
return
|
|
57
|
+
json.dump(rows, sys.stdout)
|
|
58
|
+
print("")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cmd_pending(args):
|
|
62
|
+
query = {"mode": "pending"}
|
|
63
|
+
if args.platform:
|
|
64
|
+
query["platform"] = args.platform
|
|
65
|
+
resp = api_get("/api/v1/human-dm-replies", query=query)
|
|
66
|
+
rows = ((resp or {}).get("data") or {}).get("rows") or []
|
|
67
|
+
_print_rows_or_empty(rows)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cmd_kb(args):
|
|
71
|
+
query = {"mode": "kb", "limit": args.limit}
|
|
72
|
+
resp = api_get("/api/v1/human-dm-replies", query=query)
|
|
73
|
+
rows = ((resp or {}).get("data") or {}).get("rows") or []
|
|
74
|
+
_print_rows_or_empty(rows)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cmd_patch(args):
|
|
78
|
+
body = {}
|
|
79
|
+
if args.status is not None:
|
|
80
|
+
body["status"] = args.status
|
|
81
|
+
if args.last_error is not None:
|
|
82
|
+
body["last_error"] = args.last_error
|
|
83
|
+
if args.public_reply_id is not None:
|
|
84
|
+
body["public_reply_id"] = int(args.public_reply_id)
|
|
85
|
+
if args.increment_attempts:
|
|
86
|
+
body["increment_attempts"] = True
|
|
87
|
+
if args.stamp_sent:
|
|
88
|
+
body["stamp_sent_now"] = True
|
|
89
|
+
if not body:
|
|
90
|
+
print("patch: nothing to update (pass at least one field)", file=sys.stderr)
|
|
91
|
+
sys.exit(2)
|
|
92
|
+
resp = api_patch(f"/api/v1/human-dm-replies/{int(args.id)}", body)
|
|
93
|
+
row = ((resp or {}).get("data") or {}).get("human_dm_reply") or {}
|
|
94
|
+
print(json.dumps({"id": row.get("id"), "status": row.get("status"),
|
|
95
|
+
"attempts": row.get("attempts"),
|
|
96
|
+
"public_reply_id": row.get("public_reply_id")}))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cmd_insert_public_reply(args):
|
|
100
|
+
body = {
|
|
101
|
+
"platform": args.platform,
|
|
102
|
+
"their_comment_id": args.comment_id,
|
|
103
|
+
"their_author": args.author,
|
|
104
|
+
"their_comment_url": args.comment_url,
|
|
105
|
+
"our_reply_content": args.our_content,
|
|
106
|
+
"our_reply_url": args.our_url,
|
|
107
|
+
"depth": args.depth,
|
|
108
|
+
"status": "replied",
|
|
109
|
+
"replied_at": "now",
|
|
110
|
+
}
|
|
111
|
+
if args.post_id is not None:
|
|
112
|
+
body["post_id"] = int(args.post_id)
|
|
113
|
+
# ok_on_conflict so a duplicate their_comment_id returns the existing row
|
|
114
|
+
# instead of raising; we still want its id to pair back.
|
|
115
|
+
resp = api_post("/api/v1/replies", body, ok_on_conflict=True)
|
|
116
|
+
data = (resp or {}).get("data") or {}
|
|
117
|
+
row = data.get("reply") or data.get("row") or data
|
|
118
|
+
rid = row.get("id") if isinstance(row, dict) else None
|
|
119
|
+
if rid is None and isinstance(data, dict):
|
|
120
|
+
# 409 path may nest the existing row under error.details
|
|
121
|
+
err = (resp or {}).get("error") or {}
|
|
122
|
+
details = err.get("details") if isinstance(err, dict) else None
|
|
123
|
+
if isinstance(details, dict):
|
|
124
|
+
rid = (details.get("reply") or details).get("id")
|
|
125
|
+
if rid is None:
|
|
126
|
+
print("insert-public-reply: no id in response: "
|
|
127
|
+
+ json.dumps(resp)[:300], file=sys.stderr)
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
print(int(rid))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main():
|
|
133
|
+
p = argparse.ArgumentParser(description="human_dm_replies Phase 0 helper (HTTP-only)")
|
|
134
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
135
|
+
|
|
136
|
+
sp = sub.add_parser("pending")
|
|
137
|
+
sp.add_argument("--platform", default=None)
|
|
138
|
+
sp.set_defaults(func=_cmd_pending)
|
|
139
|
+
|
|
140
|
+
sk = sub.add_parser("kb")
|
|
141
|
+
sk.add_argument("--limit", type=int, default=20)
|
|
142
|
+
sk.set_defaults(func=_cmd_kb)
|
|
143
|
+
|
|
144
|
+
spt = sub.add_parser("patch")
|
|
145
|
+
spt.add_argument("--id", required=True)
|
|
146
|
+
spt.add_argument("--status", default=None)
|
|
147
|
+
spt.add_argument("--last-error", dest="last_error", default=None)
|
|
148
|
+
spt.add_argument("--public-reply-id", dest="public_reply_id", default=None)
|
|
149
|
+
spt.add_argument("--increment-attempts", dest="increment_attempts", action="store_true")
|
|
150
|
+
spt.add_argument("--stamp-sent", dest="stamp_sent", action="store_true")
|
|
151
|
+
spt.set_defaults(func=_cmd_patch)
|
|
152
|
+
|
|
153
|
+
si = sub.add_parser("insert-public-reply")
|
|
154
|
+
si.add_argument("--post-id", dest="post_id", default=None)
|
|
155
|
+
si.add_argument("--platform", required=True)
|
|
156
|
+
si.add_argument("--comment-id", dest="comment_id", required=True)
|
|
157
|
+
si.add_argument("--author", required=True)
|
|
158
|
+
si.add_argument("--comment-url", dest="comment_url", required=True)
|
|
159
|
+
si.add_argument("--our-content", dest="our_content", required=True)
|
|
160
|
+
si.add_argument("--our-url", dest="our_url", required=True)
|
|
161
|
+
si.add_argument("--depth", type=int, default=2)
|
|
162
|
+
si.set_defaults(func=_cmd_insert_public_reply)
|
|
163
|
+
|
|
164
|
+
args = p.parse_args()
|
|
165
|
+
args.func(args)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
main()
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Passive installation identity for the open-source social-autoposter client.
|
|
3
|
+
|
|
4
|
+
Generates and stores a stable install_id on first run plus a snapshot of
|
|
5
|
+
machine fingerprint fields. Every API call to social-autoposter-website
|
|
6
|
+
carries this as an X-Installation header (base64 JSON) so the server can
|
|
7
|
+
attribute writes per install, rate-limit, and surface usage without
|
|
8
|
+
requiring any user signup.
|
|
9
|
+
|
|
10
|
+
NO data is sent until the pipeline actually calls the API. NO secrets are
|
|
11
|
+
collected. Every field captured is documented in PRIVACY.md at the repo
|
|
12
|
+
root.
|
|
13
|
+
|
|
14
|
+
CLI:
|
|
15
|
+
python3 scripts/identity.py show # print identity JSON
|
|
16
|
+
python3 scripts/identity.py header # print base64 X-Installation value
|
|
17
|
+
python3 scripts/identity.py reset # delete identity.json
|
|
18
|
+
python3 scripts/identity.py path # print path to identity.json
|
|
19
|
+
|
|
20
|
+
Library:
|
|
21
|
+
from scripts.identity import get_identity, get_identity_header
|
|
22
|
+
headers = {"X-Installation": get_identity_header()}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import platform
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
import uuid
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
IDENTITY_DIR = Path.home() / ".social-autoposter"
|
|
38
|
+
IDENTITY_FILE = IDENTITY_DIR / "identity.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _safe(fn, *args, **kwargs):
|
|
42
|
+
try:
|
|
43
|
+
return fn(*args, **kwargs)
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _hardware_uuid_macos():
|
|
49
|
+
out = _safe(
|
|
50
|
+
subprocess.check_output,
|
|
51
|
+
["ioreg", "-d2", "-c", "IOPlatformExpertDevice"],
|
|
52
|
+
stderr=subprocess.DEVNULL, timeout=5,
|
|
53
|
+
)
|
|
54
|
+
if not out:
|
|
55
|
+
return None
|
|
56
|
+
for line in out.decode("utf8", errors="ignore").splitlines():
|
|
57
|
+
if "IOPlatformUUID" in line:
|
|
58
|
+
parts = line.split('"')
|
|
59
|
+
if len(parts) >= 4:
|
|
60
|
+
return parts[3].strip()
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _hardware_uuid_linux():
|
|
65
|
+
for p in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
|
|
66
|
+
try:
|
|
67
|
+
with open(p) as f:
|
|
68
|
+
v = f.read().strip()
|
|
69
|
+
if v:
|
|
70
|
+
return v
|
|
71
|
+
except Exception:
|
|
72
|
+
continue
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _hardware_uuid_windows():
|
|
77
|
+
out = _safe(
|
|
78
|
+
subprocess.check_output,
|
|
79
|
+
["reg", "query",
|
|
80
|
+
r"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography",
|
|
81
|
+
"/v", "MachineGuid"],
|
|
82
|
+
stderr=subprocess.DEVNULL, timeout=5,
|
|
83
|
+
)
|
|
84
|
+
if not out:
|
|
85
|
+
return None
|
|
86
|
+
for line in out.decode("utf8", errors="ignore").splitlines():
|
|
87
|
+
if "MachineGuid" in line:
|
|
88
|
+
tokens = line.split()
|
|
89
|
+
if tokens:
|
|
90
|
+
return tokens[-1].strip()
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _hardware_uuid():
|
|
95
|
+
sys_name = platform.system().lower()
|
|
96
|
+
if sys_name == "darwin":
|
|
97
|
+
return _hardware_uuid_macos()
|
|
98
|
+
if sys_name == "linux":
|
|
99
|
+
return _hardware_uuid_linux()
|
|
100
|
+
if sys_name == "windows":
|
|
101
|
+
return _hardware_uuid_windows()
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _hostname():
|
|
106
|
+
sys_name = platform.system().lower()
|
|
107
|
+
if sys_name == "darwin":
|
|
108
|
+
out = _safe(
|
|
109
|
+
subprocess.check_output,
|
|
110
|
+
["scutil", "--get", "ComputerName"],
|
|
111
|
+
stderr=subprocess.DEVNULL, timeout=5,
|
|
112
|
+
)
|
|
113
|
+
if out:
|
|
114
|
+
v = out.decode("utf8", errors="ignore").strip()
|
|
115
|
+
if v:
|
|
116
|
+
return v
|
|
117
|
+
try:
|
|
118
|
+
import socket
|
|
119
|
+
return socket.gethostname() or None
|
|
120
|
+
except Exception:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _git_email():
|
|
125
|
+
out = _safe(
|
|
126
|
+
subprocess.check_output,
|
|
127
|
+
["git", "config", "--global", "user.email"],
|
|
128
|
+
stderr=subprocess.DEVNULL, timeout=3,
|
|
129
|
+
)
|
|
130
|
+
if not out:
|
|
131
|
+
return None
|
|
132
|
+
v = out.decode("utf8", errors="ignore").strip()
|
|
133
|
+
return v or None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _node_version():
|
|
137
|
+
out = _safe(
|
|
138
|
+
subprocess.check_output,
|
|
139
|
+
["node", "--version"],
|
|
140
|
+
stderr=subprocess.DEVNULL, timeout=3,
|
|
141
|
+
)
|
|
142
|
+
if not out:
|
|
143
|
+
return None
|
|
144
|
+
v = out.decode("utf8", errors="ignore").strip()
|
|
145
|
+
return v.lstrip("v") or None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _app_version():
|
|
149
|
+
"""Version of the installed S4L plugin.
|
|
150
|
+
|
|
151
|
+
On a .mcpb box the extension dir has manifest.json + package.json at its
|
|
152
|
+
root (one level above scripts/); read whichever resolves first. Honors
|
|
153
|
+
S4L_REPO_DIR / REPO_DIR when the pipeline sets it (launchd plists do).
|
|
154
|
+
"""
|
|
155
|
+
root = Path(
|
|
156
|
+
os.environ.get("S4L_REPO_DIR")
|
|
157
|
+
or os.environ.get("REPO_DIR")
|
|
158
|
+
or Path(__file__).resolve().parents[1]
|
|
159
|
+
)
|
|
160
|
+
for name in ("manifest.json", "package.json"):
|
|
161
|
+
try:
|
|
162
|
+
data = json.loads((root / name).read_text())
|
|
163
|
+
except Exception:
|
|
164
|
+
continue
|
|
165
|
+
v = data.get("version")
|
|
166
|
+
if v:
|
|
167
|
+
return str(v).strip() or None
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _claude_desktop_version():
|
|
172
|
+
"""CFBundleShortVersionString of the Claude Desktop app (macOS), or None.
|
|
173
|
+
|
|
174
|
+
Stamped into the install identity (and thus the X-Installation header on every
|
|
175
|
+
heartbeat) so the install-lane digest can correlate leaks/regressions with the
|
|
176
|
+
Desktop version. This is the variable we could not answer for Karol's box. Reads
|
|
177
|
+
Info.plist directly via plistlib; best-effort, never raises."""
|
|
178
|
+
if (platform.system() or "").lower() != "darwin":
|
|
179
|
+
return None
|
|
180
|
+
candidates = [
|
|
181
|
+
Path("/Applications/Claude.app/Contents/Info.plist"),
|
|
182
|
+
Path.home() / "Applications" / "Claude.app" / "Contents" / "Info.plist",
|
|
183
|
+
]
|
|
184
|
+
for plist in candidates:
|
|
185
|
+
try:
|
|
186
|
+
if not plist.exists():
|
|
187
|
+
continue
|
|
188
|
+
import plistlib
|
|
189
|
+
|
|
190
|
+
with plist.open("rb") as f:
|
|
191
|
+
data = plistlib.load(f)
|
|
192
|
+
v = data.get("CFBundleShortVersionString") or data.get("CFBundleVersion")
|
|
193
|
+
if v:
|
|
194
|
+
return str(v).strip() or None
|
|
195
|
+
except Exception:
|
|
196
|
+
continue
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _tz():
|
|
201
|
+
try:
|
|
202
|
+
from datetime import datetime
|
|
203
|
+
tz = datetime.now().astimezone().tzinfo
|
|
204
|
+
if tz is not None:
|
|
205
|
+
name = tz.tzname(datetime.now())
|
|
206
|
+
if name:
|
|
207
|
+
return name
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
return os.environ.get("TZ") or None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _build_fresh_identity():
|
|
214
|
+
return {
|
|
215
|
+
"install_id": str(uuid.uuid4()),
|
|
216
|
+
"hardware_uuid": _hardware_uuid(),
|
|
217
|
+
"hostname": _hostname(),
|
|
218
|
+
"os": (platform.system() or "").lower() or None,
|
|
219
|
+
"os_version": platform.release() or None,
|
|
220
|
+
"cpu_arch": platform.machine() or None,
|
|
221
|
+
"python_version": platform.python_version() or None,
|
|
222
|
+
"node_version": _node_version(),
|
|
223
|
+
"app_version": _app_version(),
|
|
224
|
+
"claude_desktop_version": _claude_desktop_version(),
|
|
225
|
+
"git_email": _git_email(),
|
|
226
|
+
"tz": _tz(),
|
|
227
|
+
"first_seen_at": int(time.time()),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def get_identity(refresh: bool = False) -> dict:
|
|
232
|
+
"""Read identity.json, creating it on first call.
|
|
233
|
+
|
|
234
|
+
refresh=True re-snapshots the volatile fields (versions, hostname,
|
|
235
|
+
git_email, tz) while preserving install_id and first_seen_at.
|
|
236
|
+
"""
|
|
237
|
+
IDENTITY_DIR.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
|
|
239
|
+
if not IDENTITY_FILE.exists():
|
|
240
|
+
ident = _build_fresh_identity()
|
|
241
|
+
IDENTITY_FILE.write_text(json.dumps(ident, indent=2))
|
|
242
|
+
try:
|
|
243
|
+
os.chmod(IDENTITY_FILE, 0o600)
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
return ident
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
ident = json.loads(IDENTITY_FILE.read_text())
|
|
250
|
+
except Exception:
|
|
251
|
+
# Corrupt file; rebuild rather than crashing the pipeline.
|
|
252
|
+
ident = _build_fresh_identity()
|
|
253
|
+
IDENTITY_FILE.write_text(json.dumps(ident, indent=2))
|
|
254
|
+
return ident
|
|
255
|
+
|
|
256
|
+
if refresh:
|
|
257
|
+
snap = _build_fresh_identity()
|
|
258
|
+
# preserve stable identifiers across refresh
|
|
259
|
+
snap["install_id"] = ident.get("install_id") or snap["install_id"]
|
|
260
|
+
snap["first_seen_at"] = ident.get("first_seen_at") or snap["first_seen_at"]
|
|
261
|
+
if snap != ident:
|
|
262
|
+
try:
|
|
263
|
+
IDENTITY_FILE.write_text(json.dumps(snap, indent=2))
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
return snap
|
|
267
|
+
return ident
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_identity_header(refresh: bool = False) -> str:
|
|
271
|
+
"""Return the base64 value to put in the X-Installation HTTP header."""
|
|
272
|
+
ident = get_identity(refresh=refresh)
|
|
273
|
+
payload = {
|
|
274
|
+
k: v for k, v in ident.items()
|
|
275
|
+
if k != "first_seen_at" and v is not None
|
|
276
|
+
}
|
|
277
|
+
raw = json.dumps(payload, separators=(",", ":")).encode("utf8")
|
|
278
|
+
return base64.b64encode(raw).decode("ascii")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def main():
|
|
282
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else "show"
|
|
283
|
+
if cmd == "show":
|
|
284
|
+
print(json.dumps(get_identity(refresh=True), indent=2))
|
|
285
|
+
elif cmd == "header":
|
|
286
|
+
print(get_identity_header(refresh=True))
|
|
287
|
+
elif cmd == "reset":
|
|
288
|
+
if IDENTITY_FILE.exists():
|
|
289
|
+
IDENTITY_FILE.unlink()
|
|
290
|
+
print(f"deleted {IDENTITY_FILE}")
|
|
291
|
+
else:
|
|
292
|
+
print(f"no identity at {IDENTITY_FILE}")
|
|
293
|
+
elif cmd == "path":
|
|
294
|
+
print(str(IDENTITY_FILE))
|
|
295
|
+
else:
|
|
296
|
+
print(f"unknown cmd: {cmd}", file=sys.stderr)
|
|
297
|
+
print("usage: identity.py [show|header|reset|path]", file=sys.stderr)
|
|
298
|
+
sys.exit(2)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
main()
|