/* ─── 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"; });