G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현 G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건) G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST) G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직 G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트 G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트 G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트 G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트 G-10: core/push_notify.py + routers/push.py + PushSubscription 모델 G-11: approvals 다중승인 (위임/서명/기한초과/마감연장) G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh 하네스: guardia-orchestrator 확장기능 Phase 반영 봇명령어: /sr /status /license /bulk 슬래시 명령어 추가 설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
204 lines
6.4 KiB
Markdown
204 lines
6.4 KiB
Markdown
---
|
|
name: guardia-messenger
|
|
description: >
|
|
GUARDiA 프로젝트에서 메신저 연동 및 sLLM 파서 코드를 작성하거나
|
|
수정할 때 사용하는 스킬. 다음 경우에 반드시 먼저 읽어라:
|
|
- FastAPI Webhook 엔드포인트 구현
|
|
- WebSocket 실시간 중계 서버 코드
|
|
- sLLM 자연어 파서 프롬프트 엔지니어링
|
|
- 메신저 응답 포맷 및 인터랙티브 버튼 구현
|
|
- Proactive 챗봇 대화 맥락 분석
|
|
---
|
|
|
|
# GUARDiA 메신저 연동 스킬
|
|
|
|
## sLLM 파서 원칙
|
|
|
|
### 출력 포맷 강제 (절대 준수)
|
|
```
|
|
- JSON 외 텍스트 출력 금지
|
|
- 부연 설명, 인삿말 포함 금지
|
|
- temperature: 0.1 (결정론적 출력)
|
|
- response_format: {"type": "json_object"}
|
|
```
|
|
|
|
### 시스템 프롬프트 기본 구조
|
|
```python
|
|
SYSTEM_PROMPT = """
|
|
당신은 인프라 자동화 시스템의 핵심 파서입니다.
|
|
1. 사용자의 자연어를 분석하여 JSON 출력물만 생성합니다.
|
|
2. 부연 설명, 인삿말, 안내 텍스트는 절대 포함하지 않습니다.
|
|
3. CMDB에 정의된 정확한 서버 ID와 매핑되지 않는 정보는 "UNKNOWN"으로 처리합니다.
|
|
4. 배포 명령 시 반드시 'requires_approval'(boolean) 값을 판단합니다.
|
|
|
|
출력 포맷 (반드시 이 구조만 사용):
|
|
{
|
|
"intent_type": "DEPLOY_UPGRADE|LOG_ANALYSIS|SERVICE_RESTART|SSL_CHECK|DB_QUERY|CRON_MANAGE",
|
|
"institution": "기관명 또는 UNKNOWN",
|
|
"system_name": "시스템명 또는 UNKNOWN",
|
|
"infrastructure_layer": "WEB|WAS|DB|ESB",
|
|
"target_nodes": [],
|
|
"action_sequence": [],
|
|
"deploy_artifacts": [],
|
|
"requires_approval": false,
|
|
"priority": "HIGH|MEDIUM|LOW"
|
|
}
|
|
"""
|
|
```
|
|
|
|
## Webhook 수신 표준
|
|
|
|
```python
|
|
@app.post("/messenger/webhook")
|
|
async def receive_message(request: Request):
|
|
# 1. 시크릿 키 검증
|
|
signature = request.headers.get("X-Messenger-Signature")
|
|
if not verify_signature(signature, await request.body()):
|
|
raise HTTPException(status_code=403)
|
|
|
|
payload = await request.json()
|
|
user_id = payload.get("user_id")
|
|
text = payload.get("text", "")
|
|
files = payload.get("files", [])
|
|
|
|
# 2. 봇 자신의 메시지는 무시 (무한 루프 방지)
|
|
if payload.get("bot") or not text.strip():
|
|
return {"status": "IGNORED"}
|
|
|
|
# 3. 파일 스테이징
|
|
staged = await stage_uploaded_files(files, user_id)
|
|
|
|
# 4. sLLM 파싱
|
|
parsed = parse_natural_language(text)
|
|
|
|
# 5. SR 생성 및 라우팅
|
|
sr_id = create_ops_task(parsed, user_id, staged)
|
|
route_to_engine(sr_id, parsed)
|
|
|
|
return {"status": "ACCEPTED", "sr_id": sr_id}
|
|
```
|
|
|
|
## 메신저 응답 포맷
|
|
|
|
### 텍스트 응답
|
|
```python
|
|
def send_text_response(room_id: str, text: str):
|
|
messenger.post_message(room_id, {
|
|
"sender": "GUARDiA-Bot",
|
|
"text": text,
|
|
"msg_type": "CHAT"
|
|
})
|
|
```
|
|
|
|
### 인터랙티브 버튼 응답
|
|
```python
|
|
def send_interactive_response(room_id: str, text: str, action_code: str, label: str):
|
|
messenger.post_message(room_id, {
|
|
"sender": "GUARDiA-Bot",
|
|
"text": text,
|
|
"is_widget": True,
|
|
"interactive_action": {
|
|
"type": "BUTTON",
|
|
"label": label,
|
|
"command_code": action_code
|
|
}
|
|
})
|
|
|
|
# 사용 예
|
|
send_interactive_response(
|
|
room_id,
|
|
"🤖 DB 지연 징후 감지. 대기 쿼리를 확인할까요?",
|
|
"FETCH_DB_LOCKS",
|
|
"대기 쿼리 확인"
|
|
)
|
|
```
|
|
|
|
### 승인 요청 응답
|
|
```python
|
|
def request_approval(approver_id: str, sr_id: str, summary: str):
|
|
messenger.post_direct_message(approver_id, {
|
|
"sender": "GUARDiA-Bot",
|
|
"text": f"[{sr_id}] 배포 승인 요청\n{summary}",
|
|
"is_widget": True,
|
|
"interactive_action": {
|
|
"type": "APPROVAL_BUTTONS",
|
|
"approve_url": f"/api/sr/approve/{sr_id}",
|
|
"reject_url": f"/api/sr/reject/{sr_id}"
|
|
}
|
|
})
|
|
```
|
|
|
|
## 금지 사항
|
|
|
|
- 메신저 응답에 서버 IP, 비밀번호, SSH 계정 노출 금지
|
|
- 봇 응답 메시지에 다시 파서 실행 금지 (무한 루프)
|
|
- 에러 메시지에 스택트레이스 전체 노출 금지 → SR ID와 요약만 전달
|
|
- 파일 스테이징 없이 바로 원격 서버 전송 금지 → 반드시 임시 디렉토리 거침
|
|
|
|
## 파일 스테이징 표준
|
|
|
|
```python
|
|
import aiofiles
|
|
from pathlib import Path
|
|
|
|
STAGING_BASE = Path("/app/guardia/staging")
|
|
|
|
async def stage_uploaded_files(files: list, user_id: str) -> list:
|
|
"""메신저 첨부 파일을 임시 디렉토리에 다운로드"""
|
|
staged = []
|
|
sr_dir = STAGING_BASE / user_id / datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
sr_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
for f in files:
|
|
filename = f["name"]
|
|
url = f["url"]
|
|
dest = sr_dir / filename
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(url) as resp:
|
|
async with aiofiles.open(dest, "wb") as fp:
|
|
await fp.write(await resp.read())
|
|
|
|
staged.append({"name": filename, "path": str(dest), "type": classify_artifact(filename)})
|
|
|
|
return staged
|
|
|
|
def classify_artifact(filename: str) -> str:
|
|
ext = Path(filename).suffix.lower()
|
|
if ext in {".class", ".jar", ".war"}: return "DYNAMIC"
|
|
if ext in {".html", ".js", ".css"}: return "STATIC"
|
|
if ext in {".png", ".jpg", ".gif"}: return "STATIC"
|
|
return "UNKNOWN"
|
|
```
|
|
|
|
## Nginx 업스트림 설정 (WebSocket)
|
|
|
|
```nginx
|
|
location /ws {
|
|
proxy_pass http://was_backend;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "Upgrade";
|
|
proxy_read_timeout 86400s;
|
|
proxy_buffering off;
|
|
client_max_body_size 100M;
|
|
}
|
|
```
|
|
|
|
## 라이선스 주의사항
|
|
|
|
메신저 봇이 SR 생성 또는 기관·서버 등록 명령을 처리할 때 라이선스 제한을 확인해야 한다.
|
|
|
|
| 기능 | 필요 에디션 | 한도 초과 시 봇 응답 |
|
|
|------|-----------|-------------------|
|
|
| SR 생성 | 모든 에디션 | 정상 처리 |
|
|
| 기관 등록 명령 | STANDARD+(50개), ENTERPRISE(무제한) | "라이선스 한도 초과: 업그레이드가 필요합니다." |
|
|
| AI 에이전트 연동 | STANDARD 이상 | "AI_AGENTS 기능은 STANDARD 이상 에디션에서만 사용 가능합니다." |
|
|
|
|
봇이 ITSM API로부터 HTTP 403을 받으면 해당 오류 메시지를 사용자에게 전달한다:
|
|
```python
|
|
if resp.status_code == 403:
|
|
detail = resp.json().get("detail", "라이선스 제한으로 요청이 거부되었습니다.")
|
|
messenger.reply(room_id, f"⚠️ {detail}")
|
|
```
|