新增 Jira 渠道扩展,支持在 Yuxi 平台中集成 Jira 项目管理渠道。 包含以下功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息与工单管理 - streaming: 流式消息处理 - parse: Jira 内容解析 - format: 消息格式转换 - agent_tools: Agent 工具集成 - security: 安全校验 - dedupe: 消息去重 - loopbreaker: 循环响应防护 - monitor: 渠道状态监控 - status: 会话与工单状态管理 - types: 类型定义
202 lines
7.7 KiB
Python
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
|