新增 Jira 渠道扩展,支持在 Yuxi 平台中集成 Jira 项目管理渠道。 包含以下功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息与工单管理 - streaming: 流式消息处理 - parse: Jira 内容解析 - format: 消息格式转换 - agent_tools: Agent 工具集成 - security: 安全校验 - dedupe: 消息去重 - loopbreaker: 循环响应防护 - monitor: 渠道状态监控 - status: 会话与工单状态管理 - types: 类型定义
282 lines
9.8 KiB
Python
282 lines
9.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import logging
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.jira.errors import MAX_RETRIES, JiraErrorHandler, classify_error
|
|
from yuxi.channel.extensions.jira.format import markdown_to_adf
|
|
from yuxi.channel.protocols import (
|
|
OutboundDeliveryCapabilities,
|
|
OutboundDeliveryMode,
|
|
OutboundPresentationCapabilities,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_error_handler = JiraErrorHandler()
|
|
|
|
|
|
class JiraOutbound:
|
|
delivery_mode = OutboundDeliveryMode.DIRECT
|
|
chunker_mode = "length"
|
|
text_chunk_limit: int = 32000
|
|
supports_polls = False
|
|
extract_markdown_images = False
|
|
presentation_capabilities = OutboundPresentationCapabilities()
|
|
delivery_capabilities = OutboundDeliveryCapabilities()
|
|
|
|
def __init__(self, config_adapter=None):
|
|
self._config_adapter = config_adapter
|
|
self._clients: dict[str, httpx.AsyncClient] = {}
|
|
|
|
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,
|
|
) -> dict | None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account or not account.get("site_url"):
|
|
logger.error("Jira send_text: account not configured")
|
|
return None
|
|
|
|
return await self._create_comment(
|
|
account,
|
|
target_id,
|
|
content,
|
|
visibility=account.get("comment_visibility", "internal"),
|
|
source="forcepilot",
|
|
)
|
|
|
|
async def send_media(
|
|
self,
|
|
target_id: str,
|
|
media_url: str,
|
|
media_type: str,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> dict | None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account or not account.get("site_url"):
|
|
return None
|
|
|
|
return await self._upload_attachment(account, target_id, media_url)
|
|
|
|
async def edit_message(
|
|
self,
|
|
target_id: str,
|
|
message_id: str,
|
|
content: str,
|
|
*,
|
|
account_id: str | None = None,
|
|
) -> dict | None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account or not account.get("site_url"):
|
|
return None
|
|
|
|
return await self._update_comment(
|
|
account,
|
|
target_id,
|
|
message_id,
|
|
content,
|
|
visibility=account.get("comment_visibility", "internal"),
|
|
)
|
|
|
|
async def delete_message(
|
|
self,
|
|
target_id: str,
|
|
message_id: str,
|
|
*,
|
|
account_id: str | None = None,
|
|
) -> dict | None:
|
|
account = await self._resolve_account(account_id)
|
|
if not account or not account.get("site_url"):
|
|
return None
|
|
|
|
client = await self._get_client(account)
|
|
url = f"{account['site_url']}/rest/api/3/issue/{target_id}/comment/{message_id}"
|
|
|
|
try:
|
|
resp = await client.delete(url)
|
|
if resp.status_code == 204:
|
|
return {"msg_id": message_id, "success": True}
|
|
return {"msg_id": message_id, "success": False, "error": f"HTTP {resp.status_code}"}
|
|
except Exception as e:
|
|
logger.error("Jira delete_message error: %s", e)
|
|
return {"msg_id": message_id, "success": False, "error": str(e)}
|
|
|
|
async def _create_comment(
|
|
self,
|
|
account: dict,
|
|
issue_key: str,
|
|
content: str,
|
|
*,
|
|
visibility: str = "internal",
|
|
source: str = "forcepilot",
|
|
) -> dict | None:
|
|
import time
|
|
|
|
client = await self._get_client(account)
|
|
adf_body = markdown_to_adf(content)
|
|
|
|
payload: dict = {
|
|
"body": adf_body,
|
|
"properties": [
|
|
{"key": "ai.agent", "value": {"generated_at": time.time(), "source": source}},
|
|
{"key": "sd.public.comment", "value": {"internal": visibility == "internal"}},
|
|
],
|
|
}
|
|
|
|
for attempt in range(MAX_RETRIES):
|
|
try:
|
|
resp = await client.post(
|
|
f"/rest/api/3/issue/{issue_key}/comment",
|
|
json=payload,
|
|
)
|
|
if resp.status_code in (200, 201):
|
|
data = resp.json()
|
|
return {
|
|
"msg_id": str(data["id"]),
|
|
"success": True,
|
|
}
|
|
severity, desc, retry_after = classify_error(resp.status_code, resp.json() if resp.content else None)
|
|
if (
|
|
_error_handler.is_retryable(httpx.HTTPStatusError("", request=resp.request, response=resp))
|
|
and attempt < MAX_RETRIES - 1
|
|
):
|
|
delay = retry_after or min(2**attempt, 10)
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
return {"msg_id": None, "success": False, "error": desc}
|
|
except Exception as e:
|
|
logger.warning("Jira create_comment error (attempt %d): %s", attempt + 1, e)
|
|
if attempt < MAX_RETRIES - 1:
|
|
await asyncio.sleep(min(2**attempt, 10))
|
|
else:
|
|
return {"msg_id": None, "success": False, "error": str(e)}
|
|
return None
|
|
|
|
async def _update_comment(
|
|
self,
|
|
account: dict,
|
|
issue_key: str,
|
|
comment_id: str,
|
|
content: str,
|
|
*,
|
|
visibility: str = "internal",
|
|
) -> dict | None:
|
|
client = await self._get_client(account)
|
|
adf_body = markdown_to_adf(content)
|
|
|
|
payload: dict = {
|
|
"body": adf_body,
|
|
"properties": [
|
|
{"key": "ai.agent", "value": {"active": True}},
|
|
{"key": "sd.public.comment", "value": {"internal": visibility == "internal"}},
|
|
],
|
|
}
|
|
|
|
try:
|
|
resp = await client.put(
|
|
f"/rest/api/3/issue/{issue_key}/comment/{comment_id}",
|
|
json=payload,
|
|
)
|
|
if resp.status_code == 200:
|
|
return {"msg_id": comment_id, "success": True}
|
|
return {"msg_id": comment_id, "success": False, "error": f"HTTP {resp.status_code}"}
|
|
except Exception as e:
|
|
logger.error("Jira update_comment error: %s", e)
|
|
return {"msg_id": comment_id, "success": False, "error": str(e)}
|
|
|
|
async def _upload_attachment(self, account: dict, issue_key: str, media_url: str) -> dict | None:
|
|
client = await self._get_client(account)
|
|
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as dl_client:
|
|
resp = await dl_client.get(media_url)
|
|
if resp.status_code != 200:
|
|
return {
|
|
"msg_id": None,
|
|
"success": False,
|
|
"error": f"download failed: HTTP {resp.status_code}",
|
|
}
|
|
file_content = resp.content
|
|
content_type = resp.headers.get("content-type", "application/octet-stream")
|
|
filename = media_url.rsplit("/", 1)[-1] or "attachment"
|
|
|
|
url = f"/rest/api/3/issue/{issue_key}/attachments"
|
|
try:
|
|
headers = dict(client.headers)
|
|
headers["X-Atlassian-Token"] = "no-check"
|
|
resp = await client.post(
|
|
url,
|
|
headers=headers,
|
|
files={"file": (filename, file_content, content_type)},
|
|
)
|
|
if resp.status_code in (200, 201):
|
|
data = resp.json()
|
|
attachment_id = str(data[0]["id"]) if isinstance(data, list) and data else ""
|
|
return {"msg_id": attachment_id, "success": True}
|
|
return {"msg_id": None, "success": False, "error": f"HTTP {resp.status_code}"}
|
|
except Exception as e:
|
|
logger.error("Jira upload_attachment error: %s", e)
|
|
return {"msg_id": None, "success": False, "error": str(e)}
|
|
|
|
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
|
|
limit = limit or self.text_chunk_limit
|
|
if len(text) <= limit:
|
|
return [text]
|
|
chunks: list[str] = []
|
|
for i in range(0, len(text), limit):
|
|
chunks.append(text[i : i + limit])
|
|
return chunks
|
|
|
|
def sanitize_text(self, text: str, payload: object) -> str:
|
|
return text[: self.text_chunk_limit]
|
|
|
|
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
|
|
|
|
async def _get_client(self, account: dict) -> httpx.AsyncClient:
|
|
account_key = account.get("account_id", "default")
|
|
if account_key not in self._clients:
|
|
credentials = f"{account['email']}:{account['api_token']}"
|
|
auth = f"Basic {base64.b64encode(credentials.encode('utf-8')).decode('utf-8')}"
|
|
self._clients[account_key] = httpx.AsyncClient(
|
|
base_url=account["site_url"].rstrip("/"),
|
|
headers={
|
|
"Authorization": auth,
|
|
"Accept": "application/json",
|
|
},
|
|
timeout=httpx.Timeout(30.0),
|
|
)
|
|
return self._clients[account_key]
|
|
|
|
async def _resolve_account(self, account_id: str | None) -> dict | None:
|
|
if self._config_adapter:
|
|
aid = account_id or self._config_adapter.default_account_id()
|
|
return await self._config_adapter.resolve_account(aid)
|
|
return None
|
|
|
|
async def close(self):
|
|
for client in self._clients.values():
|
|
await client.aclose()
|
|
self._clients.clear()
|