""" ChatOps 확장 라우터 — 채널별 webhook, 인터랙티브, 브로드캐스트, 통계 지원 채널: kakao | slack | internal 지원 명령어: /sr create, /status, /deploy, /approve, /report, /patch, /workflow 엔드포인트: POST /api/chatops/webhook/{channel} — 채널별 webhook 수신 GET /api/chatops/commands — 명령어 목록 POST /api/chatops/interactive — 인터랙티브 버튼 처리 GET /api/chatops/channels — 연동 채널 현황 POST /api/chatops/broadcast — 전 채널 공지 GET /api/chatops/stats — 사용 통계 """ from __future__ import annotations import logging from datetime import datetime, timedelta from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Path, Query from pydantic import BaseModel from sqlalchemy import select, func, and_, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from database import get_db from models import ChatOpsCommand, User logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/chatops", tags=["ChatOps Extended"]) # ── 지원 채널 정의 ──────────────────────────────────────────────────────────── SUPPORTED_CHANNELS = { "kakao": {"name": "카카오워크", "enabled": True, "icon": "💬"}, "slack": {"name": "Slack", "enabled": True, "icon": "🟢"}, "internal": {"name": "내부 메신저", "enabled": True, "icon": "🏢"}, } # ── 지원 명령어 목록 ────────────────────────────────────────────────────────── COMMAND_DEFINITIONS = [ { "command": "/sr create", "description": "서비스 요청 생성", "usage": "/sr create <제목> <내용>", "example": "/sr create 서버 재시작 web-01 서버를 재시작해주세요", "roles": ["ENGINEER", "PM", "ADMIN"], }, { "command": "/status", "description": "SR 상태 조회", "usage": "/status [SR-ID]", "example": "/status SR-2026-001", "roles": ["ENGINEER", "PM", "ADMIN"], }, { "command": "/deploy", "description": "배포 실행 요청", "usage": "/deploy <프로젝트명> <환경>", "example": "/deploy guardia-itsm prod", "roles": ["ENGINEER", "ADMIN"], }, { "command": "/approve", "description": "SR 또는 배포 승인", "usage": "/approve ", "example": "/approve SR-2026-001", "roles": ["PM", "ADMIN"], }, { "command": "/report", "description": "운영 리포트 요청", "usage": "/report [daily|weekly|monthly]", "example": "/report daily", "roles": ["PM", "ADMIN"], }, { "command": "/patch", "description": "보안 패치 적용 요청", "usage": "/patch <서버명>", "example": "/patch CVE-2024-1234 web-01", "roles": ["ENGINEER", "ADMIN"], }, { "command": "/workflow", "description": "자율 워크플로우 실행", "usage": "/workflow <워크플로우명> [인수...]", "example": "/workflow restart-service web-01 tomcat", "roles": ["ENGINEER", "ADMIN"], }, ] # ── Pydantic 스키마 ─────────────────────────────────────────────────────────── class WebhookPayload(BaseModel): """채널에서 수신하는 webhook 페이로드.""" user_id: str message: str room_id: Optional[str] = None extra: Optional[Dict[str, Any]] = None class InteractivePayload(BaseModel): """인터랙티브 버튼 클릭 처리 페이로드.""" action_id: str # 버튼 액션 ID (approve_sr / reject_sr / view_detail 등) target_id: str # 대상 리소스 ID user_id: str channel: str = "internal" extra: Optional[Dict[str, Any]] = None class BroadcastRequest(BaseModel): """전 채널 공지 요청.""" message: str title: Optional[str] = None channels: Optional[List[str]] = None # None이면 활성 전체 채널 priority: str = "NORMAL" # NORMAL | HIGH | CRITICAL class CommandOut(BaseModel): command: str description: str usage: str example: str roles: List[str] class ChannelStatus(BaseModel): channel: str name: str enabled: bool icon: str total_cmds: int success_rate: float class ChatOpsStats(BaseModel): total_commands: int commands_today: int success_rate: float top_commands: List[Dict[str, Any]] top_users: List[Dict[str, Any]] channel_breakdown: Dict[str, int] # ── 명령어 파서 ─────────────────────────────────────────────────────────────── def _parse_command(message: str) -> Optional[Dict[str, Any]]: """메시지에서 슬래시 명령어를 파싱한다.""" stripped = message.strip() if not stripped.startswith("/"): return None parts = stripped.split(None, 2) # ['/cmd', 'sub', '나머지'] if not parts: return None cmd_part = parts[0].lower() # '/sr' sub_cmd = parts[1].lower() if len(parts) > 1 else "" args = parts[2] if len(parts) > 2 else "" # 두 단어 명령어 매칭 (/sr create) full_cmd = f"{cmd_part} {sub_cmd}".strip() for defn in COMMAND_DEFINITIONS: if defn["command"] == full_cmd: return {"command": full_cmd, "args": args.strip()} # 단일 단어 명령어 매칭 (/status, /report, /patch, /workflow, /approve, /deploy) for defn in COMMAND_DEFINITIONS: base = defn["command"].split()[0] if base == cmd_part: rest = (sub_cmd + " " + args).strip() return {"command": cmd_part, "args": rest} return {"command": cmd_part, "args": (sub_cmd + " " + args).strip()} async def _execute_command( parsed: Dict[str, Any], user_id: str, channel: str, db: AsyncSession, ) -> str: """파싱된 명령어를 실행하고 응답 텍스트를 반환한다.""" cmd = parsed["command"] args = parsed["args"] if cmd == "/sr create": parts = args.split(None, 1) if args else [] title = parts[0] if parts else "미제목 SR" desc = parts[1] if len(parts) > 1 else "" return f"SR 접수 완료. 제목: {title}\n설명: {desc}\n담당자 자동 배정 중..." if cmd in ("/status",): sr_id = args.strip() if sr_id: return f"{sr_id} 상태를 조회합니다. /api/tasks/{sr_id} 에서 확인하세요." return "SR 전체 현황: /api/dashboard/stats 에서 확인하세요." if cmd in ("/deploy",): parts = args.split() if args else [] project = parts[0] if parts else "unknown" env = parts[1] if len(parts) > 1 else "dev" return f"배포 요청 등록: {project} → {env} 환경. PM 승인 후 실행됩니다." if cmd in ("/approve",): target = args.strip() if not target: return "승인 대상 ID를 입력하세요. 예) /approve SR-2026-001" return f"{target} 승인 처리 완료. 엔지니어에게 알림 발송됩니다." if cmd in ("/report",): period = args.strip() or "daily" return f"{period} 운영 리포트 생성 중... /api/report/generate 에서 확인하세요." if cmd in ("/patch",): parts = args.split() if args else [] cve = parts[0] if parts else "CVE-미지정" server = parts[1] if len(parts) > 1 else "전체" return f"보안 패치 요청: {cve} → {server}. 패치 계획이 생성되었습니다." if cmd in ("/workflow",): parts = args.split(None, 1) if args else [] wf_name = parts[0] if parts else "unknown" wf_args = parts[1] if len(parts) > 1 else "" return f"워크플로우 실행: {wf_name}({wf_args}). /api/autonomous/status 에서 확인하세요." return f"알 수 없는 명령어: {cmd}. /api/chatops/commands 에서 지원 명령어를 확인하세요." # ── 엔드포인트 ──────────────────────────────────────────────────────────────── @router.post("/webhook/{channel}") async def receive_webhook( channel: str = Path(..., description="채널 ID: kakao|slack|internal"), payload: WebhookPayload = ..., db: AsyncSession = Depends(get_db), ): """채널별 webhook 수신 및 명령어 처리.""" channel_lower = channel.lower() if channel_lower not in SUPPORTED_CHANNELS: raise HTTPException( status_code=400, detail=f"지원하지 않는 채널입니다: {channel}. 지원 채널: {list(SUPPORTED_CHANNELS.keys())}" ) if not SUPPORTED_CHANNELS[channel_lower]["enabled"]: raise HTTPException(status_code=503, detail=f"{channel} 채널이 비활성 상태입니다.") parsed = _parse_command(payload.message) success = parsed is not None args_str = parsed["args"] if parsed else None cmd_str = parsed["command"] if parsed else payload.message[:200] response_text = "" if parsed: try: response_text = await _execute_command(parsed, payload.user_id, channel_lower, db) except Exception as exc: logger.warning(f"ChatOps 명령 실행 오류: {exc}") response_text = "명령 처리 중 오류가 발생했습니다." success = False else: response_text = "명령어 형식이 올바르지 않습니다. /api/chatops/commands 에서 사용법을 확인하세요." log = ChatOpsCommand( channel=channel_lower, command=cmd_str, args=args_str, user_id=payload.user_id, response=response_text, success=success, ) db.add(log) await db.commit() await db.refresh(log) return { "id": log.id, "channel": channel_lower, "command": cmd_str, "response": response_text, "success": success, } @router.get("/commands", response_model=List[CommandOut]) async def list_commands( user: User = Depends(get_current_user), ): """지원 명령어 목록 반환.""" return [CommandOut(**d) for d in COMMAND_DEFINITIONS] @router.post("/interactive") async def handle_interactive( payload: InteractivePayload, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """인터랙티브 버튼/액션 처리.""" action = payload.action_id.lower() target = payload.target_id channel = payload.channel.lower() if action == "approve_sr": result_msg = f"SR {target} 승인 완료 (사용자: {payload.user_id})" elif action == "reject_sr": result_msg = f"SR {target} 반려 완료 (사용자: {payload.user_id})" elif action == "view_detail": result_msg = f"{target} 상세 조회 링크: /api/tasks/{target}" elif action == "deploy_confirm": result_msg = f"배포 {target} 실행 확인 (사용자: {payload.user_id})" elif action == "escalate": result_msg = f"{target} 에스컬레이션 완료 — 상위 담당자에게 알림 발송" else: result_msg = f"알 수 없는 액션: {action}" log = ChatOpsCommand( channel=channel, command=f"interactive:{action}", args=target, user_id=payload.user_id, response=result_msg, success=True, ) db.add(log) await db.commit() await db.refresh(log) return { "id": log.id, "action_id": action, "target_id": target, "result": result_msg, "processed_at": log.created_at, } @router.get("/channels", response_model=List[ChannelStatus]) async def list_channels( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """연동 채널 현황 — 각 채널의 사용량 및 성공률 포함.""" result = [] for ch_id, info in SUPPORTED_CHANNELS.items(): total_r = await db.execute( select(func.count(ChatOpsCommand.id)).where(ChatOpsCommand.channel == ch_id) ) total = total_r.scalar() or 0 success_r = await db.execute( select(func.count(ChatOpsCommand.id)).where( and_(ChatOpsCommand.channel == ch_id, ChatOpsCommand.success == True) ) ) successes = success_r.scalar() or 0 rate = round(successes / total * 100, 1) if total > 0 else 0.0 result.append(ChannelStatus( channel=ch_id, name=info["name"], enabled=info["enabled"], icon=info["icon"], total_cmds=total, success_rate=rate, )) return result @router.post("/broadcast") async def broadcast_message( req: BroadcastRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): """관리자 전용 — 전 채널 (또는 지정 채널) 공지 발송.""" target_channels = req.channels or list(SUPPORTED_CHANNELS.keys()) invalid = [c for c in target_channels if c not in SUPPORTED_CHANNELS] if invalid: raise HTTPException(status_code=400, detail=f"유효하지 않은 채널: {invalid}") sent_channels = [] for ch in target_channels: if not SUPPORTED_CHANNELS[ch]["enabled"]: continue log = ChatOpsCommand( channel=ch, command="broadcast", args=req.title or "", user_id=str(user.id), response=req.message[:2000], success=True, ) db.add(log) sent_channels.append(ch) await db.commit() return { "status": "SENT", "sent_channels": sent_channels, "skipped_channels": [c for c in target_channels if c not in sent_channels], "priority": req.priority, "message_length": len(req.message), } @router.get("/stats") async def chatops_stats( days: int = Query(7, ge=1, le=90, description="통계 기간 (일)"), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """ChatOps 사용 통계 반환.""" since = datetime.utcnow() - timedelta(days=days) today = datetime.utcnow().date() # 전체 명령 수 total_r = await db.execute( select(func.count(ChatOpsCommand.id)).where(ChatOpsCommand.created_at >= since) ) total = total_r.scalar() or 0 # 오늘 명령 수 today_r = await db.execute( select(func.count(ChatOpsCommand.id)).where( func.date(ChatOpsCommand.created_at) == today ) ) today_count = today_r.scalar() or 0 # 전체 성공률 success_r = await db.execute( select(func.count(ChatOpsCommand.id)).where( and_(ChatOpsCommand.created_at >= since, ChatOpsCommand.success == True) ) ) successes = success_r.scalar() or 0 success_rate = round(successes / total * 100, 1) if total > 0 else 0.0 # 채널별 명령 수 channel_rows = await db.execute( select(ChatOpsCommand.channel, func.count(ChatOpsCommand.id).label("cnt")) .where(ChatOpsCommand.created_at >= since) .group_by(ChatOpsCommand.channel) ) channel_breakdown = {row.channel: row.cnt for row in channel_rows} # 많이 사용된 명령어 TOP 5 cmd_rows = await db.execute( select(ChatOpsCommand.command, func.count(ChatOpsCommand.id).label("cnt")) .where(ChatOpsCommand.created_at >= since) .group_by(ChatOpsCommand.command) .order_by(desc("cnt")) .limit(5) ) top_commands = [{"command": r.command, "count": r.cnt} for r in cmd_rows] # 활성 사용자 TOP 5 user_rows = await db.execute( select(ChatOpsCommand.user_id, func.count(ChatOpsCommand.id).label("cnt")) .where(ChatOpsCommand.created_at >= since) .group_by(ChatOpsCommand.user_id) .order_by(desc("cnt")) .limit(5) ) top_users = [{"user_id": r.user_id, "count": r.cnt} for r in user_rows] return ChatOpsStats( total_commands=total, commands_today=today_count, success_rate=success_rate, top_commands=top_commands, top_users=top_users, channel_breakdown=channel_breakdown, )