from __future__ import annotations import asyncio import json import os from pathlib import Path import time from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import quote from urllib.request import Request, urlopen from cover import generate_cover from publish_one import AmbiguousWrite, DeterministicFailure, Publisher, _validate_article WORKSPACE_ID = "6082aea5-a29e-410f-97be-3cd55fbc2a3a" ROOT_VIEW_ID = "3a743541-abbf-495a-a18a-74f405fa5d70" def _json_from_text(text: str) -> Any: value: Any = json.loads(text) while isinstance(value, dict) and isinstance(value.get("result"), str): value = json.loads(value["result"]) return value class AppFlowyMcp: """Root-scoped AppFlowy access through Hermes's configured MCP endpoint.""" def __init__(self, config_path: Path | None = None): self.config_path = config_path or Path.home() / ".hermes/config.yaml" self.allowed_page_ids: set[str] = set() async def _call_async(self, tool: str, arguments: dict[str, Any]) -> Any: import httpx import yaml from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client config = yaml.safe_load(self.config_path.read_text())["mcp_servers"]["appflowy"] timeout = httpx.Timeout(30, read=120) async with httpx.AsyncClient( headers=config.get("headers", {}), follow_redirects=True, timeout=timeout, ) as http: async with streamable_http_client(config["url"], http_client=http) as streams: # type: ignore[arg-type] async with ClientSession(streams[0], streams[1]) as session: await session.initialize() result = await session.call_tool(tool, arguments) if getattr(result, "is_error", False): raise RuntimeError(f"AppFlowy MCP call failed: {tool}") if not result.content or not hasattr(result.content[0], "text"): raise RuntimeError(f"AppFlowy MCP returned no JSON: {tool}") return _json_from_text(getattr(result.content[0], "text")) def _call(self, tool: str, arguments: dict[str, Any]) -> Any: # ponytail: one session per call; keep one open only if scheduled scans become measurably slow. return asyncio.run(self._call_async(tool, arguments)) @staticmethod def _flatten(children: list[dict[str, Any]]) -> list[dict[str, Any]]: pages: list[dict[str, Any]] = [] for child in children: pages.append(child) pages.extend(AppFlowyMcp._flatten(child.get("children") or [])) return pages def list_pages(self) -> list[dict[str, Any]]: response = self._call( "get_workspace_folder", {"workspace_id": WORKSPACE_ID, "root_view_id": ROOT_VIEW_ID, "depth": 10}, ) root = response.get("data", response) if root.get("view_id") != ROOT_VIEW_ID: raise RuntimeError("AppFlowy returned the wrong source root") pages = self._flatten(root.get("children") or []) self.allowed_page_ids = {page["view_id"] for page in pages} return pages def _require_allowed(self, page_id: str) -> None: if page_id not in self.allowed_page_ids: raise PermissionError("page is outside the configured Ai research subtree") def get_blocks(self, page_id: str) -> list[dict[str, Any]]: self._require_allowed(page_id) response = self._call( "get_page_blocks", {"workspace_id": WORKSPACE_ID, "page_id": page_id}, ) return response.get("blocks") or response.get("data", {}).get("blocks") or [] def edit_block(self, page_id: str, block_id: str, text: str) -> None: self._require_allowed(page_id) self._call( "edit_block_text", { "workspace_id": WORKSPACE_ID, "page_id": page_id, "block_id": block_id, "text": text, }, ) class AgentApi: def __init__(self, key: str, base_url: str = "https://anwarpy.ai"): if not key: raise ValueError("ANWARPY_AGENT_KEY is required") self.key = key self.base_url = base_url.rstrip("/") self.body_probes: dict[str, str] = {} def _request(self, method: str, path: str, body: dict[str, Any] | None = None, *, anonymous: bool = False): data = None if body is None else json.dumps(body, ensure_ascii=False).encode() headers = {"Accept": "application/json", "User-Agent": "anwarpy-publisher/1"} if body is not None: headers["Content-Type"] = "application/json" if not anonymous: headers["x-agent-key"] = self.key request = Request(f"{self.base_url}{path}", data=data, headers=headers, method=method) try: with urlopen(request, timeout=30) as response: raw = response.read() return response.status, raw, response.headers.get("content-type", "") except HTTPError as error: return error.code, error.read(), error.headers.get("content-type", "") except (TimeoutError, URLError) as error: if method in ("POST", "PATCH"): raise AmbiguousWrite(f"agent API {method} outcome is unknown") from error raise @staticmethod def _decode(status: int, raw: bytes) -> dict[str, Any]: try: value = json.loads(raw) except (UnicodeDecodeError, json.JSONDecodeError) as error: raise RuntimeError(f"agent API returned invalid JSON ({status})") from error if not isinstance(value, dict): raise RuntimeError(f"agent API returned invalid object ({status})") return value @staticmethod def _probe(body: dict[str, Any]) -> str | None: children = body.get("content", {}).get("root", {}).get("children", []) for child in children: for text in child.get("children", []): value = text.get("text") if isinstance(value, str) and len(value.strip()) >= 20: return value.strip() return None def lookup(self, slug: str) -> dict[str, Any] | None: status, raw, _ = self._request("GET", f"/api/agent/posts/{quote(slug)}") if status == 404: return None if status != 200: raise RuntimeError(f"agent API lookup failed ({status})") return self._decode(status, raw) def create(self, body: dict[str, Any]) -> dict[str, Any]: probe = self._probe(body) if probe: self.body_probes[body["slug"]] = probe status, raw, _ = self._request("POST", "/api/agent/posts", body) if status == 409: existing = self.lookup(body["slug"]) if existing is None: raise RuntimeError("agent API returned 409 without an existing slug") return existing if status != 201: raise RuntimeError(f"agent API create failed ({status})") return self._decode(status, raw) def update(self, slug: str, body: dict[str, Any]) -> dict[str, Any]: probe = self._probe(body) if probe: self.body_probes[slug] = probe status, raw, _ = self._request("PATCH", f"/api/agent/posts/{quote(slug)}", body) if status != 200: raise RuntimeError(f"agent API update failed ({status})") return self._decode(status, raw) def _public_get(self, url: str, *, rsc: bool = False) -> tuple[int, bytes]: headers = {"Accept": "text/x-component" if rsc else "text/html", "User-Agent": "anwarpy-publisher/1"} if rsc: headers["RSC"] = "1" request = Request(url, headers=headers) try: with urlopen(request, timeout=20) as response: return response.status, response.read() except HTTPError as error: return error.code, error.read() def verify_public(self, record: dict[str, Any]) -> bool: slug = record["slug"] canonical = f"https://anwarpy.ai/posts/{slug}" if record.get("url", canonical) != canonical: return False probe = self.body_probes.get(slug, "").encode() for attempt in range(5): html_status, html = self._public_get(canonical) rsc_status, rsc = self._public_get(f"{canonical}?__rsc=publisher", rsc=True) rest_status, rest, _ = self._request( "GET", f"/api/posts?where%5Bslug%5D%5Bequals%5D={quote(slug)}&depth=0", anonymous=True, ) status_ok = html_status == 200 and rsc_status in (200, 204) and rest_status in (200, 401, 403) vip_leak = record.get("access") == "vip" and probe and any(probe in value for value in (html, rsc, rest)) if status_ok and not vip_leak: return True if attempt < 4: time.sleep(2) return False class LocalArticleGenerator: def __init__(self, endpoint: str = "http://100.64.0.5:8080/v1/chat/completions", model: str | None = None): self.endpoint = endpoint self.model = model def _model_id(self) -> str: if self.model: return self.model models_url = self.endpoint.rsplit("/chat/completions", 1)[0] + "/models" try: with urlopen(models_url, timeout=10) as response: data = json.loads(response.read()) model = data["data"][0]["id"] except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as error: raise DeterministicFailure("generation", "local model discovery failed") from error if not isinstance(model, str) or not model: raise DeterministicFailure("generation", "local model discovery returned no model") self.model = model return model def _request(self, messages: list[dict[str, str]]) -> str: payload = { "model": self._model_id(), "messages": messages, "temperature": 0.35, "max_tokens": 7000, "response_format": {"type": "json_object"}, } request = Request( self.endpoint, data=json.dumps(payload, ensure_ascii=False).encode(), headers={"Content-Type": "application/json"}, method="POST", ) try: with urlopen(request, timeout=240) as response: data = json.loads(response.read()) except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as error: raise DeterministicFailure("generation", "local article model failed") from error return data["choices"][0]["message"]["content"] @staticmethod def _parse(raw: str, access: str, expected_slug: str | None) -> dict[str, Any]: try: article = json.loads(raw) except json.JSONDecodeError as error: raise DeterministicFailure("article_schema", "model returned invalid JSON") from error article = _validate_article(article, access, expected_slug) visible_words = sum( len(paragraph.split()) for section in article["sections"] for paragraph in section["paragraphs"] ) if not 900 <= visible_words <= 2500: raise DeterministicFailure("article_schema", "article must contain 900 to 2500 visible words") return article def generate(self, title: str, evidence: str, meta: dict[str, str]) -> dict[str, Any]: expected_slug = meta.get("slug") if meta["status"] == "republish" else None prompt = f"""اكتب دليلاً عربياً أصلياً لموقع anwarpy.ai اعتماداً حصراً على الأدلة أدناه. العنوان المصدر: {title} نوع الوصول الذي حدده المالك: {meta['access']} التصنيف الذي حدده المالك: {meta['category']} أعد JSON فقط بهذه الحقول: title, slug, dek, teaser, sections. slug إنجليزي ASCII بصيغة kebab-case، ولا تغيّره عند إعادة النشر: {expected_slug or 'اختر وصفاً دقيقاً'}. sections مصفوفة من 3 إلى 6 عناصر؛ كل عنصر يحتوي heading وparagraphs وcode. النص المرئي بين 900 و2500 كلمة. رتّب الدليل: المشكلة، المتطلبات، التنفيذ، التحقق، القيود. لا تختر أو تغيّر access أو category. لا تخترع إصدارات أو أرقاماً أو نتائج أو اقتباسات. مقال VIP يحتاج teaser عربي مفيداً من فقرتين، والمقال المجاني يستخدم teaser فارغاً. الأدلة المنقحة: {evidence} """ messages = [ {"role": "system", "content": "أنت محرر تقني عربي دقيق. أعد JSON صالحاً فقط."}, {"role": "user", "content": prompt}, ] raw = self._request(messages) try: return self._parse(raw, meta["access"], expected_slug) except DeterministicFailure as first_error: corrected = self._request(messages + [ {"role": "assistant", "content": raw}, {"role": "user", "content": f"صحح JSON فقط. خطأ التحقق: {first_error}"}, ]) return self._parse(corrected, meta["access"], expected_slug) class CoverMedia: def __init__(self, output_dir: Path | None = None): self.output_dir = output_dir or Path.home() / ".hermes/cache/scratch/anwarpy-covers" def render(self, article: dict[str, Any], meta: dict[str, str]) -> dict[str, Any]: try: return generate_cover(article["slug"], meta["category"], self.output_dir) except (OSError, ValueError) as error: raise DeterministicFailure("cover", "cover generation failed") from error def main() -> int: key = os.environ.get("ANWARPY_AGENT_KEY", "") publisher = Publisher( appflowy=AppFlowyMcp(), api=AgentApi(key), generator=LocalArticleGenerator(), media=CoverMedia(), lock_path=Path.home() / ".hermes/locks/anwarpy-publish.lock", ) result = publisher.run() print(result) for error in publisher.marker_errors: print(f"marker-error: {error}") return 0 if __name__ == "__main__": raise SystemExit(main())