import asyncio import logging import re import time from collections.abc import Callable from datetime import datetime, UTC import httpx from yuxi.channel.extensions.confluence.dedupe import CommentDeduplicator from yuxi.channel.extensions.confluence.search import CQLBuilder from yuxi.channel.extensions.confluence.types import ( ConfluenceAccount, InboundConfluenceComment, PageContext, ) logger = logging.getLogger(__name__) class ConfluenceClient: def __init__(self, account: ConfluenceAccount): self._account = account self._http: httpx.AsyncClient | None = None self._username = account.email self._rate_limit_remaining = 65_000 async def __aenter__(self): auth_header = self._account.auth_header self._http = httpx.AsyncClient( base_url=self._account.base_url, headers={ **auth_header, "Accept": "application/json", "Content-Type": "application/json", }, timeout=httpx.Timeout(30.0), ) return self async def __aexit__(self, *args): if self._http: await self._http.aclose() async def _request(self, method: str, path: str, **kwargs) -> dict: if self._http is None: raise RuntimeError("Client not initialized") if self._rate_limit_remaining < 100: await asyncio.sleep(0.5) elif self._rate_limit_remaining < 500: await asyncio.sleep(0.1) resp = await self._http.request(method, path, **kwargs) self._update_rate_limit(resp.headers) if resp.status_code == 429: retry_after = int(resp.headers.get("Retry-After", "5")) logger.warning("Confluence rate limited, retry after %ds", retry_after) await asyncio.sleep(retry_after) resp = await self._http.request(method, path, **kwargs) self._update_rate_limit(resp.headers) resp.raise_for_status() return resp.json() def _update_rate_limit(self, headers: dict): remaining = headers.get("X-RateLimit-Remaining") if remaining is not None: self._rate_limit_remaining = int(remaining) async def cql_search(self, cql: str, limit: int = 50, expand: str | None = None) -> dict: params = {"cql": cql, "limit": limit} if expand: params["expand"] = expand return await self._request("GET", "/rest/api/search", params=params) async def create_footer_comment(self, payload: dict) -> dict: return await self._request("POST", "/wiki/api/v2/footer-comments", json=payload) async def get_comment_children(self, comment_id: str, limit: int = 25) -> dict: return await self._request( "GET", f"/wiki/api/v2/footer-comments/{comment_id}/children", params={"limit": limit}, ) async def get_page(self, page_id: str, body_format: str = "atlas_doc_format") -> dict: return await self._request( "GET", f"/wiki/api/v2/pages/{page_id}", params={"body-format": body_format}, ) async def update_page(self, page_id: str, payload: dict) -> dict: return await self._request("PUT", f"/wiki/api/v2/pages/{page_id}", json=payload) async def get_space(self, space_id: str) -> dict: return await self._request("GET", f"/wiki/api/v2/spaces/{space_id}") async def create_page(self, payload: dict) -> dict: return await self._request("POST", "/wiki/api/v2/pages", json=payload) async def update_footer_comment(self, comment_id: str, adf_body: str) -> dict: return await self._request( "PUT", f"/wiki/api/v2/footer-comments/{comment_id}", json={ "body": { "representation": "atlas_doc_format", "value": adf_body, }, }, ) async def delete_footer_comment(self, comment_id: str) -> None: await self._request("DELETE", f"/wiki/api/v2/footer-comments/{comment_id}") async def get_footer_comments( self, page_id: str, cursor: str | None = None, limit: int = 25, sort: str = "created-date", ) -> dict: params = {"limit": limit, "sort": sort} if cursor: params["cursor"] = cursor return await self._request( "GET", f"/wiki/api/v2/pages/{page_id}/footer-comments", params=params, ) async def get_comment(self, comment_id: str) -> dict: return await self._request("GET", f"/wiki/api/v2/footer-comments/{comment_id}") async def delete_page(self, page_id: str, purge: bool = False) -> None: params = {"purge": str(purge).lower()} await self._request("DELETE", f"/wiki/api/v2/pages/{page_id}", params=params) async def get_space_pages( self, space_id: str, cursor: str | None = None, limit: int = 25, depth: str = "all", ) -> dict: params = {"limit": limit, "depth": depth} if cursor: params["cursor"] = cursor return await self._request( "GET", f"/wiki/api/v2/spaces/{space_id}/pages", params=params, ) async def get_spaces(self, limit: int = 10, cursor: str | None = None) -> dict: params = {"limit": limit} if cursor: params["cursor"] = cursor return await self._request("GET", "/wiki/api/v2/spaces", params=params) async def get_users( self, query: str | None = None, limit: int = 25, ) -> dict: params = {"limit": limit} if query: params["query"] = query return await self._request("GET", "/wiki/api/v2/users", params=params) async def create_space(self, key: str, name: str, description: str = "") -> dict: payload = { "key": key, "name": name, } if description: payload["description"] = {"plain": {"value": description}} return await self._request("POST", "/wiki/api/v2/spaces", json=payload) async def delete_space(self, space_id: str) -> None: await self._request("DELETE", f"/wiki/api/v2/spaces/{space_id}") async def get_page_children(self, page_id: str, limit: int = 25) -> dict: return await self._request( "GET", f"/wiki/api/v2/pages/{page_id}/children", params={"limit": limit}, ) async def get_page_ancestors(self, page_id: str) -> dict: return await self._request("GET", f"/wiki/api/v2/pages/{page_id}/ancestors") async def get_page_versions(self, page_id: str, limit: int = 25) -> dict: return await self._request( "GET", f"/wiki/api/v2/pages/{page_id}/versions", params={"limit": limit}, ) async def list_all_footer_comments( self, cursor: str | None = None, limit: int = 25, sort: str = "created-date", ) -> dict: params = {"limit": limit, "sort": sort} if cursor: params["cursor"] = cursor return await self._request("GET", "/wiki/api/v2/footer-comments", params=params) async def update_page_title(self, page_id: str, title: str) -> dict: return await self._request( "PUT", f"/wiki/api/v2/pages/{page_id}/title", json={"title": title}, ) async def update_space(self, space_id: str, name: str, description: str = "") -> dict: payload: dict = {"name": name} if description: payload["description"] = {"plain": {"value": description}} return await self._request( "PUT", f"/wiki/api/v2/spaces/{space_id}", json=payload, ) async def get_blogpost(self, blogpost_id: str, body_format: str = "atlas_doc_format") -> dict: return await self._request( "GET", f"/wiki/api/v2/blogposts/{blogpost_id}", params={"body-format": body_format}, ) async def create_blogpost(self, payload: dict) -> dict: return await self._request("POST", "/wiki/api/v2/blogposts", json=payload) async def update_blogpost(self, blogpost_id: str, payload: dict) -> dict: return await self._request("PUT", f"/wiki/api/v2/blogposts/{blogpost_id}", json=payload) async def delete_blogpost(self, blogpost_id: str) -> None: await self._request("DELETE", f"/wiki/api/v2/blogposts/{blogpost_id}") async def get_tasks(self, page_id: str, limit: int = 25) -> dict: return await self._request( "GET", f"/wiki/api/v2/pages/{page_id}/tasks", params={"limit": limit}, ) async def get_task(self, task_id: str) -> dict: return await self._request("GET", f"/wiki/api/v2/tasks/{task_id}") async def update_task(self, task_id: str, state: str = "COMPLETE") -> dict: return await self._request( "PUT", f"/wiki/api/v2/tasks/{task_id}", json={"state": state}, ) async def _paginated_request( self, method: str, path: str, params: dict | None = None, max_results: int = 250, ) -> list[dict]: cursor = None all_results: list[dict] = [] params = params or {} params.setdefault("limit", 25) while len(all_results) < max_results: if cursor: params["cursor"] = cursor if self._http is None: raise RuntimeError("Client not initialized") resp = await self._http.request(method, path, params=params) self._update_rate_limit(resp.headers) resp.raise_for_status() page = resp.json() all_results.extend(page.get("results", [])) links = resp.headers.get("Link", "") cursor = self._extract_next_cursor(links) if not cursor: break return all_results[:max_results] @staticmethod def _extract_next_cursor(link_header: str) -> str | None: match = re.search(r'<[^>]*cursor=([^>&]+)[^>]*>\s*;\s*rel="next"', link_header) if match: return match.group(1) return None class ConfluenceGateway: MAX_RETRY_BACKOFF = 300.0 MAX_SEEN_COMMENTS = 50_000 def __init__(self): self._active_clients: dict[str, ConfluenceClient] = {} self._poll_tasks: dict[str, asyncio.Task] = {} self._last_poll_time: dict[str, float] = {} self._deduplicator = CommentDeduplicator(max_size=50_000, ttl_seconds=600) self._cancel_events: dict[str, asyncio.Event] = {} async def start( self, account_id: str, account: ConfluenceAccount, on_comment: Callable[[InboundConfluenceComment], None], authorize_sender: Callable[[str, dict], bool], ) -> ConfluenceClient: client = ConfluenceClient(account) await client.__aenter__() self._active_clients[account_id] = client if account.comment_poll_enabled: cancel = asyncio.Event() self._cancel_events[account_id] = cancel task = asyncio.create_task( self._poll_comments_loop(account_id, account, client, on_comment, authorize_sender, cancel) ) self._poll_tasks[account_id] = task return client async def stop(self, account_id: str): cancel = self._cancel_events.pop(account_id, None) if cancel: cancel.set() task = self._poll_tasks.pop(account_id, None) if task: task.cancel() try: await task except asyncio.CancelledError: pass client = self._active_clients.pop(account_id, None) if client: await client.__aexit__(None, None, None) async def stop_all(self): for account_id in list(self._active_clients.keys()): await self.stop(account_id) def get_client(self, account_id: str = "default") -> ConfluenceClient | None: return self._active_clients.get(account_id) async def _poll_comments_loop( self, account_id: str, account: ConfluenceAccount, client: ConfluenceClient, on_comment: Callable, authorize_sender: Callable, cancel: asyncio.Event, ): error_count = 0 last_poll = self._last_poll_time.get(account_id, time.time() - 3600) seen_in_session: set[str] = set() while not cancel.is_set(): try: since_iso = datetime.fromtimestamp(last_poll, tz=UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z") cql = CQLBuilder.comment_poll(since_iso, account.space_keys) results = await client.cql_search(cql, limit=50, expand="body.view,history.lastUpdated,container") new_max_time = last_poll for result in results.get("results", []): content = result.get("content", {}) comment_id = content.get("id", "") if not comment_id: continue if comment_id in seen_in_session: continue if self._deduplicator.is_duplicate(comment_id): continue seen_in_session.add(comment_id) self._deduplicator.mark(comment_id) author_info = content.get("history", {}).get("createdBy", {}) author_id = author_info.get("accountId", "") if self._is_self_comment(client, author_info): continue if not authorize_sender(author_id, result): continue page_ctx = self._extract_page_context(result) if page_ctx is None: continue comment_text = self._extract_comment_text(result) inbound = InboundConfluenceComment( comment_id=comment_id, author_id=author_id, author_name=author_info.get("displayName", ""), body_text=comment_text, container_id=page_ctx.page_id, parent_comment_id=self._extract_parent_comment_id(result), page_context=page_ctx, created_at=self._parse_datetime(content.get("history", {}).get("createdDate")), ) on_comment(inbound) updated = content.get("history", {}).get("lastUpdated", {}).get("when", "") updated_ts = self._parse_timestamp(updated) if updated_ts and updated_ts > new_max_time: new_max_time = updated_ts last_poll = new_max_time self._last_poll_time[account_id] = new_max_time error_count = 0 except asyncio.CancelledError: break except Exception as e: error_count += 1 backoff = min(2.0 * (2**error_count), self.MAX_RETRY_BACKOFF) logger.error( "Confluence poll error (attempt %d, retry in %.1fs): %s", error_count, backoff, e, ) try: await asyncio.wait_for(cancel.wait(), timeout=account.poll_interval) break except TimeoutError: pass def _is_self_comment(self, client: ConfluenceClient, author: dict) -> bool: author_email = author.get("email", "") return author_email and author_email.lower() == client._username.lower() def _extract_page_context(self, result: dict) -> PageContext | None: container = result.get("resultParentContainer") if not container: return None return PageContext( page_id=container.get("id", ""), page_title=container.get("title", ""), space_key=container.get("space", {}).get("key", ""), ) def _extract_comment_text(self, result: dict) -> str: body = result.get("content", {}).get("body", {}) view_value = body.get("view", {}).get("value", "") return self._strip_html(view_value) def _extract_parent_comment_id(self, result: dict) -> str | None: ancestors = result.get("content", {}).get("ancestors", []) for ancestor in reversed(ancestors): if ancestor.get("type") == "comment": return ancestor.get("id") return None @staticmethod def _strip_html(html: str) -> str: clean = re.sub(r"<[^>]+>", "", html) clean = re.sub(r"&[a-z]+;", " ", clean) return re.sub(r"\s+", " ", clean).strip() @staticmethod def _parse_timestamp(iso_str: str | None) -> float: if not iso_str: return 0.0 try: dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) return dt.timestamp() except (ValueError, TypeError): return 0.0 @staticmethod def _parse_datetime(iso_str: str | None) -> datetime | None: if not iso_str: return None try: return datetime.fromisoformat(iso_str.replace("Z", "+00:00")) except (ValueError, TypeError): return None