diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..a646e715 --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/messenger/core/__init__.py b/messenger/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/messenger/core/bot.py b/messenger/core/bot.py new file mode 100644 index 00000000..0791aa2c --- /dev/null +++ b/messenger/core/bot.py @@ -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:] diff --git a/messenger/main.py b/messenger/main.py new file mode 100644 index 00000000..c6c83aad --- /dev/null +++ b/messenger/main.py @@ -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") diff --git a/messenger/models/__init__.py b/messenger/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/messenger/models/message.py b/messenger/models/message.py new file mode 100644 index 00000000..2cf8572b --- /dev/null +++ b/messenger/models/message.py @@ -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 diff --git a/messenger/requirements.txt b/messenger/requirements.txt new file mode 100644 index 00000000..0fe6701d --- /dev/null +++ b/messenger/requirements.txt @@ -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 diff --git a/messenger/routers/__init__.py b/messenger/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/messenger/routers/messages.py b/messenger/routers/messages.py new file mode 100644 index 00000000..116f6a8d --- /dev/null +++ b/messenger/routers/messages.py @@ -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:]} diff --git a/messenger/routers/webhook.py b/messenger/routers/webhook.py new file mode 100644 index 00000000..34cd94e5 --- /dev/null +++ b/messenger/routers/webhook.py @@ -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 취소되었습니다."} diff --git a/messenger/routers/ws_relay.py b/messenger/routers/ws_relay.py new file mode 100644 index 00000000..a84906aa --- /dev/null +++ b/messenger/routers/ws_relay.py @@ -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) diff --git a/messenger/static/app.js b/messenger/static/app.js new file mode 100644 index 00000000..cd537426 --- /dev/null +++ b/messenger/static/app.js @@ -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 = `# ${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 = "💡 @bot 도움 을 입력하면 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 + ? `
` + : `
${initials}
`; + + const metaHTML = isSame + ? "" + : `
+ ${escHtml(msg.sender)} + ${time} +
`; + + const contentHTML = formatContent(msg.content); + + let widgetHTML = ""; + if (msg.is_widget && msg.interactive_action) { + const act = msg.interactive_action; + if (act.type === "BUTTON") { + widgetHTML = ` +
+ +
`; + } else if (act.type === "APPROVAL_BUTTONS") { + widgetHTML = ` +
+ + +
`; + } + } + + const bubbleClass = isBot ? "bot-bubble" : ""; + + group.innerHTML = ` + ${avatarHTML} +
+ ${metaHTML} +
+
${contentHTML}
+ ${widgetHTML} +
+
`; + + 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) => + `
${escHtml(code.trim())}
` + ); + // Inline code + text = text.replace(/`([^`]+)`/g, (_, code) => + `${escHtml(code)}` + ); + // Bold + text = text.replace(/\*\*(.+?)\*\*/g, "$1"); + // Remaining escaping for non-tagged parts (basic) + return text; +} + +function escHtml(s) { + return String(s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +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"; +}); diff --git a/messenger/static/index.html b/messenger/static/index.html new file mode 100644 index 00000000..d4d8a2d7 --- /dev/null +++ b/messenger/static/index.html @@ -0,0 +1,88 @@ + + + + + + GUARDiA Messenger + + + + + +
+
+

🛡️ GUARDiA Messenger

+

사용자 이름을 입력하고 입장하세요

+
+ + +
+
+
+ +
+ + + + + +
+ +
+ # 일반 + + 🟢 접속 중 +
+ +
+ +
+ +
+
+ + + +
+
+ @bot 도움 — 명령어 목록  |  + @bot 배포 — 배포 SR 생성  |  + @bot 서버 상태 — 인프라 현황 +
+
+
+
+ +
+ + + + diff --git a/messenger/static/style.css b/messenger/static/style.css new file mode 100644 index 00000000..e3b02e61 --- /dev/null +++ b/messenger/static/style.css @@ -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; }