diff --git a/README.md b/README.md
index 6dc4a85..117ef00 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,8 @@
# socialmag
-MCP server that posts to Threads / Facebook / Instagram / YouTube / TikTok by
-driving abang's already-logged-in Brave over CDP. No API keys, no OAuth.
-
-Built to `PROPOSAL.md` (2026-09-10). **Nothing here has been run against a live
-platform yet** — every step whose selector or timing needs a real page is
-marked `# TODO: VALIDATE LIVE`.
+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.
## Run
@@ -26,24 +23,35 @@ Requires `websockets` (17.1 present on this box). Everything else is stdlib.
## Tools
-| Tool | Args | Returns |
-|---|---|---|
-| `create_post` | `platforms[]`, `text`, `media`, `visibility` | `{platform: {status, detail}}` |
-| `check_login` | `platforms[]` (default all) | `{platform: true/false/"error: ..."}` |
-| `get_status` | — | last run's results + queue history |
+| 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) |
-`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**; it is required for
-ig/tiktok, optional for threads/fb, and rejected for yt.
+`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.
+
+## 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.
## Layout
```
-server.py MCP stdio JSON-RPC loop + the 3 tools
+server.py MCP stdio JSON-RPC loop + 7 tools
cdp.py CDP client: CDPClient (browser) + Tab (page verbs)
queue.py PostQueue — strictly sequential, one tab at a time
-selectors.py every platform selector, one file
+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)
```
Each platform module exposes:
@@ -53,6 +61,9 @@ 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.
diff --git a/dashboard.html b/dashboard.html
new file mode 100644
index 0000000..f83c0f8
--- /dev/null
+++ b/dashboard.html
@@ -0,0 +1,748 @@
+
socialmag console
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Platforms active
+
0/6
+
+
+
+
+
+
+
+
+
Timeline
+ create_post · get_notifications
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/platforms/threads.py b/platforms/threads.py
index da24503..76f7b22 100644
--- a/platforms/threads.py
+++ b/platforms/threads.py
@@ -1,15 +1,13 @@
-"""Threads posting flow (2026-09 inline composer).
+"""Threads posting and notifications flow (2026-09 inline composer).
- 1. navigate threads.com
- 2. click the inline "What's new?" composer
- 3. type text
- 4. click Post button
- 5. text clearing / post appearing = success
+ post — post via inline composer
+ check_login — verify signed in
+ get_notifications — scrape the Activity page
"""
import asyncio
-from ..cdp import human_delay
+from ..cdp import human_delay, CDPError
from . import guard, require_text, validate_media
from ..selectors import THREADS as S
@@ -117,3 +115,68 @@ async def check_login(cdp_client):
" || [...document.querySelectorAll('span,div')]"
".find(e => e.innerText && e.innerText.trim() === 'What\\'s new?'))"
)
+
+
+async def get_notifications(cdp_client, count=10):
+ """Scrape the Threads Activity page."""
+ tab, reused = await cdp_client.get_or_open_tab(
+ "https://www.threads.com/activity", "threads.com"
+ )
+ if reused:
+ await tab.eval("window.onbeforeunload = null")
+ await tab.navigate("https://www.threads.com/activity")
+
+ await asyncio.sleep(4)
+
+ count = min(count, 20)
+ js = r"""
+ (() => {
+ const region = document.querySelector('[role="region"]');
+ if (!region) return [];
+ const ORIGIN = 'https://www.threads.com';
+ const abs = (h) => !h ? null
+ : (/^https?:/.test(h) ? h : ORIGIN + (h.startsWith('/') ? h : '/' + h));
+
+ // Row detection, from the live DOM (probed 2026-09-11): a
+ // notification row is an element that links somewhere, carries a
+ // relative-time marker ("3h", "6h", "2d"), and holds no smaller
+ // element that does the same. The length floor rejects the bare
+ // "6h" time chip, which is itself a link on some rows.
+ const isRow = (el) => {
+ const t = el.innerText ? el.innerText.trim() : '';
+ return t.length >= 12
+ && /\b\d+[smhdw]\b/.test(t)
+ && !!el.querySelector('a[href]');
+ };
+ const cands = [...region.querySelectorAll('*')].filter(isRow);
+ const rows = cands.filter(el => !cands.some(o => o !== el && el.contains(o)));
+
+ const results = [];
+ for (const row of rows) {
+ const text = row.innerText.trim().replace(/\n+/g, ' ');
+ if (text.length < 5) continue;
+ const hrefs = [...row.querySelectorAll('a[href]')]
+ .map(x => x.getAttribute('href'));
+ // Prefer the post permalink; otherwise whatever the row links to
+ // (settings notices link to /settings/... — a real destination).
+ const post = hrefs.find(h => /\/@[^\/]+\/post\//.test(h || ''));
+ results.push({ text: text, url: abs(post || hrefs[0]) });
+ }
+
+ // Fallback: no anchors at all (layout change, or a notification kind
+ // that doesn't link anywhere). Keep the old text-split rather than
+ // returning an empty list that would read as "no activity".
+ if (!results.length) {
+ const items = region.innerText.trim()
+ .split(/(?<=\b\d+[hdm]\b)\s*\n\s*/);
+ for (const p of items) {
+ const t = p.trim().replace(/\n+/g, ' ');
+ if (t.length < 5) continue;
+ results.push({ text: t, url: null });
+ }
+ }
+ return results.slice(0, COUNT);
+ })()
+ """.replace("COUNT", str(count))
+
+ return await tab.eval(js) or []
diff --git a/platforms/twitter.py b/platforms/twitter.py
new file mode 100644
index 0000000..6dc5c1a
--- /dev/null
+++ b/platforms/twitter.py
@@ -0,0 +1,262 @@
+"""Twitter/X posting, reading, and replying via CDP.
+
+ post — tweet from the inline home composer
+ get_feed — scrape timeline (Following tab) or search results
+ reply — reply to a tweet found by search
+ check_login — verify the account is signed in
+"""
+
+import asyncio
+import urllib.parse
+
+from ..cdp import human_delay, CDPError
+from . import guard, require_text, validate_media
+from ..selectors import TWITTER as S
+
+PLATFORM = "twitter"
+
+
+async def post(cdp_client, text, media_path=None, options=None):
+ options = options or {}
+ require_text(PLATFORM, text)
+ validate_media(media_path)
+
+ tab, reused = await cdp_client.get_or_open_tab(S["url"], "x.com")
+ if reused:
+ await tab.eval("window.onbeforeunload = null")
+ await tab.navigate(S["url"])
+ else:
+ await asyncio.sleep(2)
+ return await guard(PLATFORM, tab, _flow(tab, text, media_path))
+
+
+async def _flow(tab, text, media_path):
+ await _wait_for_composer(tab)
+ await human_delay()
+
+ # Click the contenteditable to focus it
+ textbox = S["composer_textbox"]
+ if not await tab.exists(textbox):
+ # Fallback: try the broader testid
+ textbox = '[data-testid="tweetTextarea_0"]'
+ await tab.wait_for(textbox, timeout=10)
+
+ await tab.type_text(textbox, text)
+ await human_delay(0.5, 1.0)
+
+ if media_path:
+ file_input = 'input[type="file"]'
+ if await tab.exists(file_input):
+ await tab.set_file_input(file_input, media_path)
+ await human_delay(2.0, 4.0)
+
+ await human_delay(0.5, 1.0)
+
+ # Click the Post button
+ await tab.click_js(S["post_button_js"])
+
+ await _wait_for_post_complete(tab)
+
+
+async def _wait_for_composer(tab, timeout=20.0):
+ """Wait for the tweet compose box to appear."""
+ deadline = asyncio.get_running_loop().time() + timeout
+ while asyncio.get_running_loop().time() < deadline:
+ has_box = await tab.eval(
+ "!!(document.querySelector('[data-testid=\"tweetTextarea_0\"]'))"
+ )
+ if has_box:
+ return
+ await asyncio.sleep(0.5)
+ from ..cdp import CDPError
+ raise CDPError("twitter: compose box not found after 20s")
+
+
+async def _wait_for_post_complete(tab, timeout=30.0):
+ """Wait for the tweet to go through — composer empties or toast appears."""
+ deadline = asyncio.get_running_loop().time() + timeout
+ while asyncio.get_running_loop().time() < deadline:
+ # Check for the success toast
+ toast = await tab.eval(
+ "!!([...document.querySelectorAll('[role=\"alert\"], [data-testid=\"toast\"]')]"
+ ".find(e => e.innerText && e.innerText.includes('post')))"
+ )
+ if toast:
+ return
+
+ # Inline composer: text cleared back to empty
+ empty = await tab.eval(
+ "(() => {"
+ " const box = document.querySelector('[data-testid=\"tweetTextarea_0\"] [contenteditable=\"true\"]');"
+ " if (box && box.innerText.trim() === '') return true;"
+ " const box2 = document.querySelector('[data-testid=\"tweetTextarea_0\"]');"
+ " if (box2 && box2.innerText.trim() === '') return true;"
+ " return false;"
+ "})()"
+ )
+ if empty:
+ return
+
+ await asyncio.sleep(0.5)
+ from ..cdp import CDPError
+ raise CDPError("twitter: post didn't complete within 30s")
+
+
+async def check_login(cdp_client):
+ tab, reused = await cdp_client.get_or_open_tab(S["url"], "x.com")
+ if reused:
+ await tab.eval("window.onbeforeunload = null")
+ await tab.navigate(S["url"])
+ await asyncio.sleep(4)
+ # If we see login markers, not logged in
+ if await tab.exists(S["login_marker"]):
+ return False
+ # If we see the home timeline composer, we're in
+ return await tab.eval(
+ "!!(document.querySelector('[data-testid=\"tweetTextarea_0\"]')"
+ " || document.querySelector('[data-testid=\"SideNav_NewTweet_Button\"]'))"
+ )
+
+
+# ── Scrape tweets from a page ────────────────────────────────────────────────
+
+SCRAPE_JS = r"""
+ (() => {
+ const articles = document.querySelectorAll('article[data-testid="tweet"]');
+ const results = [];
+ for (const a of [...articles].slice(0, COUNT)) {
+ const nameEl = a.querySelector('[data-testid="User-Name"]');
+ const textEl = a.querySelector('[data-testid="tweetText"]');
+ const timeEl = a.querySelector('time');
+ const likes = a.querySelector('[data-testid="like"]');
+ const retweets = a.querySelector('[data-testid="retweet"]');
+ const reply = a.querySelector('[data-testid="reply"]');
+ const getCount = (el) => {
+ if (!el) return 0;
+ const span = el.querySelector('[data-testid$="Count"]')
+ || el.querySelector('span[data-testid]')
+ || el.querySelector('span');
+ return span ? span.innerText.trim() : '0';
+ };
+ // Permalink: the timestamp sits inside the canonical
+ // //status/ anchor. Verified live 2026-09-11 — the other
+ // status hrefs in an article are /analytics, /photo/N, /media_tags.
+ const permalink = (() => {
+ const link = timeEl ? timeEl.closest('a[href]') : null;
+ let href = link ? link.getAttribute('href') : null;
+ if (!href) {
+ href = [...a.querySelectorAll('a[href]')]
+ .map(x => x.getAttribute('href'))
+ .find(h => /^\/[^\/]+\/status\/\d+$/.test(h)) || null;
+ }
+ return href ? new URL(href, location.origin).href : null;
+ })();
+ results.push({
+ author: nameEl ? nameEl.innerText.replace(/\n/g, ' ') : '?',
+ text: textEl ? textEl.innerText.slice(0, 500) : '',
+ time: timeEl ? timeEl.getAttribute('datetime') : '?',
+ url: permalink,
+ likes: getCount(likes),
+ retweets: getCount(retweets),
+ replies: getCount(reply)
+ });
+ }
+ return results;
+ })()
+"""
+
+
+async def get_feed(cdp_client, mode="following", query=None, count=10):
+ """Scrape tweets. mode='following' for timeline, 'search' for query."""
+ if mode == "search" and not query:
+ raise CDPError("twitter get_feed: 'query' required for search mode")
+
+ if mode == "search":
+ url = S["search_url"].format(query=urllib.parse.quote(query))
+ url_match = "x.com/search"
+ else:
+ url = S["url"]
+ url_match = "x.com"
+
+ tab, reused = await cdp_client.get_or_open_tab(url, url_match)
+ if reused:
+ await tab.eval("window.onbeforeunload = null")
+ await tab.navigate(url)
+
+ await asyncio.sleep(4)
+
+ # For following mode, click the Following tab
+ if mode == "following":
+ await tab.eval(
+ "(() => { const t = %s; if (t) t.click(); })()" % S["following_tab_js"]
+ )
+ await asyncio.sleep(3)
+
+ # Scroll once to load more tweets
+ await tab.eval("window.scrollBy(0, 800)")
+ await asyncio.sleep(2)
+ await tab.eval("window.scrollTo(0, 0)")
+ await asyncio.sleep(1)
+
+ count = min(count, 20)
+ js = SCRAPE_JS.replace("COUNT", str(count))
+ tweets = await tab.eval(js)
+ return tweets or []
+
+
+async def reply(cdp_client, search_query, reply_text):
+ """Find a tweet by search and reply to it."""
+ require_text(PLATFORM, reply_text)
+ if not search_query:
+ raise CDPError("twitter reply: 'search_query' required to find the tweet")
+
+ url = S["search_url"].format(query=urllib.parse.quote(search_query))
+ tab, reused = await cdp_client.get_or_open_tab(url, "x.com/search")
+ if reused:
+ await tab.eval("window.onbeforeunload = null")
+ await tab.navigate(url)
+ await asyncio.sleep(5)
+
+ # Click reply on the first matching tweet
+ clicked = await tab.eval(r"""
+ (() => {
+ const tweet = document.querySelector('article[data-testid="tweet"]');
+ if (!tweet) return false;
+ const btn = tweet.querySelector('[data-testid="reply"]');
+ if (!btn) return false;
+ btn.click();
+ return true;
+ })()
+ """)
+ if not clicked:
+ raise CDPError("twitter reply: no tweet found for that search query")
+
+ await asyncio.sleep(3)
+
+ # Type into the reply box
+ for sel in ('[data-testid="tweetTextarea_0"] [contenteditable="true"]',
+ '[data-testid="tweetTextarea_0"]'):
+ if await tab.exists(sel):
+ await tab.type_text(sel, reply_text)
+ break
+ else:
+ raise CDPError("twitter reply: reply box not found")
+
+ await human_delay(0.5, 1.0)
+
+ # Post the reply
+ posted = await tab.eval(r"""
+ (() => {
+ const btn = document.querySelector('[data-testid="tweetButtonInline"]')
+ || document.querySelector('[data-testid="tweetButton"]');
+ if (!btn) return false;
+ btn.click();
+ return true;
+ })()
+ """)
+ if not posted:
+ raise CDPError("twitter reply: could not find the reply post button")
+
+ await asyncio.sleep(3)
+ return {"status": "ok", "detail": "twitter: replied"}
+
diff --git a/selectors.py b/selectors.py
index 31ace2c..b0cac6e 100644
--- a/selectors.py
+++ b/selectors.py
@@ -156,10 +156,43 @@ TIKTOK = {
"login_marker": 'a[href*="/login"]',
}
+# --------------------------------------------------------------------------
+# Twitter / X
+# --------------------------------------------------------------------------
+
+TWITTER = {
+ "url": "https://x.com/home",
+ "compose_url": "https://x.com/compose/post",
+ # The main tweet box is a DraftJS contenteditable inside the compose dialog
+ "composer_textbox": '[data-testid="tweetTextarea_0"] [contenteditable="true"]',
+ # Fallback: the inline composer on the home timeline
+ "inline_composer": '[data-testid="tweetTextarea_0"]',
+ "post_button_js": (
+ "document.querySelector('[data-testid=\"tweetButtonInline\"]')"
+ " || document.querySelector('[data-testid=\"tweetButton\"]')"
+ ),
+ "file_input": 'input[type="file"][accept*="image"]',
+ "media_button": '[data-testid="fileInput"]',
+ # After posting, the compose dialog closes or the timeline updates
+ "toast_text": "Your post was sent",
+ "login_marker": 'a[href="/login"], a[href*="flow/login"], input[name="text"][autocomplete="username"]',
+ # For reading feeds
+ "tweet_article": 'article[data-testid="tweet"]',
+ "tweet_text": '[data-testid="tweetText"]',
+ "tweet_user": '[data-testid="User-Name"]',
+ "reply_button": '[data-testid="reply"]',
+ "following_tab_js": (
+ "[...document.querySelectorAll('[role=\"tab\"], a[role=\"tab\"]')]"
+ ".find(t => t.innerText.trim() === 'Following')"
+ ),
+ "search_url": "https://x.com/search?q={query}&src=typed_query&f=live",
+}
+
PLATFORMS = {
"threads": THREADS,
"fb": FACEBOOK,
"ig": INSTAGRAM,
"yt": YOUTUBE,
"tiktok": TIKTOK,
+ "twitter": TWITTER,
}
diff --git a/server.py b/server.py
index 08025ed..49d1ddc 100644
--- a/server.py
+++ b/server.py
@@ -20,10 +20,12 @@ import json
import os
import sys
import time
+import urllib.parse
from .cdp import CDPClient, CDPError, DEFAULT_HTTP_ENDPOINT
-from .platforms import facebook, instagram, threads, tiktok, youtube
+from .platforms import facebook, instagram, threads, tiktok, twitter, youtube
from .queue import PostQueue
+from .selectors import PLATFORMS as PLATFORM_SELECTORS
PROTOCOL_VERSION = "2025-06-18"
SERVER_NAME = "socialmag"
@@ -37,8 +39,27 @@ MODULES = {
"ig": instagram,
"yt": youtube,
"tiktok": tiktok,
+ "twitter": twitter,
}
+
+def _domain_of(platform):
+ """The tab-match string for a platform, derived from its selectors URL —
+ 'https://www.threads.com/' -> 'threads.com'. One source of truth: change a
+ platform's URL in selectors.py and tab matching follows it."""
+ host = urllib.parse.urlsplit(PLATFORM_SELECTORS[platform]["url"]).hostname
+ if not host:
+ raise RuntimeError(
+ f"selectors.PLATFORMS[{platform!r}]['url'] has no hostname: "
+ f"{PLATFORM_SELECTORS[platform]['url']!r}"
+ )
+ return host[4:] if host.startswith("www.") else host
+
+
+# threads.com · facebook.com · instagram.com · youtube.com · tiktok.com · x.com
+PLATFORM_DOMAINS = {p: _domain_of(p) for p in MODULES}
+
+
# The endpoint is a spec constant, not a fallback for missing config. Override
# with SOCIALMAG_CDP_ENDPOINT if Brave listens elsewhere.
CDP_ENDPOINT = os.environ.get("SOCIALMAG_CDP_ENDPOINT", DEFAULT_HTTP_ENDPOINT)
@@ -113,6 +134,111 @@ TOOLS = [
"description": "Results of the last create_post run, plus queue history.",
"inputSchema": {"type": "object", "properties": {}},
},
+ {
+ "name": "get_feed",
+ "description": (
+ "Read tweets from Twitter/X. Two modes: 'following' reads the "
+ "home timeline (Following tab), 'search' scrapes live results "
+ "for a query. Supports X search operators (from:, since:, "
+ "min_faves:, lang:). Returns up to 20 tweets with author, "
+ "text, time, and engagement counts."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "mode": {
+ "enum": ["following", "search"],
+ "description": "following = home timeline, search = keyword search.",
+ "default": "following",
+ },
+ "query": {
+ "type": "string",
+ "description": (
+ "Search query (required for search mode). Supports "
+ "X operators: from:user, since:2026-01-01, "
+ "min_faves:100, lang:en, etc."
+ ),
+ },
+ "count": {
+ "type": "integer",
+ "description": "Max tweets to return (1-20, default 10).",
+ "default": 10,
+ },
+ },
+ },
+ },
+ {
+ "name": "reply_tweet",
+ "description": (
+ "Reply to a tweet found by search query. Finds the first "
+ "matching tweet and posts a reply to it."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "search_query": {
+ "type": "string",
+ "description": (
+ "Search query to find the tweet to reply to. Use "
+ "from:user + keywords for precision."
+ ),
+ },
+ "text": {
+ "type": "string",
+ "description": "Reply text.",
+ },
+ },
+ "required": ["search_query", "text"],
+ },
+ },
+ {
+ "name": "get_notifications",
+ "description": (
+ "Check notifications/activity from social platforms. Currently "
+ "supports Threads (Activity page). Returns a list of recent "
+ "notifications with text, time, and post links where available."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "platform": {
+ "enum": ["threads"],
+ "description": "Platform to check. Currently only threads.",
+ },
+ "count": {
+ "type": "integer",
+ "description": "Max notifications to return (1-20, default 10).",
+ "default": 10,
+ },
+ },
+ "required": ["platform"],
+ },
+ },
+ {
+ "name": "open_link",
+ "description": (
+ "Open a URL in the platform's existing browser tab (reuses warm "
+ "tabs). Falls back to opening a new tab if none exists. The tab is "
+ "left open — that's what makes it reusable next time."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "platform": {
+ "enum": sorted(MODULES),
+ "description": (
+ "Which platform's tab to reuse. Matched by domain: "
+ + ", ".join(f"{k} → {v}" for k, v in sorted(PLATFORM_DOMAINS.items()))
+ ),
+ },
+ "url": {
+ "type": "string",
+ "description": "Full http(s) URL to navigate to.",
+ },
+ },
+ "required": ["platform", "url"],
+ },
+ },
]
@@ -237,10 +363,111 @@ async def tool_get_status(args, queue):
return state
+async def tool_get_feed(args, queue):
+ mode = args.get("mode", "following")
+ query = args.get("query")
+ count = min(args.get("count", 10), 20)
+
+ cdp = CDPClient(CDP_ENDPOINT)
+ await cdp.connect()
+ try:
+ result = await twitter.get_feed(cdp, mode=mode, query=query, count=count)
+ finally:
+ await cdp.close()
+ return result
+
+
+async def tool_reply_tweet(args, queue):
+ search_query = args.get("search_query")
+ text = args.get("text")
+ if not search_query or not text:
+ raise ValueError("reply_tweet: both 'search_query' and 'text' are required")
+
+ cdp = CDPClient(CDP_ENDPOINT)
+ await cdp.connect()
+ try:
+ result = await twitter.reply(cdp, search_query, text)
+ finally:
+ await cdp.close()
+ return result
+
+
+async def tool_get_notifications(args, queue):
+ platform = args.get("platform")
+ count = min(args.get("count", 10), 20)
+
+ notif_modules = {
+ "threads": threads,
+ }
+ if platform not in notif_modules:
+ raise ValueError(
+ f"get_notifications: unsupported platform {platform!r}; "
+ f"supported: {sorted(notif_modules)}"
+ )
+
+ cdp = CDPClient(CDP_ENDPOINT)
+ await cdp.connect()
+ try:
+ result = await notif_modules[platform].get_notifications(cdp, count=count)
+ finally:
+ await cdp.close()
+ return result
+
+
+async def tool_open_link(args, queue):
+ platform = args.get("platform")
+ url = args.get("url")
+
+ # Validate before touching the browser: a bad call should cost nothing.
+ if platform not in PLATFORM_DOMAINS:
+ raise ValueError(
+ f"open_link: unknown platform {platform!r}; "
+ f"known: {sorted(PLATFORM_DOMAINS)}"
+ )
+ if not url or not isinstance(url, str):
+ raise ValueError("open_link: 'url' is required and must be a string")
+ scheme = urllib.parse.urlsplit(url).scheme.lower()
+ if scheme not in ("http", "https"):
+ raise ValueError(
+ f"open_link: url must be http(s), got {scheme or 'no'} scheme: {url!r}"
+ )
+
+ domain = PLATFORM_DOMAINS[platform]
+
+ async def go():
+ cdp = CDPClient(CDP_ENDPOINT)
+ await cdp.connect()
+ try:
+ tab, reused = await cdp.get_or_open_tab(url, domain)
+ if reused:
+ # A warm tab may hold an unload prompt (a half-written composer);
+ # clear it, then send the tab to the requested URL.
+ await tab.eval("window.onbeforeunload = null")
+ await tab.navigate(url)
+ # Deliberately NOT closed — leaving it open is what makes the next
+ # open_link on this platform a reuse.
+ return {
+ "status": "ok",
+ "tab_reused": reused,
+ "platform": platform,
+ "matched_domain": domain,
+ "url": url,
+ }
+ finally:
+ await cdp.close()
+
+ # Through the queue like every other browser job: one tab at a time.
+ return await queue.submit(f"open:{platform}", go)
+
+
HANDLERS = {
"create_post": tool_create_post,
"check_login": tool_check_login,
"get_status": tool_get_status,
+ "get_feed": tool_get_feed,
+ "reply_tweet": tool_reply_tweet,
+ "get_notifications": tool_get_notifications,
+ "open_link": tool_open_link,
}