feat(messenger): Slack형 실시간 채팅 메신저 구현

- FastAPI + WebSocket 백엔드 (ws_relay, webhook, messages 라우터)
- GUARDiA-Bot: @bot 명령 응답 + 선제적 맥락 분석 (DB 지연, 디스크, 장애 감지)
- 승인 워크플로우: 재기동/배포 SR → 승인/반려 인터랙티브 버튼
- 다크 테마 Slack형 프론트엔드 (5개 채널, 실시간 메시지)
- 채널: 일반/배포/운영/PM관리/알림

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-05-24 19:04:03 +09:00
parent 45f96176a6
commit 85e4901541
14 changed files with 1098 additions and 0 deletions

12
.claude/launch.json Normal file
View File

@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "guardia-messenger",
"runtimeExecutable": "uvicorn",
"runtimeArgs": ["main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"],
"cwd": "C:\\GUARDiA\\messenger",
"port": 8000
}
]
}

View File

178
messenger/core/bot.py Normal file
View File

@ -0,0 +1,178 @@
from datetime import datetime
from uuid import uuid4
from typing import Optional
# In-memory message store (room_id -> list of message dicts)
message_store: dict[str, list] = {}
ROOMS: dict[str, str] = {
"general": "일반",
"deploy": "배포",
"ops": "운영",
"pm": "PM관리",
"alerts": "알림",
}
CONTEXT_RULES = {
"db_delay": {
"keywords": ["DB", "디비", "쿼리", "느리", "타임아웃", "", "lock", "slow"],
"action": "FETCH_DB_LOCKS",
"message": "DB 지연 징후가 감지되었습니다. 대기 쿼리를 조회할까요?",
},
"disk_full": {
"keywords": ["용량", "디스크", "로그", "쌓였", "full", "disk", "no space"],
"action": "CHECK_DISK_SPACE",
"message": "디스크 공간 관련 문제가 감지되었습니다. 용량을 확인할까요?",
},
"service_down": {
"keywords": ["다운", "죽었", "응답없", "timeout", "503", "502", "500", "오류", "장애"],
"action": "CHECK_SERVICE_STATUS",
"message": "서비스 장애 징후가 감지되었습니다. 서비스 상태를 확인할까요?",
},
"ssl_expire": {
"keywords": ["ssl", "인증서", "만료", "expire", "https"],
"action": "CHECK_SSL_CERTS",
"message": "SSL 인증서 관련 문의가 감지되었습니다. 만료일을 확인할까요?",
},
}
def _new_sr() -> str:
return f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
def get_bot_response(text: str) -> Optional[dict]:
"""@bot 멘션 또는 guardia 키워드 → 명령 응답 생성."""
t = text.lower()
if "@bot" not in t and "guardia" not in t:
return None
if "도움" in text or "help" in t or "명령" in text:
return {
"content": (
"**GUARDiA Bot** 사용 가능한 명령어:\n"
"• `@bot 배포` — 배포 SR 생성\n"
"• `@bot 재기동` — WAS 재기동 요청 (PM 승인 필요)\n"
"• `@bot 로그 확인` — 에러 로그 분석\n"
"• `@bot 서버 상태` — 인프라 현황 조회\n"
"• `@bot SSL 확인` — 인증서 만료일 조회\n"
"• `@bot 정지` — 진행 중 작업 즉시 중단"
),
"is_widget": False,
}
if "배포" in text or "deploy" in t:
sr_id = _new_sr()
return {
"content": (
f"배포 SR이 생성되었습니다: `{sr_id}`\n"
"배포 파일을 첨부하고 대상 서버(기관명/시스템명)를 지정해주세요."
),
"is_widget": True,
"interactive_action": {
"type": "BUTTON",
"label": "배포 취소",
"command_code": f"CANCEL_SR:{sr_id}",
},
}
if "재기동" in text or "restart" in t or "리스타트" in text:
sr_id = _new_sr()
return {
"content": (
f"WAS 재기동 요청 `{sr_id}`이 접수되었습니다.\n"
"PM 승인 후 롤링 방식으로 실행됩니다."
),
"is_widget": True,
"interactive_action": {
"type": "APPROVAL_BUTTONS",
"approve_url": f"/api/sr/approve/{sr_id}",
"reject_url": f"/api/sr/reject/{sr_id}",
},
}
if "로그" in text or "log" in t:
return {
"content": (
"로그 분석 결과 (모의 데이터):\n"
"```\n"
"[ERROR] 2026-05-24 17:32:11 - ORA-01555: snapshot too old\n"
"[WARN] 2026-05-24 17:33:05 - Connection pool exhausted (200/200)\n"
"[ERROR] 2026-05-24 17:34:22 - java.lang.OutOfMemoryError: GC overhead\n"
"```\n"
"OutOfMemoryError 감지 — WAS 재기동을 권장합니다."
),
"is_widget": True,
"interactive_action": {
"type": "BUTTON",
"label": "WAS 재기동 요청",
"command_code": "REQUEST_RESTART",
},
}
if "서버 상태" in text or "상태" in text or "status" in t:
return {
"content": (
"**인프라 상태 요약** (모의 데이터)\n"
"• WEB-01 ✅ 정상 CPU 12% MEM 45%\n"
"• WAS-01 ✅ 정상 CPU 34% MEM 62%\n"
"• WAS-02 ⚠️ 경고 CPU 87% MEM 78%\n"
"• DB-01 ✅ 정상 연결 42/200\n"
"• DB-02 ✅ 정상 연결 11/200"
),
"is_widget": False,
}
if "ssl" in t or "인증서" in text:
return {
"content": (
"**SSL 인증서 만료일 조회** (모의 데이터)\n"
"• portal.mof.go.kr — 잔여 **D-14** ⚠️\n"
"• api.molit.go.kr — 잔여 D-87 ✅\n"
"• www.nts.go.kr — 잔여 D-203 ✅"
),
"is_widget": True,
"interactive_action": {
"type": "BUTTON",
"label": "갱신 SR 생성",
"command_code": "RENEW_SSL:portal.mof.go.kr",
},
}
if "정지" in text or "stop" in t or "중단" in text:
return {
"content": "⛔ Kill-Switch 실행 — 진행 중인 모든 작업을 중단합니다.",
"is_widget": True,
"interactive_action": {
"type": "APPROVAL_BUTTONS",
"approve_url": "/api/killswitch/confirm",
"reject_url": "/api/killswitch/cancel",
},
}
return {
"content": f"명령을 처리하고 있습니다: \"{text}\"\n자세한 명령은 `@bot 도움`을 입력하세요.",
"is_widget": False,
}
def check_proactive_context(text: str) -> Optional[dict]:
"""명시적 봇 멘션 없이도 대화 맥락에서 선제적 제안 생성."""
for rule in CONTEXT_RULES.values():
if any(kw in text for kw in rule["keywords"]):
return {
"content": f"{rule['message']}",
"is_widget": True,
"interactive_action": {
"type": "BUTTON",
"label": "즉시 확인",
"command_code": rule["action"],
},
}
return None
def store_message(room_id: str, msg: dict) -> None:
message_store.setdefault(room_id, []).append(msg)
if len(message_store[room_id]) > 200:
message_store[room_id] = message_store[room_id][-200:]

