feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
ba91abc4b2
commit
83a16ed07d
225 files changed
+59337
No files matched your search
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
import requests
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.models.jira import JiraToken
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import JiraAuthError
|
||||
from app.core.external_urls import external_base
|
||||
from app.core.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Atlassian hosts. Deployments never set these; the e2e suite points them at its
|
||||
# recording fake. The authorize URL is browser-facing, the rest are server calls.
|
||||
JIRA_AUTH_BASE = external_base("JIRA_AUTH_BASE", "https://auth.atlassian.com")
|
||||
JIRA_API_BASE = external_base("JIRA_API_BASE", "https://api.atlassian.com")
|
||||
|
||||
|
||||
def jira_authorize_url() -> str:
|
||||
return f"{JIRA_AUTH_BASE}/authorize"
|
||||
|
||||
|
||||
def jira_token_url() -> str:
|
||||
return f"{JIRA_AUTH_BASE}/oauth/token"
|
||||
|
||||
|
||||
def jira_accessible_resources_url() -> str:
|
||||
return f"{JIRA_API_BASE}/oauth/token/accessible-resources"
|
||||
|
||||
|
||||
def jira_rest_url(cloud_id: str, path: str) -> str:
|
||||
"""Jira Cloud REST v3 URL, e.g. `jira_rest_url(cloud_id, "issue")`."""
|
||||
return f"{JIRA_API_BASE}/ex/jira/{cloud_id}/rest/api/3/{path}"
|
||||
|
||||
|
||||
class JiraService:
|
||||
"""Service for handling Jira OAuth 2.0 flow and API interactions."""
|
||||
|
||||
def __init__(self):
|
||||
self.client_id = settings.JIRA_CLIENT_ID
|
||||
self.client_secret = settings.JIRA_CLIENT_SECRET
|
||||
self.redirect_uri = settings.JIRA_REDIRECT_URI
|
||||
self.auth_url = jira_authorize_url()
|
||||
self.token_url = jira_token_url()
|
||||
self.scope = "read:jira-work write:jira-work read:jira-user offline_access"
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""Generate the authorization URL for Jira OAuth flow."""
|
||||
params = {
|
||||
"audience": "api.atlassian.com",
|
||||
"client_id": self.client_id,
|
||||
"scope": self.scope,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"state": state,
|
||||
"response_type": "code",
|
||||
"prompt": "consent"
|
||||
}
|
||||
return f"{self.auth_url}?{urlencode(params)}"
|
||||
|
||||
def validate_token(self, token: JiraToken) -> bool:
|
||||
"""Check if the token is valid and not expired."""
|
||||
if token is None:
|
||||
return False
|
||||
return token.expires_at > datetime.utcnow() + timedelta(minutes=5)
|
||||
|
||||
async def exchange_code_for_token(self, code: str) -> Dict[str, Any]:
|
||||
"""Exchange authorization code for access token."""
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri
|
||||
}
|
||||
|
||||
response = requests.post(self.token_url, data=data)
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError("Failed to exchange code for token")
|
||||
|
||||
token_data = response.json()
|
||||
return {
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"token_type": token_data["token_type"],
|
||||
"expires_at": datetime.utcnow() + timedelta(seconds=token_data["expires_in"])
|
||||
}
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> Dict[str, Any]:
|
||||
"""Refresh an expired access token."""
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": refresh_token
|
||||
}
|
||||
|
||||
response = requests.post(self.token_url, data=data)
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError("Failed to refresh token")
|
||||
|
||||
token_data = response.json()
|
||||
return {
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data.get("refresh_token", refresh_token),
|
||||
"token_type": token_data["token_type"],
|
||||
"expires_at": datetime.utcnow() + timedelta(seconds=token_data["expires_in"])
|
||||
}
|
||||
|
||||
async def get_cloud_id(self, access_token: str) -> Dict[str, str]:
|
||||
"""Get Jira Cloud ID and site URL for the authenticated user."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
response = requests.get(jira_accessible_resources_url(), headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError("Failed to get Jira Cloud ID")
|
||||
|
||||
resources = response.json()
|
||||
if not resources:
|
||||
raise JiraAuthError("No Jira Cloud instances found")
|
||||
|
||||
# Use the first available cloud instance
|
||||
return {
|
||||
"cloud_id": resources[0]["id"],
|
||||
"site_url": resources[0]["url"]
|
||||
}
|
||||
|
||||
async def get_projects(self, access_token: str, cloud_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all projects from Jira."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
url = jira_rest_url(cloud_id, "project")
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError(f"Failed to get Jira projects: {response.text}")
|
||||
|
||||
projects = response.json()
|
||||
return [
|
||||
{
|
||||
"id": project["id"],
|
||||
"key": project["key"],
|
||||
"name": project["name"]
|
||||
}
|
||||
for project in projects
|
||||
]
|
||||
|
||||
async def get_issue_types(self, access_token: str, cloud_id: str, project_key: str) -> List[Dict[str, Any]]:
|
||||
"""Get all issue types for a project from Jira."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# First, get the project to find its ID
|
||||
url = jira_rest_url(cloud_id, f"project/{project_key}")
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError(f"Failed to get Jira project: {response.text}")
|
||||
|
||||
project = response.json()
|
||||
|
||||
# Get issue types for the project, filtering out sub-tasks
|
||||
issue_types = []
|
||||
for issue_type in project.get("issueTypes", []):
|
||||
# Skip sub-tasks as they require a parent issue
|
||||
if issue_type.get("subtask", False):
|
||||
logger.info(f"Skipping sub-task issue type: {issue_type['name']} (ID: {issue_type['id']})")
|
||||
continue
|
||||
|
||||
issue_types.append({
|
||||
"id": issue_type["id"],
|
||||
"name": issue_type["name"],
|
||||
"description": issue_type.get("description", ""),
|
||||
"iconUrl": issue_type.get("iconUrl", "")
|
||||
})
|
||||
|
||||
return issue_types
|
||||
|
||||
async def create_issue(self, organization, db, issue_data):
|
||||
"""Wrapper method for create_issue that takes organization, db, and issue_data parameters."""
|
||||
from app.models.jira import JiraToken
|
||||
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise JiraAuthError("No Jira connection found")
|
||||
|
||||
# Check if token is valid and refresh if needed
|
||||
is_valid = self.validate_token(token)
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await self.refresh_token(token.refresh_token)
|
||||
|
||||
# Update token in database
|
||||
for key, value in token_data.items():
|
||||
setattr(token, key, value)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh Jira token: {e}")
|
||||
raise JiraAuthError("Jira token expired and could not be refreshed")
|
||||
|
||||
# Create the issue
|
||||
result = await self.create_issue_internal(
|
||||
token.access_token,
|
||||
token.cloud_id,
|
||||
issue_data.projectKey,
|
||||
issue_data.issueTypeId,
|
||||
issue_data.summary,
|
||||
issue_data.description,
|
||||
issue_data.priority
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def create_issue_internal(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str,
|
||||
summary: str, description: str, priority_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Create a new issue in Jira."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
url = jira_rest_url(cloud_id, "issue")
|
||||
|
||||
data = {
|
||||
"fields": {
|
||||
"project": {
|
||||
"key": project_key
|
||||
},
|
||||
"issuetype": {
|
||||
"id": issue_type_id
|
||||
},
|
||||
"summary": summary,
|
||||
"description": {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": description
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Add priority if provided and available
|
||||
if priority_id:
|
||||
# Check if priority field is available for this project/issue type
|
||||
priority_available = await self.is_priority_available(access_token, cloud_id, project_key, issue_type_id)
|
||||
if priority_available:
|
||||
data["fields"]["priority"] = {
|
||||
"id": priority_id
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Priority field not available for project {project_key} and issue type {issue_type_id}. Skipping priority.")
|
||||
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
raise JiraAuthError(f"Failed to create Jira issue: {response.text}")
|
||||
|
||||
return response.json()
|
||||
|
||||
async def get_priorities(self, access_token: str, cloud_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all priorities from Jira."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
url = jira_rest_url(cloud_id, "priority")
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError(f"Failed to get Jira priorities: {response.text}")
|
||||
|
||||
priorities = response.json()
|
||||
return [
|
||||
{
|
||||
"id": priority["id"],
|
||||
"name": priority["name"],
|
||||
"description": priority.get("description", ""),
|
||||
"iconUrl": priority.get("iconUrl", "")
|
||||
}
|
||||
for priority in priorities
|
||||
]
|
||||
|
||||
async def check_field_availability(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str, field_name: str) -> bool:
|
||||
"""Check if a field is available for a project/issue type combination."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# Get the create metadata for the project/issue type
|
||||
url = jira_rest_url(cloud_id, f"issue/createmeta?projectKeys={project_key}&issuetypeIds={issue_type_id}&expand=projects.issuetypes.fields")
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to get create metadata: {response.text}")
|
||||
return False
|
||||
|
||||
metadata = response.json()
|
||||
|
||||
# Navigate through the response structure to find the field
|
||||
try:
|
||||
projects = metadata.get("projects", [])
|
||||
if not projects:
|
||||
return False
|
||||
|
||||
issue_types = projects[0].get("issuetypes", [])
|
||||
if not issue_types:
|
||||
return False
|
||||
|
||||
fields = issue_types[0].get("fields", {})
|
||||
|
||||
# Check if the field exists
|
||||
return field_name in fields
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking field availability: {e}")
|
||||
return False
|
||||
|
||||
async def is_priority_available(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str) -> bool:
|
||||
"""Check if the priority field is available for a project/issue type combination."""
|
||||
return await self.check_field_availability(access_token, cloud_id, project_key, issue_type_id, "priority")
|
||||
Reference in new issue
Block a user