socialmag v0.1 — 4/5 platforms live (Threads, FB, IG, TikTok)

This commit is contained in:
2026-09-10 21:10:55 +08:00
commit a4f33f456e
28 changed files with 2053 additions and 0 deletions
+351
View File
@@ -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