18
messenger/main.py Normal file
View File

@ -0,0 +1,18 @@
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from routers import messages, webhook, ws_relay
app = FastAPI(title="GUARDiA Messenger", version="1.0.0")
app.include_router(webhook.router)
app.include_router(messages.router)
app.include_router(ws_relay.router)
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
async def index():
return FileResponse("static/index.html")

View File

View File

@ -0,0 +1,41 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from uuid import uuid4
def new_msg_id() -> str:
return f"MSG-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:8].upper()}"
class InteractiveAction(BaseModel):
type: str # BUTTON | APPROVAL_BUTTONS
label: Optional[str] = None
command_code: Optional[str] = None
approve_url: Optional[str] = None
reject_url: Optional[str] = None
class Message(BaseModel):
message_id: str = Field(default_factory=new_msg_id)
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
room_id: str
sender: str
sender_type: str = "HUMAN" # HUMAN | BOT
msg_type: str = "CHAT"
content: str
is_widget: bool = False
interactive_action: Optional[InteractiveAction] = None
class WebhookPayload(BaseModel):
user_id: str
text: str
room_id: str
files: List[dict] = []
bot: bool = False
class IncomingChat(BaseModel):
content: str
room_id: str

View File

@ -0,0 +1,7 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
websockets>=14.0
python-multipart>=0.0.12
aiofiles>=24.1.0
pydantic>=2.10.0
python-dotenv>=1.0.1

