@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.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env python3
2
+ """Refresh Instagram Graph API long-lived tokens before they expire.
3
+
4
+ Instagram long-lived user tokens are valid for ~60 days. Each call to the
5
+ refresh_access_token endpoint extends the lifetime by another 60 days. The
6
+ token must be at least 24 hours old to be refreshable, and Meta recommends
7
+ refreshing well before expiry (we use a 14-day buffer).
8
+
9
+ This script:
10
+ 1. Iterates over every account in config.json -> instagram.accounts[].
11
+ 2. Reads the current token + expiry from ~/instagram-graph-api/.env via the
12
+ ig_long_token_env / derived IG_TOKEN_EXPIRES_<suffix> key.
13
+ 3. If the token expires within REFRESH_BUFFER_DAYS, calls the Graph API
14
+ refresh_access_token endpoint and rewrites the .env file in place
15
+ (atomic: write to tempfile then os.replace).
16
+ 4. Prints a machine-readable SUMMARY line for the wrapper to log via
17
+ scripts/log_run.py.
18
+
19
+ The .env file is the SINGLE source of truth — update_instagram_stats.py and
20
+ scan_instagram_comments.py both read it on every invocation, so a refreshed
21
+ token is picked up by the next pipeline run with no daemon-restart needed.
22
+
23
+ Usage:
24
+ python3 scripts/refresh_instagram_tokens.py [--quiet] [--force] [--dry-run]
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import sys
33
+ import tempfile
34
+ import time
35
+ import urllib.error
36
+ import urllib.parse
37
+ import urllib.request
38
+ from datetime import datetime, timezone
39
+ from pathlib import Path
40
+
41
+ IG_ENV_PATH = Path.home() / "instagram-graph-api" / ".env"
42
+ GRAPH = "https://graph.instagram.com"
43
+ SA_CONFIG = Path(__file__).resolve().parent.parent / "config.json"
44
+
45
+ # Refresh tokens that expire within this many days. 14 days gives us 2 weeks
46
+ # of headroom for cron failures, network outages, or attention lapses.
47
+ REFRESH_BUFFER_DAYS = 14
48
+ # Meta requires tokens to be at least 24h old before they can be refreshed.
49
+ MIN_TOKEN_AGE_HOURS = 24
50
+
51
+
52
+ def load_env_lines() -> list[str]:
53
+ """Return the .env file as a list of raw lines (preserving comments +
54
+ blank lines), so we can rewrite individual keys without reformatting."""
55
+ if not IG_ENV_PATH.exists():
56
+ return []
57
+ return IG_ENV_PATH.read_text().splitlines()
58
+
59
+
60
+ def env_dict_from_lines(lines: list[str]) -> dict[str, str]:
61
+ env = {}
62
+ for line in lines:
63
+ s = line.strip()
64
+ if not s or s.startswith("#") or "=" not in s:
65
+ continue
66
+ k, v = s.split("=", 1)
67
+ env[k.strip()] = v.strip()
68
+ return env
69
+
70
+
71
+ def write_env_atomic(lines: list[str]):
72
+ """Rewrite the .env file from `lines`. Atomic via temp-file + os.replace
73
+ so a Ctrl-C or crash mid-write can't truncate the file."""
74
+ dir_ = IG_ENV_PATH.parent
75
+ dir_.mkdir(parents=True, exist_ok=True)
76
+ fd, tmp = tempfile.mkstemp(prefix=".env.tmp.", dir=str(dir_))
77
+ try:
78
+ with os.fdopen(fd, "w") as f:
79
+ f.write("\n".join(lines))
80
+ if lines and not lines[-1].endswith("\n"):
81
+ f.write("\n")
82
+ os.chmod(tmp, 0o600)
83
+ os.replace(tmp, IG_ENV_PATH)
84
+ finally:
85
+ if os.path.exists(tmp):
86
+ try:
87
+ os.unlink(tmp)
88
+ except OSError:
89
+ pass
90
+
91
+
92
+ def expires_key_for(token_key: str) -> str:
93
+ """Derive the IG_TOKEN_EXPIRES env-var name from the IG_LONG_TOKEN one.
94
+
95
+ IG_LONG_TOKEN -> IG_TOKEN_EXPIRES
96
+ IG_LONG_TOKEN_MATTHEWHEARTFUL -> IG_TOKEN_EXPIRES_MATTHEWHEARTFUL
97
+ IG_LONG_TOKEN_OMIDOTME -> IG_TOKEN_EXPIRES_OMIDOTME
98
+ """
99
+ if not token_key.startswith("IG_LONG_TOKEN"):
100
+ return ""
101
+ return "IG_TOKEN_EXPIRES" + token_key[len("IG_LONG_TOKEN"):]
102
+
103
+
104
+ def parse_expires(s: str | None) -> datetime | None:
105
+ if not s:
106
+ return None
107
+ s = s.strip()
108
+ if not s:
109
+ return None
110
+ # Accept both "2026-07-05T23:06:44Z" and "2026-07-05T23:06:44+00:00".
111
+ try:
112
+ if s.endswith("Z"):
113
+ s = s[:-1] + "+00:00"
114
+ return datetime.fromisoformat(s)
115
+ except ValueError:
116
+ return None
117
+
118
+
119
+ def format_expires(dt: datetime) -> str:
120
+ """Match the existing .env convention: ISO-8601 UTC with trailing Z."""
121
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
122
+
123
+
124
+ def refresh_token(long_token: str) -> dict:
125
+ qs = urllib.parse.urlencode({
126
+ "grant_type": "ig_refresh_token",
127
+ "access_token": long_token,
128
+ })
129
+ url = f"{GRAPH}/refresh_access_token?{qs}"
130
+ try:
131
+ with urllib.request.urlopen(url, timeout=30) as r:
132
+ return json.loads(r.read())
133
+ except urllib.error.HTTPError as e:
134
+ body = e.read().decode(errors="replace")
135
+ raise RefreshError(f"HTTP {e.code}: {body[:300]}") from e
136
+
137
+
138
+ class RefreshError(Exception):
139
+ pass
140
+
141
+
142
+ def update_line(lines: list[str], key: str, value: str) -> list[str]:
143
+ """Return a new list with the line `<key>=<old>` replaced by `<key>=<value>`.
144
+ If the key isn't present, appends `<key>=<value>` at the end."""
145
+ out = []
146
+ found = False
147
+ prefix = f"{key}="
148
+ for line in lines:
149
+ if line.strip().startswith(prefix) or line.startswith(prefix):
150
+ out.append(f"{key}={value}")
151
+ found = True
152
+ else:
153
+ out.append(line)
154
+ if not found:
155
+ out.append(f"{key}={value}")
156
+ return out
157
+
158
+
159
+ def main():
160
+ parser = argparse.ArgumentParser()
161
+ parser.add_argument("--quiet", action="store_true")
162
+ parser.add_argument("--force", action="store_true",
163
+ help="Refresh every token regardless of expiry buffer")
164
+ parser.add_argument("--dry-run", action="store_true",
165
+ help="Print what would be refreshed but don't call the API")
166
+ parser.add_argument("--account", default=None,
167
+ help="Only refresh this account (default: all accounts)")
168
+ args = parser.parse_args()
169
+
170
+ def log(msg: str):
171
+ if not args.quiet:
172
+ print(msg)
173
+
174
+ if not IG_ENV_PATH.exists():
175
+ print(f"[refresh-ig-tokens] env file missing: {IG_ENV_PATH}")
176
+ print("SUMMARY:REFRESHED=0 SKIPPED=0 FAILED=0 ACCOUNTS=0")
177
+ sys.exit(0)
178
+
179
+ try:
180
+ cfg = json.loads(SA_CONFIG.read_text())
181
+ except FileNotFoundError:
182
+ cfg = {}
183
+ accounts_cfg = ((cfg.get("instagram") or {}).get("accounts") or [])
184
+
185
+ if args.account:
186
+ accounts_cfg = [a for a in accounts_cfg
187
+ if a.get("username", "").lower() == args.account.lower()]
188
+ if not accounts_cfg:
189
+ print("[refresh-ig-tokens] no instagram accounts in config")
190
+ print("SUMMARY:REFRESHED=0 SKIPPED=0 FAILED=0 ACCOUNTS=0")
191
+ sys.exit(0)
192
+
193
+ lines = load_env_lines()
194
+ env = env_dict_from_lines(lines)
195
+ now = datetime.now(timezone.utc)
196
+ buffer_secs = REFRESH_BUFFER_DAYS * 86400
197
+
198
+ refreshed = 0
199
+ skipped = 0
200
+ failed = 0
201
+
202
+ for account_cfg in accounts_cfg:
203
+ username = account_cfg.get("username", "")
204
+ token_key = account_cfg.get("ig_long_token_env", "IG_LONG_TOKEN")
205
+ exp_key = expires_key_for(token_key)
206
+ if not exp_key:
207
+ log(f"[refresh-ig-tokens] {username}: cannot derive expires key from {token_key}; skipping")
208
+ skipped += 1
209
+ continue
210
+
211
+ cur_token = env.get(token_key)
212
+ if not cur_token:
213
+ log(f"[refresh-ig-tokens] {username}: no value for {token_key}; skipping")
214
+ skipped += 1
215
+ continue
216
+
217
+ cur_exp_raw = env.get(exp_key)
218
+ cur_exp = parse_expires(cur_exp_raw)
219
+ if cur_exp is None and not args.force:
220
+ log(f"[refresh-ig-tokens] {username}: {exp_key} unparseable ({cur_exp_raw!r}); skipping (use --force to refresh anyway)")
221
+ skipped += 1
222
+ continue
223
+
224
+ if cur_exp is not None and not args.force:
225
+ remaining = (cur_exp - now).total_seconds()
226
+ if remaining > buffer_secs:
227
+ days_left = remaining / 86400
228
+ log(f"[refresh-ig-tokens] {username}: {days_left:.1f}d remaining (> {REFRESH_BUFFER_DAYS}d buffer); skipping")
229
+ skipped += 1
230
+ continue
231
+ if remaining < 0:
232
+ log(f"[refresh-ig-tokens] {username}: EXPIRED {(-remaining)/86400:.1f}d ago; attempting refresh anyway (Meta may reject)")
233
+
234
+ if args.dry_run:
235
+ log(f"[refresh-ig-tokens] {username}: DRY-RUN would refresh {token_key} (exp {cur_exp_raw})")
236
+ refreshed += 1
237
+ continue
238
+
239
+ log(f"[refresh-ig-tokens] {username}: refreshing {token_key} (current exp {cur_exp_raw})")
240
+ try:
241
+ resp = refresh_token(cur_token)
242
+ except RefreshError as e:
243
+ log(f"[refresh-ig-tokens] {username}: REFRESH FAILED: {e}")
244
+ failed += 1
245
+ continue
246
+ except Exception as e:
247
+ log(f"[refresh-ig-tokens] {username}: REFRESH FAILED (unexpected): {e}")
248
+ failed += 1
249
+ continue
250
+
251
+ new_token = resp.get("access_token")
252
+ expires_in = resp.get("expires_in")
253
+ if not new_token or not expires_in:
254
+ log(f"[refresh-ig-tokens] {username}: refresh response missing fields: {resp}")
255
+ failed += 1
256
+ continue
257
+
258
+ new_exp_dt = datetime.now(timezone.utc).fromtimestamp(time.time() + expires_in, tz=timezone.utc)
259
+ new_exp_str = format_expires(new_exp_dt)
260
+
261
+ lines = update_line(lines, token_key, new_token)
262
+ lines = update_line(lines, exp_key, new_exp_str)
263
+ env[token_key] = new_token
264
+ env[exp_key] = new_exp_str
265
+
266
+ log(f"[refresh-ig-tokens] {username}: OK, new expiry {new_exp_str} (~{expires_in/86400:.0f}d)")
267
+ refreshed += 1
268
+
269
+ if refreshed and not args.dry_run:
270
+ write_env_atomic(lines)
271
+ log(f"[refresh-ig-tokens] wrote {IG_ENV_PATH}")
272
+
273
+ print(
274
+ f"SUMMARY:REFRESHED={refreshed} SKIPPED={skipped} FAILED={failed} "
275
+ f"ACCOUNTS={len(accounts_cfg)}"
276
+ )
277
+
278
+
279
+ if __name__ == "__main__":
280
+ main()