social-autoposter 1.6.112 → 1.6.113
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/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +36 -0
- package/mcp/menubar/s4l_menubar.py +37 -3
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/project_topics.py +106 -10
- package/scripts/twitter_browser.py +2 -2
- package/scripts/twitter_post_plan.py +2 -5
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.6.
|
|
5
|
+
"version": "1.6.113",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "The disclaimer above is generic Claude boilerplate. S4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L end to end**\n\n2\\. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
|
|
8
8
|
"author": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -241,6 +241,30 @@ class _ReviewController(NSObject):
|
|
|
241
241
|
else:
|
|
242
242
|
self._render()
|
|
243
243
|
|
|
244
|
+
@objc.python_method
|
|
245
|
+
def extend_drafts(self, drafts):
|
|
246
|
+
"""Append newly-queued drafts to an OPEN card. Without this, a card built
|
|
247
|
+
when N drafts were pending froze at "of N": every draft that arrived after
|
|
248
|
+
the card opened was stranded behind it (the menu bar bailed while a panel
|
|
249
|
+
was up). Dedups by plan index `n`, never disturbs the card on screen, and
|
|
250
|
+
refreshes the title-bar counter live so the backlog is honest."""
|
|
251
|
+
if self._panel is None:
|
|
252
|
+
return 0
|
|
253
|
+
have = {d.get("n") for d in self._drafts}
|
|
254
|
+
added = [d for d in drafts if d.get("n") not in have]
|
|
255
|
+
if not added:
|
|
256
|
+
return 0
|
|
257
|
+
self._drafts.extend(added)
|
|
258
|
+
# Update only the "X of N" counter; do NOT re-render the body (that would
|
|
259
|
+
# reset the caret / clobber an in-progress edit on the current card).
|
|
260
|
+
try:
|
|
261
|
+
self._panel.setTitle_(
|
|
262
|
+
f"Review draft {self._idx + 1} of {len(self._drafts)}"
|
|
263
|
+
)
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
return len(added)
|
|
267
|
+
|
|
244
268
|
@objc.python_method
|
|
245
269
|
def _fire_decision(self):
|
|
246
270
|
# Fire the per-card callback the instant a decision is made, so an
|
|
@@ -305,3 +329,15 @@ def present_review(drafts, on_decision=None, on_complete=None):
|
|
|
305
329
|
_active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_(
|
|
306
330
|
drafts, on_decision, on_complete
|
|
307
331
|
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def extend_active(drafts):
|
|
335
|
+
"""Push newly-queued drafts into the open review card, if one is up. Returns
|
|
336
|
+
the count actually added (0 if no card is open or nothing is new). Main thread
|
|
337
|
+
only (called from the menu bar's rumps timer)."""
|
|
338
|
+
if _active is None:
|
|
339
|
+
return 0
|
|
340
|
+
try:
|
|
341
|
+
return _active.extend_drafts(drafts)
|
|
342
|
+
except Exception:
|
|
343
|
+
return 0
|
|
@@ -521,8 +521,6 @@ class S4LMenuBar(rumps.App):
|
|
|
521
521
|
self._posting_batch_done = 0
|
|
522
522
|
|
|
523
523
|
def _maybe_start_review(self):
|
|
524
|
-
if self._review_active:
|
|
525
|
-
return
|
|
526
524
|
req = st.read_review_request()
|
|
527
525
|
if not req:
|
|
528
526
|
return
|
|
@@ -546,6 +544,26 @@ class S4LMenuBar(rumps.App):
|
|
|
546
544
|
sig = tuple((d.get("n"), d.get("reply_text") or "") for d in drafts)
|
|
547
545
|
if sig == self._last_review_sig:
|
|
548
546
|
return
|
|
547
|
+
# A review is already in flight. Two cases:
|
|
548
|
+
# - A card is ON SCREEN (_panel_open): push the newly-queued drafts into
|
|
549
|
+
# the open card so the "X of N" counter and the reviewable stack grow
|
|
550
|
+
# live. This is the fix for the "card froze at 1 of 4 while 137 piled
|
|
551
|
+
# up" bug — drafts that arrived after the card opened used to be
|
|
552
|
+
# stranded because this method returned early on _review_active.
|
|
553
|
+
# - Posting is DRAINING with no panel up (_review_active but not
|
|
554
|
+
# _panel_open): leave the signature untouched so the full pending set
|
|
555
|
+
# is presented fresh once the drain completes (don't pop a card mid-post).
|
|
556
|
+
if self._review_active:
|
|
557
|
+
if self._panel_open:
|
|
558
|
+
try:
|
|
559
|
+
import s4l_card
|
|
560
|
+
|
|
561
|
+
s4l_card.extend_active(drafts)
|
|
562
|
+
except Exception as e:
|
|
563
|
+
sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
|
|
564
|
+
sys.stderr.flush()
|
|
565
|
+
self._last_review_sig = sig
|
|
566
|
+
return
|
|
549
567
|
with self._review_lock:
|
|
550
568
|
self._reset_posting_progress_locked()
|
|
551
569
|
self._review_active = True
|
|
@@ -604,7 +622,23 @@ class S4LMenuBar(rumps.App):
|
|
|
604
622
|
if self._posts_outstanding <= 0:
|
|
605
623
|
self._review_active = False
|
|
606
624
|
self._reset_posting_progress_locked()
|
|
607
|
-
|
|
625
|
+
# Only clear the review marker when the queue is actually drained. The old
|
|
626
|
+
# code cleared it unconditionally, so if the user closed the card with
|
|
627
|
+
# drafts still undecided (or more had piled up than they reviewed), the
|
|
628
|
+
# backlog was stranded — presentation is gated on this marker. Keep it when
|
|
629
|
+
# anything remains so the leftover re-presents fresh on the next tick.
|
|
630
|
+
remaining = 0
|
|
631
|
+
try:
|
|
632
|
+
req = st.read_review_request()
|
|
633
|
+
if req:
|
|
634
|
+
remaining = len(st.review_drafts(st.read_plan(req.get("plan_path") or "")))
|
|
635
|
+
except Exception:
|
|
636
|
+
remaining = 0
|
|
637
|
+
if remaining <= 0:
|
|
638
|
+
st.clear_review_request()
|
|
639
|
+
# Drop the dedup signature so whatever is left is presented fresh (not
|
|
640
|
+
# suppressed as "already shown") once posting finishes draining.
|
|
641
|
+
self._last_review_sig = None
|
|
608
642
|
if not any(d.get("approved") for d in decisions):
|
|
609
643
|
self._notify("S4L", "No drafts approved — nothing posted.")
|
|
610
644
|
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/social-autoposter-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.113",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -2,8 +2,20 @@
|
|
|
2
2
|
"""Shared accessor for project_search_topics.
|
|
3
3
|
|
|
4
4
|
Single chokepoint for every runtime consumer of the per-project topic list
|
|
5
|
-
that used to live in config.json (projects[].search_topics[]).
|
|
6
|
-
|
|
5
|
+
that used to live in config.json (projects[].search_topics[]). The DB
|
|
6
|
+
(project_search_topics) is the living runtime universe (invent/decay/exclude
|
|
7
|
+
operate on it); config.json's search_topics[] is the human-authored SEED.
|
|
8
|
+
|
|
9
|
+
Self-healing seed-on-empty: if the DB has no active topics for a project that
|
|
10
|
+
DOES carry a search_topics[] seed in config.json, this module mirrors that seed
|
|
11
|
+
into the DB once (first-run bootstrap) and re-reads. That makes "add a project
|
|
12
|
+
to config.json" sufficient to make it run, with no separate manual
|
|
13
|
+
seed_search_topics.py step to forget. Before this, a fully-configured project
|
|
14
|
+
(weight, enabled, topics) could silently never run because the manual seed was
|
|
15
|
+
skipped (Capstacker 2026-06, Karol/pamba earlier). This is a SEED-ON-EMPTY, not
|
|
16
|
+
a live config.json fallback: it fires only for a project the DB has never heard
|
|
17
|
+
of, and once rows exist the living state owns the universe and it never runs
|
|
18
|
+
again — so it does not resurrect decayed/excluded topics.
|
|
7
19
|
|
|
8
20
|
Why this module exists: 10+ scripts (pick_project, score_twitter_candidates,
|
|
9
21
|
scan_twitter_mentions_browser, scan_dm_candidates, post_reddit, post_github,
|
|
@@ -34,12 +46,15 @@ Public surface:
|
|
|
34
46
|
"""
|
|
35
47
|
from __future__ import annotations
|
|
36
48
|
|
|
49
|
+
import json
|
|
37
50
|
import os
|
|
38
51
|
import sys
|
|
39
|
-
from typing import Dict, List
|
|
52
|
+
from typing import Dict, List, Set
|
|
40
53
|
|
|
41
54
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
42
55
|
|
|
56
|
+
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
57
|
+
|
|
43
58
|
|
|
44
59
|
class TopicsError(RuntimeError):
|
|
45
60
|
"""Raised when the topics API call itself fails (network, 5xx, auth).
|
|
@@ -50,15 +65,12 @@ class TopicsError(RuntimeError):
|
|
|
50
65
|
|
|
51
66
|
|
|
52
67
|
_CACHE: Dict[str, List[str]] = {}
|
|
68
|
+
_BOOTSTRAP_ATTEMPTED: Set[str] = set()
|
|
53
69
|
|
|
54
70
|
|
|
55
|
-
def
|
|
56
|
-
"""
|
|
57
|
-
|
|
58
|
-
return []
|
|
59
|
-
key = name.strip()
|
|
60
|
-
if key in _CACHE:
|
|
61
|
-
return _CACHE[key]
|
|
71
|
+
def _fetch_active_topics(key: str) -> List[str]:
|
|
72
|
+
"""Read active topics for one project from the DB. Raises TopicsError on a
|
|
73
|
+
real API failure (network, 5xx, auth) so the cycle aborts loudly."""
|
|
62
74
|
try:
|
|
63
75
|
from http_api import api_get
|
|
64
76
|
resp = api_get(
|
|
@@ -78,12 +90,96 @@ def topics_for_project(name: str) -> List[str]:
|
|
|
78
90
|
if t and t not in seen:
|
|
79
91
|
seen.add(t)
|
|
80
92
|
topics.append(t)
|
|
93
|
+
return topics
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _config_topics_for(name: str) -> List[str]:
|
|
97
|
+
"""The seed search_topics[] for one project from config.json (de-duped).
|
|
98
|
+
Used only to bootstrap a project the DB has never seen — see
|
|
99
|
+
_bootstrap_from_config."""
|
|
100
|
+
try:
|
|
101
|
+
with open(CONFIG_PATH) as f:
|
|
102
|
+
cfg = json.load(f)
|
|
103
|
+
except Exception:
|
|
104
|
+
return []
|
|
105
|
+
key = name.strip().lower()
|
|
106
|
+
for p in cfg.get("projects", []):
|
|
107
|
+
if (p.get("name") or "").strip().lower() == key:
|
|
108
|
+
seen, out = set(), []
|
|
109
|
+
for t in (p.get("search_topics") or []):
|
|
110
|
+
t = (t or "").strip()
|
|
111
|
+
if t and t not in seen:
|
|
112
|
+
seen.add(t)
|
|
113
|
+
out.append(t)
|
|
114
|
+
return out
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _bootstrap_from_config(name: str) -> int:
|
|
119
|
+
"""One-time self-heal: mirror a project's config.json search_topics into the
|
|
120
|
+
DB when it has zero active rows. Idempotent (the API upserts on
|
|
121
|
+
install_id+project+topic), so a duplicate/concurrent run is harmless. Never
|
|
122
|
+
raises: a seed failure is logged and treated as "no topics" so the read path
|
|
123
|
+
is never worse than before, just loud instead of silent. Returns the number
|
|
124
|
+
of topics POSTed. Honors SAPS_NO_TOPIC_AUTOSEED=1 for read-only contexts."""
|
|
125
|
+
if os.environ.get("SAPS_NO_TOPIC_AUTOSEED") == "1":
|
|
126
|
+
return 0
|
|
127
|
+
topics = _config_topics_for(name)
|
|
128
|
+
if not topics:
|
|
129
|
+
return 0
|
|
130
|
+
try:
|
|
131
|
+
from http_api import api_post
|
|
132
|
+
except Exception as e:
|
|
133
|
+
sys.stderr.write(
|
|
134
|
+
f"[project_topics] auto-seed unavailable project={name!r}: {e}\n"
|
|
135
|
+
)
|
|
136
|
+
return 0
|
|
137
|
+
seeded = 0
|
|
138
|
+
for topic in topics:
|
|
139
|
+
try:
|
|
140
|
+
api_post(
|
|
141
|
+
"/api/v1/project-search-topics",
|
|
142
|
+
body={"project": name, "topic": topic,
|
|
143
|
+
"source": "seed", "status": "active"},
|
|
144
|
+
)
|
|
145
|
+
seeded += 1
|
|
146
|
+
except Exception as e:
|
|
147
|
+
sys.stderr.write(
|
|
148
|
+
f"[project_topics] auto-seed FAILED project={name!r} "
|
|
149
|
+
f"topic={topic!r}: {e}\n"
|
|
150
|
+
)
|
|
151
|
+
if seeded:
|
|
152
|
+
sys.stderr.write(
|
|
153
|
+
f"[project_topics] auto-seeded {seeded}/{len(topics)} topic(s) for "
|
|
154
|
+
f"project={name!r} from config.json (first-run bootstrap)\n"
|
|
155
|
+
)
|
|
156
|
+
return seeded
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def topics_for_project(name: str) -> List[str]:
|
|
160
|
+
"""Active topics for one project (DB-backed, process-cached, self-healing).
|
|
161
|
+
|
|
162
|
+
On the first read where the DB has no active topics but config.json carries
|
|
163
|
+
a search_topics[] seed, the seed is mirrored into the DB once and re-read,
|
|
164
|
+
so adding a project to config.json is enough to make it run. After that the
|
|
165
|
+
DB is the single living source of truth."""
|
|
166
|
+
if not name:
|
|
167
|
+
return []
|
|
168
|
+
key = name.strip()
|
|
169
|
+
if key in _CACHE:
|
|
170
|
+
return _CACHE[key]
|
|
171
|
+
topics = _fetch_active_topics(key)
|
|
172
|
+
if not topics and key not in _BOOTSTRAP_ATTEMPTED:
|
|
173
|
+
_BOOTSTRAP_ATTEMPTED.add(key)
|
|
174
|
+
if _bootstrap_from_config(key) > 0:
|
|
175
|
+
topics = _fetch_active_topics(key)
|
|
81
176
|
_CACHE[key] = topics
|
|
82
177
|
return topics
|
|
83
178
|
|
|
84
179
|
|
|
85
180
|
def clear_cache() -> None:
|
|
86
181
|
_CACHE.clear()
|
|
182
|
+
_BOOTSTRAP_ATTEMPTED.clear()
|
|
87
183
|
|
|
88
184
|
|
|
89
185
|
if __name__ == "__main__":
|
|
@@ -884,8 +884,8 @@ def _like_first_tweet_on_page(page):
|
|
|
884
884
|
print("[like] clicked like but unlike state not confirmed", file=sys.stderr)
|
|
885
885
|
return {"ok": False, "liked": False, "error": "like_unconfirmed"}
|
|
886
886
|
except Exception as e:
|
|
887
|
-
print(f"[like]
|
|
888
|
-
return {"ok": False, "error": str(e)}
|
|
887
|
+
print(f"[like] parent tweet not liked (non-fatal): {str(e).splitlines()[0]}", file=sys.stderr)
|
|
888
|
+
return {"ok": False, "error": str(e).splitlines()[0]}
|
|
889
889
|
|
|
890
890
|
|
|
891
891
|
def like_tweet(tweet_url):
|
|
@@ -667,11 +667,8 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
667
667
|
flush=True,
|
|
668
668
|
)
|
|
669
669
|
else:
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
f"{like_result.get('error', 'unknown')}",
|
|
673
|
-
flush=True,
|
|
674
|
-
)
|
|
670
|
+
err = str(like_result.get("error", "unknown")).splitlines()[0]
|
|
671
|
+
print(f"[like] candidate {cid} parent tweet not liked (non-fatal): {err}", flush=True)
|
|
675
672
|
|
|
676
673
|
if not reply_url or not REPLY_URL_RE.match(reply_url):
|
|
677
674
|
# Reply was likely sent (browser action returned ok=True with verified)
|