guardia-itsm/routers/messenger.py
DESKTOP-TKLFCPRython 62d3d14b5e feat(bot): PMS/보안/성능 봇 명령어 6개 추가
/pms /report /deliverables /issues /scan /checklist /perf
도움말 그룹 정리: SR/PMS/보안품질/배포/운영/SM

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:53:49 +09:00

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"""