| """ |
| Confluence Integration Adapter |
| |
| Provides OAuth-based integration with Atlassian Confluence for knowledge base and wiki management. |
| """ |
|
|
| import logging |
| import os |
| import httpx |
| from typing import Dict, Any, List, Optional |
| from datetime import datetime, timedelta |
| from urllib.parse import urlencode |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class ConfluenceAdapter: |
| """ |
| Adapter for Confluence OAuth integration. |
| |
| Supports: |
| - OAuth 2.0 authentication |
| - Page and blog management |
| - Space and content operations |
| - Search and attachment handling |
| """ |
|
|
| def __init__(self, db, workspace_id: str): |
| self.db = db |
| self.workspace_id = workspace_id |
| self.service_name = "confluence" |
| self.site_url = os.getenv("CONFLUENCE_SITE_URL") |
| self.base_url = f"{self.site_url}/wiki/rest/api" if self.site_url else None |
|
|
| |
| self.client_id = os.getenv("CONFLUENCE_CLIENT_ID") |
| self.client_secret = os.getenv("CONFLUENCE_CLIENT_SECRET") |
| self.redirect_uri = os.getenv("CONFLUENCE_REDIRECT_URI") |
|
|
| |
| self._access_token: Optional[str] = None |
| _refresh_token: Optional[str] = None |
| self._token_expires_at: Optional[datetime] = None |
|
|
| async def get_oauth_url(self) -> str: |
| """ |
| Generate Confluence OAuth authorization URL. |
| |
| Returns: |
| Authorization URL to redirect user to Confluence OAuth consent screen |
| """ |
| if not self.site_url or not self.client_id: |
| raise ValueError("Confluence site URL and client ID must be configured") |
|
|
| |
| auth_url = f"{self.site_url}/wiki/rest/oauth2/latest/authorization" |
|
|
| |
| params = { |
| "client_id": self.client_id, |
| "response_type": "code", |
| "redirect_uri": self.redirect_uri, |
| "scope": "read:space-content write:space-content search:confluence", |
| "state": self.workspace_id, |
| } |
|
|
| auth_url_with_params = f"{auth_url}?{urlencode(params)}" |
|
|
| logger.info(f"Generated Confluence OAuth URL for workspace {self.workspace_id}") |
| return auth_url_with_params |
|
|
| async def exchange_code_for_token(self, code: str) -> Dict[str, Any]: |
| """ |
| Exchange OAuth authorization code for access token. |
| |
| Args: |
| code: Authorization code from OAuth callback |
| |
| Returns: |
| Token response with access_token, refresh_token, expires_in, etc. |
| """ |
| if not self.site_url or not self.client_id or not self.client_secret: |
| raise ValueError("Confluence OAuth credentials not configured") |
|
|
| token_url = f"{self.site_url}/wiki/rest/oauth2/latest/token" |
|
|
| data = { |
| "grant_type": "authorization_code", |
| "code": code, |
| "redirect_uri": self.redirect_uri, |
| "client_id": self.client_id, |
| "client_secret": self.client_secret, |
| } |
|
|
| try: |
| async with httpx.AsyncClient() as client: |
| response = await client.post(token_url, data=data) |
| response.raise_for_status() |
|
|
| token_data = response.json() |
|
|
| |
| self._access_token = token_data.get("access_token") |
| _refresh_token = token_data.get("refresh_token") |
|
|
| |
| if "expires_in" in token_data: |
| self._token_expires_at = datetime.now() + timedelta( |
| seconds=token_data["expires_in"] |
| ) |
|
|
| logger.info(f"Successfully obtained Confluence access token for workspace {self.workspace_id}") |
| return token_data |
|
|
| except httpx.HTTPStatusError as e: |
| logger.error(f"Confluence token exchange failed: {e}") |
| raise |
|
|
| async def test_connection(self) -> bool: |
| """ |
| Test the Confluence API connection. |
| |
| Returns: |
| True if connection successful, False otherwise |
| """ |
| if not self.base_url or not self._access_token: |
| return False |
|
|
| try: |
| async with httpx.AsyncClient() as client: |
| |
| response = await client.get( |
| f"{self.base_url}/user/current", |
| headers={ |
| "Authorization": f"Bearer {self._access_token}" |
| } |
| ) |
| response.raise_for_status() |
|
|
| logger.info(f"Confluence connection test successful for workspace {self.workspace_id}") |
| return True |
|
|
| except Exception as e: |
| logger.error(f"Confluence connection test failed: {e}") |
| return False |
|
|
| async def search_content(self, query: str, limit: int = 10, |
| space_key: str = None, type: str = "page") -> List[Dict[str, Any]]: |
| """ |
| Search Confluence content using CQL (Confluence Query Language). |
| |
| Args: |
| query: Search query string |
| limit: Maximum number of results |
| space_key: Space key to limit search |
| type: Content type ("page", "blogpost") |
| |
| Returns: |
| List of content objects |
| """ |
| if not self.base_url or not self._access_token: |
| raise ValueError("Confluence API not configured") |
|
|
| try: |
| |
| cql = f"type={type} and text ~ '{query}'" |
| if space_key: |
| cql = f"{cql} and space.key = '{space_key}'" |
|
|
| async with httpx.AsyncClient() as client: |
| response = await client.get( |
| f"{self.base_url}/content/search", |
| headers={ |
| "Authorization": f"Bearer {self._access_token}" |
| }, |
| params={ |
| "cql": cql, |
| "limit": limit, |
| "expand": "space,version" |
| } |
| ) |
| response.raise_for_status() |
|
|
| data = response.json() |
| contents = data.get("results", []) |
|
|
| logger.info(f"Confluence search returned {len(contents)} results for workspace {self.workspace_id}") |
| return contents |
|
|
| except Exception as e: |
| logger.error(f"Confluence search failed: {e}") |
| raise |
|
|
| async def get_page(self, page_id: str, expand: str = "body.storage,version,space") -> Dict[str, Any]: |
| """ |
| Retrieve a specific Confluence page by ID. |
| |
| Args: |
| page_id: Confluence page ID |
| expand: Comma-separated list of properties to expand |
| |
| Returns: |
| Page details with content |
| """ |
| if not self.base_url or not self._access_token: |
| raise ValueError("Confluence API not configured") |
|
|
| try: |
| async with httpx.AsyncClient() as client: |
| response = await client.get( |
| f"{self.base_url}/content/{page_id}", |
| headers={ |
| "Authorization": f"Bearer {self._access_token}" |
| }, |
| params={"expand": expand} |
| ) |
| response.raise_for_status() |
|
|
| page = response.json() |
|
|
| logger.info(f"Retrieved Confluence page {page_id} for workspace {self.workspace_id}") |
| return page |
|
|
| except Exception as e: |
| logger.error(f"Failed to retrieve Confluence page {page_id}: {e}") |
| raise |
|
|
| async def create_page(self, space_key: str, title: str, content: str, |
| parent_id: str = None) -> Dict[str, Any]: |
| """ |
| Create a new Confluence page. |
| |
| Args: |
| space_key: Space key |
| title: Page title |
| content: Page content (storage format) |
| parent_id: Parent page ID (optional) |
| |
| Returns: |
| Created page object |
| """ |
| if not self.base_url or not self._access_token: |
| raise ValueError("Confluence API not configured") |
|
|
| try: |
| |
| page_data = { |
| "type": "page", |
| "title": title, |
| "space": {"key": space_key}, |
| "body": { |
| "storage": { |
| "value": content, |
| "representation": "storage" |
| } |
| } |
| } |
|
|
| if parent_id: |
| page_data["ancestors"] = [{"id": parent_id}] |
|
|
| async with httpx.AsyncClient() as client: |
| response = await client.post( |
| f"{self.base_url}/content", |
| headers={ |
| "Authorization": f"Bearer {self._access_token}", |
| "Content-Type": "application/json" |
| }, |
| json=page_data |
| ) |
| response.raise_for_status() |
|
|
| page = response.json() |
|
|
| logger.info(f"Created Confluence page {page.get('id')} for workspace {self.workspace_id}") |
| return page |
|
|
| except Exception as e: |
| logger.error(f"Failed to create Confluence page: {e}") |
| raise |
|
|
| async def update_page(self, page_id: str, title: str = None, |
| content: str = None, version: int = None) -> Dict[str, Any]: |
| """ |
| Update a Confluence page. |
| |
| Args: |
| page_id: Page ID to update |
| title: New page title |
| content: New page content |
| version: Version number (must increment) |
| |
| Returns: |
| Updated page object |
| """ |
| if not self.base_url or not self._access_token: |
| raise ValueError("Confluence API not configured") |
|
|
| try: |
| |
| if version is None: |
| current_page = await self.get_page(page_id, expand="version") |
| version = current_page.get("version", {}).get("number", 1) + 1 |
|
|
| |
| update_data = { |
| "id": page_id, |
| "type": "page", |
| "version": {"number": version} |
| } |
|
|
| if title: |
| update_data["title"] = title |
|
|
| if content: |
| update_data["body"] = { |
| "storage": { |
| "value": content, |
| "representation": "storage" |
| } |
| } |
|
|
| async with httpx.AsyncClient() as client: |
| response = await client.put( |
| f"{self.base_url}/content/{page_id}", |
| headers={ |
| "Authorization": f"Bearer {self._access_token}", |
| "Content-Type": "application/json" |
| }, |
| json=update_data |
| ) |
| response.raise_for_status() |
|
|
| page = response.json() |
|
|
| logger.info(f"Updated Confluence page {page_id} in workspace {self.workspace_id}") |
| return page |
|
|
| except Exception as e: |
| logger.error(f"Failed to update Confluence page {page_id}: {e}") |
| raise |
|
|
| async def delete_page(self, page_id: str) -> bool: |
| """ |
| Delete a Confluence page. |
| |
| Args: |
| page_id: Page ID to delete |
| |
| Returns: |
| True if successful |
| """ |
| if not self.base_url or not self._access_token: |
| raise ValueError("Confluence API not configured") |
|
|
| try: |
| async with httpx.AsyncClient() as client: |
| response = await client.delete( |
| f"{self.base_url}/content/{page_id}", |
| headers={ |
| "Authorization": f"Bearer {self._access_token}" |
| } |
| ) |
| response.raise_for_status() |
|
|
| logger.info(f"Deleted Confluence page {page_id} in workspace {self.workspace_id}") |
| return True |
|
|
| except Exception as e: |
| logger.error(f"Failed to delete Confluence page {page_id}: {e}") |
| return False |
|
|
| async def get_spaces(self, limit: int = 25) -> List[Dict[str, Any]]: |
| """ |
| Retrieve all Confluence spaces. |
| |
| Args: |
| limit: Maximum number of results |
| |
| Returns: |
| List of space objects |
| """ |
| if not self.base_url or not self._access_token: |
| raise ValueError("Confluence API not configured") |
|
|
| try: |
| async with httpx.AsyncClient() as client: |
| response = await client.get( |
| f"{self.base_url}/space", |
| headers={ |
| "Authorization": f"Bearer {self._access_token}" |
| }, |
| params={ |
| "limit": limit, |
| "expand": "description" |
| } |
| ) |
| response.raise_for_status() |
|
|
| data = response.json() |
| spaces = data.get("results", []) |
|
|
| logger.info(f"Retrieved {len(spaces)} Confluence spaces for workspace {self.workspace_id}") |
| return spaces |
|
|
| except Exception as e: |
| logger.error(f"Failed to retrieve Confluence spaces: {e}") |
| raise |
|
|
| async def add_comment(self, page_id: str, text: str) -> Dict[str, Any]: |
| """ |
| Add a comment to a Confluence page. |
| |
| Args: |
| page_id: Page ID |
| text: Comment text (storage format) |
| |
| Returns: |
| Created comment object |
| """ |
| if not self.base_url or not self._access_token: |
| raise ValueError("Confluence API not configured") |
|
|
| try: |
| async with httpx.AsyncClient() as client: |
| response = await client.post( |
| f"{self.base_url}/content/{page_id}/child/comment", |
| headers={ |
| "Authorization": f"Bearer {self._access_token}", |
| "Content-Type": "application/json" |
| }, |
| json={ |
| "type": "comment", |
| "body": { |
| "storage": { |
| "value": text, |
| "representation": "storage" |
| } |
| } |
| } |
| ) |
| response.raise_for_status() |
|
|
| comment = response.json() |
|
|
| logger.info(f"Added comment to Confluence page {page_id} in workspace {self.workspace_id}") |
| return comment |
|
|
| except Exception as e: |
| logger.error(f"Failed to add comment to Confluence page {page_id}: {e}") |
| raise |
|
|
| async def get_attachments(self, page_id: str, limit: int = 25) -> List[Dict[str, Any]]: |
| """ |
| Retrieve all attachments for a Confluence page. |
| |
| Args: |
| page_id: Page ID |
| limit: Maximum number of results |
| |
| Returns: |
| List of attachment objects |
| """ |
| if not self.base_url or not self._access_token: |
| raise ValueError("Confluence API not configured") |
|
|
| try: |
| async with httpx.AsyncClient() as client: |
| response = await client.get( |
| f"{self.base_url}/content/{page_id}/child/attachment", |
| headers={ |
| "Authorization": f"Bearer {self._access_token}" |
| }, |
| params={ |
| "limit": limit, |
| "expand": "version" |
| } |
| ) |
| response.raise_for_status() |
|
|
| data = response.json() |
| attachments = data.get("results", []) |
|
|
| logger.info(f"Retrieved {len(attachments)} attachments for page {page_id}") |
| return attachments |
|
|
| except Exception as e: |
| logger.error(f"Failed to retrieve attachments for page {page_id}: {e}") |
| raise |
|
|
| async def get_page_children(self, page_id: str, limit: int = 25) -> List[Dict[str, Any]]: |
| """ |
| Retrieve all child pages of a Confluence page. |
| |
| Args: |
| page_id: Parent page ID |
| limit: Maximum number of results |
| |
| Returns: |
| List of child page objects |
| """ |
| if not self.base_url or not self._access_token: |
| raise ValueError("Confluence API not configured") |
|
|
| try: |
| async with httpx.AsyncClient() as client: |
| response = await client.get( |
| f"{self.base_url}/content/{page_id}/child/page", |
| headers={ |
| "Authorization": f"Bearer {self._access_token}" |
| }, |
| params={ |
| "limit": limit, |
| "expand": "version" |
| } |
| ) |
| response.raise_for_status() |
|
|
| data = response.json() |
| children = data.get("results", []) |
|
|
| logger.info(f"Retrieved {len(children)} child pages for page {page_id}") |
| return children |
|
|
| except Exception as e: |
| logger.error(f"Failed to retrieve child pages for {page_id}: {e}") |
| raise |
|
|