import logging import httpx from yuxi.channel.extensions.clickup.format import sanitize_for_clickup from yuxi.channel.extensions.clickup.types import OutboundResult logger = logging.getLogger(__name__) API_V3_BASE = "https://api.clickup.com/api/v3" class ClickUpOutbound: delivery_mode = "direct" chunker_mode = "length" text_chunk_limit: int = 40000 def __init__(self, config_adapter=None): self._config_adapter = config_adapter @staticmethod def _headers(api_token: str) -> dict: return { "Authorization": api_token, "Content-Type": "application/json", } async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> OutboundResult: account = await self._resolve_account(account_id) if not account: logger.error("ClickUp send_text: account not resolved") return OutboundResult(success=False, error="account_not_resolved") api_token = account.get("api_token", "") workspace_id = account.get("workspace_id", "") if not api_token or not workspace_id: logger.error("ClickUp send_text: account not configured") return OutboundResult(success=False, error="account_not_configured") formatted = sanitize_for_clickup(content) if thread_id: from yuxi.channel.extensions.clickup.threading import send_reply return await send_reply(workspace_id, thread_id, formatted, api_token) url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/channels/{target_id}/messages" payload: dict = { "type": "message", "content": formatted, "content_format": "text/md", } if reply_to_id and not thread_id: payload["reply_to"] = reply_to_id async with httpx.AsyncClient(timeout=30.0) as client: try: resp = await client.post(url, json=payload, headers=self._headers(api_token)) if resp.status_code == 201: data = resp.json() msg_id = data.get("id", "") logger.debug("ClickUp send_text OK: msg_id=%s", msg_id) return OutboundResult(success=True, message_id=msg_id) else: error_text = resp.text[:500] logger.error("ClickUp send_text failed: status=%d body=%s", resp.status_code, error_text) return OutboundResult(success=False, error=f"http_{resp.status_code}", detail=error_text) except Exception as e: logger.exception("ClickUp send_text exception: %s", e) return OutboundResult(success=False, error="exception", detail=str(e)) async def edit_message( self, message_id: str, content: str, account_id: str | None = None, ) -> OutboundResult: account = await self._resolve_account(account_id) if not account: return OutboundResult(success=False, error="account_not_resolved") api_token = account.get("api_token", "") workspace_id = account.get("workspace_id", "") if not api_token or not workspace_id: return OutboundResult(success=False, error="account_not_configured") url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/messages/{message_id}" formatted = sanitize_for_clickup(content) payload = { "content": formatted, "content_format": "text/md", } async with httpx.AsyncClient(timeout=15.0) as client: try: resp = await client.patch(url, json=payload, headers=self._headers(api_token)) if resp.status_code == 200: return OutboundResult(success=True, message_id=message_id) else: error_text = resp.text[:500] logger.error("ClickUp edit_message failed: status=%d body=%s", resp.status_code, error_text) return OutboundResult(success=False, error=f"http_{resp.status_code}", detail=error_text) except Exception as e: logger.exception("ClickUp edit_message exception: %s", e) return OutboundResult(success=False, error="exception", detail=str(e)) async def delete_message( self, message_id: str, account_id: str | None = None, ) -> OutboundResult: account = await self._resolve_account(account_id) if not account: return OutboundResult(success=False, error="account_not_resolved") api_token = account.get("api_token", "") workspace_id = account.get("workspace_id", "") if not api_token or not workspace_id: return OutboundResult(success=False, error="account_not_configured") url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/messages/{message_id}" async with httpx.AsyncClient(timeout=15.0) as client: try: resp = await client.delete(url, headers=self._headers(api_token)) if resp.status_code == 204: return OutboundResult(success=True, message_id=message_id) else: error_text = resp.text[:500] logger.error("ClickUp delete_message failed: status=%d", resp.status_code) return OutboundResult(success=False, error=f"http_{resp.status_code}", detail=error_text) except Exception as e: logger.exception("ClickUp delete_message exception: %s", e) return OutboundResult(success=False, error="exception", detail=str(e)) async def _resolve_account(self, account_id: str | None) -> dict | None: if self._config_adapter is None: from yuxi.channel.extensions.clickup.config import ClickUpConfigAdapter self._config_adapter = ClickUpConfigAdapter() aid = account_id or self._config_adapter.default_account_id({}) return await self._config_adapter.resolve_account(aid) def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: if len(text) <= limit: return [text] chunks = [] while len(text) > limit: chunks.append(text[:limit]) text = text[limit:] if text: chunks.append(text) return chunks def sanitize_text(self, text: str, payload: object) -> str: return sanitize_for_clickup(text) def resolve_target( self, to: str | None = None, *, config: dict | None = None, allow_from: list[str] | None = None, account_id: str | None = None, mode: str | None = None, ) -> tuple[bool, str]: if not to: return False, "target required" return True, to