socialmag v0.1 — 4/5 platforms live (Threads, FB, IG, TikTok)
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
"""socialmag — MCP stdio server.
|
||||
|
||||
Three tools:
|
||||
create_post post to one or more platforms through abang's logged-in Brave
|
||||
check_login which platforms are currently signed in
|
||||
get_status the last create_post run's results
|
||||
|
||||
Transport is the standard MCP stdio pattern: one JSON-RPC message per line on
|
||||
stdin, one per line on stdout. Nothing but protocol goes to stdout — logs go
|
||||
to stderr.
|
||||
|
||||
Run: python3 -m socialmag (from the parent dir; expects Brave on :9222)
|
||||
NOT `python3 server.py` — selectors.py/queue.py shadow stdlib modules
|
||||
unless this runs as a package. See __init__.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .cdp import CDPClient, CDPError, DEFAULT_HTTP_ENDPOINT
|
||||
from .platforms import facebook, instagram, threads, tiktok, youtube
|
||||
from .queue import PostQueue
|
||||
|
||||
PROTOCOL_VERSION = "2025-06-18"
|
||||
SERVER_NAME = "socialmag"
|
||||
SERVER_VERSION = "0.1.0"
|
||||
|
||||
STATE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json")
|
||||
|
||||
MODULES = {
|
||||
"threads": threads,
|
||||
"fb": facebook,
|
||||
"ig": instagram,
|
||||
"yt": youtube,
|
||||
"tiktok": tiktok,
|
||||
}
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
def log(*args):
|
||||
print(f"[socialmag {time.strftime('%H:%M:%S')}]", *args, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "create_post",
|
||||
"description": (
|
||||
"Post to one or more social platforms through the logged-in Brave "
|
||||
"browser (CDP). Platforms run sequentially, one tab at a time. "
|
||||
"Returns a per-platform status map."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"platforms": {
|
||||
"type": "array",
|
||||
"items": {"enum": sorted(MODULES)},
|
||||
"description": "Any combination of threads, fb, ig, yt, tiktok.",
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Post body. Required for threads/fb/yt; used as the "
|
||||
"caption for ig/tiktok."
|
||||
),
|
||||
},
|
||||
"media": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Absolute path to an image/video. Optional for "
|
||||
"threads/fb; REQUIRED for ig/tiktok; not supported "
|
||||
"for yt community posts."
|
||||
),
|
||||
},
|
||||
"visibility": {
|
||||
"enum": ["public", "friends"],
|
||||
"description": "Facebook audience. Not implemented yet — see detail.",
|
||||
},
|
||||
},
|
||||
"required": ["platforms"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "check_login",
|
||||
"description": (
|
||||
"Check which platforms the browser is signed in to. Opens and "
|
||||
"closes one tab per platform; no posting."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"platforms": {
|
||||
"type": "array",
|
||||
"items": {"enum": sorted(MODULES)},
|
||||
"description": "Defaults to all five when omitted.",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_status",
|
||||
"description": "Results of the last create_post run, plus queue history.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def save_state(state):
|
||||
tmp = STATE_PATH + ".tmp"
|
||||
with open(tmp, "w") as fh:
|
||||
json.dump(state, fh, indent=2)
|
||||
os.replace(tmp, STATE_PATH)
|
||||
|
||||
|
||||
def load_state():
|
||||
if not os.path.exists(STATE_PATH):
|
||||
return {"last_post": None, "results": {}}
|
||||
with open(STATE_PATH) as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def tool_create_post(args, queue):
|
||||
platforms = args.get("platforms")
|
||||
if not platforms:
|
||||
raise ValueError("create_post: 'platforms' is required and must be non-empty")
|
||||
unknown = [p for p in platforms if p not in MODULES]
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"create_post: unknown platform(s) {unknown}; known: {sorted(MODULES)}"
|
||||
)
|
||||
|
||||
text = args.get("text")
|
||||
media = args.get("media")
|
||||
options = {}
|
||||
if "visibility" in args:
|
||||
options["visibility"] = args["visibility"]
|
||||
|
||||
results = {}
|
||||
cdp = CDPClient(CDP_ENDPOINT)
|
||||
await cdp.connect()
|
||||
log(f"connected to {cdp.browser_version} at {CDP_ENDPOINT}")
|
||||
try:
|
||||
for name in platforms:
|
||||
module = MODULES[name]
|
||||
|
||||
def factory(module=module):
|
||||
return module.post(cdp, text, media, options)
|
||||
|
||||
try:
|
||||
results[name] = await queue.submit(name, factory)
|
||||
except CDPError as exc:
|
||||
# Raised before the flow reached its guard (bad args, bad media).
|
||||
results[name] = {"status": "error", "detail": f"{name}: {exc}"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results[name] = {
|
||||
"status": "error",
|
||||
"detail": f"{name}: unexpected {type(exc).__name__}: {exc}",
|
||||
}
|
||||
log(f"{name} -> {results[name]['status']}")
|
||||
finally:
|
||||
await cdp.close()
|
||||
|
||||
state = {
|
||||
"last_post": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"results": results,
|
||||
"text": text,
|
||||
"media": media,
|
||||
}
|
||||
save_state(state)
|
||||
return results
|
||||
|
||||
|
||||
async def tool_check_login(args, queue):
|
||||
platforms = args.get("platforms") or sorted(MODULES)
|
||||
unknown = [p for p in platforms if p not in MODULES]
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"check_login: unknown platform(s) {unknown}; known: {sorted(MODULES)}"
|
||||
)
|
||||
|
||||
out = {}
|
||||
cdp = CDPClient(CDP_ENDPOINT)
|
||||
await cdp.connect()
|
||||
try:
|
||||
for name in platforms:
|
||||
module = MODULES[name]
|
||||
|
||||
def factory(module=module):
|
||||
return module.check_login(cdp)
|
||||
|
||||
try:
|
||||
out[name] = bool(await queue.submit(f"check:{name}", factory))
|
||||
except Exception as exc: # noqa: BLE001 — a failed check is not a False
|
||||
out[name] = f"error: {type(exc).__name__}: {exc}"
|
||||
finally:
|
||||
await cdp.close()
|
||||
return out
|
||||
|
||||
|
||||
async def tool_get_status(args, queue):
|
||||
state = load_state()
|
||||
state["queue_history"] = queue.history[-20:]
|
||||
state["queue_depth"] = queue.depth
|
||||
# Show open browser tabs so the caller can see what's reusable
|
||||
try:
|
||||
cdp = CDPClient(CDP_ENDPOINT)
|
||||
await cdp.connect()
|
||||
targets = await cdp.list_targets()
|
||||
state["open_tabs"] = [
|
||||
{"id": t["targetId"], "url": t.get("url", ""), "title": t.get("title", "")}
|
||||
for t in targets
|
||||
]
|
||||
await cdp.close()
|
||||
except Exception:
|
||||
state["open_tabs"] = "could not connect to browser"
|
||||
return state
|
||||
|
||||
|
||||
HANDLERS = {
|
||||
"create_post": tool_create_post,
|
||||
"check_login": tool_check_login,
|
||||
"get_status": tool_get_status,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON-RPC / MCP plumbing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Server:
|
||||
def __init__(self):
|
||||
self.queue = PostQueue()
|
||||
|
||||
async def handle(self, msg):
|
||||
"""Return a response dict, or None for notifications."""
|
||||
method = msg.get("method")
|
||||
mid = msg.get("id")
|
||||
params = msg.get("params") or {}
|
||||
|
||||
if method == "initialize":
|
||||
return self._ok(
|
||||
mid,
|
||||
{
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
||||
},
|
||||
)
|
||||
if method in ("notifications/initialized", "initialized"):
|
||||
return None
|
||||
if method == "ping":
|
||||
return self._ok(mid, {})
|
||||
if method == "tools/list":
|
||||
return self._ok(mid, {"tools": TOOLS})
|
||||
if method == "tools/call":
|
||||
return await self._call_tool(mid, params)
|
||||
if mid is None:
|
||||
return None # unknown notification — ignore, don't answer
|
||||
return self._err(mid, -32601, f"method not found: {method}")
|
||||
|
||||
async def _call_tool(self, mid, params):
|
||||
name = params.get("name")
|
||||
args = params.get("arguments") or {}
|
||||
handler = HANDLERS.get(name)
|
||||
if handler is None:
|
||||
return self._err(mid, -32602, f"unknown tool: {name}")
|
||||
try:
|
||||
result = await handler(args, self.queue)
|
||||
except Exception as exc: # noqa: BLE001 — report as a tool error, loudly
|
||||
log(f"tool {name} failed: {type(exc).__name__}: {exc}")
|
||||
return self._ok(
|
||||
mid,
|
||||
{
|
||||
"content": [
|
||||
{"type": "text", "text": f"{type(exc).__name__}: {exc}"}
|
||||
],
|
||||
"isError": True,
|
||||
},
|
||||
)
|
||||
return self._ok(
|
||||
mid,
|
||||
{"content": [{"type": "text", "text": json.dumps(result, indent=2)}]},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ok(mid, result):
|
||||
return {"jsonrpc": "2.0", "id": mid, "result": result}
|
||||
|
||||
@staticmethod
|
||||
def _err(mid, code, message):
|
||||
return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
async def main():
|
||||
server = Server()
|
||||
await server.queue.start()
|
||||
|
||||
reader = asyncio.StreamReader()
|
||||
await asyncio.get_running_loop().connect_read_pipe(
|
||||
lambda: asyncio.StreamReaderProtocol(reader), sys.stdin
|
||||
)
|
||||
log(f"listening on stdio · CDP endpoint {CDP_ENDPOINT}")
|
||||
|
||||
while True:
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
log(f"dropping unparseable line: {exc}")
|
||||
continue
|
||||
response = await server.handle(msg)
|
||||
if response is not None:
|
||||
sys.stdout.write(json.dumps(response) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
await server.queue.stop()
|
||||
log("stdin closed, exiting")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
Reference in New Issue
Block a user