feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
@@ -0,0 +1,558 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import traceback
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import String
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.jira import JiraService
|
||||
from app.models.jira import JiraToken, AgentJiraConfig
|
||||
from app.models.agent import Agent
|
||||
from app.core.auth import get_current_organization, require_permissions
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.core.logger import get_logger
|
||||
from app.core.config import settings
|
||||
from app.core.oauth_state import pop_state, store_state
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from app.core.exceptions import JiraAuthError
|
||||
|
||||
router = APIRouter()
|
||||
jira_service = JiraService()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _require_jira_agent_in_org(db, agent_id, org_id) -> None:
|
||||
"""The agent must belong to the caller's org, else 404.
|
||||
|
||||
A malformed agent_id is treated as not-found rather than a 500.
|
||||
"""
|
||||
from app.repositories.agent import AgentRepository
|
||||
try:
|
||||
agent_uuid = uuid.UUID(str(agent_id))
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
if not AgentRepository(db).get_agent_in_org(agent_uuid, org_id):
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
class JiraProject(BaseModel):
|
||||
id: str
|
||||
key: str
|
||||
name: str
|
||||
|
||||
class JiraIssueType(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class JiraPriority(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
iconUrl: Optional[str] = None
|
||||
|
||||
class AgentJiraConfigModel(BaseModel):
|
||||
enabled: bool
|
||||
projectKey: Optional[str] = None
|
||||
issueTypeId: Optional[str] = None
|
||||
|
||||
class CreateJiraIssueModel(BaseModel):
|
||||
projectKey: str
|
||||
issueTypeId: str
|
||||
summary: str
|
||||
description: str
|
||||
priority: Optional[str] = None
|
||||
chatId: Optional[str] = None
|
||||
|
||||
@router.get("/status")
|
||||
async def jira_status(
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Check if Jira is connected for the current organization.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
return {"connected": False}
|
||||
|
||||
# Check if token is valid
|
||||
is_valid = jira_service.validate_token(token)
|
||||
|
||||
# If token is expired, try to refresh it
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await jira_service.refresh_token(token.refresh_token)
|
||||
|
||||
# Update token in database
|
||||
for key, value in token_data.items():
|
||||
setattr(token, key, value)
|
||||
|
||||
db.commit()
|
||||
is_valid = True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh Jira token: {e}")
|
||||
is_valid = False
|
||||
|
||||
return {
|
||||
"connected": is_valid,
|
||||
"site_url": token.site_url if is_valid else None
|
||||
}
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_jira(
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Disconnect Jira integration for the current organization.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira connection found")
|
||||
|
||||
# Delete all agent Jira configurations for this organization
|
||||
try:
|
||||
agent_configs = db.query(AgentJiraConfig).join(
|
||||
Agent, AgentJiraConfig.agent_id == Agent.id.cast(String)
|
||||
).filter(
|
||||
Agent.organization_id == organization.id
|
||||
).all()
|
||||
|
||||
config_count = len(agent_configs)
|
||||
for config in agent_configs:
|
||||
db.delete(config)
|
||||
|
||||
# Delete the Jira token
|
||||
db.delete(token)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Jira disconnected for organization {organization.id} by user {current_user.id}. Deleted {config_count} agent configurations.")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error disconnecting Jira: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error disconnecting Jira: {str(e)}")
|
||||
|
||||
return {"message": "Jira disconnected successfully"}
|
||||
|
||||
@router.get("/authorize")
|
||||
async def authorize_jira(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Start the Jira OAuth flow by redirecting to Jira's authorization page.
|
||||
"""
|
||||
logger.info(f"Authorizing Jira for organization: {organization.id}")
|
||||
|
||||
# Generate a random state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
store_state("jira", state, str(organization.id))
|
||||
|
||||
# Get authorization URL
|
||||
auth_url = jira_service.get_authorization_url(state)
|
||||
logger.info(f"Redirecting to Jira authorization URL: {auth_url}")
|
||||
return RedirectResponse(url=auth_url)
|
||||
|
||||
@router.get("/callback")
|
||||
async def jira_oauth_callback(
|
||||
code: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
error_description: Optional[str] = None,
|
||||
request: Request = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Handle the OAuth callback from Jira.
|
||||
This endpoint handles both successful authentication and cancellation/errors.
|
||||
"""
|
||||
# Handle cancellation or error from Jira
|
||||
if error or not code or not state:
|
||||
logger.warning(f"Jira OAuth flow cancelled or error: {error} - {error_description}")
|
||||
pop_state("jira", state or "")
|
||||
# Redirect to failure page
|
||||
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason={error or 'cancelled'}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
org_id = pop_state("jira", state)
|
||||
|
||||
if not org_id:
|
||||
logger.error(f"Invalid or expired state parameter: {state}")
|
||||
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason=invalid_state"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
try:
|
||||
org_id = uuid.UUID(org_id)
|
||||
# Exchange code for token
|
||||
token_data = await jira_service.exchange_code_for_token(code)
|
||||
|
||||
# Get Jira Cloud ID and site URL
|
||||
cloud_data = await jira_service.get_cloud_id(token_data["access_token"])
|
||||
logger.info(f"Cloud data: {cloud_data}")
|
||||
# Store token in database
|
||||
jira_token = JiraToken(
|
||||
organization_id=org_id,
|
||||
access_token=token_data["access_token"],
|
||||
refresh_token=token_data["refresh_token"],
|
||||
token_type=token_data["token_type"],
|
||||
expires_at=token_data["expires_at"],
|
||||
cloud_id=cloud_data["cloud_id"],
|
||||
site_url=cloud_data["site_url"]
|
||||
)
|
||||
|
||||
# Check if token already exists for this organization
|
||||
existing_token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == org_id
|
||||
).first()
|
||||
|
||||
if existing_token:
|
||||
# Update existing token
|
||||
for key, value in token_data.items():
|
||||
setattr(existing_token, key, value)
|
||||
existing_token.cloud_id = cloud_data["cloud_id"]
|
||||
existing_token.site_url = cloud_data["site_url"]
|
||||
else:
|
||||
db.add(jira_token)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Redirect to success page using FRONTEND_URL from settings
|
||||
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=success"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during Jira OAuth callback: {e}")
|
||||
traceback.print_exc()
|
||||
# Redirect to failure page with error message
|
||||
error_message = str(e).replace(" ", "_")[:100] # Limit length and replace spaces
|
||||
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason={error_message}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
@router.get("/refresh")
|
||||
async def refresh_jira_token(
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Refresh the Jira access token using the refresh token.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira token found")
|
||||
|
||||
try:
|
||||
token_data = await jira_service.refresh_token(token.refresh_token)
|
||||
|
||||
# Update token in database
|
||||
for key, value in token_data.items():
|
||||
setattr(token, key, value)
|
||||
|
||||
db.commit()
|
||||
return {"message": "Token refreshed successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/projects", response_model=List[JiraProject])
|
||||
async def get_jira_projects(
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all Jira projects for the current organization.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira connection found")
|
||||
|
||||
# Check if token is valid
|
||||
is_valid = jira_service.validate_token(token)
|
||||
|
||||
# If token is expired, try to refresh it
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await jira_service.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 HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
||||
|
||||
try:
|
||||
projects = await jira_service.get_projects(token.access_token, token.cloud_id)
|
||||
return projects
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Jira projects: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get Jira projects")
|
||||
|
||||
@router.get("/projects/{project_key}/issue-types", response_model=List[JiraIssueType])
|
||||
async def get_jira_issue_types(
|
||||
project_key: str,
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all issue types for a Jira project.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira connection found")
|
||||
|
||||
# Check if token is valid
|
||||
is_valid = jira_service.validate_token(token)
|
||||
|
||||
# If token is expired, try to refresh it
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await jira_service.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 HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
||||
|
||||
try:
|
||||
issue_types = await jira_service.get_issue_types(token.access_token, token.cloud_id, project_key)
|
||||
return issue_types
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Jira issue types: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get Jira issue types")
|
||||
|
||||
@router.get("/priorities", response_model=List[JiraPriority])
|
||||
async def get_jira_priorities(
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all Jira priorities.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira connection found")
|
||||
|
||||
# Check if token is valid
|
||||
is_valid = jira_service.validate_token(token)
|
||||
|
||||
# If token is expired, try to refresh it
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await jira_service.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 HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
||||
|
||||
try:
|
||||
priorities = await jira_service.get_priorities(token.access_token, token.cloud_id)
|
||||
return priorities
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Jira priorities: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get Jira priorities")
|
||||
|
||||
@router.get("/projects/{project_key}/issue-types/{issue_type_id}/has-priority")
|
||||
async def check_priority_availability(
|
||||
project_key: str,
|
||||
issue_type_id: str,
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Check if priority field is available for a specific project and issue type.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira connection found")
|
||||
|
||||
# Check if token is valid
|
||||
is_valid = jira_service.validate_token(token)
|
||||
|
||||
# If token is expired, try to refresh it
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await jira_service.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 HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
||||
|
||||
try:
|
||||
has_priority = await jira_service.is_priority_available(
|
||||
token.access_token,
|
||||
token.cloud_id,
|
||||
project_key,
|
||||
issue_type_id
|
||||
)
|
||||
return {"hasPriority": has_priority}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check priority availability: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to check priority availability")
|
||||
|
||||
@router.post("/issues")
|
||||
async def create_jira_issue(
|
||||
issue_data: CreateJiraIssueModel,
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new Jira issue.
|
||||
"""
|
||||
try:
|
||||
# Create the issue using the wrapper method
|
||||
result = await jira_service.create_issue(
|
||||
organization=organization,
|
||||
db=db,
|
||||
issue_data=issue_data
|
||||
)
|
||||
|
||||
# Return the issue key and URL
|
||||
return {
|
||||
"key": result.get("key"),
|
||||
"id": result.get("id"),
|
||||
"self": result.get("self")
|
||||
}
|
||||
except JiraAuthError as e:
|
||||
logger.error(f"Jira authentication error: {e}")
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create Jira issue: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to create Jira issue")
|
||||
|
||||
@router.post("/agent-config/{agent_id}")
|
||||
async def save_agent_jira_config(
|
||||
agent_id: str,
|
||||
config: AgentJiraConfigModel,
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Save Jira configuration for an agent.
|
||||
"""
|
||||
# The agent must belong to the caller's org — otherwise any tenant could
|
||||
# flip another tenant's project_key / issue_type and hijack escalation.
|
||||
_require_jira_agent_in_org(db, agent_id, organization.id)
|
||||
|
||||
# Check if Jira is connected
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token and config.enabled:
|
||||
raise HTTPException(status_code=400, detail="Cannot enable Jira integration: Jira is not connected")
|
||||
|
||||
# Check if config already exists
|
||||
existing_config = db.query(AgentJiraConfig).filter(
|
||||
AgentJiraConfig.agent_id == agent_id
|
||||
).first()
|
||||
|
||||
if existing_config:
|
||||
# Update existing config
|
||||
existing_config.enabled = config.enabled
|
||||
existing_config.project_key = config.projectKey
|
||||
existing_config.issue_type_id = config.issueTypeId
|
||||
else:
|
||||
# Create new config
|
||||
new_config = AgentJiraConfig(
|
||||
agent_id=agent_id,
|
||||
enabled=config.enabled,
|
||||
project_key=config.projectKey,
|
||||
issue_type_id=config.issueTypeId
|
||||
)
|
||||
db.add(new_config)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": "Agent Jira configuration saved successfully"}
|
||||
|
||||
@router.get("/agent-config/{agent_id}")
|
||||
async def get_agent_jira_config(
|
||||
agent_id: str,
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get Jira configuration for an agent.
|
||||
"""
|
||||
# The agent must belong to the caller's org before its config is exposed.
|
||||
_require_jira_agent_in_org(db, agent_id, organization.id)
|
||||
|
||||
config = db.query(AgentJiraConfig).filter(
|
||||
AgentJiraConfig.agent_id == agent_id
|
||||
).first()
|
||||
|
||||
if not config:
|
||||
return {
|
||||
"enabled": False,
|
||||
"projectKey": None,
|
||||
"issueTypeId": None
|
||||
}
|
||||
|
||||
return {
|
||||
"enabled": config.enabled,
|
||||
"projectKey": config.project_key,
|
||||
"issueTypeId": config.issue_type_id
|
||||
}
|
||||
Reference in new issue
Block a user