v0.2: twitter module, feed/notifications/reply/open_link tools, dashboard

This commit is contained in:
2026-09-11 02:20:13 +08:00
parent a4f33f456e
commit eaedf4f409
6 changed files with 1368 additions and 24 deletions
+228 -1
View File
@@ -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,
}