179 lines
6.9 KiB
Python
179 lines
6.9 KiB
Python
|
|
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
|