ForcePilot/backend/package/yuxi/channel/extensions/jira/gateway.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

233 lines
9.1 KiB
Python

from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import json
import logging
import httpx
from yuxi.channel.extensions.jira.dedupe import get_deduplicator
from yuxi.channel.extensions.jira.loopbreaker import JiraLoopBreaker
from yuxi.channel.extensions.jira.monitor import JiraMonitor
from yuxi.channel.gateway.routes import webhook_registry
logger = logging.getLogger(__name__)
class JiraGateway:
def __init__(self, outbound=None, config_adapter=None, security=None):
self._config_adapter = config_adapter
self._security = security
self._http: httpx.AsyncClient | None = None
self._account: dict = {}
self._loopbreaker = JiraLoopBreaker()
self._outbound = outbound
self._monitor: JiraMonitor | None = None
self._dispatch_queue: asyncio.Queue | None = None
self._running = False
self._webhook_id: str | None = None
async def start(self, ctx) -> dict:
account_id = getattr(ctx, "account_id", "default")
config = getattr(ctx, "config", {})
if self._config_adapter:
self._config_adapter.list_account_ids(config)
account = await self._config_adapter.resolve_account(account_id)
else:
account = {}
self._account = account
if not account.get("site_url") or not account.get("email"):
logger.warning("Jira account %s not configured, skipping start", account_id)
return {"running": False, "reason": "not-configured"}
credentials = f"{account['email']}:{account['api_token']}"
auth = f"Basic {base64.b64encode(credentials.encode('utf-8')).decode('utf-8')}"
self._http = httpx.AsyncClient(
base_url=account["site_url"].rstrip("/"),
headers={"Authorization": auth, "Accept": "application/json"},
timeout=httpx.Timeout(30.0),
)
if self._security:
allowed_projects = account.get("allowed_projects", [])
project_key = account.get("project_key", "")
if project_key and project_key not in allowed_projects:
allowed_projects.append(project_key)
self._security.configure(allowed_projects)
self._monitor = JiraMonitor(account)
self._dispatch_queue = asyncio.Queue(maxsize=1000)
self._running = True
self._webhook_id = await self._ensure_webhook_registered(account)
if self._webhook_id:
account["webhook_id"] = str(self._webhook_id)
webhook_registry.register(
"jira",
self._handle_webhook,
guard_config=None,
)
logger.info(
"Jira gateway started for account %s (webhook_id=%s)",
account_id,
self._webhook_id,
)
return {
"running": True,
"account_id": account_id,
"queue": self._dispatch_queue,
"webhook_id": self._webhook_id,
}
async def stop(self, ctx) -> None:
self._running = False
if self._webhook_id and self._http:
try:
await self._http.delete(f"/rest/api/3/webhook/{self._webhook_id}")
except Exception:
logger.exception("Jira webhook cleanup failed")
if self._http:
await self._http.aclose()
self._http = None
self._webhook_id = None
self._dispatch_queue = None
logger.info("Jira gateway stopped")
async def _handle_webhook(self, payload: dict, headers: dict | None = None) -> dict:
webhook_event = payload.get("webhookEvent", "")
comment = payload.get("comment", {})
issue = payload.get("issue", {})
issue_key = issue.get("key", "")
webhook_secret = self._account.get("webhook_secret", "")
if webhook_secret and headers:
signature_header = headers.get("x-hub-signature", "")
if signature_header:
body_bytes = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
expected = hmac.new(
webhook_secret.encode("utf-8"),
body_bytes,
hashlib.sha256,
).hexdigest()
received = signature_header.replace("sha256=", "")
if not hmac.compare_digest(expected, received):
logger.warning("Jira webhook signature mismatch")
return {"status": "rejected", "reason": "invalid_signature"}
webhook_id_header = headers.get("x-atlassian-webhook-identifier", "") if headers else ""
comment_id = str(comment.get("id", "")) if comment else ""
dedupe_key = f"{webhook_event}:{issue_key}:{comment_id}" if comment_id else f"{webhook_event}:{issue_key}"
if webhook_id_header:
dedupe_key = f"{webhook_id_header}:{dedupe_key}"
deduplicator = get_deduplicator(self._account.get("account_id", "default"))
if deduplicator.is_duplicate(dedupe_key):
return {"status": "skipped", "reason": "duplicate"}
properties = comment.get("properties", []) if comment else []
for prop in properties:
if prop.get("key") == "ai.agent":
return {"status": "skipped", "reason": "ai_generated_comment"}
author = comment.get("author", {}) if comment else payload.get("user", {})
bot_account_id = self._account.get("bot_account_id", "")
if bot_account_id and author.get("accountId") == bot_account_id:
return {"status": "skipped", "reason": "self_authored"}
changelog = payload.get("changelog", {})
if self._security and self._security.is_self_changelog(changelog.get("items", []), bot_account_id):
return {"status": "skipped", "reason": "self_changelog"}
fields = issue.get("fields", {})
project_key = fields.get("project", {}).get("key", "")
if self._security and not self._security.is_project_allowed(project_key):
return {"status": "skipped", "reason": "project_not_allowed"}
if not self._loopbreaker.record_and_check(issue_key):
return {"status": "skipped", "reason": "loop_breaker_tripped"}
if not self._monitor:
return {"status": "error", "reason": "monitor_not_initialized"}
unified = self._monitor.convert_webhook_to_unified(payload, self._account.get("account_id", "default"))
if not unified:
return {"status": "ignored", "reason": "unrecognized_event_type"}
if self._dispatch_queue:
try:
self._dispatch_queue.put_nowait(unified)
except asyncio.QueueFull:
logger.warning("Jira dispatch queue full, dropping event %s", dedupe_key)
return {"status": "ok"}
async def _ensure_webhook_registered(self, account: dict) -> str | None:
callback_base = account.get("callback_base_url", "")
webhook_url = f"{callback_base.rstrip('/')}/webhook/jira"
try:
existing = await self._http.get("/rest/api/3/webhook")
if existing.status_code == 200:
webhooks = existing.json()
for wh in webhooks:
if wh.get("url") == webhook_url:
return wh.get("id")
except Exception:
logger.exception("Failed to list existing webhooks")
jql = account.get("jql_filter", "") or f"project = {account.get('project_key', '')}"
try:
result = await self._http.post(
"/rest/api/3/webhook",
json={
"name": f"ForcePilot-{account.get('account_id', 'default')}",
"url": webhook_url,
"events": [
"comment_created",
"comment_updated",
"comment_deleted",
"jira:issue_created",
"jira:issue_updated",
"jira:issue_deleted",
"attachment_created",
"attachment_deleted",
],
"filters": {
"issue-related-events-section-filter": jql,
}
if jql
else None,
"enabled": True,
"excludeBody": False,
},
)
if result.status_code in (200, 201):
data = result.json()
if isinstance(data, dict):
results = data.get("webhookRegistrationResult", [])
if results:
wh_id = results[0].get("createdWebhookId") or results[0].get("id")
return str(wh_id)
if isinstance(data, list) and data:
return str(data[0].get("id", ""))
return str(data.get("id", ""))
except Exception:
logger.exception("Failed to register Jira webhook")
return None
@property
def dispatch_queue(self):
return self._dispatch_queue
@property
def account(self):
return self._account