""" Slack 커넥터 — Incoming Webhook + Slash Commands + Block Kit 기능: - Slack으로 GUARDiA 이벤트 알림 발송 - Slack Slash Commands 수신 (/guardia status, /guardia sr) - Block Kit 리치 메시지 (버튼·섹션·헤더) - 기존 메신저봇 명령어와 연동 엔드포인트: POST /api/slack/config — Slack 설정 등록 GET /api/slack/config — 설정 조회 POST /api/slack/notify — 알림 발송 POST /api/slack/commands — Slack Slash Commands 수신 GET /api/slack/test — 테스트 메시지 발송 """ from __future__ import annotations import hashlib import hmac import json import logging import time from datetime import datetime from typing import Optional import httpx from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from database import get_db from models import User, SRRequest, SRStatus, SlackConfig # 신규 모델 logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/slack", tags=["Slack 연동"]) # ── Pydantic 스키마 ────────────────────────────────────────────────────────── class SlackConfigCreate(BaseModel): name: str = Field(..., max_length=100) webhook_url: str = Field(..., description="Incoming Webhook URL") signing_secret: Optional[str] = None default_channel: str = Field("#guardia-ops", max_length=100) notify_sr_create: bool = True notify_incident: bool = True notify_deploy: bool = True notify_sla_breach: bool = True class SlackNotifyRequest(BaseModel): channel: Optional[str] = None text: str level: str = Field("info", pattern="^(info|warning|error|success)$") sr_id: Optional[int] = None # ── Block Kit 빌더 ─────────────────────────────────────────────────────────── def _color_for_level(level: str) -> str: return {"info": "#003366", "warning": "#F59E0B", "error": "#EF4444", "success": "#10B981"}.get(level, "#003366") def _build_sr_blocks(sr: SRRequest) -> list: """SR 알림용 Block Kit.""" return [ { "type": "header", "text": {"type": "plain_text", "text": f"🎫 SR-{sr.id} {sr.title[:50]}"} }, { "type": "section", "fields": [ {"type": "mrkdwn", "text": f"*상태*\n{sr.status}"}, {"type": "mrkdwn", "text": f"*우선순위*\n{sr.priority or 'MEDIUM'}"}, {"type": "mrkdwn", "text": f"*카테고리*\n{sr.category or '-'}"}, {"type": "mrkdwn", "text": f"*등록일*\n{sr.created_at.strftime('%m/%d %H:%M') if sr.created_at else '-'}"}, ] }, { "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": "상세 보기"}, "url": f"https://zioinfo.co.kr:8443/sr/{sr.id}", "style": "primary", }, ] } ] def _build_text_blocks(text: str, level: str) -> list: """일반 텍스트 알림용 Block Kit.""" emoji = {"info": "ℹ️", "warning": "⚠️", "error": "❌", "success": "✅"}.get(level, "ℹ️") return [ { "type": "section", "text": {"type": "mrkdwn", "text": f"{emoji} {text}"} }, {"type": "context", "elements": [ {"type": "mrkdwn", "text": f"GUARDiA ITSM | {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}"} ]} ] # ── Slack API 발송 ──────────────────────────────────────────────────────────── async def _send_slack(webhook_url: str, payload: dict) -> bool: try: async with httpx.AsyncClient(timeout=10) as client: r = await client.post(webhook_url, json=payload) return r.status_code == 200 except Exception as e: logger.error(f"Slack 발송 실패: {e}") return False # ── 서명 검증 ──────────────────────────────────────────────────────────────── def _verify_slack_signature(signing_secret: str, body: bytes, timestamp: str, signature: str) -> bool: """Slack 요청 서명 검증 (HMAC-SHA256).""" if abs(time.time() - float(timestamp)) > 300: return False # 5분 이상 오래된 요청 거부 base = f"v0:{timestamp}:{body.decode()}" expected = "v0=" + hmac.new( signing_secret.encode(), base.encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature) # ── 엔드포인트 ─────────────────────────────────────────────────────────────── @router.post("/config") async def save_slack_config( req: SlackConfigCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): """Slack 설정 저장.""" existing = await db.execute( select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id) ) cfg = existing.scalar_one_or_none() if cfg: cfg.webhook_url = req.webhook_url cfg.signing_secret = req.signing_secret cfg.default_channel = req.default_channel cfg.notify_sr_create = req.notify_sr_create cfg.notify_incident = req.notify_incident cfg.notify_deploy = req.notify_deploy cfg.notify_sla_breach = req.notify_sla_breach else: cfg = SlackConfig( tenant_id=user.tenant_id, name=req.name, webhook_url=req.webhook_url, signing_secret=req.signing_secret, default_channel=req.default_channel, notify_sr_create=req.notify_sr_create, notify_incident=req.notify_incident, notify_deploy=req.notify_deploy, notify_sla_breach=req.notify_sla_breach, is_active=True, ) db.add(cfg) await db.commit() return {"ok": True} @router.get("/config") async def get_slack_config( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): row = await db.execute(select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id)) cfg = row.scalar_one_or_none() if not cfg: return None return { "name": cfg.name, "webhook_url": cfg.webhook_url[:20] + "***", # 마스킹 "default_channel": cfg.default_channel, "is_active": cfg.is_active, } @router.get("/test") async def test_slack( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """테스트 메시지 발송.""" row = await db.execute(select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id)) cfg = row.scalar_one_or_none() if not cfg: raise HTTPException(404, "Slack 설정 없음") payload = { "channel": cfg.default_channel, "blocks": _build_text_blocks(f"GUARDiA ITSM 연동 테스트 메시지 (by {user.email})", "success"), } ok = await _send_slack(cfg.webhook_url, payload) return {"ok": ok} @router.post("/notify") async def slack_notify( req: SlackNotifyRequest, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """Slack 알림 발송.""" row = await db.execute(select(SlackConfig).where(SlackConfig.tenant_id == user.tenant_id)) cfg = row.scalar_one_or_none() if not cfg: raise HTTPException(404, "Slack 설정 없음") # SR 첨부 시 Block Kit 사용 if req.sr_id: sr_row = await db.execute(select(SRRequest).where(SRRequest.id == req.sr_id)) sr = sr_row.scalar_one_or_none() if sr: payload = { "channel": req.channel or cfg.default_channel, "text": req.text, "attachments": [{"color": _color_for_level(req.level), "blocks": _build_sr_blocks(sr)}], } else: payload = {"channel": req.channel or cfg.default_channel, "blocks": _build_text_blocks(req.text, req.level)} else: payload = {"channel": req.channel or cfg.default_channel, "blocks": _build_text_blocks(req.text, req.level)} ok = await _send_slack(cfg.webhook_url, payload) return {"ok": ok} @router.post("/commands") async def slack_commands( request: Request, x_slack_signature: str = Header(None, alias="X-Slack-Signature"), x_slack_request_timestamp: str = Header(None, alias="X-Slack-Request-Timestamp"), db: AsyncSession = Depends(get_db), ): """ Slack Slash Commands 처리. Slack 앱에서 /guardia 명령어 설정 필요: Request URL: https://zioinfo.co.kr:8443/api/slack/commands """ body = await request.body() # 서명 검증 (설정된 경우) # 모든 테넌트 설정 조회는 생략, 실제로는 team_id 기반 조회 form_data = {} for item in body.decode().split("&"): if "=" in item: k, v = item.split("=", 1) form_data[k] = v.replace("+", " ") command = form_data.get("command", "") text = form_data.get("text", "").strip() user_name = form_data.get("user_name", "unknown") if command == "/guardia": if text.startswith("status"): # 시스템 현황 반환 return { "response_type": "in_channel", "blocks": [ {"type": "header", "text": {"type": "plain_text", "text": "🛡️ GUARDiA 현황"}}, {"type": "section", "text": {"type": "mrkdwn", "text": f"@{user_name}님이 조회했습니다. "}}, ] } elif text.startswith("sr"): return { "response_type": "ephemeral", "text": "SR 접수는 웹 대시보드를 이용하거나 /sr 명령어를 사용하세요.", } else: return { "response_type": "ephemeral", "text": "사용법: `/guardia status` | `/guardia sr`", } return {"response_type": "ephemeral", "text": "알 수 없는 명령어입니다."}