v0.3: dashboard local mode, openbridge tab reuse, tab activation fix
- dashboard.html: local dashboard.json mode (10s poll) alongside artifact db mode; clickable cards behind an http(s)-only safeUrl filter; meta charset; mode-aware freshness line - openbridge.py: localhost shim exposing open_link so dashboard clicks reuse the platform's warm tab instead of spawning one per click - cdp.py: Tab.activate(); get_or_open_tab activates reused tabs — a backgrounded tab never finishes rendering, which made YouTube's login check read a signed-in account as signed out - README/MANIFEST: corrected CDP port (9336, was documented as 9222), removed the false claim that the server writes dashboard.json, documented the reboot/VPS requirements - .gitignore: __pycache__/, *.pyc, state.json, Archived/; removed stale tracked pycache and state.json from index
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
state.json
|
||||
Archived/
|
||||
+103
-34
@@ -1,39 +1,50 @@
|
||||
# socialmag — Social Media MCP Server
|
||||
|
||||
**One tool, all platforms.** Post to Threads, Facebook, Instagram, TikTok (and YouTube when eligible) through a single MCP call. Built on CDP — no API keys, no rate limits, no approval process. Just a browser and a logged-in account.
|
||||
**One tool, all platforms.** Post to Threads, Facebook, Instagram, TikTok, Twitter/X (and YouTube when eligible) through a single MCP call. Built on CDP — no API keys, no rate limits, no approval process. Just a browser and a logged-in account.
|
||||
|
||||
## What It Does
|
||||
|
||||
**Write**
|
||||
- **`create_post`** — Post text/media to one or more platforms in one call
|
||||
- **`reply_tweet`** — Reply to a tweet found by search
|
||||
|
||||
**Read**
|
||||
- **`check_login`** — Verify which platforms are logged in
|
||||
- **`get_status`** — Last post results + open browser tabs
|
||||
- **`get_feed`** — Twitter timeline or search, with X operators (`from:`, `min_faves:`, …)
|
||||
- **`get_notifications`** — Platform activity (Threads)
|
||||
|
||||
**Navigate**
|
||||
- **`open_link`** — Send a platform's existing tab to a URL, instead of spawning a new one
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Claude/MCP Client
|
||||
↓ stdio (JSON-RPC)
|
||||
socialmag server.py
|
||||
Claude/MCP Client dashboard.html (browser)
|
||||
↓ stdio (JSON-RPC) ↓ fetch (localhost)
|
||||
socialmag server.py ←────────────── openbridge.py :8322
|
||||
↓ CDP WebSocket
|
||||
Brave Browser (dedicated instance, port 9336)
|
||||
↓ real browser sessions
|
||||
Threads · Facebook · Instagram · TikTok · YouTube
|
||||
Threads · Facebook · Instagram · TikTok · YouTube · Twitter/X
|
||||
```
|
||||
|
||||
- **Tab reuse** — attaches to existing tabs, no cold loads
|
||||
- **Tab activation** — a reused tab is brought to the front, because a backgrounded tab is not guaranteed to have rendered (see *Journey*)
|
||||
- **Per-platform modules** — each platform has its own flow + selectors
|
||||
- **Sequential queue** — one platform at a time, no race conditions
|
||||
- **Error screenshots** — failed flows capture the page state to `/tmp/socialmag/`
|
||||
|
||||
## Platform Status
|
||||
|
||||
| Platform | Text | Media | Tab Reuse | Status |
|
||||
|----------|:----:|:-----:|:---------:|--------|
|
||||
| Threads | ✅ | 🔲 | ✅ | **Live** — inline composer |
|
||||
| Facebook | ✅ | 🔲 | ✅ | **Live** — dialog flow |
|
||||
| Instagram | — | ✅ | ✅ | **Live** — media required |
|
||||
| TikTok | — | ✅ | ✅ | **Live** — video/photo upload |
|
||||
| YouTube | 🔲 | 🔲 | ✅ | **Parked** — needs 500 subs |
|
||||
| Platform | Text | Media | Read | Status |
|
||||
|----------|:----:|:-----:|:----:|--------|
|
||||
| Threads | ✅ | 🔲 | ✅ | **Live** — inline composer + activity scraping |
|
||||
| Facebook | ✅ | 🔲 | — | **Live** — dialog flow |
|
||||
| Instagram | — | ✅ | — | **Live** — media required |
|
||||
| TikTok | — | ✅ | — | **Live** — video/photo upload |
|
||||
| Twitter/X | ✅ | 🔲 | ✅ | **Live** — post, reply, timeline + search |
|
||||
| YouTube | 🔲 | 🔲 | — | **Parked** — needs 500 subs |
|
||||
|
||||
✅ = tested live · 🔲 = coded but untested · — = not applicable
|
||||
|
||||
@@ -56,33 +67,86 @@ brave --remote-debugging-port=9336 \
|
||||
|
||||
Log into each platform manually once. socialmag reuses those sessions.
|
||||
|
||||
## Future Roadmap
|
||||
---
|
||||
|
||||
### v0.2 — Read Side
|
||||
- [ ] `get_notifications(platform)` — pull notifications via CDP scraping
|
||||
- [ ] `get_comments(platform, post_id?)` — read comments on recent posts
|
||||
- [ ] `get_engagement(platform)` — likes, shares, reach per post
|
||||
## Journey So Far
|
||||
|
||||
### v0.3 — Monitoring
|
||||
- [ ] Cron/loop polling — periodic checks, dump to JSON
|
||||
### v0.1 — 2026-09-10 · it posts
|
||||
Five platforms coded, four live. Threads, Facebook, Instagram, TikTok posting
|
||||
through a real logged-in browser. YouTube parked behind its 500-subscriber gate.
|
||||
First automated post from the house went out that night.
|
||||
|
||||
### v0.2 — 2026-09-11 (early hours) · it reads
|
||||
Twitter/X joined as a full module — post, reply, timeline, search.
|
||||
`get_notifications` landed for Threads. `open_link` built for warm-tab reuse.
|
||||
Pushed to a private GitHub repo.
|
||||
|
||||
**What the read side cost that the plan didn't predict:**
|
||||
- The Threads scraper was believed to already return a post `link`. It did not —
|
||||
it returned `{text}` only. Row detection had to be built from the live DOM. The
|
||||
rule that worked: *a row is an element that links somewhere, carries a
|
||||
relative-time marker, and contains no smaller element that does the same.*
|
||||
- A tweet's permalink is **not** "the first `/status/` href" — an article carries
|
||||
three (canonical, `/analytics`, `/photo/N`). It's the anchor wrapping `<time>`.
|
||||
|
||||
### v0.3 — 2026-09-11 (morning) · it shows, and it stops lying
|
||||
|
||||
**Dashboard, two modes.** Artifact mode reads four live `db` collections over
|
||||
`onSnapshot`. Local mode falls back to `dashboard.json` beside the page, polling
|
||||
every 10s, served off any plain HTTP server.
|
||||
|
||||
**Clicks stopped exploding.** `openbridge.py` exposes `open_link` over localhost so
|
||||
a dashboard card navigates the platform's existing tab. Verified: 8 tabs before two
|
||||
clicks, 8 after. It imports `tool_open_link` rather than reimplementing it.
|
||||
|
||||
**The YouTube false-negative.** `check_login` reported YouTube signed out while the
|
||||
account was signed in. Not a race, not an account problem — a **backgrounded tab
|
||||
never finishes rendering**. YouTube leaves its masthead as
|
||||
`#masthead-skeleton-icons` with an empty `#buttons`, so `#avatar-btn` — the element
|
||||
the check looks for — never mounts. Measured: still absent after 23 seconds, while
|
||||
`ytcfg.get('LOGGED_IN')` was `true` the whole time. Fixed by activating a reused tab
|
||||
(`Target.activateTarget`): `yt` now returns `True` 3/3, against `False` 3/3 before.
|
||||
|
||||
The general lesson, written down because it will bite again: **a DOM-render signal
|
||||
needs the foreground; a JS-state signal does not.**
|
||||
|
||||
**What was lost.** The v2 dashboard files were written to `/tmp/socialmag/` and did
|
||||
not survive a `/tmp` clear. Git held only the pre-v2 version. They were rebuilt from
|
||||
the field handback. Everything since is written into the repo.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Done
|
||||
- [x] `get_notifications(platform)` — Threads activity via CDP scraping *(v0.2)*
|
||||
- [x] `get_feed` — Twitter timeline + search *(v0.2)*
|
||||
- [x] `reply_tweet` *(v0.2)*
|
||||
- [x] Web dashboard — one view for all platform activity *(v0.3)*
|
||||
- [x] `open_link` + openbridge — tab reuse from the dashboard *(v0.3)*
|
||||
|
||||
### Next — monitoring
|
||||
- [ ] **A writer for `dashboard.json`** — nothing writes it today; it is hand-maintained
|
||||
- [ ] Periodic refresh (systemd timer over cron: journal + ordering + `Persistent=true`)
|
||||
- [ ] Units so `:8321` and `:8322` survive reboot
|
||||
- [ ] Unified notification feed across all platforms
|
||||
- [ ] Alert on new comments/DMs
|
||||
- [ ] `get_comments`, `get_engagement`
|
||||
|
||||
### v0.4 — Dashboard
|
||||
- [ ] Web dashboard — one view for all platform activity
|
||||
- [ ] Post scheduler — queue posts for specific times
|
||||
- [ ] Analytics — engagement trends, best posting times
|
||||
|
||||
### v0.5 — Media Pipeline
|
||||
- [ ] Threads media posting (image + video)
|
||||
- [ ] Facebook media posting
|
||||
- [ ] YouTube video upload (separate from community posts)
|
||||
- [ ] Auto-resize/format media per platform requirements
|
||||
⚠️ **The blocker isn't the code.** Interval refresh now steals browser focus by
|
||||
design, since tab activation is what makes login checks truthful. And Brave is not a
|
||||
service — after boot there is no CDP until it's opened by hand, so a refresher must
|
||||
treat "no browser" as a waiting state, not a failure. See README → *Surviving
|
||||
reboot*.
|
||||
|
||||
### Later
|
||||
- [ ] Post scheduler + content calendar
|
||||
- [ ] Analytics — engagement trends, best posting times
|
||||
- [ ] Media pipeline — Threads/Facebook media, YouTube video upload, per-platform resize
|
||||
- [ ] Multi-account support
|
||||
- [ ] Platform API integration (where available + worthwhile)
|
||||
- [ ] Content calendar
|
||||
- [ ] VPS deployment — **a different product, not a port.** Needs a headless browser
|
||||
with all six accounts logged in *there*, a display (unrendered pages give wrong
|
||||
answers — see the YouTube finding), and it puts real accounts at risk of being
|
||||
challenged from a datacentre IP
|
||||
- [ ] AI-assisted caption generation
|
||||
|
||||
## File Structure
|
||||
@@ -91,17 +155,22 @@ Log into each platform manually once. socialmag reuses those sessions.
|
||||
socialmag/
|
||||
├── __init__.py # package marker
|
||||
├── __main__.py # python -m socialmag entry
|
||||
├── server.py # MCP stdio server + tool handlers
|
||||
├── cdp.py # CDP WebSocket client
|
||||
├── server.py # MCP stdio server + 7 tool handlers
|
||||
├── cdp.py # CDP WebSocket client (CDPClient + Tab, incl. activate)
|
||||
├── selectors.py # all platform selectors in one file
|
||||
├── queue.py # sequential post queue
|
||||
├── openbridge.py # localhost HTTP shim — open_link for the dashboard
|
||||
├── dashboard.html # status/timeline viewer (artifact db or local JSON)
|
||||
├── dashboard.json # local-mode data — hand-maintained, nothing writes it
|
||||
├── platforms/
|
||||
│ ├── __init__.py # shared helpers (guard, require_media, etc.)
|
||||
│ ├── threads.py
|
||||
│ ├── facebook.py
|
||||
│ ├── instagram.py
|
||||
│ ├── tiktok.py
|
||||
│ ├── twitter.py
|
||||
│ └── youtube.py
|
||||
├── README.md # how to run it, and what it costs to keep running
|
||||
└── MANIFEST.md # this file
|
||||
```
|
||||
|
||||
|
||||
@@ -16,8 +16,14 @@ 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`.
|
||||
|
||||
Brave must be listening on `http://127.0.0.1:9222`. Override with
|
||||
`SOCIALMAG_CDP_ENDPOINT`.
|
||||
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` (17.1 present on this box). Everything else is stdlib.
|
||||
|
||||
@@ -37,21 +43,89 @@ Requires `websockets` (17.1 present on this box). Everything else is stdlib.
|
||||
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 — open it in a browser tab to see platform
|
||||
status, post timeline, and Twitter intel. Reads from `dashboard.json` (written
|
||||
by the server at runtime), auto-refreshes every 10 seconds.
|
||||
`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)
|
||||
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()
|
||||
dashboard.html local status/timeline viewer (reads dashboard.json)
|
||||
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:
|
||||
@@ -68,19 +142,108 @@ the existing selector set). Threads exposes `get_notifications`.
|
||||
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.
|
||||
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` — explicitly deferred until after live validation.
|
||||
- `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").
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -176,9 +176,14 @@ class CDPClient:
|
||||
|
||||
async def get_or_open_tab(self, url, url_contains):
|
||||
"""Reuse a warm existing tab if one matches, otherwise open a fresh one.
|
||||
Returns (tab, reused: bool). Caller should skip tab.close() when reused."""
|
||||
Returns (tab, reused: bool). Caller should skip tab.close() when reused.
|
||||
|
||||
A reused tab is brought to the front — see Tab.activate() for why a
|
||||
backgrounded tab cannot be trusted to have rendered.
|
||||
"""
|
||||
try:
|
||||
tab = await self.attach_existing(url_contains)
|
||||
await tab.activate()
|
||||
return tab, True
|
||||
except CDPError:
|
||||
tab = await self.open_tab(url)
|
||||
@@ -205,6 +210,18 @@ class Tab:
|
||||
await self.client.send("Target.closeTarget", {"targetId": self.target_id})
|
||||
self.client._drop_session(self.session_id)
|
||||
|
||||
async def activate(self):
|
||||
"""Bring this tab to the front.
|
||||
|
||||
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 a login check that looks
|
||||
for a rendered element reads a signed-in account as signed out — and
|
||||
waiting does not help (measured 2026-09-11: still absent after 23s,
|
||||
while ytcfg LOGGED_IN was true the whole time).
|
||||
"""
|
||||
await self.client.send("Target.activateTarget", {"targetId": self.target_id})
|
||||
|
||||
# ---- navigation ----------------------------------------------------
|
||||
|
||||
async def navigate(self, url, timeout=DEFAULT_TIMEOUT):
|
||||
|
||||
+198
-17
@@ -1,3 +1,4 @@
|
||||
<meta charset="utf-8">
|
||||
<title>socialmag console</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -374,6 +375,40 @@
|
||||
}
|
||||
.notice code { font-family: var(--mono); font-size: 11.5px; }
|
||||
|
||||
/* Local-JSON mode is information, not failure — the orange above stays for
|
||||
real errors. */
|
||||
.notice.info {
|
||||
border-color: #2C3B4E;
|
||||
background: #141A22;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.notice.info code { color: var(--accent-dim); }
|
||||
|
||||
/* ── clickable cards ───────────────────────────────────────────────── */
|
||||
|
||||
a.cardlink {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: 0 var(--r-md) var(--r-md) 0;
|
||||
}
|
||||
a.cardlink:hover .card,
|
||||
a.cardlink:focus-visible .card {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--line);
|
||||
}
|
||||
a.cardlink:hover .tweet,
|
||||
a.cardlink:focus-visible .tweet {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
a.cardlink .tweet {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
footer.foot {
|
||||
margin-top: 34px;
|
||||
padding-top: 14px;
|
||||
@@ -472,11 +507,77 @@
|
||||
};
|
||||
const norm = id => BY_ID[id] ? id : (ALIAS[String(id || "").toLowerCase()] || null);
|
||||
|
||||
const state = { platforms: {}, posts: [], notifications: [], feed: [], lastData: null };
|
||||
const state = { platforms: {}, posts: [], notifications: [], feed: [], lastData: null, mode: null };
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
// Escapes " as well — these strings go into href/title attributes now.
|
||||
const esc = s => String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
// Scraped URLs are untrusted input. Anything that isn't http(s) — a
|
||||
// javascript: href, a relative path that never got absolutised — renders as
|
||||
// a plain unclickable card rather than a live link.
|
||||
const safeUrl = u => {
|
||||
if (!u) return null;
|
||||
try {
|
||||
const p = new URL(String(u), location.href);
|
||||
return (p.protocol === "http:" || p.protocol === "https:") ? p.href : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
const linkWrap = (url, inner, platform) => {
|
||||
const href = safeUrl(url);
|
||||
if (!href) return inner;
|
||||
const pd = platform ? ` data-platform="${esc(platform)}"` : "";
|
||||
return `<a class="cardlink" href="${esc(href)}"${pd} target="_blank" rel="noopener noreferrer">${inner}</a>`;
|
||||
};
|
||||
|
||||
// ── tab reuse ───────────────────────────────────────────────────────
|
||||
// target="_blank" spawns a tab per click. socialmag already knows how to
|
||||
// reuse a warm tab per platform (open_link), but a static page can't speak
|
||||
// MCP — openbridge.py exposes that one call over localhost.
|
||||
//
|
||||
// Progressive enhancement, deliberately: if the bridge isn't running, the
|
||||
// click is left alone and the anchor opens a new tab as before. The page is
|
||||
// never worse than it was without this.
|
||||
const BRIDGE = "http://127.0.0.1:8322";
|
||||
let bridgeUp = null; // null = unknown, true/false = probed
|
||||
|
||||
fetch(BRIDGE + "/health", { cache: "no-store" })
|
||||
.then(r => { bridgeUp = r.ok; })
|
||||
.catch(() => { bridgeUp = false; });
|
||||
|
||||
document.addEventListener("click", ev => {
|
||||
const a = ev.target.closest && ev.target.closest("a.cardlink[data-platform]");
|
||||
if (!a) return;
|
||||
// Respect the browser's own gestures — ctrl/cmd/middle-click means the
|
||||
// person explicitly asked for a new tab.
|
||||
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || ev.button !== 0) return;
|
||||
if (bridgeUp === false) return;
|
||||
|
||||
ev.preventDefault();
|
||||
const url = a.getAttribute("href");
|
||||
const platform = a.getAttribute("data-platform");
|
||||
const q = `?platform=${encodeURIComponent(platform)}&url=${encodeURIComponent(url)}`;
|
||||
|
||||
fetch(BRIDGE + "/open" + q, { cache: "no-store" })
|
||||
.then(r => r.json().then(body => ({ ok: r.ok, body })))
|
||||
.then(({ ok, body }) => {
|
||||
bridgeUp = true;
|
||||
if (!ok) {
|
||||
// The bridge answered and refused. Don't silently open a tab instead —
|
||||
// say why, since that's a real error the person should see.
|
||||
notice(`Couldn't reuse the ${esc(platform)} tab:
|
||||
<code>${esc(body && body.error || "unknown error")}</code>`);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Bridge went away mid-session: fall back for this click and every
|
||||
// click after it.
|
||||
bridgeUp = false;
|
||||
window.open(url, "_blank", "noopener");
|
||||
});
|
||||
});
|
||||
|
||||
// ── time ────────────────────────────────────────────────────────────
|
||||
// Rows carry ts / time / createdAt / checkedAt depending on which tool
|
||||
@@ -555,6 +656,7 @@
|
||||
text: d.text || d.detail || "",
|
||||
detail: d.detail && d.status && String(d.status).toLowerCase() !== "ok" ? d.detail : "",
|
||||
t: parseTime(d),
|
||||
url: d.url || d.link || null,
|
||||
stats: { views: d.views, likes: d.likes, replies: d.replies, reposts: d.reposts ?? d.retweets }
|
||||
});
|
||||
}
|
||||
@@ -567,6 +669,7 @@
|
||||
who: BY_ID[pid] ? BY_ID[pid].name : String(d.platform),
|
||||
text: d.text || "",
|
||||
t: parseTime(d),
|
||||
url: d.url || d.link || null,
|
||||
stats: {}
|
||||
});
|
||||
}
|
||||
@@ -605,7 +708,7 @@
|
||||
const glyph = p
|
||||
? `<span class="glyph ${p.id}">${esc(p.mark)}</span>`
|
||||
: `<span class="glyph" style="background:var(--surface-2);color:var(--text-faint)">?</span>`;
|
||||
return `<article class="card" style="--stripe:${stripe}">
|
||||
return linkWrap(r.url, `<article class="card" style="--stripe:${stripe}">
|
||||
${glyph}
|
||||
<div class="body">
|
||||
<div class="meta">
|
||||
@@ -617,7 +720,7 @@
|
||||
${r.detail ? `<div class="text quiet" style="font-size:12px">${esc(r.detail)}</div>` : ""}
|
||||
${engagementHTML(r.stats)}
|
||||
</div>
|
||||
</article>`;
|
||||
</article>`, r.pid);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
@@ -635,7 +738,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
$("feed").innerHTML = rows.map(t => `<div class="tweet">
|
||||
$("feed").innerHTML = rows.map(t => linkWrap(t.url, `<div class="tweet">
|
||||
<div class="who">${esc(t.author || "unknown")}</div>
|
||||
<div class="text">${esc(t.text || "")}</div>
|
||||
<div class="meta engagement">
|
||||
@@ -644,7 +747,7 @@
|
||||
<span><b>${esc(showCount(t.replies))}</b> replies</span>
|
||||
<span class="ts">${esc(ago(parseTime(t)))}</span>
|
||||
</div>
|
||||
</div>`).join("");
|
||||
</div>`, "twitter")).join("");
|
||||
}
|
||||
|
||||
// ── stats ───────────────────────────────────────────────────────────
|
||||
@@ -677,8 +780,19 @@
|
||||
$("pulse").className = "pulse " + (live ? "live" : "cold");
|
||||
}
|
||||
|
||||
function notice(html) {
|
||||
$("notice").innerHTML = html ? `<div class="notice">${html}</div>` : "";
|
||||
function notice(html, cls) {
|
||||
$("notice").innerHTML = html
|
||||
? `<div class="notice${cls ? " " + cls : ""}">${html}</div>` : "";
|
||||
}
|
||||
|
||||
// Mode-aware freshness. This used to be inlined in the ticker and rewrote the
|
||||
// line to "live · …" unconditionally, so local mode claimed "live" one tick
|
||||
// after load.
|
||||
function tickFreshness() {
|
||||
if (!state.lastData || !state.mode) return;
|
||||
const s = Math.round((Date.now() - state.lastData) / 1000);
|
||||
const when = s < 60 ? (s < 3 ? "just now" : s + "s ago") : ago(state.lastData);
|
||||
setFreshness(state.mode + " · updated " + when, state.mode === "live");
|
||||
}
|
||||
|
||||
// ── first paint: the layout, with empty states, before any store call ─
|
||||
@@ -688,15 +802,84 @@
|
||||
|
||||
const docsOf = snap => snap.docs.map(d => Object.assign({ id: d.id }, d.data()));
|
||||
|
||||
// ── local JSON mode ─────────────────────────────────────────────────
|
||||
// When the artifact store isn't there (served from a plain file server, or
|
||||
// opened as a local file), the page reads dashboard.json beside itself and
|
||||
// re-polls it. Same schema as the db collections.
|
||||
const LOCAL_POLL_MS = 10000;
|
||||
|
||||
function applyLocal(data) {
|
||||
state.platforms = {};
|
||||
const plats = data.platforms || {};
|
||||
if (Array.isArray(plats)) {
|
||||
for (const row of plats) {
|
||||
const id = norm(row.id || row.platform);
|
||||
if (id) state.platforms[id] = row;
|
||||
}
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(plats)) {
|
||||
const id = norm(k);
|
||||
if (id) state.platforms[id] = (v && typeof v === "object") ? v : { loggedIn: v };
|
||||
}
|
||||
}
|
||||
state.posts = data.posts || [];
|
||||
state.notifications = data.notifications || [];
|
||||
state.feed = data.feed || [];
|
||||
state.lastData = Date.now();
|
||||
renderAll();
|
||||
tickFreshness();
|
||||
}
|
||||
|
||||
async function pollLocal(first) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch("dashboard.json", { cache: "no-store" });
|
||||
} catch (e) {
|
||||
if (first) {
|
||||
setFreshness("local · can't read dashboard.json", false);
|
||||
notice(`Couldn't fetch <code>dashboard.json</code>. A page opened as
|
||||
<code>file://</code> can't fetch its neighbours — serve the folder over
|
||||
HTTP instead (<code>python3 -m http.server</code>).`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (first) {
|
||||
setFreshness(`local · no dashboard.json (HTTP ${res.status})`, false);
|
||||
notice(`No <code>dashboard.json</code> next to this page
|
||||
(HTTP <code>${res.status}</code>). Write one with the same shape as the
|
||||
<code>platforms · posts · notifications · feed</code> collections.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let data;
|
||||
try { data = await res.json(); }
|
||||
catch (e) {
|
||||
if (first) {
|
||||
setFreshness("local · dashboard.json is not valid JSON", false);
|
||||
notice(`<code>dashboard.json</code> loaded but didn't parse:
|
||||
<code>${esc(e.message)}</code>.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (first) {
|
||||
notice(`Local mode — reading <code>dashboard.json</code> from disk, refreshed
|
||||
every ${LOCAL_POLL_MS / 1000}s. The artifact store isn't available here.`, "info");
|
||||
}
|
||||
applyLocal(data);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const db = window.claude && await window.claude.use("db");
|
||||
if (!db) {
|
||||
setFreshness("store unavailable", false);
|
||||
notice(`Not reading from the artifact store — this view can't run the
|
||||
<code>db</code> capability. The layout below is the empty state.`);
|
||||
state.mode = "local";
|
||||
setFreshness("local · loading…", false);
|
||||
await pollLocal(true);
|
||||
setInterval(() => pollLocal(false), LOCAL_POLL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
state.mode = "live";
|
||||
setFreshness("live", true);
|
||||
|
||||
const onErr = where => e => {
|
||||
@@ -735,14 +918,12 @@
|
||||
})();
|
||||
|
||||
// Relative timestamps and the freshness line go stale on their own; retick
|
||||
// them every 30s. The subscriptions above deliver the data itself.
|
||||
// them on the same cadence as the local poll. The data itself arrives from
|
||||
// the subscriptions (live) or pollLocal (local).
|
||||
setInterval(() => {
|
||||
renderTimeline();
|
||||
renderFeed();
|
||||
if (state.lastData) {
|
||||
const s = Math.round((Date.now() - state.lastData) / 1000);
|
||||
setFreshness(s < 60 ? "live · updated " + s + "s ago" : "live · updated " + ago(state.lastData), true);
|
||||
}
|
||||
}, 30000);
|
||||
tickFreshness();
|
||||
}, LOCAL_POLL_MS);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"_note": "Local-mode data for dashboard.html. Same shape as the artifact db collections. Written 2026-09-11 ~10:05 from live socialmag probes: check_login, get_status, get_feed, get_notifications.",
|
||||
"_yt_caveat": "yt is really LOGGED IN (ytcfg.get('LOGGED_IN') === true, verified live). socialmag check_login reports false because it tests for '#avatar-btn', which never renders in a backgrounded tab — YouTube leaves the masthead in skeleton state. Recorded here as the measured truth, not as check_login's answer.",
|
||||
"platforms": {
|
||||
"threads": { "loggedIn": true, "checkedAt": "2026-09-11T10:00:00+08:00" },
|
||||
"fb": { "loggedIn": true, "checkedAt": "2026-09-11T10:00:00+08:00" },
|
||||
"ig": { "loggedIn": true, "checkedAt": "2026-09-11T10:00:00+08:00" },
|
||||
"yt": { "loggedIn": true, "checkedAt": "2026-09-11T10:05:00+08:00" },
|
||||
"tiktok": { "loggedIn": true, "checkedAt": "2026-09-11T10:00:00+08:00" },
|
||||
"twitter": { "loggedIn": true, "checkedAt": "2026-09-11T10:00:00+08:00" }
|
||||
},
|
||||
"posts": [
|
||||
{
|
||||
"platform": "twitter",
|
||||
"text": "midnight oil burns brightest ✨ first automated post from the house 🏠",
|
||||
"status": "ok",
|
||||
"detail": "twitter: posted",
|
||||
"ts": "2026-09-11T00:00:58+08:00",
|
||||
"url": "https://x.com/MarkoTropoj4"
|
||||
}
|
||||
],
|
||||
"notifications": [
|
||||
{
|
||||
"platform": "threads",
|
||||
"text": "Your thread got over 50 views. 11h late night coding sessions with good music is peak vibes ngl 🎧✨",
|
||||
"url": "https://www.threads.com/@beethovenkassim9917/post/DdG2Sz1FOZp",
|
||||
"ts": "2026-09-10T23:00:00+08:00",
|
||||
"_ts_note": "stamped here from the row's relative marker (11h) — get_notifications returns no timestamp"
|
||||
},
|
||||
{
|
||||
"platform": "threads",
|
||||
"text": "You created a new Threads profile in your Meta Account. Review how to log in. 14h",
|
||||
"url": "https://accountscenter.threads.com/settings-updates?settings_notif_id=2060336524603315&num_profiles=1",
|
||||
"ts": "2026-09-10T20:00:00+08:00"
|
||||
},
|
||||
{
|
||||
"platform": "threads",
|
||||
"text": "threads 14h Your profile is public. You can manage your privacy in settings anytime.",
|
||||
"url": "https://www.threads.com/settings/privacy",
|
||||
"ts": "2026-09-10T20:00:00+08:00"
|
||||
}
|
||||
],
|
||||
"feed": [
|
||||
{
|
||||
"author": "Boris Cherny @bcherny",
|
||||
"text": "Every day, I get a lot of of emails and messages like this one. I try to respond to as many as I can.\n\nSharing my response below, for anyone else in a similar situation. What do you think?",
|
||||
"time": "2026-09-11T01:10:26.000Z",
|
||||
"url": "https://x.com/bcherny/status/2098217571153838124",
|
||||
"likes": "384",
|
||||
"retweets": "14",
|
||||
"replies": "40"
|
||||
},
|
||||
{
|
||||
"author": "Boris Cherny @bcherny",
|
||||
"text": "Hey ████,\n\nI think there is room for both.\n\n1. Prototypes and other throw-away code can be treated as totally black box. If you're going to throw it away anyway, and if the blast radius of it breaking is low, it doesn't need to be perfect.\n2. Production code written by Claude",
|
||||
"time": "2026-09-11T01:10:27.000Z",
|
||||
"url": "https://x.com/bcherny/status/2098217573276131577",
|
||||
"likes": "260",
|
||||
"retweets": "13",
|
||||
"replies": "22"
|
||||
},
|
||||
{
|
||||
"author": "Elon Musk @elonmusk",
|
||||
"text": "Grok @Bot summary of SpaceX CFO talk",
|
||||
"time": "2026-09-11T01:09:06.000Z",
|
||||
"url": "https://x.com/elonmusk/status/2098217236771295567",
|
||||
"likes": "1.3K",
|
||||
"retweets": "269",
|
||||
"replies": "352"
|
||||
},
|
||||
{
|
||||
"author": "Boris Cherny @bcherny",
|
||||
"text": "wait what",
|
||||
"time": "2026-09-11T00:39:52.000Z",
|
||||
"url": "https://x.com/bcherny/status/2098209880310264048",
|
||||
"likes": "282",
|
||||
"retweets": "15",
|
||||
"replies": "19"
|
||||
},
|
||||
{
|
||||
"author": "Kevin O'Connor @KevinOConnor",
|
||||
"text": "New pod with @SawyerMerritt diving into the Tesla Cybercab: What we loved, what can improve, our FSD experiences, and what's next for @robotaxi.\n\nWatch the full conversation here on X or listen to The Kevin O'Connor Show on your favorite podcast app.",
|
||||
"time": "2026-09-10T20:37:00.000Z",
|
||||
"url": "https://x.com/KevinOConnor/status/2098148756902355336",
|
||||
"likes": "735",
|
||||
"retweets": "101",
|
||||
"replies": "74"
|
||||
}
|
||||
]
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""openbridge — a localhost shim so the dashboard can reuse warm tabs.
|
||||
|
||||
The dashboard is a static page served off a plain file server; it cannot speak
|
||||
MCP. This exposes exactly one capability of the socialmag server — `open_link` —
|
||||
over 127.0.0.1, so clicking a card navigates the platform's existing tab instead
|
||||
of spawning a new one on every click.
|
||||
|
||||
It does not reimplement open_link. It imports `tool_open_link` and calls it, so
|
||||
validation (unknown platform, missing url, non-http(s) scheme) and tab matching
|
||||
stay in one place: change server.py and this follows.
|
||||
|
||||
Run:
|
||||
python3 -m socialmag.openbridge # 127.0.0.1:8322
|
||||
python3 -m socialmag.openbridge --port N
|
||||
|
||||
Endpoints:
|
||||
GET /health -> {"ok": true, "platforms": [...]}
|
||||
GET /open?platform=X&url=Y -> the open_link result, or {"error": ...}
|
||||
|
||||
The dashboard treats an unreachable bridge as "just open a new tab" — it falls
|
||||
back to the anchor's normal behaviour, so the page works with this not running.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from .queue import PostQueue
|
||||
from .server import PLATFORM_DOMAINS, tool_open_link
|
||||
|
||||
# The dashboard's clicks are human-paced and must feel instant. The posting
|
||||
# queue's 3-6s inter-job spacing exists to look human while publishing; applied
|
||||
# to a click it would just look broken.
|
||||
CLICK_DELAY = (0.0, 0.0)
|
||||
|
||||
|
||||
class _Loop:
|
||||
"""One asyncio loop on a background thread, with a started PostQueue.
|
||||
|
||||
Clicks still go through a queue: two fast clicks must not open two CDP
|
||||
connections racing for the same tab.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.queue = PostQueue(inter_platform_delay=CLICK_DELAY)
|
||||
self._ready = threading.Event()
|
||||
self.thread = threading.Thread(target=self._run, name="openbridge", daemon=True)
|
||||
self.thread.start()
|
||||
if not self._ready.wait(timeout=10):
|
||||
raise RuntimeError("openbridge: event loop failed to start within 10s")
|
||||
|
||||
def _run(self):
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_until_complete(self.queue.start())
|
||||
self.loop.call_soon(self._ready.set)
|
||||
self.loop.run_forever()
|
||||
|
||||
def call(self, coro, timeout=120):
|
||||
return asyncio.run_coroutine_threadsafe(coro, self.loop).result(timeout)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "socialmag-openbridge"
|
||||
bridge = None # set in serve()
|
||||
|
||||
def _send(self, code, payload):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
# The dashboard is served from a different port, so this is cross-origin.
|
||||
# The listener is bound to 127.0.0.1, so the reachable surface is local.
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
parts = urllib.parse.urlsplit(self.path)
|
||||
query = urllib.parse.parse_qs(parts.query)
|
||||
|
||||
if parts.path == "/health":
|
||||
return self._send(200, {"ok": True, "platforms": sorted(PLATFORM_DOMAINS)})
|
||||
|
||||
if parts.path != "/open":
|
||||
return self._send(404, {"error": f"no such endpoint: {parts.path}"})
|
||||
|
||||
platform = (query.get("platform") or [None])[0]
|
||||
url = (query.get("url") or [None])[0]
|
||||
try:
|
||||
result = self.bridge.call(
|
||||
tool_open_link({"platform": platform, "url": url}, self.bridge.queue)
|
||||
)
|
||||
except ValueError as e:
|
||||
# open_link's own validation — a bad request, not a broken bridge.
|
||||
return self._send(400, {"error": str(e)})
|
||||
except Exception as e:
|
||||
return self._send(502, {"error": f"{type(e).__name__}: {e}"})
|
||||
return self._send(200, result)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("[openbridge] %s\n" % (fmt % args))
|
||||
|
||||
|
||||
def serve(port):
|
||||
Handler.bridge = _Loop()
|
||||
# No port fallback on purpose: if 8322 is taken, something else is already
|
||||
# answering there and silently moving would point the dashboard at nothing.
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
print(
|
||||
f"[openbridge] listening on http://127.0.0.1:{port} "
|
||||
f"(/health, /open?platform=&url=)",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("[openbridge] stopping", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--port", type=int, default=8322)
|
||||
serve(ap.parse_args().port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-11
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"last_post": "2026-09-10T21:06:30",
|
||||
"results": {
|
||||
"tiktok": {
|
||||
"status": "ok",
|
||||
"detail": "tiktok: posted"
|
||||
}
|
||||
},
|
||||
"text": "vibes check, late night energy \ud83c\udf19\u2728",
|
||||
"media": "/mnt/playground/Server_files/Personal_pic/O.E.P/bahaya_tema_ni_haha.mp4"
|
||||
}
|
||||
Reference in New Issue
Block a user