zioinfo-mail/workspace/guardia-itsm/routers/slack_connector.py
DESKTOP-TKLFCPR\ython 09bab3c2ff feat(expansion): GUARDiA v3 P2 — 5 routers + 5 DB tables
라우터 (611개 엔드포인트, P1+P2 75개 신규):
- kubernetes.py: K8s 에이전트리스 관리 (SSH kubectl)
- sso_provider.py: SAML 2.0 / OIDC / OAuth2 통합 인증
- predictive_ops.py: SLA위반·SR급증·서버장애 예측 + Ollama 인사이트
- slack_connector.py: Slack Incoming Webhook + Slash Commands
- white_label.py: 기관별 브랜딩 + CSS 변수 동적 생성

DB 모델 (5개 신규):
tb_k8s_cluster, tb_sso_config, tb_sso_session,
tb_slack_config, tb_tenant_branding

수정: K8sCluster ForeignKey tb_server → tb_server_info

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 05:57:02 +09:00

293 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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}님이 조회했습니다. <https://zioinfo.co.kr:8443|대시보드 보기>"}},
]
}
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": "알 수 없는 명령어입니다."}