- CLAUDE.md: project context and architecture spec - docs/: system specs, DB schema, messenger integration, deployment engine - skills/: guardia-deploy, guardia-agent, guardia-messenger - .claude/settings.json: project-level permissions - .gitignore: Python/FastAPI project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
187 lines
5.6 KiB
Markdown
187 lines
5.6 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;
|
|
}
|
|
```
|