ForcePilot/backend/package/yuxi/channel/extensions/jira/outbound.py
Kris c5d8e3550e feat(channel): 添加 Jira 渠道扩展
新增 Jira 渠道扩展,支持在 Yuxi 平台中集成 Jira 项目管理渠道。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息与工单管理
- streaming: 流式消息处理
- parse: Jira 内容解析
- format: 消息格式转换
- agent_tools: Agent 工具集成
- security: 安全校验
- dedupe: 消息去重
- loopbreaker: 循环响应防护
- monitor: 渠道状态监控
- status: 会话与工单状态管理
- types: 类型定义
2026-05-21 11:10:54 +08:00

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()