@m13v/s4l 1.7.6-rc.8 → 1.7.6
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 +1 -1
- package/bin/cli.js +33 -9
- package/mcp/dist/index.js +290 -177
- package/mcp/dist/panel.html +31 -17
- package/mcp/dist/setup.js +27 -7
- package/mcp/dist/telemetry.js +15 -6
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +10 -2
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +63 -4
- package/mcp/menubar/s4l_card_canvas.py +62 -11
- package/mcp/menubar/s4l_menubar.py +380 -95
- package/mcp/menubar/s4l_state.py +119 -41
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +58 -14
- package/scripts/amplitude_24h_signups.py +7 -4
- package/scripts/amplitude_signups.py +7 -4
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/claude_job.py +70 -10
- package/scripts/dm_conversation.py +15 -12
- package/scripts/draft_prompt_core.py +132 -64
- package/scripts/engage_github.py +11 -5
- package/scripts/engage_reddit.py +12 -5
- package/scripts/engagement_signal_feedback.py +1 -4
- package/scripts/engagement_styles.py +144 -105
- package/scripts/feedback_digest.py +54 -11
- package/scripts/find_threads.py +9 -7
- package/scripts/github_tools.py +23 -10
- package/scripts/learned_preferences.py +112 -78
- package/scripts/li_discovery.py +4 -1
- package/scripts/link_tail.py +1 -1
- package/scripts/live_status.py +151 -0
- package/scripts/merge_review_queue.py +15 -2
- package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
- package/scripts/pick_project.py +7 -4
- package/scripts/pick_thread_target.py +64 -13
- package/scripts/pick_twitter_thread_target.py +7 -4
- package/scripts/platform_strike_events.py +139 -7
- package/scripts/post_github.py +376 -35
- package/scripts/post_reddit.py +202 -192
- package/scripts/preflight.sh +23 -3
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/project_stats.py +7 -4
- package/scripts/reddit_ban_check.py +324 -0
- package/scripts/reddit_browser.py +56 -19
- package/scripts/reddit_chat_sync.py +10 -11
- package/scripts/reddit_tools.py +20 -4
- package/scripts/release-mcpb.sh +47 -7
- package/scripts/run_moltbook_cycle.py +9 -5
- package/scripts/s4l_ctl.sh +2 -2
- package/scripts/s4l_posting_mode.py +67 -6
- package/scripts/scan_dm_candidates.py +15 -9
- package/scripts/scan_github_replies.py +9 -7
- package/scripts/scan_moltbook_replies.py +7 -6
- package/scripts/scan_reddit_replies.py +11 -7
- package/scripts/scan_twitter_mentions_browser.py +7 -6
- package/scripts/scan_twitter_thread_followups.py +7 -6
- package/scripts/schedule_state.py +8 -4
- package/scripts/score_twitter_candidates.py +8 -3
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/snapshot.py +19 -0
- package/scripts/stats.py +12 -10
- package/scripts/strike_alert.py +165 -12
- package/scripts/sub_moderation_history.py +129 -0
- package/scripts/subreddit_bans_client.py +157 -0
- package/scripts/test_cross_language_parity.py +185 -0
- package/scripts/test_no_silent_fallbacks.py +84 -0
- package/scripts/test_percard_posting.py +3 -3
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/scripts/twitter_gen_links.py +3 -3
- package/scripts/twitter_post_plan.py +4 -4
- package/scripts/virality.py +151 -0
- package/setup/SKILL.md +1 -1
- package/skill/engage-dm-replies.sh +11 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-draft-and-publish.sh +16 -0
- package/skill/run-reddit-search-launchd.sh +1 -0
- package/skill/run-reddit-search.sh +10 -1
- package/skill/run-reddit-threads.sh +12 -0
- package/skill/run-twitter-cycle.sh +104 -88
|
@@ -1,44 +1,61 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Raw-websocket CDP readiness probe for the harness Chrome.
|
|
3
3
|
|
|
4
|
-
Exit 0 when a
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
downstream attach then eats Playwright's 180s default timeout while holding
|
|
9
|
-
the browser lock (S4L-4H, Karol 2026-07-11; identical wedge locally 2026-07-09,
|
|
10
|
-
twice, same Chrome instance both times).
|
|
4
|
+
Exit 0 when a real DevTools websocket handshake completes against the given
|
|
5
|
+
CDP URL (browser-level connect + Browser.getVersion round-trip), 1 when it
|
|
6
|
+
does not. /json/version alone is a LIVENESS check: a Chrome whose ws accept
|
|
7
|
+
path died still passes it; this probe exercises the actual session dispatch.
|
|
11
8
|
|
|
12
|
-
|
|
9
|
+
HISTORY — why raw websocket and NOT Playwright (2026-07-17): the original
|
|
10
|
+
probe used sync_playwright().connect_over_cdp. Wedge-diag captures
|
|
11
|
+
(skill/logs/wedge-diag/, v3 discriminator) proved that during every observed
|
|
12
|
+
"wedge" the raw ws handshake completed in <5s while the Playwright connect
|
|
13
|
+
timed out at 20s — with Chrome's DevToolsHandlerThread idle in kevent64 the
|
|
14
|
+
whole time. The stall lives in Playwright's node driver under machine load,
|
|
15
|
+
NOT in Chrome; the Playwright-based verdict was executing a healthy browser
|
|
16
|
+
roughly hourly for days (every cdp_wedge relaunch 07-14..07-17). A probe must
|
|
17
|
+
not depend on machinery heavier than the thing it probes. The S4L-4H /
|
|
18
|
+
Karol-box incidents that motivated the deep probe are equally covered by the
|
|
19
|
+
ws round-trip below (and in hindsight may have been this same client-side
|
|
20
|
+
stall).
|
|
21
|
+
|
|
22
|
+
Also preserves the renderer-liveness sweep (2026-07-14): a tab whose RENDERER
|
|
23
|
+
crashed ("Aw, Snap") keeps its title/url in every listing while sitting dead;
|
|
24
|
+
probe each page with a trivial Runtime.evaluate and reload it IN PLACE on
|
|
25
|
+
failure — fresh renderer, no kill, no new window, no focus change.
|
|
13
26
|
|
|
14
|
-
|
|
15
|
-
(twitter-backend.sh writes it to skill/logs/cdp-health.json, which
|
|
16
|
-
memory_snapshot.py carries onto the per-minute heartbeat sample).
|
|
27
|
+
Usage: cdp_ready_check.py [CDP_URL] [TIMEOUT_MS]
|
|
17
28
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
liveness
|
|
29
|
+
Prints a one-line JSON verdict to stdout (same shape/keys as before; mode is
|
|
30
|
+
now "raw_ws"). Falls back to an HTTP-only probe when websocket-client is not
|
|
31
|
+
importable, so a bare python3 caller degrades to liveness instead of failing.
|
|
21
32
|
"""
|
|
22
33
|
import json
|
|
23
34
|
import sys
|
|
24
35
|
import time
|
|
36
|
+
import urllib.request
|
|
37
|
+
|
|
38
|
+
# ProxyHandler({}): loopback CDP must never route through a proxy. macOS
|
|
39
|
+
# system proxy settings leak into urllib's default opener, and a box-wide
|
|
40
|
+
# forwarder 403s 127.0.0.1 probes (2026-07-13 root cause of a "wedged
|
|
41
|
+
# Chrome" misdiagnosis).
|
|
42
|
+
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_json(url: str, timeout: float):
|
|
46
|
+
with _OPENER.open(url, timeout=timeout) as r:
|
|
47
|
+
return json.loads(r.read())
|
|
25
48
|
|
|
26
49
|
|
|
27
50
|
def main() -> int:
|
|
28
51
|
url = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:9555").rstrip("/")
|
|
29
|
-
|
|
52
|
+
timeout_s = (int(sys.argv[2]) if len(sys.argv) > 2 else 20000) / 1000.0
|
|
30
53
|
t0 = time.time()
|
|
31
54
|
try:
|
|
32
|
-
|
|
55
|
+
import websocket # websocket-client, present in the owned runtime venv
|
|
33
56
|
except Exception:
|
|
34
|
-
import urllib.request
|
|
35
57
|
try:
|
|
36
|
-
|
|
37
|
-
# macOS system proxy settings leak into urllib's default opener, and
|
|
38
|
-
# a box-wide forwarder 403s 127.0.0.1 probes (2026-07-13 root cause
|
|
39
|
-
# of the "wedged Chrome" misdiagnosis).
|
|
40
|
-
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
41
|
-
opener.open(f"{url}/json/version", timeout=3)
|
|
58
|
+
_OPENER.open(f"{url}/json/version", timeout=3)
|
|
42
59
|
print(json.dumps({"ready": True, "mode": "http-only"}))
|
|
43
60
|
return 0
|
|
44
61
|
except Exception as e:
|
|
@@ -46,35 +63,49 @@ def main() -> int:
|
|
|
46
63
|
"ready": False, "mode": "http-only", "error": str(e)[:120],
|
|
47
64
|
}))
|
|
48
65
|
return 1
|
|
66
|
+
|
|
67
|
+
def _ws_call(ws_url: str, method: str, timeout: float, **params):
|
|
68
|
+
# suppress_origin: Chrome 111+ rejects ws clients whose Origin header
|
|
69
|
+
# is not in --remote-allow-origins (same pattern as the tab parker).
|
|
70
|
+
ws = websocket.create_connection(ws_url, timeout=timeout, suppress_origin=True)
|
|
71
|
+
try:
|
|
72
|
+
ws.send(json.dumps({"id": 1, "method": method, "params": params}))
|
|
73
|
+
deadline = time.time() + timeout
|
|
74
|
+
while time.time() < deadline:
|
|
75
|
+
msg = json.loads(ws.recv())
|
|
76
|
+
if msg.get("id") == 1:
|
|
77
|
+
return msg
|
|
78
|
+
raise TimeoutError(f"no reply to {method}")
|
|
79
|
+
finally:
|
|
80
|
+
ws.close()
|
|
81
|
+
|
|
49
82
|
try:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
# Browser-level handshake: the readiness verdict.
|
|
84
|
+
info = _get_json(f"{url}/json/version", timeout=3)
|
|
85
|
+
_ws_call(info["webSocketDebuggerUrl"], "Browser.getVersion", timeout_s)
|
|
86
|
+
|
|
87
|
+
# Renderer-liveness sweep: revive crashed tabs in place, best-effort;
|
|
88
|
+
# revival must never fail the readiness verdict the wedge gate uses.
|
|
89
|
+
revived = 0
|
|
90
|
+
pages = []
|
|
91
|
+
try:
|
|
92
|
+
pages = [t for t in _get_json(f"{url}/json/list", timeout=3)
|
|
93
|
+
if t.get("type") == "page" and t.get("webSocketDebuggerUrl")]
|
|
94
|
+
for t in pages:
|
|
95
|
+
try:
|
|
96
|
+
_ws_call(t["webSocketDebuggerUrl"], "Runtime.evaluate",
|
|
97
|
+
4.0, expression="1")
|
|
98
|
+
except Exception:
|
|
66
99
|
try:
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
_ws_call(t["webSocketDebuggerUrl"], "Page.reload", 8.0)
|
|
101
|
+
revived += 1
|
|
69
102
|
except Exception:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
pass
|
|
75
|
-
browser.close()
|
|
103
|
+
pass
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
76
107
|
out = {
|
|
77
|
-
"ready": True, "mode": "
|
|
108
|
+
"ready": True, "mode": "raw_ws", "contexts": len(pages),
|
|
78
109
|
"elapsed_s": round(time.time() - t0, 2),
|
|
79
110
|
}
|
|
80
111
|
if revived:
|
|
@@ -83,7 +114,7 @@ def main() -> int:
|
|
|
83
114
|
return 0
|
|
84
115
|
except Exception as e:
|
|
85
116
|
print(json.dumps({
|
|
86
|
-
"ready": False, "mode": "
|
|
117
|
+
"ready": False, "mode": "raw_ws",
|
|
87
118
|
"elapsed_s": round(time.time() - t0, 2),
|
|
88
119
|
"error": str(e)[:200].replace("\n", " "),
|
|
89
120
|
}))
|
package/scripts/claude_job.py
CHANGED
|
@@ -608,14 +608,26 @@ def _atomic_write_text(path: str, text: str) -> None:
|
|
|
608
608
|
|
|
609
609
|
|
|
610
610
|
def _sweep_stale() -> int:
|
|
611
|
-
"""
|
|
611
|
+
"""GC job files orphaned by dead producers. Returns count removed.
|
|
612
|
+
|
|
613
|
+
Two different TTLs on purpose:
|
|
614
|
+
- pending/: a job unclaimed past the producer's whole wait window
|
|
615
|
+
(DEFAULT_TIMEOUT_S + margin) has no live producer left to consume its
|
|
616
|
+
result — drafting it later only burns a worker turn on a stale prompt.
|
|
617
|
+
This is the leak signature of a producer that was SIGKILLed by its
|
|
618
|
+
external wrapper before its own timeout-cleanup ran (2026-07-18: three
|
|
619
|
+
orphaned reddit-draft jobs piled up while paused).
|
|
620
|
+
- running/: a claimed job may legitimately still be drafting, so it gets
|
|
621
|
+
the longer STALE_TTL_S before we conclude the worker died too.
|
|
622
|
+
"""
|
|
612
623
|
removed = 0
|
|
613
624
|
now = time.time()
|
|
614
|
-
|
|
625
|
+
pending_ttl = DEFAULT_TIMEOUT_S + 120
|
|
626
|
+
roots = [(running_dir(), STALE_TTL_S)]
|
|
615
627
|
pend = os.path.join(queue_root(), "pending")
|
|
616
628
|
if os.path.isdir(pend):
|
|
617
|
-
roots += [os.path.join(pend, d) for d in os.listdir(pend)]
|
|
618
|
-
for d in roots:
|
|
629
|
+
roots += [(os.path.join(pend, d), pending_ttl) for d in os.listdir(pend)]
|
|
630
|
+
for d, ttl in roots:
|
|
619
631
|
if not os.path.isdir(d):
|
|
620
632
|
continue
|
|
621
633
|
for name in os.listdir(d):
|
|
@@ -625,7 +637,7 @@ def _sweep_stale() -> int:
|
|
|
625
637
|
try:
|
|
626
638
|
with open(fp) as f:
|
|
627
639
|
created = json.load(f).get("created_at", 0)
|
|
628
|
-
if now - float(created) >
|
|
640
|
+
if now - float(created) > ttl:
|
|
629
641
|
os.remove(fp)
|
|
630
642
|
removed += 1
|
|
631
643
|
except Exception:
|
|
@@ -719,6 +731,13 @@ def cmd_provider(ns) -> int:
|
|
|
719
731
|
% (_before_len - len(prompt))
|
|
720
732
|
)
|
|
721
733
|
|
|
734
|
+
# S4L paused: refuse to enqueue at all. Workers won't claim while paused, so
|
|
735
|
+
# an enqueued job would just sit in pending/ while this producer burns its
|
|
736
|
+
# whole wait window "drafting" and then dies to its external timeout.
|
|
737
|
+
if _is_paused():
|
|
738
|
+
_plog(f"S4L paused; refusing to enqueue {qtype} job")
|
|
739
|
+
return 1
|
|
740
|
+
|
|
722
741
|
job_id = uuid.uuid4().hex
|
|
723
742
|
created = time.time()
|
|
724
743
|
# Cycle batch id (run-twitter-cycle.sh exports BATCH_ID) so every provider.log
|
|
@@ -749,11 +768,31 @@ def cmd_provider(ns) -> int:
|
|
|
749
768
|
_act_write(qtype)
|
|
750
769
|
|
|
751
770
|
res_path = os.path.join(result_dir(), f"{job_id}.json")
|
|
752
|
-
|
|
771
|
+
# Give up before ns.timeout, not at it: external wrappers (post_reddit's
|
|
772
|
+
# subprocess timeout, gtimeout in shell lanes) start their clocks at
|
|
773
|
+
# process start — before stdin read + enqueue — so an internal deadline of
|
|
774
|
+
# exactly created + timeout LOSES that race and the producer gets SIGKILLed
|
|
775
|
+
# before the cleanup below ever runs (2026-07-18 deathwatch "unexpected
|
|
776
|
+
# death" at enqueue+1800s, leaking pending files and a stuck "drafting"
|
|
777
|
+
# label). The 60s margin guarantees the graceful path wins.
|
|
778
|
+
deadline = created + max(60, ns.timeout - 60)
|
|
753
779
|
last_hb = created # last menu-bar heartbeat (see below)
|
|
754
780
|
claimed_at = None # set the moment a worker moves the job pending/ -> running/
|
|
755
781
|
while time.time() < deadline:
|
|
756
782
|
now = time.time()
|
|
783
|
+
# Pause landed mid-wait: abandon immediately with the same cleanup the
|
|
784
|
+
# timeout path does, instead of blocking the full wait window against a
|
|
785
|
+
# queue no worker will drain while paused.
|
|
786
|
+
if _is_paused():
|
|
787
|
+
_plog(f"job {job_id} abandoned: S4L paused mid-wait; cleaning up")
|
|
788
|
+
for p in (pending_path, running_path):
|
|
789
|
+
try:
|
|
790
|
+
os.remove(p)
|
|
791
|
+
except OSError:
|
|
792
|
+
pass
|
|
793
|
+
_act_clear()
|
|
794
|
+
_disarm_deathwatch(job_id)
|
|
795
|
+
return 1
|
|
757
796
|
# A worker claims a job by atomically renaming pending/ -> running/, so the
|
|
758
797
|
# pending file vanishing is our signal that drafting actually STARTED (vs.
|
|
759
798
|
# the job still sitting unclaimed). Latch the claim time once so the label
|
|
@@ -972,12 +1011,26 @@ def _attempt_claim(ns, qtype: str) -> bool:
|
|
|
972
1011
|
return False
|
|
973
1012
|
|
|
974
1013
|
|
|
1014
|
+
def _is_paused() -> bool:
|
|
1015
|
+
# S4L paused (mcp/src/index.ts pauseS4L / the menubar pause button): the
|
|
1016
|
+
# one flag file every pipeline surface honors.
|
|
1017
|
+
return os.path.exists(os.path.join(state_dir(), "paused.flag"))
|
|
1018
|
+
|
|
1019
|
+
|
|
975
1020
|
def cmd_next(ns) -> int:
|
|
976
1021
|
_apply_state_dir_override(ns)
|
|
977
|
-
#
|
|
978
|
-
#
|
|
979
|
-
#
|
|
980
|
-
|
|
1022
|
+
# GC jobs orphaned by dead/killed producers BEFORE claiming (and before the
|
|
1023
|
+
# pause check, so orphans left behind by a pause don't sit until the next
|
|
1024
|
+
# enqueue-side sweep — while paused nothing enqueues, so this is the only
|
|
1025
|
+
# sweeper running). Without this, a resume would hand workers stale jobs
|
|
1026
|
+
# whose producer died long ago.
|
|
1027
|
+
swept = _sweep_stale()
|
|
1028
|
+
if swept:
|
|
1029
|
+
_plog(f"swept {swept} stale queue job file(s) before claim pass")
|
|
1030
|
+
# S4L paused: report no work instead of claiming a job, so an
|
|
1031
|
+
# already-queued draft can't drain/post while paused even though the
|
|
1032
|
+
# scheduled task worker still fires on its own cadence.
|
|
1033
|
+
if _is_paused():
|
|
981
1034
|
print(json.dumps({}))
|
|
982
1035
|
return 0
|
|
983
1036
|
qtype = ns.type
|
|
@@ -997,6 +1050,13 @@ def cmd_next(ns) -> int:
|
|
|
997
1050
|
start = time.time()
|
|
998
1051
|
attempt = 0
|
|
999
1052
|
while True:
|
|
1053
|
+
# Re-check pause on every iteration, not just at entry: a worker
|
|
1054
|
+
# already inside its wait window when Pause is clicked must stop
|
|
1055
|
+
# claiming immediately (observed 2026-07-18: pause at 12:24, a
|
|
1056
|
+
# pre-pause poller claimed a reddit-draft job at 12:29 anyway).
|
|
1057
|
+
if _is_paused():
|
|
1058
|
+
_plog(f"polling {qtype}: paused.flag appeared mid-wait; exiting without claiming")
|
|
1059
|
+
break
|
|
1000
1060
|
if _attempt_claim(ns, qtype):
|
|
1001
1061
|
return 0
|
|
1002
1062
|
attempt += 1
|
|
@@ -42,7 +42,13 @@ import sys
|
|
|
42
42
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
43
43
|
import db as dbmod
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
46
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
47
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
48
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
49
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
50
|
+
from config import config_path as _canonical_config_path, load_config
|
|
51
|
+
CONFIG_PATH = _canonical_config_path()
|
|
46
52
|
|
|
47
53
|
|
|
48
54
|
def _valid_chat_url(platform, url):
|
|
@@ -76,24 +82,21 @@ def _valid_chat_url(platform, url):
|
|
|
76
82
|
return u
|
|
77
83
|
|
|
78
84
|
|
|
79
|
-
def load_config():
|
|
80
|
-
if os.path.exists(CONFIG_PATH):
|
|
81
|
-
with open(CONFIG_PATH) as f:
|
|
82
|
-
return json.load(f)
|
|
83
|
-
return {}
|
|
84
85
|
|
|
85
86
|
|
|
86
87
|
def get_our_account(config, platform):
|
|
87
88
|
accounts = config.get("accounts", {})
|
|
89
|
+
# No hardcoded fallback on any platform: a default handle/username/name stamped
|
|
90
|
+
# on a DM silently mis-attributes it to the repo owner. Resolve from config / env
|
|
91
|
+
# through the one resolver; "" means unknown (caller decides how to degrade).
|
|
92
|
+
from account_resolver import resolve as _resolve_account
|
|
88
93
|
if platform == "reddit":
|
|
89
|
-
return accounts.get("reddit", {}).get("username"
|
|
94
|
+
return accounts.get("reddit", {}).get("username") or _resolve_account("reddit") or ""
|
|
90
95
|
elif platform == "linkedin":
|
|
91
|
-
return accounts.get("linkedin", {}).get("name"
|
|
96
|
+
return accounts.get("linkedin", {}).get("name") or _resolve_account("linkedin") or ""
|
|
92
97
|
elif platform == "x":
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
# and fail loud if absent.
|
|
96
|
-
from account_resolver import resolve as _resolve_account
|
|
98
|
+
# Twitter fails LOUD if unresolved (an outbound DM must carry a real
|
|
99
|
+
# sender); reddit/linkedin above degrade to "" since their callers can.
|
|
97
100
|
h = _resolve_account("twitter")
|
|
98
101
|
if not h:
|
|
99
102
|
raise RuntimeError(
|