/pms /report /deliverables /issues /scan /checklist /perf 도움말 그룹 정리: SR/PMS/보안품질/배포/운영/SM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
893 lines
39 KiB
Python
893 lines
39 KiB
Python
"""
|
|
GUARDiA Messenger 연동 라우터.
|
|
|
|
역할:
|
|
1. ITSM에서 메신저로 보내는 outbound 알림 webhook 수신 (event dispatch)
|
|
2. 메신저 봇 → ITSM으로 들어오는 명령어 처리 (inbound bot commands)
|
|
|
|
봇 명령어:
|
|
!vibe <sr_id> [project_id] → 바이브 코딩 세션 시작
|
|
!build <session_id> → 빌드 실행
|
|
!deploy <session_id> → SSH 배포
|
|
!status <sr_id> → SR + 세션 상태 조회
|
|
!cancel <session_id> → 세션 취소
|
|
!sm <server> <script> → SM 스크립트 원격 실행
|
|
!health <server> → 시스템 헬스체크
|
|
!log <server> → 로그 분석
|
|
!help → 명령어 도움말
|
|
"""
|
|
import logging
|
|
import os
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, BackgroundTasks, Depends
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from database import get_db, SessionLocal
|
|
from models import (
|
|
SRRequest, VibeSession, VibeSessionStatus, Project,
|
|
User, UserRole,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/messenger", tags=["messenger"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MESSENGER_BASE_URL = os.getenv("MESSENGER_BASE_URL", "http://localhost:8002")
|
|
BOT_SEND_API = os.getenv("BOT_SEND_API", "")
|
|
|
|
|
|
# ── 스키마 ────────────────────────────────────────────────────────────────────
|
|
|
|
class MessengerEvent(BaseModel):
|
|
"""ITSM → 메신저 아웃바운드 이벤트."""
|
|
event: str
|
|
room: str = "ops"
|
|
sr_id: Optional[str] = None
|
|
title: Optional[str] = None
|
|
sr_type: Optional[str] = None
|
|
requested_by: Optional[str] = None
|
|
target_server: Optional[str] = None
|
|
result_summary: Optional[str] = None
|
|
# 바이브 세션 이벤트 추가 필드
|
|
session_id: Optional[int] = None
|
|
actor: Optional[str] = None
|
|
workspace: Optional[str] = None
|
|
success: Optional[bool] = None
|
|
summary: Optional[str] = None
|
|
|
|
|
|
class BotCommand(BaseModel):
|
|
"""메신저 봇 → ITSM 인바운드 명령."""
|
|
room: str
|
|
user: str
|
|
command: str # 예: "!vibe SR-20260525-ABCD" 또는 "!status SR-20260525-ABCD"
|
|
message: Optional[str] = None
|
|
|
|
|
|
class BotReply(BaseModel):
|
|
room: str
|
|
text: str
|
|
|
|
|
|
# ── 이벤트 수신 (ITSM outbound) ───────────────────────────────────────────────
|
|
|
|
@router.post("/webhook")
|
|
async def receive_event(
|
|
event: MessengerEvent,
|
|
bg: BackgroundTasks,
|
|
):
|
|
"""
|
|
ITSM 내부에서 발송하는 이벤트 수신 후 메신저 봇으로 relay.
|
|
실제 메신저 서버 연동 시 이 엔드포인트에서 외부 webhook 호출.
|
|
"""
|
|
logger.info("[Messenger] 이벤트 수신: %s | SR: %s", event.event, event.sr_id)
|
|
bg.add_task(_relay_event, event)
|
|
return {"ok": True}
|
|
|
|
|
|
async def _relay_event(event: MessengerEvent):
|
|
"""이벤트를 메신저 채널로 포맷팅하여 중계."""
|
|
msg = _format_event_message(event)
|
|
logger.info("[Messenger → %s] %s", event.room, msg[:100])
|
|
payload = {"room": event.room, "text": msg, "event": event.event}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
await client.post(f"{MESSENGER_BASE_URL}/api/webhook/itsm", json=payload)
|
|
except Exception:
|
|
pass # Fail-Safe: 메신저 미동작 시 ITSM 계속 진행
|
|
|
|
|
|
def _format_event_message(event: MessengerEvent) -> str:
|
|
"""이벤트 → 메신저 메시지 텍스트."""
|
|
if event.event == "sr_created":
|
|
return (
|
|
f"[신규 SR] {event.sr_id}\n"
|
|
f"제목: {event.title}\n"
|
|
f"유형: {event.sr_type} | 요청자: {event.requested_by}\n"
|
|
f"대상 서버: {event.target_server or '—'}\n"
|
|
f"!vibe {event.sr_id} (바이브 코딩 시작)"
|
|
)
|
|
elif event.event == "itsm_complete":
|
|
return (
|
|
f"[SR 완료] {event.sr_id}\n"
|
|
f"제목: {event.title}\n"
|
|
f"결과: {event.result_summary or '완료'}"
|
|
)
|
|
elif event.event == "vibe_started":
|
|
return (
|
|
f"[바이브 세션 시작] Session #{event.session_id}\n"
|
|
f"SR: {event.sr_id or '—'} | 담당: {event.actor or '—'}\n"
|
|
f"워크스페이스: {event.workspace or '—'}\n"
|
|
f"!build {event.session_id} (빌드 준비 완료 시)"
|
|
)
|
|
elif event.event == "build_result":
|
|
status = "성공" if event.success else "실패"
|
|
return (
|
|
f"[빌드 {status}] Session #{event.session_id}\n"
|
|
f"SR: {event.sr_id or '—'}\n"
|
|
f"{event.summary or ''}\n"
|
|
+ (f"!deploy {event.session_id} (배포 진행)" if event.success else "")
|
|
)
|
|
elif event.event == "deploy_result":
|
|
status = "성공" if event.success else "실패"
|
|
return (
|
|
f"[배포 {status}] Session #{event.session_id}\n"
|
|
f"SR: {event.sr_id or '—'}\n"
|
|
f"{event.summary or ''}"
|
|
)
|
|
else:
|
|
return f"[{event.event}] SR: {event.sr_id or '—'}"
|
|
|
|
|
|
# ── 봇 명령어 처리 (inbound) ──────────────────────────────────────────────────
|
|
|
|
@router.post("/bot/command", response_model=BotReply)
|
|
async def handle_bot_command(
|
|
cmd: BotCommand,
|
|
bg: BackgroundTasks,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
GUARDiA 메신저 봇에서 전달되는 명령어 처리.
|
|
메신저 봇이 사용자 명령을 이 엔드포인트로 POST 전달.
|
|
"""
|
|
text = cmd.command.strip()
|
|
parts = text.split()
|
|
if not parts:
|
|
return BotReply(room=cmd.room, text="명령어를 입력해주세요. !help")
|
|
|
|
keyword = parts[0].lower()
|
|
|
|
# ── !help ──────────────────────────────────────────────────────────────────
|
|
if keyword == "!help":
|
|
return BotReply(room=cmd.room, text=_help_text())
|
|
|
|
# ── !vibe <sr_id> [project_id] ────────────────────────────────────────────
|
|
elif keyword == "!vibe":
|
|
if len(parts) < 2:
|
|
return BotReply(room=cmd.room, text="사용법: !vibe <sr_id> [project_id]")
|
|
sr_id = parts[1]
|
|
project_id = int(parts[2]) if len(parts) >= 3 else None
|
|
bg.add_task(_cmd_start_vibe, cmd.room, cmd.user, sr_id, project_id)
|
|
return BotReply(room=cmd.room,
|
|
text=f"[바이브 세션] SR {sr_id} 에 대한 코딩 세션을 시작합니다...")
|
|
|
|
# ── !build <session_id> ───────────────────────────────────────────────────
|
|
elif keyword == "!build":
|
|
if len(parts) < 2:
|
|
return BotReply(room=cmd.room, text="사용법: !build <session_id>")
|
|
try:
|
|
sid = int(parts[1])
|
|
except ValueError:
|
|
return BotReply(room=cmd.room, text="세션 ID는 숫자여야 합니다.")
|
|
bg.add_task(_cmd_build, cmd.room, cmd.user, sid)
|
|
return BotReply(room=cmd.room, text=f"[빌드] Session #{sid} 빌드를 시작합니다...")
|
|
|
|
# ── !deploy <session_id> ─────────────────────────────────────────────────
|
|
elif keyword == "!deploy":
|
|
if len(parts) < 2:
|
|
return BotReply(room=cmd.room, text="사용법: !deploy <session_id>")
|
|
try:
|
|
sid = int(parts[1])
|
|
except ValueError:
|
|
return BotReply(room=cmd.room, text="세션 ID는 숫자여야 합니다.")
|
|
bg.add_task(_cmd_deploy, cmd.room, cmd.user, sid)
|
|
return BotReply(room=cmd.room, text=f"[배포] Session #{sid} 배포 파이프라인을 시작합니다...")
|
|
|
|
# ── !status <sr_id> ──────────────────────────────────────────────────────
|
|
elif keyword == "!status":
|
|
if len(parts) < 2:
|
|
return BotReply(room=cmd.room, text="사용법: !status <sr_id>")
|
|
sr_id = parts[1]
|
|
reply = await _cmd_status(sr_id, db)
|
|
return BotReply(room=cmd.room, text=reply)
|
|
|
|
# ── !cancel <session_id> ─────────────────────────────────────────────────
|
|
elif keyword == "!cancel":
|
|
if len(parts) < 2:
|
|
return BotReply(room=cmd.room, text="사용법: !cancel <session_id>")
|
|
try:
|
|
sid = int(parts[1])
|
|
except ValueError:
|
|
return BotReply(room=cmd.room, text="세션 ID는 숫자여야 합니다.")
|
|
reply = await _cmd_cancel(sid, db)
|
|
return BotReply(room=cmd.room, text=reply)
|
|
|
|
# ── !sm <server> <script> ─────────────────────────────────────────────────
|
|
elif keyword == "!sm":
|
|
if len(parts) < 3:
|
|
return BotReply(room=cmd.room, text="사용법: !sm <server명> <script키>")
|
|
server, script_key = parts[1], parts[2]
|
|
bg.add_task(_cmd_sm, cmd.room, cmd.user, server, script_key, None)
|
|
return BotReply(room=cmd.room,
|
|
text=f"[SM 점검] {server} 서버에 {script_key} 스크립트를 실행합니다...")
|
|
|
|
# ── !health <server> ─────────────────────────────────────────────────────
|
|
elif keyword == "!health":
|
|
if len(parts) < 2:
|
|
return BotReply(room=cmd.room, text="사용법: !health <server명>")
|
|
server = parts[1]
|
|
bg.add_task(_cmd_sm, cmd.room, cmd.user, server, "system_health", None)
|
|
return BotReply(room=cmd.room,
|
|
text=f"[헬스체크] {server} 서버 시스템 점검을 시작합니다...")
|
|
|
|
# ── !log <server> [logfile] ───────────────────────────────────────────────
|
|
elif keyword == "!log":
|
|
if len(parts) < 2:
|
|
return BotReply(room=cmd.room, text="사용법: !log <server명> [로그파일경로]")
|
|
server = parts[1]
|
|
env_vars = {}
|
|
if len(parts) >= 3:
|
|
env_vars["LOG_FILES"] = parts[2]
|
|
bg.add_task(_cmd_sm, cmd.room, cmd.user, server, "log_analysis", env_vars)
|
|
return BotReply(room=cmd.room,
|
|
text=f"[로그 분석] {server} 서버 로그 분석을 시작합니다...")
|
|
|
|
# ── /sr <제목> (슬래시 스타일 SR 접수) ──────────────────────────────────
|
|
elif keyword in ("/sr", "!sr"):
|
|
title = " ".join(parts[1:]) if len(parts) >= 2 else ""
|
|
if not title:
|
|
return BotReply(room=cmd.room, text="사용법: /sr <SR 제목>")
|
|
bg.add_task(_cmd_create_sr, cmd.room, cmd.user, title)
|
|
return BotReply(room=cmd.room, text=f"[SR 접수] '{title}' 처리 중...")
|
|
|
|
# ── /status (슬래시 스타일 시스템 현황) ─────────────────────────────────
|
|
elif keyword == "/status":
|
|
reply = await _cmd_dashboard_status()
|
|
return BotReply(room=cmd.room, text=reply)
|
|
|
|
# ── /license (슬래시 스타일 라이선스 조회) ──────────────────────────────
|
|
elif keyword == "/license":
|
|
reply = await _cmd_license_status(db)
|
|
return BotReply(room=cmd.room, text=reply)
|
|
|
|
# ── /bulk <action> <sr_ids...> ───────────────────────────────────────────
|
|
elif keyword in ("/bulk", "!bulk"):
|
|
if len(parts) < 3:
|
|
return BotReply(room=cmd.room, text="사용법: /bulk <action> <SR-ID1> [SR-ID2 ...]\naction: close|assign|status")
|
|
action = parts[1].upper()
|
|
sr_ids = parts[2:]
|
|
bg.add_task(_cmd_bulk_action, cmd.room, cmd.user, action, sr_ids)
|
|
return BotReply(room=cmd.room, text=f"[대량처리] {len(sr_ids)}건 {action} 처리 중...")
|
|
|
|
# ── /report <프로젝트코드> [daily|weekly|monthly] ─────────────────────────
|
|
elif keyword in ("/report", "!report"):
|
|
if len(parts) < 2:
|
|
return BotReply(room=cmd.room, text="사용법: /report <프로젝트코드> [daily|weekly|monthly]")
|
|
proj_code = parts[1]
|
|
rtype = parts[2].lower() if len(parts) >= 3 else "weekly"
|
|
if rtype not in ("daily", "weekly", "monthly"):
|
|
return BotReply(room=cmd.room, text="보고서 유형: daily | weekly | monthly")
|
|
bg.add_task(_cmd_project_report, cmd.room, cmd.user, proj_code, rtype, db)
|
|
rtype_ko = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(rtype, rtype)
|
|
return BotReply(room=cmd.room, text=f"[{rtype_ko}보고서] {proj_code} 보고서 생성 중...")
|
|
|
|
# ── /pms <프로젝트코드> ─── 프로젝트 현황 요약 ───────────────────────────
|
|
elif keyword in ("/pms", "!pms"):
|
|
if len(parts) < 2:
|
|
return BotReply(room=cmd.room, text="사용법: /pms <프로젝트코드>")
|
|
proj_code = parts[1]
|
|
reply = await _cmd_pms_status(proj_code, db)
|
|
return BotReply(room=cmd.room, text=reply)
|
|
|
|
# ── /deliverables <프로젝트코드> ─── 산출물 현황 ────────────────────────
|
|
elif keyword in ("/deliverables", "/산출물", "!deliverables"):
|
|
if len(parts) < 2:
|
|
return BotReply(room=cmd.room, text="사용법: /deliverables <프로젝트코드>")
|
|
proj_code = parts[1]
|
|
reply = await _cmd_deliverables(proj_code, db)
|
|
return BotReply(room=cmd.room, text=reply)
|
|
|
|
# ── /scan ─── 시큐어코딩 / 보안 스캔 ────────────────────────────────────
|
|
elif keyword in ("/scan", "!scan"):
|
|
bg.add_task(_cmd_compliance_scan, cmd.room, cmd.user)
|
|
return BotReply(room=cmd.room, text="[보안 스캔] 시큐어코딩/웹접근성/개인정보 점검 시작...")
|
|
|
|
# ── /checklist ─── 공공기관 체크리스트 현황 ──────────────────────────────
|
|
elif keyword in ("/checklist", "!checklist"):
|
|
reply = await _cmd_checklist_status()
|
|
return BotReply(room=cmd.room, text=reply)
|
|
|
|
# ── /perf [url] ─── 성능 테스트 ─────────────────────────────────────────
|
|
elif keyword in ("/perf", "!perf"):
|
|
target = parts[1] if len(parts) >= 2 else "http://localhost:8001"
|
|
bg.add_task(_cmd_perf_test, cmd.room, cmd.user, target)
|
|
return BotReply(room=cmd.room, text=f"[성능 테스트] {target} 대상 10명/30초 부하 테스트 시작...")
|
|
|
|
# ── /issues <프로젝트코드> ─── 미결 이슈 목록 ────────────────────────────
|
|
elif keyword in ("/issues", "!issues"):
|
|
if len(parts) < 2:
|
|
return BotReply(room=cmd.room, text="사용법: /issues <프로젝트코드>")
|
|
proj_code = parts[1]
|
|
reply = await _cmd_open_issues(proj_code, db)
|
|
return BotReply(room=cmd.room, text=reply)
|
|
|
|
# ── /help ─────────────────────────────────────────────────────────────────
|
|
elif keyword == "/help":
|
|
return BotReply(room=cmd.room, text=_help_text())
|
|
|
|
else:
|
|
return BotReply(room=cmd.room,
|
|
text=f"알 수 없는 명령어: {keyword}\n!help 또는 /help 로 도움말 확인")
|
|
|
|
|
|
# ── 백그라운드 명령 실행 헬퍼 ────────────────────────────────────────────────
|
|
|
|
async def _cmd_start_vibe(room: str, actor: str, sr_id: str,
|
|
project_id: Optional[int]):
|
|
"""바이브 세션 생성."""
|
|
try:
|
|
async with SessionLocal() as db:
|
|
# SR 확인
|
|
r = await db.execute(
|
|
select(SRRequest).where(SRRequest.sr_id == sr_id)
|
|
)
|
|
sr = r.scalars().first()
|
|
if not sr:
|
|
await _send_to_room(room, f"SR을 찾을 수 없습니다: {sr_id}")
|
|
return
|
|
|
|
vs = VibeSession(
|
|
sr_id=sr_id,
|
|
project_id=project_id,
|
|
started_by=actor,
|
|
status=VibeSessionStatus.CODING,
|
|
)
|
|
db.add(vs)
|
|
await db.commit()
|
|
await db.refresh(vs)
|
|
msg = (
|
|
f"[바이브 세션 생성됨]\n"
|
|
f"세션 ID: #{vs.id}\n"
|
|
f"SR: {sr_id}\n"
|
|
f"담당: {actor}\n"
|
|
f"코딩 완료 후: !build {vs.id}"
|
|
)
|
|
await _send_to_room(room, msg)
|
|
except Exception as e:
|
|
logger.error("[Bot !vibe] 오류: %s", str(e)[:100])
|
|
await _send_to_room(room, f"세션 생성 오류: {str(e)[:100]}")
|
|
|
|
|
|
async def _cmd_build(room: str, actor: str, session_id: int):
|
|
"""빌드 실행."""
|
|
import httpx
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
r = await client.post(
|
|
f"http://localhost:8000/api/vibe/{session_id}/build",
|
|
json={},
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
status = data.get("status", "?")
|
|
log = (data.get("build_log") or "")[-200:]
|
|
await _send_to_room(
|
|
room,
|
|
f"[빌드 완료] Session #{session_id} → {status}\n{log}"
|
|
+ (f"\n배포 진행: !deploy {session_id}" if status == "TESTING" else "")
|
|
)
|
|
else:
|
|
await _send_to_room(room, f"[빌드 오류] {r.status_code}: {r.text[:100]}")
|
|
except Exception as e:
|
|
await _send_to_room(room, f"[빌드 오류] {str(e)[:100]}")
|
|
|
|
|
|
async def _cmd_deploy(room: str, actor: str, session_id: int):
|
|
"""배포 실행."""
|
|
import httpx
|
|
try:
|
|
async with httpx.AsyncClient(timeout=120) as client:
|
|
r = await client.post(
|
|
f"http://localhost:8000/api/vibe/{session_id}/deploy",
|
|
json={"skip_test": False},
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
status = data.get("status", "?")
|
|
log = (data.get("deploy_log") or "")[-200:]
|
|
await _send_to_room(
|
|
room,
|
|
f"[배포 {'완료' if status == 'COMPLETED' else '실패'}] "
|
|
f"Session #{session_id} → {status}\n{log}"
|
|
)
|
|
else:
|
|
await _send_to_room(room, f"[배포 오류] {r.status_code}: {r.text[:100]}")
|
|
except Exception as e:
|
|
await _send_to_room(room, f"[배포 오류] {str(e)[:100]}")
|
|
|
|
|
|
async def _cmd_status(sr_id: str, db: AsyncSession) -> str:
|
|
"""SR + 바이브 세션 상태 조회."""
|
|
try:
|
|
r = await db.execute(
|
|
select(SRRequest).where(SRRequest.sr_id == sr_id)
|
|
)
|
|
sr = r.scalars().first()
|
|
if not sr:
|
|
return f"SR을 찾을 수 없습니다: {sr_id}"
|
|
|
|
lines = [
|
|
f"[{sr_id}] {sr.title}",
|
|
f"상태: {sr.status} | 우선순위: {sr.priority}",
|
|
f"담당: {sr.assigned_to or '미배정'} | 서버: {sr.target_server or '—'}",
|
|
]
|
|
|
|
# 바이브 세션
|
|
rv = await db.execute(
|
|
select(VibeSession).where(VibeSession.sr_id == sr_id)
|
|
.order_by(VibeSession.started_at.desc()).limit(1)
|
|
)
|
|
vs = rv.scalars().first()
|
|
if vs:
|
|
lines.append(f"바이브 세션: #{vs.id} → {vs.status}")
|
|
if vs.claude_session_id:
|
|
lines.append(f"Claude 세션 ID: {vs.claude_session_id}")
|
|
|
|
return "\n".join(lines)
|
|
except Exception as e:
|
|
return f"조회 오류: {str(e)[:100]}"
|
|
|
|
|
|
async def _cmd_cancel(session_id: int, db: AsyncSession) -> str:
|
|
"""세션 취소."""
|
|
try:
|
|
r = await db.execute(
|
|
select(VibeSession).where(VibeSession.id == session_id)
|
|
)
|
|
vs = r.scalars().first()
|
|
if not vs:
|
|
return f"세션을 찾을 수 없습니다: #{session_id}"
|
|
if vs.status in (VibeSessionStatus.COMPLETED, VibeSessionStatus.FAILED):
|
|
return f"이미 종료된 세션입니다: #{session_id} ({vs.status})"
|
|
vs.status = VibeSessionStatus.CANCELLED
|
|
await db.commit()
|
|
return f"[취소됨] 세션 #{session_id}"
|
|
except Exception as e:
|
|
return f"취소 오류: {str(e)[:100]}"
|
|
|
|
|
|
async def _cmd_sm(room: str, actor: str, server: str,
|
|
script_key: str, env_vars: Optional[dict]):
|
|
"""SM 스크립트 실행 후 결과 전송."""
|
|
from core.ssh_exec import exec_script
|
|
import glob, os
|
|
|
|
SCRIPTS_ROOT = os.path.realpath(
|
|
os.path.join(os.path.dirname(__file__), "..", "scripts", "sm")
|
|
)
|
|
# 스크립트 경로 탐색
|
|
found = None
|
|
for sh in glob.glob(os.path.join(SCRIPTS_ROOT, "**", "*.sh"), recursive=True):
|
|
name = os.path.splitext(os.path.basename(sh))[0]
|
|
if name == script_key or name.replace("_sm", "") == script_key or \
|
|
name.replace("was_", "").replace("_sm", "") == script_key or \
|
|
name.replace("db_", "").replace("_sm", "") == script_key:
|
|
found = sh
|
|
break
|
|
|
|
if not found:
|
|
await _send_to_room(room, f"스크립트를 찾을 수 없습니다: {script_key}")
|
|
return
|
|
|
|
result = await exec_script(
|
|
server_name=server,
|
|
script_path=found,
|
|
env_vars=env_vars,
|
|
timeout=300,
|
|
actor=actor,
|
|
)
|
|
|
|
# 결과 요약 (최대 800자)
|
|
output = result.stdout or result.error or "출력 없음"
|
|
summary = output[-800:] if len(output) > 800 else output
|
|
status_tag = "[OK]" if result.success else "[WARN/CRIT]"
|
|
await _send_to_room(
|
|
room,
|
|
f"[SM 점검 결과] {server} / {script_key} {status_tag}\n"
|
|
f"소요: {result.elapsed}s | exit={result.exit_code}\n"
|
|
f"---\n{summary}"
|
|
)
|
|
|
|
|
|
async def _cmd_project_report(room: str, actor: str, proj_code: str, rtype: str, db):
|
|
"""/report — 프로젝트 보고서 생성 후 요약 발송."""
|
|
import httpx as _httpx
|
|
rtype_ko = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(rtype, rtype)
|
|
try:
|
|
# 프로젝트 조회
|
|
from models import SiProject
|
|
from sqlalchemy import select as sel
|
|
async with SessionLocal() as _db:
|
|
proj = (await _db.execute(
|
|
sel(SiProject).where(SiProject.project_code == proj_code)
|
|
)).scalars().first()
|
|
|
|
if not proj:
|
|
await _send_to_room(room, f"프로젝트를 찾을 수 없습니다: {proj_code}")
|
|
return
|
|
|
|
# 메신저 발송
|
|
async with _httpx.AsyncClient(timeout=30.0) as client:
|
|
r = await client.post(
|
|
f"http://localhost:8001/api/si/projects/{proj.id}/report/send",
|
|
json={"room": room, "report_type": rtype, "fmt": "excel"},
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
await _send_to_room(room, data.get("summary", f"[{rtype_ko}보고서] {proj_code} 완료"))
|
|
else:
|
|
await _send_to_room(room, f"[{rtype_ko}보고서] 생성 실패 ({r.status_code})")
|
|
except Exception as e:
|
|
await _send_to_room(room, f"[보고서 오류] {str(e)[:100]}")
|
|
|
|
|
|
async def _cmd_pms_status(proj_code: str, db) -> str:
|
|
"""/pms — 프로젝트 현황 요약."""
|
|
import httpx as _httpx
|
|
try:
|
|
async with SessionLocal() as _db:
|
|
from models import SiProject
|
|
from sqlalchemy import select as sel
|
|
proj = (await _db.execute(
|
|
sel(SiProject).where(SiProject.project_code == proj_code)
|
|
)).scalars().first()
|
|
|
|
if not proj:
|
|
return f"프로젝트를 찾을 수 없습니다: {proj_code}"
|
|
|
|
async with _httpx.AsyncClient(timeout=10.0) as client:
|
|
r = await client.get(
|
|
f"http://localhost:8001/api/si/projects/{proj.id}/report/status",
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 200:
|
|
d = r.json()
|
|
lines = [
|
|
f"[PMS 현황] {d.get('project_name','')} ({proj_code})",
|
|
f"단계: {d.get('phase','?')} | 건강: {d.get('health_status','?')}",
|
|
f"진척률: {d.get('overall_progress',0)}% | 예산: {d.get('budget_pct',0)}%",
|
|
f"WBS: {d.get('wbs_done',0)}/{d.get('wbs_total',0)} 완료 | 지연: {d.get('wbs_delayed',0)}건",
|
|
f"미결 이슈: {d.get('issue_open',0)}건 | 고위험: {d.get('high_risks',0)}건",
|
|
]
|
|
if d.get("upcoming_milestones"):
|
|
m = d["upcoming_milestones"][0]
|
|
lines.append(f"다음 마일스톤: {m['name']} ({m['target_date']})")
|
|
return "\n".join(lines)
|
|
return f"프로젝트 현황 조회 실패 ({r.status_code})"
|
|
except Exception as e:
|
|
return f"PMS 조회 오류: {str(e)[:100]}"
|
|
|
|
|
|
async def _cmd_deliverables(proj_code: str, db) -> str:
|
|
"""/deliverables — 산출물 제출 현황."""
|
|
import httpx as _httpx
|
|
try:
|
|
async with SessionLocal() as _db:
|
|
from models import SiProject
|
|
from sqlalchemy import select as sel
|
|
proj = (await _db.execute(
|
|
sel(SiProject).where(SiProject.project_code == proj_code)
|
|
)).scalars().first()
|
|
if not proj:
|
|
return f"프로젝트를 찾을 수 없습니다: {proj_code}"
|
|
|
|
async with _httpx.AsyncClient(timeout=10.0) as client:
|
|
r = await client.get(
|
|
f"http://localhost:8001/api/si/projects/{proj.id}/deliverables/summary",
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 200:
|
|
d = r.json()
|
|
lines = [
|
|
f"[산출물 현황] {proj_code}",
|
|
f"전체: {d['total']}건 | 제출률: {d['submit_rate']}% | 승인률: {d['approval_rate']}%",
|
|
]
|
|
if d.get("overdue"):
|
|
lines.append(f"⚠️ 기한 초과: {len(d['overdue'])}건")
|
|
for o in d["overdue"][:3]:
|
|
lines.append(f" - {o['name']} ({o['days_overdue']}일 초과)")
|
|
return "\n".join(lines)
|
|
return f"산출물 현황 조회 실패 ({r.status_code})"
|
|
except Exception as e:
|
|
return f"산출물 조회 오류: {str(e)[:100]}"
|
|
|
|
|
|
async def _cmd_compliance_scan(room: str, actor: str):
|
|
"""/scan — 시큐어코딩/웹접근성/개인정보 자동 점검."""
|
|
import httpx as _httpx
|
|
try:
|
|
async with _httpx.AsyncClient(timeout=60.0) as client:
|
|
r = await client.post(
|
|
"http://localhost:8001/api/compliance/scan",
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 200:
|
|
d = r.json()
|
|
msg = (
|
|
f"[보안 스캔 완료]\n"
|
|
f"종합 위험도: {d.get('risk_level','?')}\n"
|
|
f"총 발견: {d.get('total_findings',0)}건\n"
|
|
f"시큐어코딩: {d.get('summary',{}).get('secure_coding',0)}건\n"
|
|
f"웹접근성: {d.get('summary',{}).get('accessibility',0)}건\n"
|
|
f"개인정보: {d.get('summary',{}).get('privacy',0)}건\n"
|
|
f"상세: http://localhost:8001/compliance"
|
|
)
|
|
else:
|
|
msg = f"[보안 스캔 실패] {r.status_code}"
|
|
await _send_to_room(room, msg)
|
|
except Exception as e:
|
|
await _send_to_room(room, f"[스캔 오류] {str(e)[:100]}")
|
|
|
|
|
|
async def _cmd_checklist_status() -> str:
|
|
"""/checklist — 공공기관 체크리스트 현황."""
|
|
import httpx as _httpx
|
|
try:
|
|
async with _httpx.AsyncClient(timeout=10.0) as client:
|
|
r = await client.get(
|
|
"http://localhost:8001/api/public/status",
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 200:
|
|
d = r.json()
|
|
by_cat = d.get("by_category", {})
|
|
lines = [
|
|
f"[공공기관 체크리스트]",
|
|
f"GUARDiA 구현율: {d.get('impl_rate',0)}% ({d.get('guardia_impl',0)}/{d.get('total_items',0)})",
|
|
f"검증 완료: {d.get('verify_rate',0)}% ({d.get('verified',0)}/{d.get('total_items',0)})",
|
|
]
|
|
for cat, v in by_cat.items():
|
|
lines.append(f" {cat}: {v['impl']}/{v['total']} 구현")
|
|
actions = d.get("action_items", [])
|
|
if actions:
|
|
lines.append(f"미완료 조치: {len(actions)}건")
|
|
for a in actions[:2]:
|
|
lines.append(f" - {a[:60]}")
|
|
return "\n".join(lines)
|
|
return f"체크리스트 조회 실패 ({r.status_code})"
|
|
except Exception as e:
|
|
return f"체크리스트 오류: {str(e)[:100]}"
|
|
|
|
|
|
async def _cmd_perf_test(room: str, actor: str, target_url: str):
|
|
"""/perf — 성능 테스트 실행."""
|
|
import httpx as _httpx
|
|
try:
|
|
async with _httpx.AsyncClient(timeout=90.0) as client:
|
|
r = await client.post(
|
|
"http://localhost:8001/api/perf/run",
|
|
json={
|
|
"target_url": target_url,
|
|
"endpoints": ["/", "/api/tasks", "/api/dashboard/me"],
|
|
"users": 10,
|
|
"duration": 30,
|
|
"ramp_up": 5,
|
|
"think_time": 0.1,
|
|
},
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 200:
|
|
d = r.json()
|
|
s = d.get("summary", {})
|
|
msg = (
|
|
f"[성능 테스트 완료] {target_url}\n"
|
|
f"TPS: {s.get('tps',0)} | 평균: {s.get('avg_ms',0)}ms\n"
|
|
f"P95: {s.get('p95_ms',0)}ms | 에러율: {s.get('error_rate_pct',0)}%\n"
|
|
f"결과 ID: {d.get('result_id','?')}\n"
|
|
f"보고서: http://localhost:8001/api/perf/results/{d.get('result_id','')}/html"
|
|
)
|
|
else:
|
|
msg = f"[성능 테스트 실패] {r.status_code}"
|
|
await _send_to_room(room, msg)
|
|
except Exception as e:
|
|
await _send_to_room(room, f"[성능 테스트 오류] {str(e)[:100]}")
|
|
|
|
|
|
async def _cmd_open_issues(proj_code: str, db) -> str:
|
|
"""/issues — 프로젝트 미결 이슈 목록."""
|
|
import httpx as _httpx
|
|
try:
|
|
async with SessionLocal() as _db:
|
|
from models import SiProject
|
|
from sqlalchemy import select as sel
|
|
proj = (await _db.execute(
|
|
sel(SiProject).where(SiProject.project_code == proj_code)
|
|
)).scalars().first()
|
|
if not proj:
|
|
return f"프로젝트를 찾을 수 없습니다: {proj_code}"
|
|
|
|
async with _httpx.AsyncClient(timeout=10.0) as client:
|
|
r = await client.get(
|
|
f"http://localhost:8001/api/si/projects/{proj.id}/issues?status=OPEN&limit=5",
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 200:
|
|
issues = r.json()
|
|
if not issues:
|
|
return f"[이슈] {proj_code} — 미결 이슈 없음 ✅"
|
|
lines = [f"[미결 이슈] {proj_code} ({len(issues)}건)"]
|
|
for iss in issues[:5]:
|
|
lines.append(f" [{iss.get('issue_id','?')}] {iss.get('title','')[:40]} — {iss.get('assigned_to','미배정')}")
|
|
return "\n".join(lines)
|
|
return f"이슈 조회 실패 ({r.status_code})"
|
|
except Exception as e:
|
|
return f"이슈 조회 오류: {str(e)[:100]}"
|
|
|
|
|
|
async def _cmd_create_sr(room: str, actor: str, title: str):
|
|
"""슬래시 /sr 명령 — SR 빠른 접수 (내부 API 호출)."""
|
|
import httpx as _httpx
|
|
try:
|
|
async with _httpx.AsyncClient(timeout=10.0) as client:
|
|
r = await client.post(
|
|
"http://localhost:8001/api/tasks",
|
|
json={"title": title, "requested_by": actor, "sr_type": "OTHER", "priority": "MEDIUM"},
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 201:
|
|
data = r.json()
|
|
await _send_to_room(room, f"[SR 접수 완료] {data.get('sr_id')}\n제목: {title}\n담당: {data.get('assigned_to') or '배정 대기'}")
|
|
else:
|
|
await _send_to_room(room, f"[SR 접수 실패] {r.status_code}")
|
|
except Exception as e:
|
|
await _send_to_room(room, f"[SR 접수 오류] {str(e)[:100]}")
|
|
|
|
|
|
async def _cmd_dashboard_status() -> str:
|
|
"""슬래시 /status — 시스템 현황 요약."""
|
|
import httpx as _httpx
|
|
try:
|
|
async with _httpx.AsyncClient(timeout=10.0) as client:
|
|
r = await client.get(
|
|
"http://localhost:8001/api/dashboard/overview",
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
return (
|
|
f"[GUARDiA 현황]\n"
|
|
f"전체 SR: {data.get('total_sr', 0)}건\n"
|
|
f"처리 중: {data.get('in_progress', 0)}건\n"
|
|
f"SLA 위반: {data.get('sla_breached', 0)}건\n"
|
|
f"장애 진행: {data.get('open_incidents', 0)}건"
|
|
)
|
|
return f"현황 조회 실패 ({r.status_code})"
|
|
except Exception as e:
|
|
return f"현황 조회 오류: {str(e)[:80]}"
|
|
|
|
|
|
async def _cmd_license_status(db) -> str:
|
|
"""슬래시 /license — 라이선스 상태 조회."""
|
|
try:
|
|
from routers.license import get_license_status
|
|
status = await get_license_status(db)
|
|
if status.get("valid"):
|
|
label = "[체험판]" if status.get("is_trial") else ""
|
|
return (
|
|
f"[GUARDiA 라이선스 {label}]\n"
|
|
f"에디션: {status.get('edition')}\n"
|
|
f"고객: {status.get('customer')}\n"
|
|
f"만료: {status.get('expires_at', '?')[:10]} (D-{status.get('days_remaining', '?')})"
|
|
)
|
|
elif status.get("expired"):
|
|
return "❌ 라이선스 만료됨 — /license 에서 갱신하세요."
|
|
else:
|
|
return "⚠️ 라이선스 미등록 — /license 에서 체험판을 시작하세요."
|
|
except Exception as e:
|
|
return f"라이선스 조회 오류: {str(e)[:80]}"
|
|
|
|
|
|
async def _cmd_bulk_action(room: str, actor: str, action: str, sr_ids: list):
|
|
"""슬래시 /bulk — SR 대량 처리."""
|
|
import httpx as _httpx
|
|
try:
|
|
async with _httpx.AsyncClient(timeout=30.0) as client:
|
|
r = await client.post(
|
|
"http://localhost:8001/api/tasks/bulk",
|
|
json={"sr_ids": sr_ids, "action": action, "params": {"note": f"봇 대량처리 by {actor}"}},
|
|
headers={"Authorization": f"Bearer {_get_internal_token()}"},
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
await _send_to_room(
|
|
room,
|
|
f"[대량처리 완료] {action}\n"
|
|
f"전체: {data['total']}건 | 성공: {data['success']}건 | 실패: {data['failed']}건"
|
|
)
|
|
else:
|
|
await _send_to_room(room, f"[대량처리 실패] {r.status_code}: {r.text[:100]}")
|
|
except Exception as e:
|
|
await _send_to_room(room, f"[대량처리 오류] {str(e)[:100]}")
|
|
|
|
|
|
async def _send_to_room(room: str, text: str):
|
|
"""내부 봇 채널로 메시지 전송."""
|
|
logger.info("[Bot → %s] %s", room, text[:100])
|
|
if BOT_SEND_API:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
await client.post(BOT_SEND_API, json={"room": room, "text": text})
|
|
except Exception:
|
|
pass
|
|
else:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
await client.post(
|
|
f"{MESSENGER_BASE_URL}/api/webhook/bot-reply",
|
|
json={"room": room, "text": text},
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _get_internal_token() -> str:
|
|
"""내부 API 호출용 토큰 (환경변수 또는 서비스 계정)."""
|
|
import os
|
|
return os.environ.get("INTERNAL_API_TOKEN", "")
|
|
|
|
|
|
def _help_text() -> str:
|
|
return """GUARDiA ITSM 봇 명령어
|
|
━━━━━━━━━━━━━━━━━━━━
|
|
[SR 관리]
|
|
/sr <제목> → SR 빠른 접수
|
|
!status <sr_id> → SR + 세션 상태 조회
|
|
/bulk <action> <SR-ID...> → SR 대량 처리 (close/assign/status)
|
|
|
|
[PMS 프로젝트 관리]
|
|
/pms <프로젝트코드> → 프로젝트 진척 현황
|
|
/report <코드> [daily|weekly|monthly] → 보고서 발송
|
|
/deliverables <프로젝트코드> → 산출물 제출 현황
|
|
/issues <프로젝트코드> → 미결 이슈 목록
|
|
|
|
[보안/품질]
|
|
/scan → 시큐어코딩/웹접근성/개인정보 점검
|
|
/checklist → 공공기관 필수 기능 현황
|
|
/perf [url] → 성능 테스트 실행
|
|
|
|
[배포]
|
|
!vibe <sr_id> [project_id] → 바이브 코딩 세션 시작
|
|
!build <session_id> → 빌드 실행
|
|
!deploy <session_id> → 배포 (SSH + WAS 재기동)
|
|
!cancel <session_id> → 세션 취소
|
|
|
|
[운영 정보]
|
|
/status → 시스템 현황 요약
|
|
/license → 라이선스 상태 조회
|
|
|
|
[SM 원격 제어]
|
|
!sm <server> <script> → SM 스크립트 실행
|
|
!health <server> → 시스템 헬스체크
|
|
!log <server> [logfile] → 로그 분석
|
|
━━━━━━━━━━━━━━━━━━━━
|
|
SM 스크립트 키: system, tomcat, jboss, jeus, weblogic,
|
|
postgresql, oracle, mysql, tibero, esb, elasticsearch,
|
|
solr, pinpoint, scouter, ping, log_analysis"""
|