diff --git a/.gitignore b/.gitignore index 8143aa8..e5b0edb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ state.json Archived/ socialmag.db deploy/kdmirah/ +.venv/ +*.egg-info/ diff --git a/README.md b/README.md index 598d261..9d60cd6 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,57 @@ MCP server that posts, reads, and replies across 6 platforms — Threads, Facebo 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 -cd /mnt/playground/Playground_V2 -python3 -m socialmag # stdio MCP server, logs to stderr +socialmag # stdio MCP server, logs to stderr ``` -⚠️ **Must run as a package.** `python3 socialmag/server.py` puts the package -dir first on `sys.path`, where `selectors.py` and `queue.py` shadow the stdlib -modules `asyncio` imports — asyncio then dies at import with -`module 'selectors' has no attribute 'SelectSelector'`. Run `-m`. +⚠️ **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`. @@ -25,7 +65,7 @@ for this server, but no code looks at it — grep says the only env var consulte 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` (17.1 present on this box). Everything else is stdlib. +Requires `websockets`. Everything else is stdlib. ## Tools diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..deda6a6 --- /dev/null +++ b/cli.py @@ -0,0 +1,46 @@ +"""Console-script entry points. + +These exist for one reason: `python3 -m socialmag` from inside the repo +directory dies at `import asyncio`. + +The repo root is the package dir, so it holds `selectors.py` and `queue.py` — +names the stdlib also uses. `-m` puts the current directory first on +`sys.path`, so asyncio's own `import selectors` finds ours and fails with +`module 'selectors' has no attribute 'SelectSelector'`. Running from one +directory up avoids it, which is what the old PYTHONPATH units did. + +A console script is generated in the venv's `bin/` and run by absolute path, so +the current directory never reaches `sys.path` at all. `cd socialmag` — the +first thing anyone does after a clone — stops being a trap. + +The `-m` forms still work from outside the repo directory. +""" + +import asyncio +import sys + +from . import openbridge as _openbridge +from . import refresher as _refresher +from . import server as _server + + +def serve() -> None: + """socialmag — the stdio MCP server.""" + try: + asyncio.run(_server.main()) + except KeyboardInterrupt: + pass + + +def refresh() -> int: + """socialmag-refresh — one refresher pass, then exit.""" + return asyncio.run(_refresher.main()) + + +def openbridge() -> None: + """socialmag-openbridge — the localhost open_link HTTP shim.""" + _openbridge.main() + + +if __name__ == "__main__": + sys.exit(serve()) diff --git a/deploy/user/socialmag-dash.service.in b/deploy/user/socialmag-dash.service.in new file mode 100644 index 0000000..a2e7ba6 --- /dev/null +++ b/deploy/user/socialmag-dash.service.in @@ -0,0 +1,19 @@ +[Unit] +Description=socialmag dashboard file server (:8321) +After=network.target + +[Service] +Type=simple +# Serves the repo directory itself, so the page is at +# http://127.0.0.1:8321/dashboard.html — refresher writes dashboard.json in +# here too, both resolved off the package's own __file__. +WorkingDirectory=@REPO@ +# No --bind: the page is reachable from the LAN, not only from this box. +# openbridge stays loopback-only, so a LAN viewer just falls back to opening a +# new tab — progressive enhancement, and the page is never worse than it was. +ExecStart=@VENV@/bin/python -m http.server 8321 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/deploy/user/socialmag-openbridge.service.in b/deploy/user/socialmag-openbridge.service.in new file mode 100644 index 0000000..7485803 --- /dev/null +++ b/deploy/user/socialmag-openbridge.service.in @@ -0,0 +1,17 @@ +[Unit] +Description=socialmag openbridge (:8322) — open_link over localhost HTTP +After=network.target + +[Service] +Type=simple +WorkingDirectory=@REPO@ +Environment=SOCIALMAG_CDP_ENDPOINT=@CDP_ENDPOINT@ +ExecStart=@VENV@/bin/socialmag-openbridge +# openbridge refuses to move to another port if :8322 is taken — no silent +# fallback — so a clash fails loudly here instead of listening somewhere nobody +# is looking. +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/deploy/user/socialmag-refresh.service.in b/deploy/user/socialmag-refresh.service.in new file mode 100644 index 0000000..15aa87a --- /dev/null +++ b/deploy/user/socialmag-refresh.service.in @@ -0,0 +1,16 @@ +[Unit] +Description=socialmag refresh — login states, activity, timeline capture +# The browser is not a service. There is nothing to order against, so a pass +# that finds no CDP records the third state and exits 0. + +[Service] +Type=oneshot +WorkingDirectory=@REPO@ +Environment=SOCIALMAG_CDP_ENDPOINT=@CDP_ENDPOINT@ +# --skip-foreground is the whole reason this can run on a desktop someone is +# using. Every other platform reads a JS-state signal in a hidden tab; tiktok +# still activates, so it is left out and keeps its last stored result with its +# own older checkedAt. Stale is honest. Drop the flag for a full pass by hand. +ExecStart=@VENV@/bin/socialmag-refresh --skip-foreground +# No Restart=: a down browser is a waiting state the refresher records and exits +# 0 for. The timer brings the next attempt, not a crash loop. diff --git a/deploy/user/socialmag-refresh.timer b/deploy/user/socialmag-refresh.timer new file mode 100644 index 0000000..d42cd9f --- /dev/null +++ b/deploy/user/socialmag-refresh.timer @@ -0,0 +1,15 @@ +[Unit] +Description=socialmag refresh every 30 minutes + +[Timer] +# 2 min after login so the browser has a chance to be up; a pass that finds no +# browser still records the unknown state rather than skipping. +OnStartupSec=2min +OnUnitActiveSec=30min +# A pass takes ~30s with --skip-foreground. 30min is the desktop number: there +# is a human at this box, and the metrics table only needs enough resolution to +# watch engagement move, not to watch it live. +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..35fd5c4 --- /dev/null +++ b/install.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# socialmag installer — clone, run this, sign in, done. +# +# What it does not do: log you in. socialmag holds no passwords, which is the +# whole design. The last step is yours and it only happens once. +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV="${SOCIALMAG_VENV:-$REPO/.venv}" +CDP_ENDPOINT="${SOCIALMAG_CDP_ENDPOINT:-http://127.0.0.1:9336}" +UNIT_DIR="$HOME/.config/systemd/user" +HERMES_CONFIG="${HERMES_CONFIG:-$HOME/.hermes/config.yaml}" + +say() { printf '\n\033[1;36m==>\033[0m %s\n' "$*"; } +ok() { printf ' \033[32mok\033[0m %s\n' "$*"; } +warn() { printf ' \033[33m!!\033[0m %s\n' "$*"; } +die() { printf '\n\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +# ---------------------------------------------------------------- python + +say "Python" +command -v python3 >/dev/null 2>&1 || die "python3 not found on PATH" +# Every python3 call below runs from / rather than from here. 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 `python3 -m venv` +# run from inside a fresh clone dies at `import subprocess` with +# "module 'selectors' has no attribute 'SelectSelector'". Running from / is the +# whole fix. (This is the same trap cli.py exists to keep off users.) +(cd / && python3 - <<'PY') || die "socialmag needs Python 3.10 or newer" +import sys +sys.exit(0 if sys.version_info >= (3, 10) else 1) +PY +ok "$(cd / && python3 -V)" + +# ---------------------------------------------------------------- venv + +say "Virtualenv + package" +if [[ ! -x "$VENV/bin/python" ]]; then + (cd / && python3 -m venv "$VENV") || die "could not create a venv at $VENV + Two things cause this. Read the traceback above to tell them apart: + - 'No module named venv' -> the venv module is not installed. + Debian/Ubuntu: sudo apt install python3-venv + - anything else -> report it; the installer already runs python + from / to keep this repo off sys.path." + ok "created $VENV" +else + ok "reusing $VENV" +fi + +# Editable on purpose. socialmag resolves socialmag.db, state.json and +# dashboard.json relative to its own __file__; a non-editable install would put +# all three inside site-packages, where nothing else looks for them. +(cd / && "$VENV/bin/pip" install --quiet --upgrade pip) >/dev/null +(cd / && "$VENV/bin/pip" install --quiet -e "$REPO") || die "pip install failed" +ok "socialmag installed (editable), websockets pulled in" + +for script in socialmag socialmag-refresh socialmag-openbridge; do + [[ -x "$VENV/bin/$script" ]] || die "console script $script was not generated — pyproject.toml [project.scripts] did not take" +done +ok "console scripts: socialmag, socialmag-refresh, socialmag-openbridge" + +# ---------------------------------------------------------------- systemd + +say "systemd user units" +if ! command -v systemctl >/dev/null 2>&1; then + warn "systemctl not found — skipping units" + warn "run the three processes by hand: socialmag, socialmag-openbridge, and 'python -m http.server 8321' in $REPO" +else + mkdir -p "$UNIT_DIR" + for src in "$REPO"/deploy/user/*.in; do + dest="$UNIT_DIR/$(basename "${src%.in}")" + sed -e "s|@REPO@|$REPO|g" \ + -e "s|@VENV@|$VENV|g" \ + -e "s|@CDP_ENDPOINT@|$CDP_ENDPOINT|g" \ + "$src" > "$dest" + ok "$(basename "$dest")" + done + cp "$REPO/deploy/user/socialmag-refresh.timer" "$UNIT_DIR/socialmag-refresh.timer" + ok "socialmag-refresh.timer" + + systemctl --user daemon-reload + systemctl --user enable --now socialmag-dash.service socialmag-openbridge.service >/dev/null + systemctl --user enable --now socialmag-refresh.timer >/dev/null + ok "enabled: dash (:8321), openbridge (:8322), refresh timer (30min)" + + # These die at logout unless lingering is on. Say so rather than let someone + # discover it when the dashboard is empty tomorrow. + if ! loginctl show-user "$USER" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then + warn "user services stop when you log out. To keep them running:" + warn " sudo loginctl enable-linger $USER" + fi +fi + +# ---------------------------------------------------------------- hermes + +say "Hermes MCP registration" +HERMES_PY="/usr/local/lib/hermes-agent/venv/bin/python" + +if ! command -v hermes >/dev/null 2>&1; then + warn "hermes not on PATH — skipping. Register manually later:" + printf '\n mcp_servers:\n socialmag:\n command: "%s/bin/socialmag"\n args: []\n env:\n SOCIALMAG_CDP_ENDPOINT: "%s"\n\n' "$VENV" "$CDP_ENDPOINT" +elif [[ ! -f "$HERMES_CONFIG" ]]; then + die "hermes is installed but $HERMES_CONFIG does not exist. + Start hermes once so it writes a config, then re-run this installer." +else + # Hermes treats the mcp python package as optional: without it the entire MCP + # subsystem is a no-op that logs at debug level. A server that never loaded + # looks exactly like one that loaded and did nothing, so check it here rather + # than let that be discovered later. + if [[ -x "$HERMES_PY" ]]; then + if "$HERMES_PY" -c "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('mcp') else 1)"; then + ok "hermes has the mcp package" + else + die "hermes cannot load MCP servers: the 'mcp' package is missing from + $HERMES_PY + Without it hermes silently ignores every mcp_servers entry — no error, only a + debug log line. Install it into that interpreter, then re-run: + $HERMES_PY -m pip install mcp" + fi + else + warn "could not find hermes's interpreter at $HERMES_PY — cannot check for the mcp package" + warn "if socialmag's tools never appear in hermes, that is the first thing to check" + fi + + if grep -qE '^\s*mcp_servers\s*:' "$HERMES_CONFIG"; then + warn "$HERMES_CONFIG already has an mcp_servers: key — not touching it." + warn "Merging YAML blind is how someone's other servers disappear. Add this by hand:" + printf '\n socialmag:\n command: "%s/bin/socialmag"\n args: []\n env:\n SOCIALMAG_CDP_ENDPOINT: "%s"\n\n' "$VENV" "$CDP_ENDPOINT" + else + backup="$HERMES_CONFIG.bak-pre-socialmag-$(date +%Y%m%d%H%M%S)" + cp "$HERMES_CONFIG" "$backup" + cat >> "$HERMES_CONFIG" </dev/null 2>&1; then + BRAVE="$(command -v "$candidate")" + break + fi +done + +if [[ -z "$BRAVE" ]]; then + echo "error: no brave binary found on PATH" >&2 + echo " looked for: brave, brave-browser, brave-browser-stable" >&2 + echo " install Brave, or set BRAVE=/path/to/brave and re-run" >&2 + exit 1 +fi + +if curl -sf --max-time 2 "http://127.0.0.1:${PORT}/json/version" >/dev/null 2>&1; then + echo "error: something is already listening on 127.0.0.1:${PORT}" >&2 + echo " socialmag would drive that browser, not the one this script opens." >&2 + echo " close it first, or set SOCIALMAG_CDP_PORT to a free port." >&2 + exit 1 +fi + +mkdir -p "$PROFILE" + +echo "brave : $BRAVE" +echo "profile : $PROFILE" +echo "cdp : http://127.0.0.1:${PORT}" +echo + +# --disable-background-timer-throttling is load-bearing, not tidiness. socialmag +# reads five of six platforms in a HIDDEN tab so it never steals your focus. A +# throttled background tab stops running the timers those pages use to fetch +# their own data, and the reads come back empty with no error. +exec "$BRAVE" \ + --remote-debugging-port="$PORT" \ + --user-data-dir="$PROFILE" \ + --disable-background-timer-throttling \ + "$@" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4f19d77 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "socialmag" +version = "0.6.0" +description = "MCP server that posts, reads, and replies across 6 platforms by driving an already-logged-in Brave over CDP" +requires-python = ">=3.10" +dependencies = ["websockets>=12"] + +[project.urls] +Repository = "https://github.com/juxtapo9090/socialmag" + +# Console scripts, not `-m`. See cli.py for why: `-m socialmag` run from inside +# the repo directory dies at `import asyncio`, because the repo root is the +# package dir and ships `selectors.py`/`queue.py`. A generated bin/ script never +# puts the current directory on sys.path. +[project.scripts] +socialmag = "socialmag.cli:serve" +socialmag-refresh = "socialmag.cli:refresh" +socialmag-openbridge = "socialmag.cli:openbridge" + +# The repo root IS the package dir — `socialmag/__init__.py` sits beside this +# file. That is why every unit used to carry PYTHONPATH=: the import +# name only resolved from one directory up. The mapping below tells setuptools +# the same thing once, so an installed package imports from anywhere and the +# PYTHONPATH line disappears from the units. +[tool.setuptools] +package-dir = { "socialmag" = "." } +packages = ["socialmag", "socialmag.platforms"] + +[tool.setuptools.package-data] +socialmag = ["dashboard.html", "dashboard.json"]