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
+ ? `
${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 @@
+
+
+
+
+
+ 사용자 이름을 입력하고 입장하세요
+ +