View File

View File

@ -0,0 +1,15 @@
from fastapi import APIRouter
from core.bot import ROOMS, message_store
router = APIRouter(prefix="/api")
@router.get("/rooms")
async def list_rooms():
return {"rooms": ROOMS}
@router.get("/messages/{room_id}")
async def get_messages(room_id: str, limit: int = 50):
msgs = message_store.get(room_id, [])
return {"room_id": room_id, "messages": msgs[-limit:]}

View File

@ -0,0 +1,60 @@
import hashlib
import hmac
import json
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
router = APIRouter(prefix="/api")
WEBHOOK_SECRET = "guardia-webhook-secret-2026"
def _verify(signature: str, body: bytes) -> bool:
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
def _new_sr() -> str:
return f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
@router.post("/messenger/webhook")
async def receive_webhook(request: Request):
body = await request.body()
sig = request.headers.get("X-Messenger-Signature", "")
# 개발 환경에서 서명 검증 생략 (프로덕션에서 활성화)
# if sig and not _verify(sig, body):
# raise HTTPException(status_code=403, detail="Invalid signature")
payload = json.loads(body)
if payload.get("bot") or not payload.get("text", "").strip():
return {"status": "IGNORED"}
sr_id = _new_sr()
return {"status": "ACCEPTED", "sr_id": sr_id}
@router.post("/sr/approve/{sr_id}")
async def approve_sr(sr_id: str):
return {"status": "APPROVED", "sr_id": sr_id, "message": f"SR {sr_id} 승인 완료 — 실행을 시작합니다."}
@router.post("/sr/reject/{sr_id}")
async def reject_sr(sr_id: str):
return {"status": "REJECTED", "sr_id": sr_id, "message": f"SR {sr_id} 반려 처리되었습니다."}
@router.post("/killswitch/confirm")
async def killswitch_confirm():
return {"status": "STOPPED", "message": "모든 진행 중인 작업이 중단되었습니다."}
@router.post("/killswitch/cancel")
async def killswitch_cancel():
return {"status": "CANCELLED", "message": "Kill-Switch 취소되었습니다."}

View File

@ -0,0 +1,107 @@
import asyncio
import json
from datetime import datetime
from typing import Dict, List
from uuid import uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from core.bot import (
ROOMS,
check_proactive_context,
get_bot_response,
message_store,
store_message,
)
router = APIRouter()
# room_id → list of connected WebSocket clients
room_channels: Dict[str, List[WebSocket]] = {}
def _new_msg_id() -> str:
return f"MSG-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:8].upper()}"
async def broadcast(room_id: str, payload: dict) -> None:
dead: List[WebSocket] = []
for ws in room_channels.get(room_id, []):
try:
await ws.send_text(json.dumps(payload, ensure_ascii=False))
except Exception:
dead.append(ws)
for ws in dead:
room_channels[room_id].remove(ws)
def _make_bot_msg(room_id: str, reply: dict) -> dict:
return {
"message_id": _new_msg_id(),
"timestamp": datetime.now().isoformat(),
"room_id": room_id,
"sender": "GUARDiA-Bot",
"sender_type": "BOT",
"msg_type": "CHAT",
"content": reply.get("content", ""),
"is_widget": reply.get("is_widget", False),
"interactive_action": reply.get("interactive_action"),
}
@router.websocket("/ws/chat/{room_id}/{client_id}")
async def chat_endpoint(ws: WebSocket, room_id: str, client_id: str):
await ws.accept()
room_channels.setdefault(room_id, []).append(ws)
# 접속 시 최근 메시지 + 방 목록 전달
history = message_store.get(room_id, [])[-50:]
await ws.send_text(json.dumps(
{"type": "init", "messages": history, "rooms": ROOMS},
ensure_ascii=False,
))
try:
while True:
raw = await ws.receive_text()
data = json.loads(raw)
text = data.get("content", "").strip()
if not text:
continue
# 사용자 메시지 브로드캐스트
user_msg = {
"message_id": _new_msg_id(),
"timestamp": datetime.now().isoformat(),
"room_id": room_id,
"sender": client_id,
"sender_type": "HUMAN",
"msg_type": "CHAT",
"content": text,
"is_widget": False,
"interactive_action": None,
}
store_message(room_id, user_msg)
await broadcast(room_id, user_msg)
# 봇 명령 응답
bot_reply = get_bot_response(text)
if bot_reply:
await asyncio.sleep(0.4)
bot_msg = _make_bot_msg(room_id, bot_reply)
store_message(room_id, bot_msg)
await broadcast(room_id, bot_msg)
continue
# 선제적 맥락 분석
proactive = check_proactive_context(text)
if proactive:
await asyncio.sleep(1.2)
pro_msg = _make_bot_msg(room_id, proactive)
store_message(room_id, pro_msg)
await broadcast(room_id, pro_msg)
except WebSocketDisconnect:
lst = room_channels.get(room_id, [])
if ws in lst:
lst.remove(ws)

