zioinfo-mail/messenger/static/app.js
DESKTOP-TKLFCPR\ython 85e4901541 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>
2026-05-24 19:04:03 +09:00

308 lines
10 KiB
JavaScript

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