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

202 lines
7.7 KiB
Python

from __future__ import annotations
import logging
from datetime import datetime
from yuxi.channel.extensions.jira.parse import adf_to_plain_text
logger = logging.getLogger(__name__)
class JiraMonitor:
def __init__(self, config: dict):
self._config = config
def convert_webhook_to_unified(self, payload: dict, account_id: str) -> dict | None:
webhook_event = payload.get("webhookEvent", "")
comment = payload.get("comment", {})
issue = payload.get("issue", {})
changelog = payload.get("changelog", {})
issue_key = issue.get("key", "")
# 处理无 issue 对象的事件(如 issue_deleted 可能以不同结构传递)
if not issue_key:
issue_key = self._extract_issue_key(payload)
if not issue_key:
return None
# 针对新增事件类型的特殊处理
if webhook_event == "jira:issue_deleted":
return self._convert_issue_deleted(payload, account_id, issue_key)
if webhook_event in ("attachment_created", "attachment_deleted"):
return self._convert_attachment_event(payload, account_id, issue_key, webhook_event)
comment_id = str(comment.get("id", "")) if comment else ""
comment_body_adf = comment.get("body", {}) if comment else {}
comment_body_plain = adf_to_plain_text(comment_body_adf)
author = comment.get("author", {}) if comment else payload.get("user", {})
author_account_id = author.get("accountId", "")
author_display_name = author.get("displayName", "")
fields = issue.get("fields", {})
issue_type = fields.get("issuetype", {})
priority = fields.get("priority", {})
status = fields.get("status", {})
project = fields.get("project", {})
properties = comment.get("properties", []) if comment else []
timestamp = None
created = comment.get("created", "") or issue.get("fields", {}).get("created", "")
if created:
try:
timestamp = datetime.fromisoformat(created.replace("Z", "+00:00"))
except ValueError:
pass
return {
"msg_id": f"jira:{issue_key}:{comment_id}" if comment_id else f"jira:{issue_key}:{webhook_event}",
"channel_type": "jira",
"account_id": account_id,
"content": comment_body_plain or fields.get("summary", ""),
"message_type": "TEXT",
"media_urls": [],
"sender": {
"id": f"jira:{author_account_id}",
"display_name": author_display_name or f"Jira:{author_account_id}",
"kind": "GROUP",
},
"group": {
"id": issue_key,
"kind": "thread",
"name": fields.get("summary", ""),
},
"timestamp": timestamp,
"reply_to_id": None,
"thread_id": issue_key,
"thread_parent_id": issue_key,
"raw_payload": payload,
"metadata": {
"webhook_event": webhook_event,
"issue_key": issue_key,
"issue_id": issue.get("id", ""),
"issue_type": issue_type.get("name", ""),
"issue_status": status.get("name", ""),
"issue_priority": priority.get("name", ""),
"project_key": project.get("key", ""),
"project_name": project.get("name", ""),
"comment_id": comment_id,
"comment_visibility": "internal" if _is_internal_comment(properties) else "public",
"is_ai_comment": _has_property(properties, "ai.agent"),
"author_account_id": author_account_id,
"changelog_items": changelog.get("items", []),
},
}
def convert_to_response_payload(self, unified_msg: dict) -> dict:
return {
"content": unified_msg.get("content", ""),
"issue_key": unified_msg.get("metadata", {}).get("issue_key", ""),
"comment_id": unified_msg.get("metadata", {}).get("comment_id", ""),
"project_key": unified_msg.get("metadata", {}).get("project_key", ""),
}
def _extract_issue_key(self, payload: dict) -> str:
"""从无 issue 对象的事件 payload 中提取 issue key."""
# issue_deleted 事件中 issue 信息可能在不同位置
issue = payload.get("issue", {})
if issue.get("key"):
return issue["key"]
# 某些事件中可能在 payload 根级别
return payload.get("issue_key", "")
def _convert_issue_deleted(self, payload: dict, account_id: str, issue_key: str) -> dict:
user = payload.get("user", {})
author_account_id = user.get("accountId", "")
author_display_name = user.get("displayName", "")
return {
"msg_id": f"jira:{issue_key}:issue_deleted",
"channel_type": "jira",
"account_id": account_id,
"content": f"Issue {issue_key} 已被删除",
"message_type": "EVENT",
"media_urls": [],
"sender": {
"id": f"jira:{author_account_id}",
"display_name": author_display_name or f"Jira:{author_account_id}",
"kind": "GROUP",
},
"group": {
"id": issue_key,
"kind": "thread",
"name": f"Issue {issue_key}",
},
"reply_to_id": None,
"thread_id": issue_key,
"thread_parent_id": issue_key,
"raw_payload": payload,
"metadata": {
"webhook_event": "jira:issue_deleted",
"issue_key": issue_key,
},
}
def _convert_attachment_event(self, payload: dict, account_id: str, issue_key: str, webhook_event: str) -> dict:
attachment = payload.get("attachment", {})
filename = attachment.get("filename", "unknown")
user = payload.get("user", {})
author_account_id = user.get("accountId", "")
author_display_name = user.get("displayName", "")
return {
"msg_id": f"jira:{issue_key}:{webhook_event}:{attachment.get('id', '')}",
"channel_type": "jira",
"account_id": account_id,
"content": f"{'新增' if webhook_event == 'attachment_created' else '删除'}附件: {filename}",
"message_type": "EVENT",
"media_urls": [attachment.get("content", "")] if attachment.get("content") else [],
"sender": {
"id": f"jira:{author_account_id}",
"display_name": author_display_name or f"Jira:{author_account_id}",
"kind": "GROUP",
},
"group": {
"id": issue_key,
"kind": "thread",
"name": f"Issue {issue_key}",
},
"reply_to_id": None,
"thread_id": issue_key,
"thread_parent_id": issue_key,
"raw_payload": payload,
"metadata": {
"webhook_event": webhook_event,
"issue_key": issue_key,
"attachment_id": attachment.get("id"),
"attachment_filename": filename,
"attachment_size": attachment.get("size"),
},
}
def _is_internal_comment(properties: list[dict]) -> bool:
for prop in properties:
if prop.get("key") == "sd.public.comment":
value = prop.get("value", {})
if isinstance(value, dict) and value.get("internal") is True:
return True
return False
def _has_property(properties: list[dict], key: str) -> bool:
for prop in properties:
if prop.get("key") == key:
return True
return False