307
messenger/static/app.js Normal file
View File

@ -0,0 +1,307 @@
/* ─── State ─────────────────────────────────────────── */
let ws = null;
let currentRoom = "general";
let currentUser = "";
let rooms = {};
let reconnectTimer = null;
let reconnectDelay = 1000;
/* ─── DOM refs ──────────────────────────────────────── */
const messagesEl = document.getElementById("messages");
const msgInput = document.getElementById("msg-input");
const channelNameEl = document.getElementById("ch-name");
const channelDescEl = document.getElementById("ch-desc");
const channelListEl = document.getElementById("channel-list");
const userDisplayEl = document.getElementById("user-display");
const connStatus = document.getElementById("conn-status");
const userModal = document.getElementById("user-modal-overlay");
const userNameInput = document.getElementById("username-input");
const typingEl = document.getElementById("typing-indicator");
/* ─── Init ──────────────────────────────────────────── */
window.addEventListener("DOMContentLoaded", () => {
const saved = sessionStorage.getItem("guardia_user");
if (saved) { currentUser = saved; startApp(); }
else { userModal.classList.add("show"); userNameInput.focus(); }
});
document.getElementById("username-form").addEventListener("submit", e => {
e.preventDefault();
const name = userNameInput.value.trim();
if (!name) return;
currentUser = name;
sessionStorage.setItem("guardia_user", name);
userModal.classList.remove("show");
startApp();
});
function startApp() {
userDisplayEl.textContent = currentUser;
connect();
}
/* ─── WebSocket ─────────────────────────────────────── */
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const url = `${proto}://${location.host}/ws/chat/${currentRoom}/${encodeURIComponent(currentUser)}`;
ws = new WebSocket(url);
showStatus("reconnecting", "연결 중…");
ws.onopen = () => {
showStatus("online", "연결됨");
reconnectDelay = 1000;
setTimeout(() => connStatus.classList.remove("show"), 2000);
};
ws.onmessage = e => {
const data = JSON.parse(e.data);
if (data.type === "init") {
rooms = data.rooms || {};
renderChannelList();
updateChannelHeader();
messagesEl.innerHTML = "";
if (data.messages?.length) {
data.messages.forEach(renderMessage);
scrollBottom();
} else {
renderWelcome();
}
} else {
renderMessage(data);
scrollBottom();
}
};
ws.onclose = () => {
showStatus("offline", "연결 끊김 — 재연결 중…");
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, 15000);
connect();
}, reconnectDelay);
};
ws.onerror = () => ws.close();
}
function sendMessage() {
const text = msgInput.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ content: text }));
msgInput.value = "";
msgInput.style.height = "auto";
}
/* ─── Channel switching ─────────────────────────────── */
function switchRoom(roomId) {
if (roomId === currentRoom) return;
currentRoom = roomId;
if (ws) { ws.onclose = null; ws.close(); }
clearTimeout(reconnectTimer);
connect();
document.querySelectorAll(".channel-item").forEach(el => {
el.classList.toggle("active", el.dataset.room === roomId);
});
updateChannelHeader();
}
function updateChannelHeader() {
const name = rooms[currentRoom] || currentRoom;
channelNameEl.textContent = "# " + name;
channelDescEl.textContent = roomDesc(currentRoom);
}
function roomDesc(id) {
const descs = {
general: "팀 전체 공지 및 일반 대화",
deploy: "배포 작업 요청 및 진행 현황",
ops: "서버 운영 및 장애 대응",
pm: "PM 승인 워크플로우 및 진척 관리",
alerts: "자동 알림 및 임계치 경보",
};
return descs[id] || "";
}
/* ─── Render ────────────────────────────────────────── */
function renderChannelList() {
channelListEl.innerHTML = "";
Object.entries(rooms).forEach(([id, label]) => {
const el = document.createElement("div");
el.className = "channel-item" + (id === currentRoom ? " active" : "");
el.dataset.room = id;
el.innerHTML = `<span class="ch-icon">#</span> ${label}`;
el.addEventListener("click", () => switchRoom(id));
channelListEl.appendChild(el);
});
}
function renderWelcome() {
const div = document.createElement("div");
div.className = "date-divider";
div.textContent = `# ${rooms[currentRoom] || currentRoom} 채널에 오신 것을 환영합니다`;
messagesEl.appendChild(div);
const hint = document.createElement("div");
hint.style.cssText = "padding:8px 0;font-size:13px;color:var(--text-muted)";
hint.innerHTML = "💡 <code>@bot 도움</code> 을 입력하면 GUARDiA Bot 명령어를 확인할 수 있습니다.";
messagesEl.appendChild(hint);
}
let lastDate = "";
let lastSender = "";
function renderMessage(msg) {
const msgDate = new Date(msg.timestamp).toLocaleDateString("ko-KR", {
month: "long", day: "numeric", weekday: "long"
});
if (msgDate !== lastDate) {
const divider = document.createElement("div");
divider.className = "date-divider";
divider.textContent = msgDate;
messagesEl.appendChild(divider);
lastDate = msgDate;
lastSender = "";
}
const isBot = msg.sender_type === "BOT";
const isSame = msg.sender === lastSender && !isBot;
lastSender = msg.sender;
const group = document.createElement("div");
group.className = "msg-group";
group.dataset.msgId = msg.message_id;
const initials = msg.sender.slice(0, 2).toUpperCase();
const time = new Date(msg.timestamp).toLocaleTimeString("ko-KR", {
hour: "2-digit", minute: "2-digit"
});
const avatarHTML = isSame
? `<div style="width:36px;flex-shrink:0"></div>`
: `<div class="avatar ${isBot ? "bot" : "human"}">${initials}</div>`;
const metaHTML = isSame
? ""
: `<div class="msg-meta">
<span class="msg-sender ${isBot ? "bot" : ""}">${escHtml(msg.sender)}</span>
<span class="msg-time">${time}</span>
</div>`;
const contentHTML = formatContent(msg.content);
let widgetHTML = "";
if (msg.is_widget && msg.interactive_action) {
const act = msg.interactive_action;
if (act.type === "BUTTON") {
widgetHTML = `
<div class="widget-actions">
<button class="btn-action btn-primary"
onclick="handleAction(this,'${escAttr(act.command_code)}','','')">
${escHtml(act.label || "실행")}
</button>
</div>`;
} else if (act.type === "APPROVAL_BUTTONS") {
widgetHTML = `
<div class="widget-actions">
<button class="btn-action btn-approve"
onclick="handleAction(this,'APPROVE','${escAttr(act.approve_url)}','승인')">
승인
</button>
<button class="btn-action btn-reject"
onclick="handleAction(this,'REJECT','${escAttr(act.reject_url)}','반려')">
반려
</button>
</div>`;
}
}
const bubbleClass = isBot ? "bot-bubble" : "";
group.innerHTML = `
${avatarHTML}
<div class="msg-body">
${metaHTML}
<div class="${bubbleClass}">
<div class="msg-content">${contentHTML}</div>
${widgetHTML}
</div>
</div>`;
messagesEl.appendChild(group);
}
/* ─── Interactive actions ───────────────────────────── */
async function handleAction(btn, code, url, label) {
btn.classList.add("clicked");
const siblings = btn.closest(".widget-actions")?.querySelectorAll(".btn-action");
siblings?.forEach(b => b.classList.add("clicked"));
let resultText = "";
if (url) {
try {
const res = await fetch(url, { method: "POST" });
const data = await res.json();
resultText = data.message || `${label} 처리 완료`;
} catch {
resultText = "처리 중 오류가 발생했습니다.";
}
} else {
resultText = `명령 전송됨: ${code}`;
}
const result = document.createElement("div");
result.className = "btn-result";
result.textContent = "→ " + resultText;
btn.closest(".widget-actions").insertAdjacentElement("afterend", result);
}
/* ─── Formatting helpers ────────────────────────────── */
function formatContent(text) {
// Code blocks
text = text.replace(/```([\s\S]*?)```/g, (_, code) =>
`<pre>${escHtml(code.trim())}</pre>`
);
// Inline code
text = text.replace(/`([^`]+)`/g, (_, code) =>
`<code>${escHtml(code)}</code>`
);
// Bold
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
// Remaining escaping for non-tagged parts (basic)
return text;
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function escAttr(s) {
return String(s || "").replace(/'/g, "\\'");
}
/* ─── UI helpers ────────────────────────────────────── */
function scrollBottom() {
requestAnimationFrame(() => {
messagesEl.scrollTop = messagesEl.scrollHeight;
});
}
function showStatus(cls, text) {
connStatus.className = `show ${cls}`;
connStatus.textContent = text;
}
/* ─── Input events ──────────────────────────────────── */
document.getElementById("send-btn").addEventListener("click", sendMessage);
msgInput.addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
msgInput.addEventListener("input", () => {
msgInput.style.height = "auto";
msgInput.style.height = Math.min(msgInput.scrollHeight, 120) + "px";
});

View File

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GUARDiA Messenger</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- 사용자 이름 설정 모달 -->
<div id="user-modal-overlay">
<div id="user-modal">
<h2>🛡️ GUARDiA Messenger</h2>
<p>사용자 이름을 입력하고 입장하세요</p>
<form id="username-form">
<input id="username-input" type="text" placeholder="이름 입력 (예: ENGINEER_01)"
maxlength="30" autocomplete="off" required>
<button type="submit">입장</button>
</form>
</div>
</div>
<div id="app">
<!-- ── Sidebar ────────────────────────────────────── -->
<nav id="sidebar">
<div id="workspace-header">
<div id="workspace-name">
<span class="status-dot"></span>
GUARDiA
</div>
<div id="user-display">로딩 중…</div>
</div>
<input id="sidebar-search" type="text" placeholder="🔍 검색">
<div class="sidebar-section">
<div class="sidebar-section-header">▾ 채널</div>
<div id="channel-list"><!-- JS 렌더링 --></div>
</div>
<div class="sidebar-section" style="margin-top:auto; padding:12px 16px; border-top:1px solid var(--border)">
<div style="font-size:11px; color:var(--text-muted); line-height:1.6">
<div>📡 WebSocket 실시간 연동</div>
<div>🤖 GUARDiA-Bot 내장</div>
<div>🔒 ITSM ChatOps v1.0</div>
</div>
</div>
</nav>
<!-- ── Main ──────────────────────────────────────── -->
<main id="main">
<header id="channel-header">
<span id="ch-name"># 일반</span>
<span id="ch-desc" class="ch-desc"></span>
<span id="member-count">🟢 접속 중</span>
</header>
<div id="messages"></div>
<div id="typing-indicator"></div>
<div id="input-area">
<div id="input-box">
<button class="input-btn" title="파일 첨부">📎</button>
<textarea
id="msg-input"
rows="1"
placeholder="메시지 입력 — @bot 으로 GUARDiA Bot 명령 / Shift+Enter 줄바꿈"
></textarea>
<button class="input-btn" id="send-btn" title="전송"></button>
</div>
<div id="input-hint">
<strong>@bot 도움</strong> — 명령어 목록 &nbsp;|&nbsp;
<strong>@bot 배포</strong> — 배포 SR 생성 &nbsp;|&nbsp;
<strong>@bot 서버 상태</strong> — 인프라 현황
</div>
</div>
</main>
</div>
<div id="conn-status"></div>
<script src="/static/app.js"></script>
</body>
</html>

265
messenger/static/style.css Normal file
View File

@ -0,0 +1,265 @@
/* ─── Reset & Base ─────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--sidebar-bg: #1a1d21;
--sidebar-hover:#222529;
--sidebar-active:#1164a3;
--main-bg: #222529;
--msg-area-bg: #222529;
--input-bg: #2c2d30;
--border: #363636;
--text-primary: #d1d2d3;
--text-muted: #8b8c8e;
--text-bright: #ffffff;
--bot-bubble: #1a3a5c;
--bot-border: #1164a3;
--accent: #1d9bd1;
--green: #2bac76;
--yellow: #e8a317;
--red: #e01e5a;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--radius: 6px;
}
html, body { height: 100%; font-family: var(--font); background: var(--main-bg); color: var(--text-primary); }
/* ─── Layout ────────────────────────────────────────── */
#app { display: flex; height: 100vh; overflow: hidden; }
/* ─── Sidebar ───────────────────────────────────────── */
#sidebar {
width: 260px; min-width: 200px;
background: var(--sidebar-bg);
display: flex; flex-direction: column;
border-right: 1px solid var(--border);
flex-shrink: 0;
}
#workspace-header {
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
}
#workspace-name {
font-size: 16px; font-weight: 700;
color: var(--text-bright); cursor: pointer;
display: flex; align-items: center; gap: 6px;
}
#workspace-name .status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--green); display: inline-block;
}
#user-display {
font-size: 12px; color: var(--text-muted); margin-top: 4px;
}
#sidebar-search {
margin: 8px 10px;
background: var(--sidebar-hover); border: 1px solid var(--border);
border-radius: var(--radius); padding: 6px 10px;
color: var(--text-primary); font-size: 13px; outline: none;
width: calc(100% - 20px);
}
#sidebar-search::placeholder { color: var(--text-muted); }
.sidebar-section { margin: 6px 0; }
.sidebar-section-header {
padding: 4px 10px; font-size: 12px; font-weight: 600;
color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em;
cursor: pointer; display: flex; align-items: center; gap: 4px;
user-select: none;
}
.sidebar-section-header:hover { color: var(--text-primary); }
.channel-item {
padding: 5px 10px 5px 20px; font-size: 14px;
color: var(--text-muted); cursor: pointer; border-radius: var(--radius);
margin: 1px 6px; display: flex; align-items: center; gap: 6px;
transition: background .12s;
}
.channel-item:hover { background: var(--sidebar-hover); color: var(--text-primary); }
.channel-item.active { background: var(--sidebar-active); color: var(--text-bright); font-weight: 500; }
.channel-item .ch-icon { color: var(--text-muted); font-size: 13px; }
.channel-item.active .ch-icon { color: var(--text-bright); }
.channel-item .unread-badge {
margin-left: auto; background: var(--red);
color: #fff; font-size: 10px; font-weight: 700;
min-width: 16px; height: 16px; border-radius: 8px;
display: flex; align-items: center; justify-content: center; padding: 0 4px;
}
/* ─── User modal ────────────────────────────────────── */
#user-modal-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.55); z-index: 100;
align-items: center; justify-content: center;
}
#user-modal-overlay.show { display: flex; }
#user-modal {
background: var(--sidebar-bg); border: 1px solid var(--border);
border-radius: 10px; padding: 28px 32px; width: 340px; text-align: center;
}
#user-modal h2 { font-size: 18px; color: var(--text-bright); margin-bottom: 6px; }
#user-modal p { font-size: 13px; color: var(--text-muted); margin-bottom: 18px; }
#user-modal input {
width: 100%; padding: 10px 12px; background: var(--input-bg);
border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text-bright); font-size: 14px; outline: none; margin-bottom: 14px;
}
#user-modal input:focus { border-color: var(--accent); }
#user-modal button {
width: 100%; padding: 10px; background: var(--accent);
color: #fff; border: none; border-radius: var(--radius);
font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .15s;
}
#user-modal button:hover { opacity: .85; }
/* ─── Main area ─────────────────────────────────────── */
#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
#channel-header {
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--main-bg);
display: flex; align-items: center; gap: 10px;
}
#channel-header .ch-name {
font-size: 16px; font-weight: 700; color: var(--text-bright);
}
#channel-header .ch-desc {
font-size: 13px; color: var(--text-muted); margin-left: 8px;
}
#member-count { margin-left: auto; font-size: 12px; color: var(--text-muted); }
/* ─── Messages ──────────────────────────────────────── */
#messages {
flex: 1; overflow-y: auto; padding: 16px 20px 8px;
display: flex; flex-direction: column; gap: 2px;
}
#messages::-webkit-scrollbar { width: 5px; }
#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.msg-group { display: flex; gap: 10px; padding: 4px 0; }
.msg-group:hover { background: rgba(255,255,255,.03); border-radius: var(--radius); }
.avatar {
width: 36px; height: 36px; border-radius: var(--radius);
font-size: 14px; font-weight: 700; display: flex; align-items: center;
justify-content: center; flex-shrink: 0; margin-top: 2px;
}
.avatar.human { background: #4a9a6e; color: #fff; }
.avatar.bot { background: var(--accent); color: #fff; }
.msg-body { flex: 1; min-width: 0; }
.msg-meta { display: flex; align-items: baseline; gap: 8px; margin-bottom: 2px; }
.msg-sender { font-size: 14px; font-weight: 700; color: var(--text-bright); }
.msg-sender.bot { color: var(--accent); }
.msg-time { font-size: 11px; color: var(--text-muted); }
.msg-content {
font-size: 14px; line-height: 1.55; color: var(--text-primary);
white-space: pre-wrap; word-break: break-word;
}
.msg-content code {
background: rgba(0,0,0,.35); padding: 1px 5px;
border-radius: 3px; font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 12px;
}
.msg-content pre {
background: rgba(0,0,0,.45); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 12px; margin-top: 6px;
overflow-x: auto; font-size: 12px; line-height: 1.5;
font-family: "JetBrains Mono", "Consolas", monospace;
}
/* Bot bubble */
.bot-bubble {
background: var(--bot-bubble); border-left: 3px solid var(--bot-border);
border-radius: 0 var(--radius) var(--radius) 0;
padding: 10px 12px; margin-top: 4px;
}
.bot-bubble .msg-content { color: #c9dff0; }
/* Interactive widgets */
.widget-actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
.btn-action {
padding: 7px 14px; border-radius: var(--radius);
font-size: 13px; font-weight: 500; cursor: pointer;
border: 1px solid transparent; transition: opacity .15s;
}
.btn-action:hover { opacity: .8; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-approve { background: var(--green); color: #fff; }
.btn-reject { background: var(--red); color: #fff; }
.btn-secondary { background: transparent; border-color: var(--border); color: var(--text-primary); }
.btn-action.clicked { opacity: .5; pointer-events: none; }
.btn-result {
font-size: 12px; color: var(--text-muted);
margin-top: 6px; padding: 4px 0;
}
/* Date divider */
.date-divider {
display: flex; align-items: center; gap: 10px;
margin: 14px 0; font-size: 12px; color: var(--text-muted);
}
.date-divider::before, .date-divider::after {
content: ""; flex: 1; height: 1px; background: var(--border);
}
/* Typing indicator */
#typing-indicator {
height: 20px; padding: 0 20px; font-size: 12px;
color: var(--text-muted); font-style: italic;
}
/* ─── Input area ────────────────────────────────────── */
#input-area {
padding: 8px 20px 16px;
border-top: 1px solid var(--border);
background: var(--main-bg);
}
#input-box {
background: var(--input-bg); border: 1px solid var(--border);
border-radius: var(--radius); display: flex; align-items: flex-end; gap: 6px;
padding: 8px 10px; transition: border-color .15s;
}
#input-box:focus-within { border-color: var(--accent); }
#msg-input {
flex: 1; background: transparent; border: none; outline: none;
color: var(--text-bright); font-size: 14px; resize: none;
max-height: 120px; line-height: 1.5; font-family: var(--font);
}
#msg-input::placeholder { color: var(--text-muted); }
.input-btn {
background: none; border: none; color: var(--text-muted);
cursor: pointer; padding: 4px; border-radius: var(--radius);
font-size: 18px; line-height: 1; transition: color .12s;
flex-shrink: 0;
}
.input-btn:hover { color: var(--text-primary); }
#send-btn { color: var(--accent); font-size: 20px; }
#send-btn:hover { color: var(--text-bright); }
#input-hint {
font-size: 11px; color: var(--text-muted);
margin-top: 4px; padding: 0 2px;
}
/* ─── Connection status ─────────────────────────────── */
#conn-status {
position: fixed; bottom: 16px; right: 16px;
padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 500;
display: none;
}
#conn-status.show { display: block; }
#conn-status.online { background: var(--green); color: #fff; }
#conn-status.offline{ background: var(--red); color: #fff; }
#conn-status.reconnecting { background: var(--yellow); color: #000; }
/* ─── Scrollbar ─────────────────────────────────────── */
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }