A clone + ./install.sh + ./launch-brave.sh + sign in once. Nothing else. pyproject.toml declares the one real dependency (websockets) and maps the repo-root-is-the-package layout, which retires the PYTHONPATH=<parent> line every unit used to carry. cli.py backs three console scripts. They exist because `python3 -m socialmag` run from INSIDE a clone dies at `import asyncio`: the repo root is the package dir, so it ships selectors.py and queue.py, and python puts the current directory first on sys.path. `cd socialmag` is the first thing anyone does after a clone, so the documented workaround failed at exactly the moment it would be tried. A generated bin/ script never puts cwd on sys.path. The same trap bit `python3 -m venv` inside install.sh — the installer was broken by the thing it exists to fix. Every python3 call now runs from /. deploy/user/*.in replaces root system units with systemd --user templates: no User=, WantedBy=default.target, venv paths, @REPO@/@VENV@/@CDP_ENDPOINT@ substituted at install time. deploy/home/ is untouched and still live here. launch-brave.sh carries --disable-background-timer-throttling. That is load-bearing, not tidiness: five of six platforms are read in a hidden tab, and a throttled background tab stops running the timers those pages use to fetch their own data — the reads come back empty with no error. install.sh checks the mcp package in hermes's own interpreter and refuses to continue without it. Hermes treats mcp as optional and silently no-ops the whole subsystem when it is missing, so a server that never loaded looks exactly like one that loaded and did nothing. It will not merge into an existing mcp_servers: key — it backs up config.yaml and appends only when the key is absent, otherwise it prints the block. Blind YAML merging is how someone's other servers disappear. Verified end-to-end in a sandbox with a fake HOME and stubbed systemctl: real MCP initialize handshake from inside the repo dir returns all 7 tools; hermes's own yaml parser re-reads the written config with every pre-existing key intact; a second run leaves the mcp_servers key count at 1; launch-brave.sh exits 1 against a busy port; no unsubstituted @PLACEHOLDER@ in any rendered unit. Not fixed, pre-existing: server.py reports SERVER_VERSION 0.1.0 while the repo is at v0.7. The handshake shows 0.1.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
290 lines
14 KiB
Markdown
290 lines
14 KiB
Markdown
# socialmag
|
|
|
|
MCP server that posts, reads, and replies across 6 platforms — Threads, Facebook,
|
|
Instagram, YouTube, TikTok, and Twitter/X — by driving abang's already-logged-in
|
|
Brave over CDP. No API keys, no OAuth.
|
|
|
|
## Install
|
|
|
|
```bash
|
|
git clone https://github.com/juxtapo9090/socialmag.git
|
|
cd socialmag
|
|
./install.sh
|
|
```
|
|
|
|
That builds a venv, installs the package, writes four systemd **user** units,
|
|
and registers socialmag with Hermes as an MCP server. Then, once:
|
|
|
|
```bash
|
|
./launch-brave.sh # opens Brave on the debugging port, own profile
|
|
```
|
|
|
|
Sign in to your platforms in that window. socialmag stores no passwords — it
|
|
drives the session that now belongs to that browser profile, which is why this
|
|
step is yours and cannot be scripted.
|
|
|
|
Afterwards: dashboard at `http://127.0.0.1:8321/dashboard.html`, a refresh pass
|
|
every 30 minutes, and `socialmag` / `socialmag-refresh` / `socialmag-openbridge`
|
|
on the venv's `PATH`.
|
|
|
|
⚠️ **Hermes treats the `mcp` Python package as optional.** Without it, every
|
|
`mcp_servers` entry is ignored — no error, one debug log line. `install.sh`
|
|
checks for it in Hermes's own interpreter and refuses to continue rather than
|
|
leave you with a server that looks registered and never loads.
|
|
|
|
⚠️ **`install.sh` will not merge into an existing `mcp_servers:` key.** It backs
|
|
up `config.yaml` and appends only when the key is absent; otherwise it prints the
|
|
block for you to paste. Blind YAML merging is how someone's other servers vanish.
|
|
|
|
⚠️ **User units stop at logout** unless `sudo loginctl enable-linger $USER`.
|
|
The installer says so when linger is off.
|
|
|
|
## Run
|
|
|
|
```bash
|
|
socialmag # stdio MCP server, logs to stderr
|
|
```
|
|
|
|
⚠️ **Use the console script, not `python3 -m socialmag`, from inside the repo.**
|
|
The repo root is the package dir, so it ships `selectors.py` and `queue.py` —
|
|
names the stdlib also uses. Python puts the current directory first on
|
|
`sys.path`, so from inside a clone, asyncio's own `import selectors` finds ours
|
|
and dies with `module 'selectors' has no attribute 'SelectSelector'`. The
|
|
generated `bin/socialmag` never puts the current directory on `sys.path`, so it
|
|
works from anywhere. `-m` still works from outside the repo.
|
|
|
|
The same trap bites `python3 -m venv` during install, which is why `install.sh`
|
|
runs every `python3` from `/`.
|
|
|
|
Brave must be listening on `http://127.0.0.1:9336` (`cdp.py:29`,
|
|
`DEFAULT_HTTP_ENDPOINT`). Override with `SOCIALMAG_CDP_ENDPOINT`.
|
|
|
|
⚠️ **`SOCIALMAG_CDP_PORT` is not read by anything.** `/root/.claude.json` sets it
|
|
for this server, but no code looks at it — grep says the only env var consulted is
|
|
`SOCIALMAG_CDP_ENDPOINT` (`server.py:65`). It appears to work today purely because
|
|
the default endpoint is already `:9336`. Change the port there and nothing happens,
|
|
silently. Fix the name in the config, or teach `server.py` to read the port.
|
|
|
|
Requires `websockets`. Everything else is stdlib.
|
|
|
|
## Tools
|
|
|
|
| Tool | Description |
|
|
|------|-------------|
|
|
| `create_post` | Post to one or more platforms (threads, fb, ig, yt, tiktok, twitter) |
|
|
| `check_login` | Check which platforms are signed in |
|
|
| `get_status` | Last post results + open browser tabs |
|
|
| `get_feed` | Read Twitter timeline or search with X operators |
|
|
| `reply_tweet` | Reply to a tweet found by search |
|
|
| `get_notifications` | Check platform activity/notifications (Threads) |
|
|
| `open_link` | Navigate platform's browser tab to a URL (tab reuse) |
|
|
|
|
`create_post` — `text` is required for threads/fb/yt and used as the caption for
|
|
ig/tiktok. `media` must be an **absolute path to an existing file**; required for
|
|
ig/tiktok, optional for threads/fb, rejected for yt.
|
|
|
|
## Tab reuse and focus
|
|
|
|
`cdp.get_or_open_tab(url, domain)` attaches to the first open tab whose URL
|
|
contains `domain`, and only opens a new tab when none matches. Every tool goes
|
|
through it, so socialmag does not spawn a tab per action.
|
|
|
|
A reused tab is then brought to the front via `Tab.activate()`
|
|
(`Target.activateTarget`). **This is not cosmetic.** A backgrounded tab is not
|
|
guaranteed to finish rendering: YouTube leaves its masthead as
|
|
`#masthead-skeleton-icons` with an empty `#buttons` and never mounts
|
|
`#avatar-btn`, so `youtube.check_login` — which tests for that element — reported
|
|
a signed-in account as signed out. Measured 2026-09-11: `#avatar-btn` still absent
|
|
after 23s in a background tab, while `ytcfg.get('LOGGED_IN')` was `true` the whole
|
|
time. With activation, `yt` returns `True` 3/3; without it, `False` 3/3.
|
|
|
|
**The cost:** any socialmag call that touches a platform now pulls Brave's focus to
|
|
that tab. Fine for a deliberate call, disruptive on a timer — see *Surviving reboot*
|
|
below.
|
|
|
|
A DOM-render signal (`#avatar-btn`) needs the foreground. A JS-state signal
|
|
(`ytcfg.get('LOGGED_IN')`) does not. That distinction is the lever if focus-stealing
|
|
ever has to go away.
|
|
|
|
## Dashboard
|
|
|
|
`dashboard.html` is a local viewer — platform status strip, post/notification
|
|
timeline, and a Twitter intel rail. It runs in two modes:
|
|
|
|
- **Artifact mode** — published as a Claude Artifact with `capabilities: {db: {}}`,
|
|
reading four live collections (`platforms`, `posts`, `notifications`, `feed`)
|
|
over `onSnapshot`.
|
|
- **Local mode** — served over plain HTTP, it falls back to fetching
|
|
`dashboard.json` from beside itself and re-polls every 10s. The freshness line
|
|
reads `local · updated Xs ago`, and the banner is informational, not a warning.
|
|
|
|
```bash
|
|
cd /mnt/playground/Playground_V2 && python3 -m http.server 8321
|
|
# http://127.0.0.1:8321/socialmag/dashboard.html
|
|
```
|
|
|
|
⚠️ **Nothing writes `dashboard.json`.** It is hand-maintained. There is no
|
|
refresher, no writer loop — an earlier version of this README claimed the server
|
|
wrote it at runtime; it does not. See *Surviving reboot* for what building one
|
|
actually costs.
|
|
|
|
Cards and feed rows with a `url` are clickable, behind a `safeUrl()` filter that
|
|
admits `http(s)` only — scraped URLs are untrusted input.
|
|
|
|
### openbridge — clicking without tab explosion
|
|
|
|
A static page cannot speak MCP, so a plain `target="_blank"` card opens a new tab
|
|
on every click. `openbridge.py` exposes exactly one capability — `open_link` — over
|
|
localhost so a click navigates the platform's existing tab instead.
|
|
|
|
```bash
|
|
cd /mnt/playground/Playground_V2 && python3 -m socialmag.openbridge # 127.0.0.1:8322
|
|
```
|
|
|
|
| Endpoint | Returns |
|
|
|---|---|
|
|
| `GET /health` | `{"ok": true, "platforms": [...]}` |
|
|
| `GET /open?platform=X&url=Y` | the `open_link` result, or `{"error": ...}` |
|
|
|
|
It imports `tool_open_link` rather than reimplementing it, so validation and domain
|
|
matching stay in one place. Verified 2026-09-11: 8 tabs before two clicks, 8 after,
|
|
both reused.
|
|
|
|
The dashboard treats an unreachable bridge as "just open a new tab" — progressive
|
|
enhancement, so the page is never worse than without it. Ctrl/Cmd/middle-click is
|
|
left alone. A bridge that answers and *refuses* surfaces the error instead of
|
|
quietly opening a tab.
|
|
|
|
## Layout
|
|
|
|
```
|
|
server.py MCP stdio JSON-RPC loop + 7 tools
|
|
cdp.py CDP client: CDPClient (browser) + Tab (page verbs, incl. activate)
|
|
queue.py PostQueue — strictly sequential, one tab at a time
|
|
selectors.py every platform selector + PLATFORMS dict, one file
|
|
platforms/ one module per platform: post() + check_login()
|
|
openbridge.py localhost HTTP shim exposing open_link to the dashboard
|
|
dashboard.html status/timeline viewer (artifact db mode, or local dashboard.json)
|
|
dashboard.json local-mode data — hand-maintained, nothing writes it
|
|
```
|
|
|
|
Each platform module exposes:
|
|
|
|
```python
|
|
async def post(cdp_client, text, media_path, options) -> {"status": ..., "detail": ...}
|
|
async def check_login(cdp_client) -> bool
|
|
```
|
|
|
|
Twitter additionally exposes `get_feed`, `reply`, and `check_login` (reusing
|
|
the existing selector set). Threads exposes `get_notifications`.
|
|
|
|
`post` opens its own tab, drives the flow, closes the tab. A failure inside the
|
|
flow is caught by `platforms.guard`, which screenshots the page as it actually
|
|
looked into `/tmp/socialmag/` and puts the path in the result.
|
|
|
|
⚠️ **Don't leave anything you want to keep in `/tmp`.** The v2 dashboard work was
|
|
written to `/tmp/socialmag/` and was lost to a `/tmp` clear — git only held the
|
|
pre-v2 file, and it had to be rebuilt from the handback. Guard screenshots are
|
|
disposable by design; build output is not.
|
|
|
|
## House rules honoured
|
|
|
|
- **No silent fallbacks.** Missing media, missing text, an unknown platform, a
|
|
selector that never appears, a CDP call that errors — all raise with the
|
|
reason. Nothing picks a default and carries on. `openbridge` refuses to move to
|
|
a different port if `:8322` is taken, for the same reason.
|
|
- Unmapped flows **refuse** rather than guess: Facebook `visibility` switching
|
|
and YouTube media both raise, because clicking through an unmapped dialog
|
|
outward-facing is worse than a stop.
|
|
- Human-like delays (`random.uniform`) between every discrete action, longer on
|
|
TikTok.
|
|
|
|
---
|
|
|
|
# Surviving reboot, interval updates, and the VPS question
|
|
|
|
**Status: not built.** This section is the requirements map, not a description of
|
|
something running. Everything below was checked on this box on 2026-09-11.
|
|
|
|
## What has to survive, and what each piece actually needs
|
|
|
|
| Piece | Survives reboot today? | What it needs |
|
|
|---|---|---|
|
|
| `dashboard.html` / `dashboard.json` | ✅ files on disk | nothing — edit and refresh; `:8321` reads from disk, JSON re-polls every 10s |
|
|
| file server `:8321` | ❌ | a systemd unit. Started by hand today — parent process is `/bin/fish` |
|
|
| `openbridge` `:8322` | ❌ | a systemd unit |
|
|
| socialmag MCP server | ❌ and **systemd cannot help** | Claude Code spawns it as `"type": "stdio"` (`/root/.claude.json`). No unit can own that process. Editing `server.py`/`cdp.py` requires Claude Code to respawn it |
|
|
| Brave + CDP `:9336` | ❌ | **not a service** — pid 267116, launched by abang. After boot there is no CDP until he opens Brave |
|
|
| the data refresher | ❌ **does not exist** | has to be written — see below |
|
|
|
|
The house already runs this exact shape as system units (`cc-sidepanel-bridge.service`
|
|
— *"localhost HTTP shim to injectd"*), so the pattern to copy is
|
|
`/etc/systemd/system/*.service`, `User=juxtapo`, `Restart=on-failure`,
|
|
`WantedBy=multi-user.target`.
|
|
|
|
## The refresher, and the two things that make it hard
|
|
|
|
Making the dashboard update on an interval means writing something that calls
|
|
`check_login` / `get_feed` / `get_notifications` on a timer and merges the results
|
|
into `dashboard.json` (atomically — write a temp file and rename, so a poll never
|
|
reads a half-written file).
|
|
|
|
That much is small. These two are the real constraints:
|
|
|
|
**1 · A timer steals focus.** Because `get_or_open_tab` now activates the tab it
|
|
reuses (and it has to, or YouTube reads as signed-out), every scheduled refresh
|
|
yanks Brave to whatever tab it is scraping. Three ways out, none free:
|
|
|
|
- accept it, and pick a cadence sparse enough to tolerate (30-60 min)
|
|
- refresh **on demand only** — dashboard always served, data as fresh as the last
|
|
call. No timer, no focus theft
|
|
- rewrite the six `check_login` implementations to use JS-state signals
|
|
(`ytcfg.get('LOGGED_IN')` and equivalents) which work in a background tab, and
|
|
keep the genuinely DOM-dependent work (`get_feed`, `get_notifications`) on demand
|
|
|
|
**2 · Boot order cannot be satisfied.** Brave is not a service, so at boot there is
|
|
no CDP endpoint. A refresher unit must treat "no Brave" as a normal waiting state —
|
|
log it and retry on the next tick — not crash-loop. `Restart=on-failure` with a
|
|
timer is the right shape; a `Type=simple` daemon that exits on a failed connect is
|
|
not.
|
|
|
|
A systemd `.timer` is a better fit than cron here: it survives a missed window with
|
|
`Persistent=true`, logs to the journal, and can be ordered after the network. Cron
|
|
would work, but gives up the journal and the ordering.
|
|
|
|
## Running on a VPS — read this before planning it
|
|
|
|
socialmag's whole premise is *drive the browser abang is already logged into*. A VPS
|
|
has no such browser. Moving it there is not a port, it is a different product. What
|
|
it would actually require:
|
|
|
|
- **A headless Chrome/Brave on the VPS with a persisted profile**, and every one of
|
|
the six accounts logged in *there*. Cookies would have to be established on the
|
|
VPS and kept alive.
|
|
- **A display.** Several of these flows depend on rendered layout — and we now have
|
|
it in writing that background/unrendered pages give wrong answers (the YouTube
|
|
masthead skeleton above). Headless-with-Xvfb is the usual answer; "headless is
|
|
fine" is the usual mistake.
|
|
- **Surviving anti-bot.** A datacentre IP logging into Meta/TikTok/X accounts that
|
|
normally appear from a home connection is the single most likely thing to get the
|
|
accounts challenged or locked. This is a real risk to abang's actual accounts, not
|
|
a theoretical one.
|
|
- **Re-auth with no human present.** Every one of these platforms will eventually
|
|
demand a re-login or a 2FA prompt. On the desktop abang just does it. On a VPS,
|
|
something has to notice and tell him — which means the failure path needs to be
|
|
loud and reach him, not sit in a log.
|
|
|
|
None of this is refused — it is a build, and a larger one than the units above.
|
|
The honest order is: units for the two servers and a refresher on this box first,
|
|
because they are cheap and the answers they produce (how often does a session drop?
|
|
how often does a platform challenge a login?) are exactly the data needed to know
|
|
whether the VPS version is worth it.
|
|
|
|
## Not built (out of the brief's scope)
|
|
|
|
- `post_youtube_video` (video upload) — the brief scoped yt to community posts.
|
|
- `socialmag.service`, `socialmag-bridge.service`, `socialmag-refresh.timer` — the
|
|
units above. Deferred pending abang's call on the focus/cadence trade-off.
|
|
- The refresher itself. Nothing writes `dashboard.json`.
|
|
- Scheduling, analytics, multi-account (proposal "Future").
|