import asyncio import logging from yuxi.channel.extensions.confluence.format import ADFBuilder from yuxi.channel.extensions.confluence.gateway import ConfluenceGateway from yuxi.channel.extensions.confluence.search import CQLBuilder from yuxi.channel.extensions.confluence.types import OutboundResult logger = logging.getLogger(__name__) MAX_ADF_BLOCK_CHARS = 2000 MAX_RETRY = 3 RETRY_BASE_DELAY = 1.0 class ConfluenceOutbound: delivery_mode = "comment" def __init__(self, gateway: ConfluenceGateway): self._gateway = gateway async def reply_to_comment( self, page_id: str, parent_comment_id: str, text: str, account_id: str = "default", ) -> OutboundResult: client = self._gateway.get_client(account_id) if client is None: return OutboundResult(success=False, error="Client not found") adf_body = ADFBuilder.from_markdown(text) payload = { "body": { "representation": "atlas_doc_format", "value": adf_body, }, "containerId": page_id, "parentCommentId": parent_comment_id, } return await self._send_with_retry(client, "POST", "/wiki/api/v2/footer-comments", payload) async def create_comment( self, page_id: str, text: str, account_id: str = "default", ) -> OutboundResult: client = self._gateway.get_client(account_id) if client is None: return OutboundResult(success=False, error="Client not found") adf_body = ADFBuilder.from_markdown(text) payload = { "body": { "representation": "atlas_doc_format", "value": adf_body, }, "containerId": page_id, } return await self._send_with_retry(client, "POST", "/wiki/api/v2/footer-comments", payload) async def edit_comment( self, comment_id: str, text: str, account_id: str = "default", ) -> OutboundResult: client = self._gateway.get_client(account_id) if client is None: return OutboundResult(success=False, error="Client not found") adf_body = ADFBuilder.from_markdown(text) return await self._send_with_retry( client, "PUT", f"/wiki/api/v2/footer-comments/{comment_id}", {"body": {"representation": "atlas_doc_format", "value": adf_body}}, ) async def delete_comment( self, comment_id: str, account_id: str = "default", ) -> OutboundResult: client = self._gateway.get_client(account_id) if client is None: return OutboundResult(success=False, error="Client not found") try: await client.delete_footer_comment(comment_id) return OutboundResult(success=True, comment_id=comment_id) except Exception as e: return OutboundResult(success=False, error=str(e)) async def get_comment_thread( self, comment_id: str, account_id: str = "default", max_depth: int = 3, ) -> list[dict]: client = self._gateway.get_client(account_id) if client is None: return [] results = [] children = await client.get_comment_children(comment_id, limit=25) for child in children.get("results", []): results.append(child) if max_depth > 1: deeper = await self.get_comment_thread( child["id"], account_id, max_depth - 1, ) results.extend(deeper) return results async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str = "default", ) -> OutboundResult: page_id = target_id parent_id = reply_to_id or thread_id if parent_id: return await self.reply_to_comment(page_id, parent_id, content, account_id) return await self.create_comment(page_id, content, account_id) async def read_page( self, page_id: str, account_id: str = "default" ) -> dict | None: client = self._gateway.get_client(account_id) if client is None: return None return await client.get_page(page_id) async def update_page( self, page_id: str, title: str, content: str, space_id: str, current_version: int, version_comment: str = "AI 自动更新", account_id: str = "default", ) -> OutboundResult: client = self._gateway.get_client(account_id) if client is None: return OutboundResult(success=False, error="Client not found") adf_body = ADFBuilder.from_markdown(content) payload = { "id": page_id, "status": "current", "title": title, "spaceId": space_id, "body": { "representation": "atlas_doc_format", "value": adf_body, }, "version": { "number": current_version + 1, "message": version_comment, }, } return await self._send_with_retry(client, "PUT", f"/wiki/api/v2/pages/{page_id}", payload) async def update_page_with_conflict_retry( self, page_id: str, title: str, content: str, space_id: str, version_comment: str = "AI 自动更新", account_id: str = "default", max_retries: int = 3, ) -> OutboundResult: for attempt in range(max_retries): page = await self.read_page(page_id, account_id) if page is None: return OutboundResult(success=False, error="Page not found") current_version = page.get("version", {}).get("number", 1) current_title = page.get("title", title) result = await self.update_page( page_id, current_title, content, space_id, current_version, version_comment, account_id, ) if result.success: return result if "409" in result.error or "conflict" in result.error.lower(): delay = RETRY_BASE_DELAY * (2 ** attempt) logger.info( "Page update conflict, retry %d/%d after %.1fs", attempt + 1, max_retries, delay, ) await asyncio.sleep(delay) continue return result return OutboundResult(success=False, error="Max retries exceeded for update_page") async def search_pages( self, query: str, space_keys: list[str] | None = None, limit: int = 10, account_id: str = "default", ) -> list[dict]: client = self._gateway.get_client(account_id) if client is None: return [] cql = CQLBuilder.search_pages(query, space_keys) try: results = await client.cql_search(cql, limit=limit, expand="body.view") return results.get("results", []) except Exception as e: logger.error("CQL search failed for query '%s': %s", query, e) return [] async def _send_with_retry( self, client, method: str, path: str, payload: dict, ) -> OutboundResult: for attempt in range(MAX_RETRY): try: result = await client._request(method, path, json=payload) comment_id = result.get("id", "") return OutboundResult(success=True, comment_id=comment_id) except Exception as e: status_code = getattr(getattr(e, "response", None), "status_code", 0) if status_code == 409: return OutboundResult(success=False, error=f"409 Conflict: {e}") if status_code == 403: return OutboundResult(success=False, error=f"403 Forbidden (permissions): {e}") if attempt == MAX_RETRY - 1: return OutboundResult(success=False, error=str(e)) delay = RETRY_BASE_DELAY * (2 ** attempt) logger.warning( "Outbound retry %d/%d after %.1fs: %s", attempt + 1, MAX_RETRY, delay, e, ) await asyncio.sleep(delay) return OutboundResult(success=False, error="Unknown retry failure") def chunker(self, text: str, limit: int = 2000, ctx=None) -> list[str]: chunks = [] paragraphs = text.split("\n\n") current = "" for para in paragraphs: if len(current) + len(para) + 2 > limit and current: chunks.append(current.rstrip()) current = para else: current = f"{current}\n\n{para}" if current else para if current: chunks.append(current.rstrip()) return chunks