@vibexnpm/talkx 8.9.0 → 8.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"talkflow-sdk.umd.js","sources":["../src/utils/EventEmitter.js","../src/constants.js","../src/utils/Logger.js","../src/utils/ApiClient.js","../src/auth/AuthSession.js","../src/utils/jwtUtils.js","../src/core/ConnectionManager.js","../src/chat/ChatClient.js","../src/webrtc/MediaStreamManager.js","../src/webrtc/PeerConnectionManager.js","../src/webrtc/WebRTCClient.js","../src/push/PushManager.js","../src/talkflow/mailNotifications.js","../src/talkflow/eventForwarding.js","../src/talkflow/delegates.js","../src/TalkFlowClient.js","../src/talkflow/session.js","../src/chat/RoomUnreadReconciler.js"],"sourcesContent":["/**\r\n * EventEmitter 유틸리티\r\n * 이벤트 기반 통신을 위한 간단한 구현\r\n */\r\n\r\nclass EventEmitter {\r\n constructor() {\r\n this._events = new Map();\r\n }\r\n\r\n /**\r\n * 이벤트 리스너 등록\r\n * @param {string} event - 이벤트 이름\r\n * @param {Function} listener - 콜백 함수\r\n * @returns {Function} - 구독 해제 함수\r\n */\r\n on(event, listener) {\r\n if (!this._events.has(event)) {\r\n this._events.set(event, new Set());\r\n }\r\n this._events.get(event).add(listener);\r\n\r\n // 구독 해제 함수 반환\r\n return () => this.off(event, listener);\r\n }\r\n\r\n /**\r\n * 일회성 이벤트 리스너 등록\r\n * @param {string} event - 이벤트 이름\r\n * @param {Function} listener - 콜백 함수\r\n * @returns {Function} - 구독 해제 함수\r\n */\r\n once(event, listener) {\r\n const onceWrapper = (...args) => {\r\n this.off(event, onceWrapper);\r\n listener.apply(this, args);\r\n };\r\n onceWrapper._originalListener = listener;\r\n return this.on(event, onceWrapper);\r\n }\r\n\r\n /**\r\n * 이벤트 리스너 제거\r\n * @param {string} event - 이벤트 이름\r\n * @param {Function} listener - 콜백 함수\r\n */\r\n off(event, listener) {\r\n const listeners = this._events.get(event);\r\n if (listeners) {\r\n // once로 등록된 리스너도 제거 가능하도록\r\n for (const l of listeners) {\r\n if (l === listener || l._originalListener === listener) {\r\n listeners.delete(l);\r\n break;\r\n }\r\n }\r\n if (listeners.size === 0) {\r\n this._events.delete(event);\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * 이벤트 발생.\r\n * payload 가 없는 이벤트({@code loggedOut}, {@code pushEnabled} 등) 는 {@code data} 생략 가능.\r\n * @param {string} event - 이벤트 이름\r\n * @param {*} [data] - 이벤트 데이터 (선택)\r\n */\r\n emit(event, data) {\r\n const listeners = this._events.get(event);\r\n if (listeners) {\r\n listeners.forEach(listener => {\r\n try {\r\n listener(data);\r\n } catch (error) {\r\n console.error(`Error in event listener for '${event}':`, error);\r\n }\r\n });\r\n }\r\n }\r\n\r\n /**\r\n * 특정 이벤트의 모든 리스너 제거 (이벤트명 생략 시 전체 제거)\r\n * @param {string} [event] - 이벤트 이름 (선택)\r\n */\r\n removeAllListeners(event) {\r\n if (event) {\r\n this._events.delete(event);\r\n } else {\r\n this._events.clear();\r\n }\r\n }\r\n\r\n /**\r\n * 이벤트 리스너 수 반환\r\n * @param {string} event - 이벤트 이름\r\n * @returns {number}\r\n */\r\n listenerCount(event) {\r\n const listeners = this._events.get(event);\r\n return listeners ? listeners.size : 0;\r\n }\r\n\r\n /**\r\n * 등록된 이벤트 이름 목록\r\n * @returns {string[]}\r\n */\r\n eventNames() {\r\n return Array.from(this._events.keys());\r\n }\r\n}\r\n\r\nexport default EventEmitter;\r\n","/**\r\n * TalkFlow SDK Constants\r\n * 클라이언트 상태, 에러 타입, 시그널 타입 등 상수 정의\r\n */\r\n\r\n/**\r\n * 클라이언트 연결 상태\r\n */\r\nexport const ConnectionState = {\r\n DISCONNECTED: 'disconnected',\r\n CONNECTING: 'connecting',\r\n CONNECTED: 'connected',\r\n RECONNECTING: 'reconnecting',\r\n ERROR: 'error'\r\n};\r\n\r\n/**\r\n * 에러 타입\r\n */\r\nexport const ErrorTypes = {\r\n // Connection errors\r\n CONNECTION_FAILED: 'CONNECTION_FAILED',\r\n CONNECTION_LOST: 'CONNECTION_LOST',\r\n CONNECTION_TIMEOUT: 'CONNECTION_TIMEOUT',\r\n // 회사 구독 없음 — 서버가 NOT_SUBSCRIBED 로 거부했다(결제 전·해지). 재연결하지 않고 connect() 를 기다린다.\r\n SUBSCRIPTION_REQUIRED: 'SUBSCRIPTION_REQUIRED',\r\n\r\n // Authentication errors\r\n JWT_INVALID: 'JWT_INVALID',\r\n JWT_EXPIRED: 'JWT_EXPIRED',\r\n JWT_PARSE_FAILED: 'JWT_PARSE_FAILED',\r\n UNAUTHORIZED: 'UNAUTHORIZED',\r\n\r\n // API errors\r\n API_ERROR: 'API_ERROR',\r\n API_TIMEOUT: 'API_TIMEOUT',\r\n\r\n // Chat errors\r\n CHAT_ROOM_NOT_FOUND: 'CHAT_ROOM_NOT_FOUND',\r\n CHAT_MESSAGE_FAILED: 'CHAT_MESSAGE_FAILED',\r\n CHAT_SUBSCRIPTION_FAILED: 'CHAT_SUBSCRIPTION_FAILED',\r\n\r\n // WebRTC errors\r\n MEDIA_ACCESS_DENIED: 'MEDIA_ACCESS_DENIED',\r\n SCREEN_SHARE_DENIED: 'SCREEN_SHARE_DENIED',\r\n PEER_CONNECTION_FAILED: 'PEER_CONNECTION_FAILED',\r\n ICE_CONNECTION_FAILED: 'ICE_CONNECTION_FAILED',\r\n SIGNALING_FAILED: 'SIGNALING_FAILED',\r\n DEVICE_SWITCH_FAILED: 'DEVICE_SWITCH_FAILED',\r\n ENUMERATE_DEVICES_FAILED: 'ENUMERATE_DEVICES_FAILED',\r\n CALL_ROOM_NOT_FOUND: 'CALL_ROOM_NOT_FOUND',\r\n\r\n // General errors\r\n INVALID_STATE: 'INVALID_STATE',\r\n UNKNOWN_ERROR: 'UNKNOWN_ERROR'\r\n};\r\n\r\n/**\r\n * WebRTC 시그널 타입 (서버 WebRTCMessageType과 일치)\r\n */\r\nexport const SignalTypes = {\r\n // Offer/Answer (WebRTC SDP)\r\n CALL_OFFER: 'call_offer',\r\n CALL_ANSWER: 'call_answer',\r\n\r\n // ICE\r\n ICE_CANDIDATE: 'ice_candidate',\r\n\r\n // Room events\r\n JOIN_ROOM: 'join_room',\r\n LEAVE_ROOM: 'leave_room',\r\n PEER_JOINED: 'peer_joined',\r\n PEER_LEFT: 'peer_left',\r\n\r\n // Media state\r\n VIDEO_STATE_CHANGED: 'video_state_changed',\r\n AUDIO_STATE_CHANGED: 'audio_state_changed',\r\n SCREEN_SHARE_STARTED: 'screen_share_started',\r\n SCREEN_SHARE_ENDED: 'screen_share_ended',\r\n\r\n // Call events (UI 레벨)\r\n CALL_REQUEST: 'call_request',\r\n CALL_ACCEPT: 'call_accept',\r\n CALL_REJECT: 'call_reject',\r\n CALL_CANCEL: 'call_cancel',\r\n CALL_END: 'call_end',\r\n CALL_INVITATION: 'call_invitation',\r\n CALL_BUSY: 'call_busy'\r\n};\r\n\r\n/**\r\n * 채팅 메시지 타입\r\n */\r\nexport const ChatMessageType = {\r\n TEXT: 'TEXT',\r\n IMAGE: 'IMAGE',\r\n FILE: 'FILE',\r\n VIDEO: 'VIDEO',\r\n AUDIO: 'AUDIO',\r\n DOCUMENT: 'DOCUMENT',\r\n /**\r\n * 서로 다른 카테고리의 첨부가 섞인 메시지 — 예: 이미지 2 + PDF 1.\r\n * <p>전송 1회 = 메시지 1건 계약에서 새로 생긴 값. 클라이언트가 모르면 렌더가 깨진다.</p>\r\n */\r\n MIXED: 'MIXED',\r\n /**\r\n * @deprecated 복수 첨부(타입 무관)를 뜻하던 <b>과거</b> 값. 신규 저장에서는 생성되지 않지만\r\n * 기존 데이터에 남아 있다. 동영상·문서 복수도 전부 이 값이므로 <b>IMAGE 로 매핑하면 안 되고</b>,\r\n * {@code ChatClient#resolveMessageCategory} 로 fileMetaDataList 의 MIME 에서 복원해야 한다.\r\n */\r\n GALLERY: 'GALLERY',\r\n SYSTEM: 'SYSTEM'\r\n};\r\n\r\n/**\r\n * AI 의사결정 카드({@code message.decisionRequest}) 렌더 상한 — 서버가 강제하는 값의 사본.\r\n *\r\n * <p>🔴 <b>카드는 새 {@code chatMessageType} 이 아니다.</b> 카드 메시지의 타입은 그냥 {@code TEXT} 이고,\r\n * 선택 요청은 {@code decisionRequest} <b>필드</b>로 온다. 그래서 필드를 모르는 렌더러는 <b>평범한 텍스트\r\n * 메시지로 그리면 되고 그게 정상 동작</b>이다 — {@code content} 에 질문과 후보가 사람이 읽는 형태로\r\n * 들어 있다.</p>\r\n *\r\n * <p>🔴 반대로 카드를 그린다면 {@code content} 를 <b>다시 그리지 마라</b> — 버튼과 본문에 같은 질문·후보가\r\n * 두 번 보인다. 둘 중 하나만 그린다.</p>\r\n *\r\n * <p>클라가 이 값으로 <b>검증할 필요는 없다</b>(서버가 이미 강제한다). 레이아웃 계산용 참고값이다.</p>\r\n */\r\nexport const DecisionCardLimits = {\r\n /** 선택지 최소 개수 — 1개면 물을 이유가 없어 서버가 카드를 만들지 않는다. */\r\n MIN_OPTIONS: 2,\r\n /** 선택지 최대 개수 — 그 이상은 선택이 아니라 목록이다. */\r\n MAX_OPTIONS: 5,\r\n /** 라벨 표시 길이(초과분은 서버가 말줄임 처리해서 보낸다). */\r\n LABEL_MAX_LENGTH: 80,\r\n /** 질문 표시 길이. */\r\n QUESTION_MAX_LENGTH: 200\r\n};\r\n\r\n/**\r\n * 메시지 발신자 타입 — 서버 {@code SenderType} 과 일치.\r\n *\r\n * <ul>\r\n * <li>{@code USER} — 사람 사용자가 보낸 메시지 (기존 모든 메시지의 default)</li>\r\n * <li>{@code ASSISTANT} — AI 어시스턴트 페르소나가 생성한 메시지. {@code userId} 자리는 페르소나 ID</li>\r\n * </ul>\r\n *\r\n * <p>서버 응답에서 {@code senderType} 필드가 없으면 {@code USER} 로 간주 (기존 데이터 graceful).</p>\r\n */\r\nexport const SenderType = {\r\n USER: 'USER',\r\n ASSISTANT: 'ASSISTANT'\r\n};\r\n\r\n/**\r\n * 어시스턴트 페르소나 분류 — 서버 {@code PersonaRole} enum 과 일치 (14개).\r\n *\r\n * <p>{@code PM} 은 PM_BACKSTAGE 방의 단일 PM Front 전용 — 기존 14인 PERSONA_MULTI 라인업과 별개 구성이며\r\n * {@code PROJECT_MANAGEMENT}(PM 비서)와도 다르다.</p>\r\n */\r\nexport const PersonaRole = {\r\n LEGAL_ADVISOR: 'LEGAL_ADVISOR',\r\n MARKETING: 'MARKETING',\r\n PRODUCT_PLANNING: 'PRODUCT_PLANNING',\r\n HR: 'HR',\r\n FINANCE: 'FINANCE',\r\n CUSTOMER_SUPPORT: 'CUSTOMER_SUPPORT',\r\n SALES: 'SALES',\r\n ENGINEERING: 'ENGINEERING',\r\n DATA_ANALYST: 'DATA_ANALYST',\r\n PROJECT_MANAGEMENT: 'PROJECT_MANAGEMENT',\r\n RESEARCH: 'RESEARCH',\r\n TRANSLATION: 'TRANSLATION',\r\n DESIGN: 'DESIGN',\r\n // PM_BACKSTAGE 전용 — PM Front 단일 응답 (PERSONA_MULTI 14인과 별개)\r\n PM: 'PM'\r\n};\r\n\r\n/**\r\n * 채팅 요약 포맷 — 서버 {@code SummarizeFormat} enum 과 일치 (5개).\r\n *\r\n * <p>{@link ChatClient#summarizeWithAssistant} 의 {@code format} 인자.\r\n * 서버가 대소문자 무관하게 매핑하며, 미지정/알 수 없는 값이면 {@code SHORT} 로 처리.</p>\r\n *\r\n * <ul>\r\n * <li>{@code MINUTES} — 회의록 (참석/안건/핵심 논의/결정사항)</li>\r\n * <li>{@code SHORT} — 짧은 요약 (몇 문장 핵심). 기본값.</li>\r\n * <li>{@code TIMELINE} — 시간순 타임라인</li>\r\n * <li>{@code ACTIONS} — 액션 아이템 (task/owner/기한)</li>\r\n * <li>{@code OPTIONS} — 옵션 비교 (논의된 선택지별 장단점)</li>\r\n * </ul>\r\n */\r\nexport const SummarizeFormat = {\r\n MINUTES: 'MINUTES',\r\n SHORT: 'SHORT',\r\n TIMELINE: 'TIMELINE',\r\n ACTIONS: 'ACTIONS',\r\n OPTIONS: 'OPTIONS'\r\n};\r\n\r\n\r\n/**\r\n * 채팅방 타입 (서버 {@code ChatRoomType} enum 과 일치).\r\n *\r\n * <ul>\r\n * <li>{@code DIRECT} — 1:1 채팅방</li>\r\n * <li>{@code GROUP} — 공개 그룹 채팅방</li>\r\n * <li>{@code PRIVATE_GROUP} — 비밀 그룹 채팅방 (입장 시 비밀번호 필요)</li>\r\n * <li>{@code TEAM} — 팀 채팅방 (초대 전용 — 직접 입장 불가, 멤버는 입장 시점과 무관하게\r\n * 방 전체 히스토리 열람. 비참여자에게는 탐색/전체 목록에서도 숨김. 비밀번호 없음)</li>\r\n * </ul>\r\n */\r\nexport const ChatRoomType = {\r\n DIRECT: 'DIRECT',\r\n GROUP: 'GROUP',\r\n PRIVATE_GROUP: 'PRIVATE_GROUP',\r\n TEAM: 'TEAM'\r\n};\r\n\r\n/**\r\n * 어시스턴트 참여 모드 (서버 {@code AssistantMode} enum 과 일치).\r\n *\r\n * <ul>\r\n * <li>{@code GENERAL}: 자동 라우팅 + @mention 모두 활성</li>\r\n * <li>{@code PEOPLE_ONLY}: AI 응답 전면 차단</li>\r\n * <li>{@code CALL_ONLY}: @mention / 명시 호출 시만 응답</li>\r\n * </ul>\r\n */\r\nexport const AssistantMode = {\r\n GENERAL: 'GENERAL',\r\n PEOPLE_ONLY: 'PEOPLE_ONLY',\r\n CALL_ONLY: 'CALL_ONLY'\r\n};\r\n\r\n/**\r\n * 방의 AI 사용 방식 (상위 SSOT) — 서버 {@code RoomAiType} enum 과 일치.\r\n *\r\n * <p>{@link ChatClient#createGroupRoom} 의 {@code roomAiType} 으로 방 생성 시 의도를 명시한다.\r\n * 미지정 시 서버가 API 키 권한(capability) + 첨부 페르소나로 파생한다 (레거시 호환).\r\n * <b>단, PERSONA_MULTI 권한이 없는 PM 전용 키는 미지정 시 {@code NONE}(사람 방)으로 파생되므로</b>\r\n * {@link ChatClient#getRoomAiMeta} 로 확인 후 고른 타입을 명시 전송할 것.</p>\r\n *\r\n * <ul>\r\n * <li>{@code NONE}: AI 미사용 (순수 사람 채팅). {@code invitedAssistantPersonaIds}/{@code assistantMode} 동시 지정 불가.</li>\r\n * <li>{@code PERSONA_MULTI}: 다중 페르소나, LLM 이 판단해 호출. {@code assistantMode} 로 세부 제어. (현재 유일 활성)</li>\r\n * <li>{@code PM_BACKSTAGE}: PM + 백스테이지. PM 단일 응답 구현됨 — {@code engagementIntensity} 로 개입 강도 제어. 백스테이지 위임은 후속.</li>\r\n * </ul>\r\n */\r\nexport const RoomAiType = {\r\n NONE: 'NONE',\r\n PERSONA_MULTI: 'PERSONA_MULTI',\r\n PM_BACKSTAGE: 'PM_BACKSTAGE'\r\n};\r\n\r\n/**\r\n * PM_BACKSTAGE 방의 PM 개입 강도 (서버 {@code EngagementIntensity} enum 과 일치).\r\n *\r\n * <p>{@link ChatClient#createGroupRoom} / {@code updateGroupRoom} 의 {@code engagementIntensity}.\r\n * <b>PM_BACKSTAGE 방 전용</b> — 그 외 방 타입에 지정 시 서버가 거절한다. 14인 PERSONA_MULTI 는\r\n * 쿨다운 + Planner 자동 판단이라 무관하며, 이 값은 PM 단일 응답의 개입 빈도만 제어한다.</p>\r\n *\r\n * <ul>\r\n * <li>{@code QUIET}: 보수적 — 명확한 질문/도움 요청에만 PM 응답</li>\r\n * <li>{@code NORMAL}: 기본 — 업무 신호에 응답, 잡담엔 침묵</li>\r\n * <li>{@code ACTIVE}: 적극 — 가벼운 신호에도 응답</li>\r\n * </ul>\r\n */\r\nexport const EngagementIntensity = {\r\n QUIET: 'QUIET',\r\n NORMAL: 'NORMAL',\r\n ACTIVE: 'ACTIVE'\r\n};\r\n\r\n/**\r\n * PM 프롬프트 레이어 차원 — 서버 {@code PmPromptLayerScope} enum 과 일치.\r\n *\r\n * <p>전역 PM 프롬프트 위에 합성되는 추가 지시문 2종. 합성 순서: 전역 → PROJECT → ROOM.\r\n * SDK 가 다루는 것은 ROOM(방 레이어 — {@link ChatClient#getRoomPmPrompt} 등)이며,\r\n * PROJECT(회사 레이어)는 어드민 콘솔 영역이다.</p>\r\n */\r\nexport const PmPromptLayerScope = {\r\n /** 회사(프로젝트) 레이어 — 어드민 콘솔(PROJECT_ADMIN) 관리. */\r\n PROJECT: 'PROJECT',\r\n /** 방 레이어 — 방 주인(OWNER) 관리. PM_BACKSTAGE 방 전용. */\r\n ROOM: 'ROOM'\r\n};\r\n\r\n/**\r\n * PM 프롬프트 레이어 수정 주체 (감사 추적) — 서버 {@code PmPromptLayerEditorType} enum 과 일치.\r\n */\r\nexport const PmPromptLayerEditorType = {\r\n /** 플랫폼 운영자 (대리 수정). */\r\n SUPER_ADMIN: 'SUPER_ADMIN',\r\n /** 회사 관리자 — PROJECT 레이어. */\r\n PROJECT_ADMIN: 'PROJECT_ADMIN',\r\n /** 방 주인(OWNER) — ROOM 레이어. */\r\n ROOM_OWNER: 'ROOM_OWNER'\r\n};\r\n\r\n/**\r\n * 채팅방 리스트 실시간 업데이트 이벤트 타입 (카톡 스타일).\r\n *\r\n * 서버 RoomListEventType enum 과 일치해야 함.\r\n *\r\n * @example\r\n * client.on('roomListUpdate', (event) => {\r\n * switch (event.eventType) {\r\n * case RoomListEventType.MESSAGE_RECEIVED:\r\n * // 해당 방을 최상단으로, unread +1 (본인 메시지는 제외)\r\n * break;\r\n * case RoomListEventType.ROOM_LEFT:\r\n * // actorId === currentUserId 이면 리스트에서 제거\r\n * break;\r\n * }\r\n * });\r\n */\r\nexport const RoomListEventType = {\r\n /** 새 메시지 수신 → 리스트 최상단 이동, unread 카운트 +1 (본인 메시지 제외). */\r\n MESSAGE_RECEIVED: 'MESSAGE_RECEIVED',\r\n /** 현재 lastMessage 삭제 → 리스트 preview 갱신, unread 카운트 변경 없음. */\r\n MESSAGE_DELETED: 'MESSAGE_DELETED',\r\n /** 현재 lastMessage 편집 → 리스트 preview 새 content 로 갱신, unread 카운트 변경 없음. */\r\n MESSAGE_UPDATED: 'MESSAGE_UPDATED',\r\n /** 방 생성 (Direct/Group) → 참가자 리스트에 신규 item 추가. */\r\n ROOM_CREATED: 'ROOM_CREATED',\r\n /** 기존 방에 누군가 입장 → 참가자 수 갱신. */\r\n ROOM_JOINED: 'ROOM_JOINED',\r\n /** 방 퇴장 → 나간 본인 리스트에서는 제거, 남은 참가자는 참가자 수 감소. */\r\n ROOM_LEFT: 'ROOM_LEFT',\r\n /** 방 정보 변경 (이름, 설명 등). 미래 확장용. */\r\n ROOM_UPDATED: 'ROOM_UPDATED',\r\n /**\r\n * 방장에 의한 단순 추방 → 대상은 리스트에서 방 제거, 남은 참가자는 참가자 수 감소.\r\n * 재입장 가능 (ban 아님). actorId 는 추방한 방장, members 에 추방된 사용자 정보.\r\n */\r\n ROOM_KICKED: 'ROOM_KICKED',\r\n /** 방장에 의한 추방 + 영구 차단 → payload 는 ROOM_KICKED 와 동일, 재입장 거부됨. */\r\n ROOM_BANNED: 'ROOM_BANNED',\r\n /**\r\n * 방장 위임 → actorId 는 이전 방장, members[0] 이 새 방장. 방장 표시·관리 권한을 갱신한다.\r\n * «위임 후 나가기» 면 이어서 같은 이전 방장의 ROOM_LEFT 가 온다.\r\n * SDK 는 이를 roomListOwnerChanged 이벤트로 emit 한다.\r\n */\r\n ROOM_OWNER_CHANGED: 'ROOM_OWNER_CHANGED',\r\n /**\r\n * 방장이 방을 삭제 → 리스트에서 제거 + 열린 방 화면 닫기. actorId 는 삭제한 방장.\r\n * 이후 enterRoom·getRoomInfo 는 403(ACCESS_FORBIDDEN — 더는 참가자가 아님), joinGroupRoom 은 404.\r\n * SDK 는 이를 roomListDeleted 이벤트로 emit 한다.\r\n */\r\n ROOM_DELETED: 'ROOM_DELETED',\r\n /** 메시지 보관 기간 정리 → cutoffTime 이전 메시지를 UI에서 제거. */\r\n MESSAGE_RETENTION_CLEANUP: 'MESSAGE_RETENTION_CLEANUP',\r\n /**\r\n * 내 unread 가 변경됨 → 방 리스트 badge 를 roomUnreadCount 절대값으로 세팅(delta 아님).\r\n * 내가 어느 기기에서 읽든 같은 사용자의 다른 세션에 전달돼 크로스-디바이스로 badge 를 맞춘다.\r\n * unreadVersion 으로 순서 역전(읽음 0 이 신규 메시지보다 먼저 도착)을 방어한다.\r\n * SDK 는 이를 roomListUnreadUpdated 이벤트로 emit 한다.\r\n */\r\n ROOM_UNREAD_UPDATED: 'ROOM_UNREAD_UPDATED'\r\n};\r\n\r\n/**\r\n * WebSocket 경로\r\n */\r\nexport const WebSocketPaths = {\r\n // Endpoints\r\n SOCKJS_ENDPOINT: '/ws-chat',\r\n NATIVE_ENDPOINT: '/ws-chat-native',\r\n\r\n // Prefixes\r\n APP_PREFIX: '/app',\r\n TOPIC_PREFIX: '/topic',\r\n QUEUE_PREFIX: '/queue',\r\n USER_PREFIX: '/user',\r\n\r\n // Chat destinations\r\n getChatDestination: (roomId) => `/topic/chat/${roomId}`,\r\n getChatReadDestination: (roomId) => `/topic/chat/${roomId}/read`,\r\n getChatTypingDestination: (roomId) => `/topic/chat/${roomId}/typing`,\r\n // AI 진행 표시(생각중/검색중/작성중) + 토큰 스트리밍 — typing 과 별개 채널(가산 UX 레이어).\r\n getChatAssistantStreamDestination: (roomId) => `/topic/chat/${roomId}/assistant-stream`,\r\n // Rolling 누적 요약 종결 알림(READY/NO_CHANGE/NO_MESSAGES/FAILED) — refreshSummary 비동기 완료 채널.\r\n getChatSummaryDestination: (roomId) => `/topic/chat/${roomId}/summary`,\r\n\r\n // Room list (카톡 스타일 리스트 실시간 업데이트)\r\n // 서버가 convertAndSendToUser(userId, \"/queue/rooms\", event) 로 전송\r\n // 클라이언트는 \"/user/queue/rooms\" 구독 (Spring STOMP 가 세션별 자동 격리)\r\n ROOM_LIST_USER_DESTINATION: '/user/queue/rooms',\r\n\r\n // Gmail 메일함 변화 (서버 GMAIL_MAILBOX_FEATURES_DESIGN.md §6-6·§6-7)\r\n // 서버가 convertAndSendToUser(userId, \"/queue/mail\", MAIL_CHANGED) 로 전송 — 새 메일이면 messages[] 미리보기 포함\r\n MAIL_USER_DESTINATION: '/user/queue/mail',\r\n\r\n // WebRTC destinations\r\n getWebRTCDestination: (roomId) => `/topic/webrtc/${roomId}`,\r\n getWebRTCUserDestination: () => `/user/queue/webrtc`,\r\n\r\n // Send destinations\r\n CHAT_SEND: '/app/chat/send',\r\n CHAT_READ: '/app/chat/read',\r\n CHAT_TYPING: '/app/chat/typing',\r\n WEBRTC_SIGNAL: '/app/webrtc/signal'\r\n};\r\n\r\n/**\r\n * 환경 타입\r\n */\r\nexport const Environment = {\r\n DEVELOPMENT: 'development',\r\n STAGING: 'staging',\r\n PRODUCTION: 'production'\r\n};\r\n\r\n/**\r\n * 환경별 서버 설정\r\n */\r\nexport const Endpoints = {\r\n [Environment.DEVELOPMENT]: {\r\n serverUrl: 'https://dev-chat.apiorbit.net',\r\n wsEndpoint: '/ws-chat'\r\n },\r\n [Environment.STAGING]: {\r\n serverUrl: 'https://stg-api.talk-x.app',\r\n wsEndpoint: '/ws-chat'\r\n },\r\n [Environment.PRODUCTION]: {\r\n serverUrl: 'https://prod-api.talk-x.app',\r\n wsEndpoint: '/ws-chat'\r\n }\r\n};\r\n\r\n/**\r\n * 기본 설정값\r\n */\r\nexport const DefaultConfig = {\r\n // 기본 환경 (production)\r\n environment: Environment.PRODUCTION,\r\n\r\n // Connection\r\n reconnectDelay: 5000,\r\n // 지수 백오프 상한 — 서버 재시작 시 전 클라이언트가 고정 간격으로 몰리는 것을 완화.\r\n maxReconnectDelay: 30000,\r\n maxReconnectAttempts: 10,\r\n reconnectRecoveryDelay: 60000,\r\n heartbeatIncoming: 10000,\r\n heartbeatOutgoing: 10000,\r\n\r\n // 포그라운드 복귀 / 네트워크 복구 시 자동 재연결 (RECONNECT_SYNC_DESIGN §6.1). false = 킬 스위치.\r\n resumeOnVisibility: true,\r\n\r\n // 갭 복구 (RECONNECT_SYNC_DESIGN §6.3). false = 킬 스위치.\r\n syncOnReconnect: true,\r\n // 멀티 인스턴스 시계 스큐 방어 되감기 폭 — 첫 페이지에만, 서버가 clamp 후 1회 차감.\r\n rewindMillis: 3000,\r\n // 갭 페이지 상한. 초과 시 버퍼 폐기 + gapTruncated + 최신 페이지 재로드.\r\n maxSyncPages: 5,\r\n syncPageSize: 100,\r\n\r\n // API\r\n apiTimeout: 30000,\r\n\r\n // WebRTC\r\n iceServers: [\r\n { urls: 'stun:stun.l.google.com:19302' },\r\n { urls: 'stun:stun1.l.google.com:19302' }\r\n ],\r\n\r\n // Logging\r\n logLevel: 2 // WARN\r\n};\r\n\r\n/**\r\n * 환경에 따른 서버 URL 반환\r\n * @param {string} env - Environment 값\r\n * @returns {string} 서버 URL\r\n */\r\nexport function getServerUrl(env) {\r\n const endpoint = Endpoints[env] || Endpoints[Environment.PRODUCTION];\r\n return endpoint.serverUrl;\r\n}\r\n\r\n/**\r\n * 로그 레벨\r\n */\r\nexport const LogLevel = {\r\n DEBUG: 0,\r\n INFO: 1,\r\n WARN: 2,\r\n ERROR: 3,\r\n NONE: 4\r\n};\r\n","/**\r\n * Logger 유틸리티\r\n * 로그 레벨에 따른 콘솔 출력 관리\r\n */\r\n\r\nimport { LogLevel } from '../constants.js';\r\n\r\nclass Logger {\r\n /**\r\n * @param {number} [level] - 로그 레벨\r\n * @param {string} [prefix] - 로그 접두사\r\n */\r\n constructor(level = LogLevel.WARN, prefix = 'TalkFlow') {\r\n this.level = level;\r\n this.prefix = prefix;\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level - 로그 레벨\r\n */\r\n setLevel(level) {\r\n this.level = level;\r\n }\r\n\r\n /**\r\n * 로그 접두사 설정\r\n * @param {string} prefix - 로그 접두사\r\n */\r\n setPrefix(prefix) {\r\n this.prefix = prefix;\r\n }\r\n\r\n /**\r\n * 로그 포맷 생성\r\n * @private\r\n * @param {string} level - 로그 레벨 문자열\r\n * @param {...*} args - 로그 인자들\r\n * @returns {Array} 포맷된 로그 배열\r\n */\r\n _format(level, ...args) {\r\n const timestamp = new Date().toISOString();\r\n return [`[${timestamp}] [${level}] [${this.prefix}]`, ...args];\r\n }\r\n\r\n /**\r\n * 디버그 로그 출력\r\n * @param {...*} args - 로그 인자들\r\n */\r\n debug(...args) {\r\n if (this.level <= LogLevel.DEBUG) {\r\n console.debug(...this._format('DEBUG', ...args));\r\n }\r\n }\r\n\r\n /**\r\n * 정보 로그 출력\r\n * @param {...*} args - 로그 인자들\r\n */\r\n info(...args) {\r\n if (this.level <= LogLevel.INFO) {\r\n console.info(...this._format('INFO', ...args));\r\n }\r\n }\r\n\r\n /**\r\n * 경고 로그 출력\r\n * @param {...*} args - 로그 인자들\r\n */\r\n warn(...args) {\r\n if (this.level <= LogLevel.WARN) {\r\n console.warn(...this._format('WARN', ...args));\r\n }\r\n }\r\n\r\n /**\r\n * 에러 로그 출력\r\n * @param {...*} args - 로그 인자들\r\n */\r\n error(...args) {\r\n if (this.level <= LogLevel.ERROR) {\r\n console.error(...this._format('ERROR', ...args));\r\n }\r\n }\r\n}\r\n\r\nexport default Logger;\r\nexport { LogLevel };\r\n","/**\r\n * API Client\r\n * REST API 호출을 위한 HTTP 클라이언트\r\n */\r\n\r\nimport { ErrorTypes, DefaultConfig } from '../constants.js';\r\nimport Logger from './Logger.js';\r\n\r\nclass ApiClient {\r\n /**\r\n * @param {Object} options\r\n * @param {string} options.baseUrl - API 기본 URL\r\n * @param {string} options.apiKey - API 키\r\n * @param {string} options.projectId - 프로젝트 ID\r\n * @param {string} options.jwtToken - JWT 토큰\r\n * @param {number} [options.timeout] - 요청 타임아웃 (ms)\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options) {\r\n this.baseUrl = options.baseUrl.replace(/\\/$/, '');\r\n this.apiKey = options.apiKey;\r\n this.projectId = options.projectId;\r\n this.jwtToken = options.jwtToken;\r\n this.timeout = options.timeout || DefaultConfig.apiTimeout;\r\n\r\n this.logger = new Logger(options.logLevel, 'ApiClient');\r\n }\r\n\r\n /**\r\n * JWT 토큰 업데이트.\r\n * logout 경로에서 토큰 제거를 위해 {@code null} 도 전달 가능.\r\n * @param {string|null} token\r\n */\r\n setJwtToken(token) {\r\n this.jwtToken = token;\r\n }\r\n\r\n /**\r\n * 공통 헤더 생성\r\n * @returns {Object}\r\n */\r\n _getHeaders() {\r\n const headers = {\r\n 'Content-Type': 'application/json',\r\n 'X-API-KEY': this.apiKey,\r\n 'X-PROJECT-ID': this.projectId\r\n };\r\n\r\n if (this.jwtToken) {\r\n headers['Authorization'] = this.jwtToken.startsWith('Bearer ')\r\n ? this.jwtToken\r\n : `Bearer ${this.jwtToken}`;\r\n }\r\n\r\n return headers;\r\n }\r\n\r\n /**\r\n * HTTP 요청 실행\r\n * @param {string} method - HTTP 메서드\r\n * @param {string} path - API 경로\r\n * @param {Object} [options] - 옵션\r\n * @param {Object} [options.body] - 요청 본문\r\n * @param {Object} [options.params] - URL 파라미터\r\n * @param {Object} [options.headers] - 이 요청에만 얹는 추가 헤더. 기본 헤더(Authorization 등)와 병합된다.\r\n * 같은 이름이면 이 값이 이긴다 — 인증 헤더를 덮어쓰지 않도록 호출자가 주의한다.\r\n * @returns {Promise<Object>}\r\n */\r\n async request(method, path, options = {}) {\r\n if (!this.authProvider || options.skipAuth) return this._request(method, path, options);\r\n this.jwtToken = await this.authProvider.getAccessToken();\r\n const sentToken = this.jwtToken;\r\n const generation = this.authProvider.generation;\r\n try { return await this._request(method, path, options); }\r\n catch (error) {\r\n if (error.status === 401 && ['ACCESS_UNAUTHORIZED', 'TOKEN_INVALID'].includes(error.response?.code)) {\r\n if (generation !== this.authProvider.generation) throw error;\r\n this.jwtToken = await this.authProvider.getAccessToken({ forceRefresh: true, rejectedToken: sentToken });\r\n const retryToken = this.jwtToken;\r\n const retryGeneration = this.authProvider.generation;\r\n try { return await this._request(method, path, options); }\r\n catch (retryError) { await this.authProvider.authenticationFailed(retryError, { rejectedToken: retryToken, generation: retryGeneration }); throw retryError; }\r\n }\r\n await this.authProvider.authenticationFailed(error, { rejectedToken: sentToken, generation });\r\n throw error;\r\n }\r\n }\r\n\r\n async _request(method, path, options = {}) {\r\n const { body, params, headers } = options;\r\n\r\n // URL 빌드\r\n let url = `${this.baseUrl}${path}`;\r\n if (params) {\r\n const searchParams = new URLSearchParams();\r\n Object.entries(params).forEach(([key, value]) => {\r\n if (value !== undefined && value !== null) {\r\n searchParams.append(key, value);\r\n }\r\n });\r\n const queryString = searchParams.toString();\r\n if (queryString) {\r\n url += `?${queryString}`;\r\n }\r\n }\r\n\r\n // AbortController for timeout\r\n const controller = new AbortController();\r\n const timeoutId = setTimeout(() => controller.abort(), this.timeout);\r\n\r\n let response;\r\n try {\r\n this.logger.debug(`${method} ${url}`);\r\n\r\n response = await fetch(url, {\r\n method,\r\n headers: { ...this._getHeaders(), ...headers },\r\n credentials: this.authProvider ? 'omit' : 'same-origin',\r\n body: body ? JSON.stringify(body) : undefined,\r\n signal: controller.signal\r\n });\r\n } catch (error) {\r\n clearTimeout(timeoutId);\r\n\r\n if (error.name === 'AbortError') {\r\n const timeoutError = new Error('Request timeout');\r\n timeoutError.code = ErrorTypes.API_TIMEOUT;\r\n throw timeoutError;\r\n }\r\n\r\n this.logger.error(`API Error: ${method} ${url}`, error);\r\n throw error;\r\n }\r\n\r\n clearTimeout(timeoutId);\r\n\r\n // 응답 파싱\r\n const contentType = response.headers.get('content-type');\r\n let data;\r\n\r\n if (contentType && contentType.includes('application/json')) {\r\n data = await response.json();\r\n } else {\r\n data = await response.text();\r\n }\r\n\r\n // 에러 처리\r\n if (!response.ok) {\r\n const error = new Error(data?.message || `HTTP ${response.status}`);\r\n error.code = ErrorTypes.API_ERROR;\r\n error.status = response.status;\r\n error.response = data;\r\n this.logger.error(`API Error: ${method} ${url}`, error);\r\n throw error;\r\n }\r\n\r\n this.logger.debug(`Response ${response.status}`);\r\n return data;\r\n }\r\n\r\n // HTTP 메서드 헬퍼\r\n\r\n /**\r\n * GET 요청\r\n * @param {string} path - API 경로\r\n * @param {Object} [params] - URL 파라미터\r\n * @returns {Promise<Object>}\r\n */\r\n get(path, params) {\r\n return this.request('GET', path, { params });\r\n }\r\n\r\n /**\r\n * POST 요청\r\n * @param {string} path - API 경로\r\n * @param {Object} [body] - 요청 본문\r\n * @param {Object} [params] - URL 파라미터\r\n * @param {Object} [headers] - 이 요청에만 얹는 추가 헤더 (예: {@code X-Idempotency-Key}).\r\n * 기존 호출자는 인자를 넘기지 않으므로 동작이 바뀌지 않는다.\r\n * @returns {Promise<Object>}\r\n */\r\n post(path, body, params, headers) {\r\n return this.request('POST', path, { body, params, headers });\r\n }\r\n\r\n /**\r\n * PUT 요청\r\n * @param {string} path - API 경로\r\n * @param {Object} [body] - 요청 본문\r\n * @returns {Promise<Object>}\r\n */\r\n put(path, body) {\r\n return this.request('PUT', path, { body });\r\n }\r\n\r\n /**\r\n * PATCH 요청\r\n * @param {string} path - API 경로\r\n * @param {Object} [body] - 요청 본문\r\n * @returns {Promise<Object>}\r\n */\r\n patch(path, body) {\r\n return this.request('PATCH', path, { body });\r\n }\r\n\r\n /**\r\n * DELETE 요청\r\n * @param {string} path - API 경로\r\n * @param {Object} [params] - URL 파라미터\r\n * @returns {Promise<Object>}\r\n */\r\n delete(path, params) {\r\n return this.request('DELETE', path, { params });\r\n }\r\n\r\n /**\r\n * multipart/form-data 파일 업로드.\r\n *\r\n * <p>{@code fetch} 가 업로드 진행률을 지원하지 않아 {@code XMLHttpRequest} 사용.\r\n * Content-Type 은 브라우저가 boundary 를 포함해 자동 세팅하므로 명시하지 않는다.</p>\r\n *\r\n * @param {string} path - API 경로 (baseUrl 뒤에 붙음)\r\n * @param {File|Blob} file - 업로드할 파일\r\n * @param {Object} [options]\r\n * @param {string} [options.fieldName='file'] - multipart field 이름 (서버 @RequestParam 과 일치)\r\n * @param {Function} [options.onProgress] - {@code ({loaded, total, percent}) => void} — 업로드 진행률 콜백\r\n * @param {AbortSignal} [options.signal] - 업로드 취소용 AbortSignal\r\n * @param {number} [options.timeout=600000] - 타임아웃 (ms). 기본 10분.\r\n * @returns {Promise<Object>} 서버 응답 JSON (SuccessResponse 래퍼 포함)\r\n */\r\n async upload(path, file, options = {}) {\r\n if (!this.authProvider) return this._upload(path, file, options);\r\n this.jwtToken = await this.authProvider.getAccessToken();\r\n const sentToken = this.jwtToken;\r\n const generation = this.authProvider.generation;\r\n try { return await this._upload(path, file, options); }\r\n catch (error) {\r\n if (error.status === 401 && ['ACCESS_UNAUTHORIZED', 'TOKEN_INVALID'].includes(error.response?.code)) {\r\n if (generation !== this.authProvider.generation) throw error;\r\n this.jwtToken = await this.authProvider.getAccessToken({ forceRefresh: true, rejectedToken: sentToken });\r\n const retryToken = this.jwtToken;\r\n const retryGeneration = this.authProvider.generation;\r\n try { return await this._upload(path, file, options); }\r\n catch (retryError) { await this.authProvider.authenticationFailed(retryError, { rejectedToken: retryToken, generation: retryGeneration }); throw retryError; }\r\n }\r\n await this.authProvider.authenticationFailed(error, { rejectedToken: sentToken, generation });\r\n throw error;\r\n }\r\n }\r\n\r\n _upload(path, file, options = {}) {\r\n const {\r\n fieldName = 'file',\r\n onProgress,\r\n signal,\r\n timeout = 600_000\r\n } = options;\r\n\r\n return new Promise((resolve, reject) => {\r\n const url = `${this.baseUrl}${path}`;\r\n const xhr = new XMLHttpRequest();\r\n const form = new FormData();\r\n form.append(fieldName, file);\r\n\r\n xhr.open('POST', url);\r\n xhr.timeout = timeout;\r\n\r\n // Content-Type 은 XHR 이 multipart boundary 포함해 자동 세팅 — 명시 금지\r\n xhr.setRequestHeader('X-API-KEY', this.apiKey);\r\n xhr.setRequestHeader('X-PROJECT-ID', this.projectId);\r\n if (this.jwtToken) {\r\n xhr.setRequestHeader(\r\n 'Authorization',\r\n this.jwtToken.startsWith('Bearer ') ? this.jwtToken : `Bearer ${this.jwtToken}`\r\n );\r\n }\r\n\r\n if (typeof onProgress === 'function') {\r\n xhr.upload.onprogress = (e) => {\r\n if (e.lengthComputable) {\r\n onProgress({\r\n loaded: e.loaded,\r\n total: e.total,\r\n percent: Math.round((e.loaded / e.total) * 100)\r\n });\r\n }\r\n };\r\n }\r\n\r\n // 외부 AbortSignal 과 연동 (취소 지원)\r\n const onAbort = () => xhr.abort();\r\n if (signal) {\r\n if (signal.aborted) {\r\n reject(new Error('Upload cancelled'));\r\n return;\r\n }\r\n signal.addEventListener('abort', onAbort);\r\n }\r\n const cleanupSignal = () => {\r\n if (signal) signal.removeEventListener('abort', onAbort);\r\n };\r\n\r\n xhr.onload = () => {\r\n cleanupSignal();\r\n let parsed = xhr.responseText;\r\n const contentType = xhr.getResponseHeader('content-type') || '';\r\n if (contentType.includes('application/json')) {\r\n try { parsed = JSON.parse(xhr.responseText); } catch { /* keep text */ }\r\n }\r\n if (xhr.status >= 200 && xhr.status < 300) {\r\n this.logger.debug(`Upload ${xhr.status}:`, parsed);\r\n resolve(parsed);\r\n } else {\r\n const error = new Error(parsed?.message || `HTTP ${xhr.status}`);\r\n error.code = ErrorTypes.API_ERROR;\r\n error.status = xhr.status;\r\n error.response = parsed;\r\n this.logger.error(`Upload Error: POST ${url}`, error);\r\n reject(error);\r\n }\r\n };\r\n\r\n xhr.onerror = () => {\r\n cleanupSignal();\r\n const err = new Error('Network error during upload');\r\n err.code = ErrorTypes.API_ERROR;\r\n this.logger.error(`Upload Network Error: POST ${url}`, err);\r\n reject(err);\r\n };\r\n\r\n xhr.ontimeout = () => {\r\n cleanupSignal();\r\n const err = new Error('Upload timeout');\r\n err.code = ErrorTypes.API_TIMEOUT;\r\n this.logger.error(`Upload Timeout: POST ${url}`, err);\r\n reject(err);\r\n };\r\n\r\n xhr.onabort = () => {\r\n cleanupSignal();\r\n reject(new Error('Upload cancelled'));\r\n };\r\n\r\n this.logger.debug(`POST ${url} (multipart, ${file.size} bytes)`);\r\n xhr.send(form);\r\n });\r\n }\r\n}\r\n\r\nexport default ApiClient;\r\n","import EventEmitter from '../utils/EventEmitter.js';\r\n\r\nconst sessions = new Map();\r\nconst TERMINAL_CODES = new Set(['REFRESH_INVALID', 'REFRESH_REUSED', 'SESSION_REVOKED', 'ACCOUNT_BLOCKED', 'LOGIN_FAILED']);\r\nexport const isTerminalAuthError = error => TERMINAL_CODES.has(error?.response?.code || error?.code);\r\n\r\n/**\r\n * 로그인 회사 선택 결과 — 토큰이 없다. 목록이 비었거나 회사 id 가 없는 응답은 계약 위반이라 인증 불가로 끊는다(빈 선택 화면을 띄우지 않게).\r\n */\r\nfunction workspaceSelection(data) {\r\n const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];\r\n if (workspaces.length === 0 || workspaces.some(workspace => !workspace?.projectId)) {\r\n const error = new Error('Invalid authentication response'); error.code = 'AUTH_UNAVAILABLE'; throw error;\r\n }\r\n return {\r\n workspaceSelectionRequired: true,\r\n workspaces: workspaces.map(({ projectId, companyName, companyRole }) =>\r\n ({ projectId, companyName: companyName ?? null, companyRole }))\r\n };\r\n}\r\n\r\nexport function tokenClaims(token) {\r\n try {\r\n const value = token.replace(/^Bearer\\s+/i, '').split('.')[1].replace(/-/g, '+').replace(/_/g, '/');\r\n return JSON.parse(atob(value));\r\n } catch { return {}; }\r\n}\r\n\r\n/** 서버별 인증 상태. Access는 메모리, Refresh와 로그아웃 의도는 하나의 저장 레코드로 관리한다. */\r\nexport default class AuthSession extends EventEmitter {\r\n static forServer(serverUrl) {\r\n const url = serverUrl.replace(/\\/$/, '');\r\n if (!sessions.has(url)) sessions.set(url, new AuthSession(url));\r\n return sessions.get(url);\r\n }\r\n\r\n constructor(serverUrl) {\r\n super();\r\n this.serverUrl = serverUrl;\r\n this.state = 'checking';\r\n this.data = null;\r\n this.clients = new Set();\r\n this.generation = 0;\r\n this.refreshPromise = null;\r\n this.timer = null;\r\n this.peerWaiters = new Map();\r\n this.markerKey = `talkflow:auth:${serverUrl}`;\r\n this.marker = this._readMarker();\r\n this.serverClock = null;\r\n this.channel = null;\r\n this.storageListener = event => {\r\n if (event.key !== this.markerKey) return;\r\n const stored = this._readMarker();\r\n if (stored.revision !== this.marker.revision || stored.logout) {\r\n this.marker = stored;\r\n this.generation++;\r\n this._clear(false).catch(error => this.emit('authError', error));\r\n }\r\n };\r\n globalThis.addEventListener?.('storage', this.storageListener);\r\n if (typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined') {\r\n this.channel = new BroadcastChannel(`talkflow:auth:${serverUrl}`);\r\n this.channel.onmessage = event => this._receive(event.data);\r\n this.channel.unref?.();\r\n }\r\n }\r\n\r\n _readMarker() {\r\n try {\r\n const value = globalThis.localStorage?.getItem(this.markerKey);\r\n return value ? JSON.parse(value) : { revision: 'initial', logout: false };\r\n } catch { throw this._storageError(); }\r\n }\r\n _storageError() { const error = new Error('Authentication storage is unavailable'); error.code = 'AUTH_STORAGE_UNAVAILABLE'; return error; }\r\n _writeMarker(marker) {\r\n try {\r\n if (!globalThis.localStorage) throw this._storageError();\r\n globalThis.localStorage.setItem(this.markerKey, JSON.stringify(marker));\r\n this.marker = marker;\r\n } catch { throw this._storageError(); }\r\n }\r\n _newMarker(logout, pending = logout) {\r\n const previous = this._readMarker();\r\n const marker = { revision: globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(), logout,\r\n deviceId: previous.deviceId, pending,\r\n pendingRefreshToken: logout && pending ? previous.refreshToken || previous.pendingRefreshToken : undefined };\r\n this._writeMarker(marker);\r\n return marker;\r\n }\r\n setDeviceId(deviceId) { this._writeMarker({ ...this._readMarker(), deviceId }); }\r\n _monotonic() { return globalThis.performance?.now?.() ?? Date.now(); }\r\n _serverNow() {\r\n if (!this.serverClock) return Date.now();\r\n return this.serverClock.server + Math.max(this._monotonic() - this.serverClock.mono, Date.now() - this.serverClock.wall);\r\n }\r\n _shareData() { return this.data ? { ...this.data, refreshToken: undefined, serverTime: this._serverNow() } : null; }\r\n _publish(type, data, marker = this.marker) {\r\n const { revision, logout, pending } = marker;\r\n this.channel?.postMessage({ type, marker: { revision, logout, pending }, data });\r\n }\r\n _setState(state, error, force = false) {\r\n const previous = this.state;\r\n this.state = state;\r\n if (previous !== state || error || force) this.emit('authStateChanged', { state, previous, error });\r\n }\r\n getAuthState() { return this.state; }\r\n isLoggedIn() { return this.state === 'authenticated'; }\r\n _fresh(token = this.data?.accessToken) { return Number(tokenClaims(token || '').exp || 0) * 1000 > this._serverNow() + (this.refreshMargin ?? 60000); }\r\n\r\n attach(client) { this.clients.add(client); }\r\n detach(client) { this.clients.delete(client); this.schedule(); }\r\n\r\n _enqueue(work) {\r\n const operation = (this.transition || Promise.resolve()).then(work);\r\n this.transition = operation.catch(() => {});\r\n return operation;\r\n }\r\n\r\n _accept(data, publish = true) {\r\n const generation = this.generation;\r\n const marker = this.marker;\r\n return this._enqueue(async () => {\r\n if (generation !== this.generation) throw this._cancelled();\r\n if (!data?.accessToken || !data?.projectId || !data?.clientKey || !data?.user?.userId) {\r\n const error = new Error('Invalid authentication response'); error.code = 'AUTH_UNAVAILABLE'; throw error;\r\n }\r\n const identityChanged = this.data && (this.data.user.userId !== data.user.userId || this.data.projectId !== data.projectId);\r\n if (publish && data.refreshToken) {\r\n const stored = this._readMarker();\r\n if (stored.revision !== marker.revision || stored.logout) throw this._cancelled();\r\n this._writeMarker({ ...stored, refreshToken: data.refreshToken, refreshRequestId: undefined, pending: false, pendingRefreshToken: undefined });\r\n }\r\n if (Number.isFinite(data.serverTime)) this.serverClock = {\r\n server: data.serverTime + (data._roundTripMillis || 0), mono: this._monotonic(), wall: Date.now()\r\n };\r\n this.refreshMargin = Math.min(60000, Math.max(0, Number(tokenClaims(data.accessToken).exp) * 1000 - this._serverNow()) / 10);\r\n // Access 응답만 메모리에 남긴다. Refresh는 매 갱신 시 저장소에서 다시 읽는다.\r\n this.data = { ...data, refreshToken: undefined };\r\n await Promise.all([...this.clients].map(client => client._acceptAuthSession(data)));\r\n if (generation !== this.generation) throw this._cancelled();\r\n this._setState('authenticated', undefined, identityChanged);\r\n if (publish) this._publish('tokens', this._shareData(), this.marker);\r\n this._reconnectClients();\r\n this.schedule();\r\n return data;\r\n });\r\n }\r\n\r\n _reconnectClients() {\r\n for (const client of this.clients) {\r\n if (client._authConnectionRequested && client.isConnected?.()) {\r\n const manager = client.connectionManager;\r\n if (manager?._authenticatedToken !== this.data?.accessToken) {\r\n manager.forceReconnect('access-token-renewed').catch(error => this.emit('authError', error));\r\n }\r\n }\r\n }\r\n }\r\n\r\n _clear(publish = true) {\r\n const marker = this.marker;\r\n return this._enqueue(async () => {\r\n this.data = null;\r\n clearTimeout(this.timer);\r\n this.timer = null;\r\n this._setState('unauthenticated');\r\n await Promise.all([...this.clients].map(client => client._clearAuthSession()));\r\n if (publish) this._publish('logout', undefined, marker);\r\n });\r\n }\r\n\r\n async _receive(message) {\r\n if (!message?.marker) return;\r\n try {\r\n const stored = this._readMarker();\r\n if (message.marker.revision !== stored.revision && stored.revision !== this.marker.revision) return;\r\n if (message.type === 'logout' || message.type === 'login-start') {\r\n if (message.marker.revision !== stored.revision) return;\r\n this.marker = message.marker;\r\n this.generation++;\r\n await this._clear(false);\r\n if (message.type === 'login-start') this._setState('checking');\r\n return;\r\n }\r\n if (message.marker.revision !== this._readMarker().revision || message.marker.logout) return;\r\n if (message.type === 'request-token' && this._fresh()) {\r\n this._publish('peer-token', { requestId: message.data.requestId, session: this._shareData() });\r\n } else if (message.type === 'peer-token') {\r\n this.peerWaiters.get(message.data.requestId)?.(message.data.session);\r\n } else if (message.type === 'tokens') {\r\n if (this.marker.revision !== message.marker.revision) this.generation++;\r\n this.marker = message.marker;\r\n // 지연된 방송이 더 최근 토큰을 덮어쓰지 않도록 한다.\r\n const incoming = tokenClaims(message.data?.accessToken || '');\r\n const current = tokenClaims(this.data?.accessToken || '');\r\n if (incoming.sid === current.sid && incoming.exp < current.exp) return;\r\n await this._accept(message.data, false);\r\n }\r\n } catch (error) { this.emit('authError', error); }\r\n }\r\n\r\n async _peerToken(rejectedToken) {\r\n if (!this.channel) return null;\r\n const requestId = `${Date.now()}-${Math.random()}`;\r\n return new Promise(resolve => {\r\n const done = data => {\r\n if (data && (data.accessToken === rejectedToken || Number(tokenClaims(data.accessToken).exp) * 1000 <= Number(data.serverTime) + 60000)) return;\r\n clearTimeout(timer);\r\n this.peerWaiters.delete(requestId);\r\n resolve(data);\r\n };\r\n const timer = setTimeout(() => done(null), 100);\r\n this.peerWaiters.set(requestId, done);\r\n this._publish('request-token', { requestId });\r\n });\r\n }\r\n\r\n _locked(work) {\r\n const locks = typeof navigator !== 'undefined' ? navigator.locks : null;\r\n const operation = (this.httpQueue || Promise.resolve()).then(() =>\r\n locks?.request ? locks.request(`talkflow:auth:${this.serverUrl}`, work) : work());\r\n this.httpQueue = operation.catch(() => {});\r\n return operation;\r\n }\r\n\r\n _newRefreshRequestId() {\r\n const bytes = new Uint8Array(32);\r\n if (!globalThis.crypto?.getRandomValues) {\r\n const error = new Error('Secure randomness is unavailable'); error.code = 'AUTH_RANDOM_UNAVAILABLE'; throw error;\r\n }\r\n globalThis.crypto.getRandomValues(bytes);\r\n return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');\r\n }\r\n\r\n async _request(path, body, headers = {}) {\r\n const started = this._monotonic();\r\n const controller = new AbortController();\r\n const timeout = setTimeout(() => controller.abort(), 15000);\r\n try {\r\n const response = await fetch(`${this.serverUrl}${path}`, {\r\n method: 'POST', credentials: 'omit', signal: controller.signal,\r\n headers: { 'Content-Type': 'application/json', 'X-TalkFlow-Session': 'refresh', ...headers },\r\n body: body === undefined ? undefined : JSON.stringify(body)\r\n });\r\n const text = response.headers.get('content-type')?.includes('application/json')\r\n ? await response.json() : await response.text();\r\n if (!response.ok) {\r\n const error = new Error(text?.message || `HTTP ${response.status}`);\r\n error.status = response.status; error.code = text?.code; error.response = text;\r\n throw error;\r\n }\r\n const data = text?.data !== undefined ? text.data : text;\r\n if (data && typeof data === 'object') return Object.defineProperty({ ...data }, '_roundTripMillis', { value: this._monotonic() - started });\r\n return data;\r\n } finally { clearTimeout(timeout); }\r\n }\r\n\r\n /**\r\n * 같은 이메일로 여러 회사 계정이 있고 비밀번호가 둘 이상 맞으면 서버는 토큰 없이 회사 목록만 준다. 그 응답은\r\n * {@link workspaceSelection} 결과로 돌려준다. 저장소·인증 상태·이전 세션은 건드리지 않는다 — 서버도 이 단계에서는\r\n * 이전 세션을 끊지 않는다. 호출자는 고른 {@code projectId} 를 실어 같은 자격으로 다시 부른다(서버가 비밀번호를 다시 검증).\r\n */\r\n async login({ email, password, projectId }) {\r\n const generation = ++this.generation;\r\n return this._locked(async () => {\r\n const previous = this._readMarker();\r\n this._writeMarker(previous);\r\n if (previous.pendingRefreshToken) await this._request('/v1/auth/signout', { refreshToken: previous.pendingRefreshToken, deviceId: previous.deviceId });\r\n const data = await this._request('/api/v1/auth/login',\r\n { email, password, previousRefreshToken: previous.refreshToken, ...(projectId ? { projectId } : {}) });\r\n if (data?.workspaceSelectionRequired === true) {\r\n if (generation !== this.generation) throw this._cancelled();\r\n return workspaceSelection(data);\r\n }\r\n if (!data?.refreshToken) {\r\n const error = new Error('Server does not support the refresh session contract');\r\n error.code = 'AUTH_PROTOCOL_UNSUPPORTED';\r\n throw error;\r\n }\r\n if (generation !== this.generation || this._readMarker().revision !== previous.revision) {\r\n await this._request('/v1/auth/signout', { refreshToken: data.refreshToken });\r\n throw this._cancelled();\r\n }\r\n this._newMarker(false);\r\n return this._accept(data);\r\n });\r\n }\r\n\r\n async restoreSession() {\r\n if (this._readMarker().logout) {\r\n if (this._readMarker().pending) await this.logout();\r\n else await this._clear(false);\r\n return null;\r\n }\r\n if (this._fresh()) return this.data;\r\n if (!this._readMarker().refreshToken) { await this._clear(false); return null; }\r\n if (!this.data) this._setState('checking');\r\n try { await this.getAccessToken(); return this.data; }\r\n catch (error) { if (isTerminalAuthError(error)) return null; throw error; }\r\n }\r\n\r\n async getAccessToken({ forceRefresh = false, rejectedToken } = {}) {\r\n if (this._readMarker().logout) throw this._loggedOut();\r\n if (this._fresh() && (!forceRefresh || (rejectedToken && rejectedToken !== this.data.accessToken)))\r\n return this.data.accessToken;\r\n if (this.refreshPromise) return this.refreshPromise;\r\n const generation = this.generation;\r\n const revision = this._readMarker().revision;\r\n const operation = this._locked(async () => {\r\n if (generation !== this.generation || revision !== this._readMarker().revision) throw this._cancelled();\r\n if (this._fresh() && (!forceRefresh || (rejectedToken && rejectedToken !== this.data.accessToken)))\r\n return this.data.accessToken;\r\n this.marker = this._readMarker();\r\n if (!this.marker.refreshToken) throw this._loggedOut();\r\n // 회전 전에 저장 가능 여부를 확인한다. 잠금 대기 전 읽은 토큰은 사용하지 않는다.\r\n this._writeMarker(this.marker);\r\n const peer = await this._peerToken(rejectedToken);\r\n let data = peer;\r\n if (!data) {\r\n // 요청 전에 자격과 식별자를 함께 저장한다. 실패·재시작 뒤에도 같은 요청으로 복구한다.\r\n const stored = this._readMarker();\r\n if (generation !== this.generation || revision !== stored.revision || stored.logout) throw this._cancelled();\r\n const requestId = stored.refreshRequestId || this._newRefreshRequestId();\r\n this._writeMarker({ ...stored, refreshRequestId: requestId });\r\n data = await this._request('/api/v1/auth/refresh', { refreshToken: stored.refreshToken, requestId });\r\n }\r\n if (generation !== this.generation || revision !== this._readMarker().revision) throw this._cancelled();\r\n await this._accept(data, !peer);\r\n return data.accessToken;\r\n }).catch(async error => {\r\n if (generation === this.generation && revision === this._readMarker().revision) {\r\n if (isTerminalAuthError(error)) await this.authenticationFailed(error);\r\n else if (!this.data) this._setState('unavailable', error);\r\n else this.emit('authError', error);\r\n }\r\n throw error;\r\n }).finally(() => { if (this.refreshPromise === operation) this.refreshPromise = null; });\r\n this.refreshPromise = operation;\r\n return operation;\r\n }\r\n\r\n async logout() {\r\n const token = this.data?.accessToken;\r\n this.generation++;\r\n const marker = this._newMarker(true);\r\n // 로컬 정리보다 먼저 HTTP 순서를 예약해 뒤의 로그인이 로그아웃을 추월하지 못하게 한다.\r\n const cleanup = this._clear();\r\n const request = this._locked(async () => {\r\n const result = await this._request('/v1/auth/signout', { refreshToken: marker.pendingRefreshToken, deviceId: marker.deviceId },\r\n token ? { Authorization: 'Bearer ' + token.replace(/^Bearer\\s+/i, '') } : {});\r\n if (this._readMarker().revision === marker.revision) this._writeMarker({ ...marker, pending: false, pendingRefreshToken: undefined });\r\n return result;\r\n });\r\n await Promise.all([cleanup, request]);\r\n if (this._readMarker().revision === marker.revision) {\r\n this._writeMarker({ ...marker, pending: false, pendingRefreshToken: undefined });\r\n }\r\n // 명시적 로그인 전에는 재시작해도 로그아웃 의도를 유지한다.\r\n }\r\n\r\n async changePassword(request) {\r\n await this.getAccessToken();\r\n const generation = ++this.generation;\r\n return this._locked(async () => {\r\n const response = await request();\r\n if (generation !== this.generation) throw this._cancelled();\r\n const data = response?.data ?? response;\r\n if (data?.reauthenticationRequired) {\r\n this._newMarker(true, false);\r\n await this._clear();\r\n return response;\r\n }\r\n this._newMarker(false);\r\n await this._accept(data);\r\n return response;\r\n }).catch(async error => {\r\n if (generation === this.generation) await this.authenticationFailed(error);\r\n throw error;\r\n });\r\n }\r\n\r\n _loggedOut() { const error = new Error('Sign in is required'); error.code = 'SESSION_REVOKED'; return error; }\r\n async authenticationFailed(error, { rejectedToken, generation } = {}) {\r\n if (generation !== undefined && generation !== this.generation) return;\r\n if (rejectedToken && this.data && rejectedToken !== this.data.accessToken) return;\r\n if (isTerminalAuthError(error)) {\r\n this.generation++;\r\n this._newMarker(true, false);\r\n await this._clear();\r\n }\r\n }\r\n replaceAccessToken(accessToken) {\r\n if (!this.data || this.data.accessToken === accessToken) return;\r\n const claims = tokenClaims(accessToken);\r\n if (claims.sid !== tokenClaims(this.data.accessToken).sid || claims.sub !== this.data.user.userId) return;\r\n this.data = { ...this.data, accessToken, expiresIn: claims.exp };\r\n this._publish('tokens', this._shareData());\r\n this._reconnectClients();\r\n this.schedule();\r\n }\r\n _cancelled() { const error = new Error('Authentication operation was superseded'); error.code = 'AUTH_OPERATION_CANCELLED'; return error; }\r\n\r\n schedule(delay) {\r\n clearTimeout(this.timer);\r\n this.timer = null;\r\n if (!this.data || ![...this.clients].some(client => client._authConnectionRequested)) return;\r\n const wait = delay ?? Math.max(1000, Number(tokenClaims(this.data.accessToken).exp || 0) * 1000 - this._serverNow() - (this.refreshMargin ?? 60000));\r\n this.timer = setTimeout(async () => {\r\n try {\r\n const before = this.data?.accessToken;\r\n await this.getAccessToken({ forceRefresh: true, rejectedToken: before });\r\n if (this.data) {\r\n await Promise.all([...this.clients].filter(client => client._authConnectionRequested\r\n && !client.isConnected())\r\n .map(client => client.connectionManager.forceReconnect('access-token-renewed')));\r\n }\r\n this.schedule();\r\n } catch (error) {\r\n if (!isTerminalAuthError(error)) this.schedule(30000);\r\n }\r\n }, wait);\r\n this.timer.unref?.();\r\n }\r\n\r\n destroy() {\r\n this.generation++;\r\n clearTimeout(this.timer);\r\n this.channel?.close();\r\n globalThis.removeEventListener?.('storage', this.storageListener);\r\n this.clients.clear();\r\n this.removeAllListeners();\r\n if (sessions.get(this.serverUrl) === this) sessions.delete(this.serverUrl);\r\n }\r\n}\r\n","/**\r\n * JWT 유틸리티 함수\r\n * JWT 토큰 파싱 및 검증\r\n */\r\n\r\nimport { ErrorTypes } from '../constants.js';\r\n\r\n/**\r\n * Bearer 접두사 제거\r\n * @param {string} token - JWT 토큰\r\n * @returns {string}\r\n */\r\nfunction stripBearer(token) {\r\n if (!token || typeof token !== 'string') {\r\n return '';\r\n }\r\n return token.replace(/^Bearer\\s+/i, '');\r\n}\r\n\r\n/**\r\n * Base64URL 디코딩 (UTF-8 안전)\r\n * @param {string} str - Base64URL 인코딩된 문자열\r\n * @returns {string}\r\n */\r\nfunction base64UrlDecode(str) {\r\n let base64 = str.replace(/-/g, '+').replace(/_/g, '/');\r\n const padding = base64.length % 4;\r\n if (padding) {\r\n base64 += '='.repeat(4 - padding);\r\n }\r\n // atob()은 Latin-1 기반이라 멀티바이트 UTF-8(한국어 등)이 깨짐\r\n // → Uint8Array 변환 후 TextDecoder로 UTF-8 디코딩\r\n const binaryString = atob(base64);\r\n const bytes = Uint8Array.from(binaryString, c => c.charCodeAt(0));\r\n return new TextDecoder().decode(bytes);\r\n}\r\n\r\n/**\r\n * JWT 토큰 검증 및 파싱\r\n * @param {string} jwtToken - JWT 토큰 (Bearer 접두사 포함 가능)\r\n * @param {Object} [options] - 옵션\r\n * @param {number} [options.bufferSeconds=30] - 만료 버퍼 시간 (초)\r\n * @param {boolean} [options.validateExpiry=true] - 만료 시간 검증 여부\r\n * @returns {{userId: string, payload: Object}}\r\n * @throws {Error}\r\n */\r\nexport function validateAndParseJWT(jwtToken, options = {}) {\r\n const { bufferSeconds = 30, validateExpiry = true, validateIssuedAt = false } = options;\r\n\r\n if (!jwtToken || typeof jwtToken !== 'string') {\r\n const error = new Error('JWT token is required');\r\n error.code = ErrorTypes.JWT_INVALID;\r\n throw error;\r\n }\r\n\r\n const tokenWithoutBearer = stripBearer(jwtToken);\r\n const parts = tokenWithoutBearer.split('.');\r\n\r\n if (parts.length !== 3) {\r\n const error = new Error('Invalid JWT format: expected 3 parts separated by dots');\r\n error.code = ErrorTypes.JWT_INVALID;\r\n throw error;\r\n }\r\n\r\n let payload;\r\n try {\r\n payload = JSON.parse(base64UrlDecode(parts[1]));\r\n } catch (e) {\r\n const error = new Error('Invalid JWT: failed to decode payload');\r\n error.code = ErrorTypes.JWT_PARSE_FAILED;\r\n error.originalError = e;\r\n throw error;\r\n }\r\n\r\n if (validateExpiry && payload.exp) {\r\n const expiryTime = payload.exp * 1000;\r\n const bufferTime = bufferSeconds * 1000;\r\n const now = Date.now();\r\n\r\n if (expiryTime < now + bufferTime) {\r\n const error = new Error(\r\n expiryTime < now\r\n ? 'JWT token expired'\r\n : `JWT token expires within ${bufferSeconds} seconds`\r\n );\r\n error.code = ErrorTypes.JWT_EXPIRED;\r\n error.expiredAt = new Date(expiryTime).toISOString();\r\n throw error;\r\n }\r\n }\r\n\r\n if (validateIssuedAt && payload.iat) {\r\n const issuedAt = payload.iat * 1000;\r\n const clockSkew = 60 * 1000;\r\n\r\n if (issuedAt > Date.now() + clockSkew) {\r\n const error = new Error('JWT token issued in the future');\r\n error.code = ErrorTypes.JWT_INVALID;\r\n throw error;\r\n }\r\n }\r\n\r\n const userId = payload.userId || payload.sub || payload.user_id;\r\n\r\n if (!userId) {\r\n const error = new Error('Cannot extract userId from JWT: missing userId, sub, or user_id claim');\r\n error.code = ErrorTypes.JWT_INVALID;\r\n throw error;\r\n }\r\n\r\n return { userId, payload };\r\n}\r\n\r\n/**\r\n * JWT에서 userId만 추출\r\n * @param {string} jwtToken - JWT 토큰\r\n * @returns {string|null}\r\n */\r\nexport function extractUserIdFromJWT(jwtToken) {\r\n try {\r\n const tokenWithoutBearer = stripBearer(jwtToken);\r\n const parts = tokenWithoutBearer.split('.');\r\n\r\n if (parts.length !== 3) {\r\n return null;\r\n }\r\n\r\n const payload = JSON.parse(base64UrlDecode(parts[1]));\r\n return payload.userId || payload.sub || payload.user_id || null;\r\n } catch (error) {\r\n console.error('Failed to extract userId from JWT:', error);\r\n return null;\r\n }\r\n}\r\n\r\n/**\r\n * JWT 만료 여부 확인\r\n * @param {string} jwtToken - JWT 토큰\r\n * @param {number} [bufferSeconds=0] - 버퍼 시간 (초)\r\n * @returns {boolean}\r\n */\r\nexport function isJWTExpired(jwtToken, bufferSeconds = 0) {\r\n try {\r\n const tokenWithoutBearer = stripBearer(jwtToken);\r\n const parts = tokenWithoutBearer.split('.');\r\n\r\n if (parts.length !== 3) {\r\n return true;\r\n }\r\n\r\n const payload = JSON.parse(base64UrlDecode(parts[1]));\r\n\r\n if (!payload.exp) {\r\n return false;\r\n }\r\n\r\n const expiryTime = payload.exp * 1000;\r\n const bufferTime = bufferSeconds * 1000;\r\n\r\n return expiryTime < Date.now() + bufferTime;\r\n } catch (error) {\r\n console.error('Failed to check JWT expiration:', error);\r\n return true;\r\n }\r\n}\r\n\r\n/**\r\n * JWT 남은 유효 시간 반환\r\n * @param {string} jwtToken - JWT 토큰\r\n * @returns {number} - 밀리초, 만료 시 음수\r\n */\r\nexport function getJWTRemainingTime(jwtToken) {\r\n try {\r\n const tokenWithoutBearer = stripBearer(jwtToken);\r\n const parts = tokenWithoutBearer.split('.');\r\n\r\n if (parts.length !== 3) {\r\n return -1;\r\n }\r\n\r\n const payload = JSON.parse(base64UrlDecode(parts[1]));\r\n\r\n if (!payload.exp) {\r\n return Infinity;\r\n }\r\n\r\n return (payload.exp * 1000) - Date.now();\r\n } catch (error) {\r\n console.error('Failed to get JWT remaining time:', error);\r\n return -1;\r\n }\r\n}\r\n\r\n/**\r\n * JWT payload 디코딩 (검증 없이)\r\n * @param {string} jwtToken - JWT 토큰\r\n * @returns {Object|null}\r\n */\r\nexport function decodeJWTPayload(jwtToken) {\r\n try {\r\n const tokenWithoutBearer = stripBearer(jwtToken);\r\n const parts = tokenWithoutBearer.split('.');\r\n\r\n if (parts.length !== 3) {\r\n return null;\r\n }\r\n\r\n return JSON.parse(base64UrlDecode(parts[1]));\r\n } catch (error) {\r\n console.error('Failed to decode JWT payload:', error);\r\n return null;\r\n }\r\n}\r\n","/**\r\n * ConnectionManager\r\n * WebSocket/STOMP 연결 관리 (Chat, WebRTC 공유)\r\n */\r\n\r\nimport { isTerminalAuthError } from '../auth/AuthSession.js';\r\nimport EventEmitter from '../utils/EventEmitter.js';\r\nimport Logger from '../utils/Logger.js';\r\nimport { ConnectionState, ErrorTypes, WebSocketPaths, DefaultConfig, LogLevel } from '../constants.js';\r\nimport { Client as StompClient, ReconnectionTimeMode, StompSocketState } from '@stomp/stompjs';\r\nimport SockJS from 'sockjs-client';\r\n\r\n/** 서버 구독 관문의 거부 코드(STOMP ERROR 본문 code · HTTP 402) — 결제 전·해지 회사. */\r\nconst SUBSCRIPTION_REQUIRED_CODE = 'NOT_SUBSCRIBED';\r\n/** 서버가 구독 상태를 잠시 확인하지 못했다(503) — 대기가 아니라 일시 실패다. */\r\nconst SUBSCRIPTION_CHECK_UNAVAILABLE_CODE = 'SUBSCRIPTION_CHECK_UNAVAILABLE';\r\n\r\n// STOMP Client 가져오기 (CDN 사용 시 전역 객체 우선)\r\nconst getStompClient = () => {\r\n // noinspection JSUnresolvedReference\r\n if (typeof window !== 'undefined' && window.StompJs && window.StompJs.Client) {\r\n return window.StompJs.Client;\r\n }\r\n return StompClient;\r\n};\r\n\r\n// SockJS 가져오기 (CDN 사용 시 전역 객체 우선)\r\nconst getSockJS = () => {\r\n // noinspection JSUnresolvedReference\r\n if (typeof window !== 'undefined' && window.SockJS) {\r\n return window.SockJS;\r\n }\r\n return SockJS;\r\n};\r\n\r\nclass ConnectionManager extends EventEmitter {\r\n /**\r\n * @param {Object} options\r\n * @param {string} options.serverUrl - 서버 URL\r\n * @param {string} options.jwtToken - JWT 토큰\r\n * @param {string} options.apiKey - API 키\r\n * @param {string} options.projectId - 프로젝트 ID\r\n * @param {boolean} [options.useSockJS=true] - SockJS 사용 여부\r\n * @param {number} [options.reconnectDelay] - 재연결 지연 시간\r\n * @param {number} [options.maxReconnectAttempts] - 한 묶음의 최대 재연결 시도 횟수\r\n * @param {number} [options.reconnectRecoveryDelay=60000] - 기존 연결의 재시도 소진 후 대기(ms), 0이면 비활성화\r\n * @param {number} [options.heartbeatIncoming] - 수신 하트비트 간격\r\n * @param {number} [options.heartbeatOutgoing] - 발신 하트비트 간격\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options) {\r\n super();\r\n\r\n this.serverUrl = options.serverUrl.replace(/\\/$/, '');\r\n this.jwtToken = options.jwtToken;\r\n this.apiKey = options.apiKey;\r\n this.projectId = options.projectId;\r\n this.useSockJS = options.useSockJS !== false;\r\n\r\n this.reconnectDelay = options.reconnectDelay || DefaultConfig.reconnectDelay;\r\n this.maxReconnectDelay = options.maxReconnectDelay || DefaultConfig.maxReconnectDelay;\r\n this.maxReconnectAttempts = options.maxReconnectAttempts || DefaultConfig.maxReconnectAttempts;\r\n this.reconnectRecoveryDelay = options.reconnectRecoveryDelay ?? DefaultConfig.reconnectRecoveryDelay;\r\n this.heartbeatIncoming = options.heartbeatIncoming || DefaultConfig.heartbeatIncoming;\r\n this.heartbeatOutgoing = options.heartbeatOutgoing || DefaultConfig.heartbeatOutgoing;\r\n this.resumeOnVisibility = options.resumeOnVisibility !== false;\r\n\r\n this.logger = new Logger(options.logLevel || LogLevel.WARN, 'ConnectionManager');\r\n\r\n // 상태\r\n this.state = ConnectionState.DISCONNECTED;\r\n this.stompClient = null;\r\n this.reconnectAttempts = 0;\r\n this.subscriptions = new Map();\r\n this.pendingSubscriptions = [];\r\n this.userId = null;\r\n\r\n // 사용자가 명시적으로 끊은 상태인지 — 포그라운드 복귀가 이걸 되살리면 안 된다.\r\n this._intentionalDisconnect = false;\r\n // forceReconnect 의 teardown 중 발생하는 close 이벤트를 재연결 시도로 오해하지 않도록.\r\n this._tearingDown = false;\r\n // connect() 가 최소 1회 호출됐는지 — 복귀 자동 재연결의 전제 조건.\r\n this._connectRequested = false;\r\n // forceReconnect 단일 실행 보장 (복귀 이벤트 3종이 거의 동시에 들어온다).\r\n this._forceReconnectPromise = null;\r\n this._lifecycleHandlers = null;\r\n this._recoveryTimer = null;\r\n this._terminalAuthError = false;\r\n this._recoveryEligible = false;\r\n // 회사 구독 대기 — 서버가 CONNECT·SUBSCRIBE·SEND 를 NOT_SUBSCRIBED 로 거부했다(결제 전·해지 회사).\r\n // 인증 거부와 다르다: 토큰은 그대로 유효하고, 결제가 끝나면 앱이 같은 토큰으로 connect() 를 다시 부른다.\r\n this._subscriptionRequired = false;\r\n\r\n this._installLifecycleListeners();\r\n }\r\n\r\n /**\r\n * 포그라운드 복귀 / 네트워크 복구 감지 (RECONNECT_SYNC_DESIGN §6.1).\r\n *\r\n * <p>이게 없으면 백그라운드에서 재시도 횟수를 소진해 ERROR 로 죽은 연결이 복귀해도 되살아나지 않아\r\n * <b>새로고침만이 유일한 복구 수단</b>이 된다. 모바일은 OS 가 타이머까지 얼려서 재시도 루프 자체가\r\n * 돌지 못하므로 복귀 시점의 능동적 깨우기가 필수다.</p>\r\n *\r\n * <p>SSR/Node 환경에서는 no-op.</p>\r\n *\r\n * @private\r\n */\r\n _installLifecycleListeners() {\r\n if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') {\r\n return;\r\n }\r\n\r\n const onVisibilityChange = () => {\r\n if (typeof document !== 'undefined' && document.visibilityState === 'visible') {\r\n this._onResume('visibilitychange');\r\n }\r\n };\r\n // bfcache 복원 — visibilitychange 가 안 오는 경로가 있어 별도로 받는다.\r\n const onPageShow = () => this._onResume('pageshow');\r\n const onOnline = () => this._onResume('online');\r\n\r\n if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {\r\n document.addEventListener('visibilitychange', onVisibilityChange);\r\n }\r\n window.addEventListener('pageshow', onPageShow);\r\n window.addEventListener('online', onOnline);\r\n\r\n this._lifecycleHandlers = { onVisibilityChange, onPageShow, onOnline };\r\n }\r\n\r\n /** @private */\r\n _removeLifecycleListeners() {\r\n if (!this._lifecycleHandlers) return;\r\n\r\n const { onVisibilityChange, onPageShow, onOnline } = this._lifecycleHandlers;\r\n if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {\r\n document.removeEventListener('visibilitychange', onVisibilityChange);\r\n }\r\n if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {\r\n window.removeEventListener('pageshow', onPageShow);\r\n window.removeEventListener('online', onOnline);\r\n }\r\n this._lifecycleHandlers = null;\r\n }\r\n\r\n /**\r\n * 복귀 시점 판정.\r\n *\r\n * <p><b>이벤트 계약</b> — {@code resumed} 와 {@code reconnected} 는 <b>상호 배타적</b>이며 둘을 합쳐야\r\n * \"재동기화가 필요한 시점\" 전체를 덮는다:</p>\r\n * <ul>\r\n * <li>{@code resumed} — 복귀했는데 연결이 <b>살아 있어 재연결이 필요 없었다</b>. 그래도 백그라운드\r\n * 동안 프레임을 놓쳤을 수 있으므로 갭 복구는 돌아야 한다.</li>\r\n * <li>{@code reconnected} — 재연결에 <b>성공</b>했다. 복귀가 유발한 경우든(좀비 소켓/ERROR 복구)\r\n * 탭이 열린 채 일시 끊겼다 붙은 경우든 이 이벤트로 수렴한다.</li>\r\n * </ul>\r\n *\r\n * <p>재연결이 일어나는 경로에서는 {@code resumed} 를 발행하지 않는다 — 하나의 복귀에 두 이벤트가\r\n * 겹쳐 나가면 소비자가 갭 복구를 두 번 돌린다. 소비자는 <b>둘 다</b> 구독해야 한다.</p>\r\n *\r\n * @private\r\n */\r\n _onResume(reason) {\r\n if (!this.resumeOnVisibility || this._intentionalDisconnect || this._terminalAuthError || this._subscriptionRequired) {\r\n return;\r\n }\r\n\r\n // connect() 를 아직 한 번도 호출하지 않았으면 복귀가 연결을 만들어내면 안 된다.\r\n // 이 경로는 TalkFlowClient.connect() 를 우회하므로 roomList 자동 구독 / WebRTC enable 등\r\n // 상위 초기화가 통째로 빠진 \"반쪽 연결\" 이 된다. 최초 연결은 반드시 사용자 호출로 시작한다.\r\n // (1회 연결된 뒤의 재연결은 _restoreSubscriptions 가 STOMP 구독을 되살리므로 안전하다.)\r\n if (!this._connectRequested) {\r\n this.logger.debug(`Resume (${reason}) ignored — connect() has not been called yet`);\r\n return;\r\n }\r\n\r\n // 연결 시도 중이거나 강제 재연결이 진행 중이면 그대로 둔다 — 중복 activate 방지.\r\n if (this.state === ConnectionState.CONNECTING || this._forceReconnectPromise) {\r\n return;\r\n }\r\n\r\n if (this.state === ConnectionState.CONNECTED) {\r\n if (this._isSocketAlive()) {\r\n this.logger.debug(`Resume (${reason}): connection alive`);\r\n this.emit('resumed', { reason });\r\n return;\r\n }\r\n // 좀비 소켓 — STOMP 가 아직 close 를 인지하지 못한 상태.\r\n this.logger.warn(`Resume (${reason}): zombie socket detected, forcing reconnect`);\r\n this.forceReconnect(`zombie-socket:${reason}`);\r\n return;\r\n }\r\n\r\n // DISCONNECTED(비의도) / RECONNECTING / ERROR — 재시도 횟수를 리셋하고 즉시 되살린다.\r\n this.logger.info(`Resume (${reason}): reviving connection from state=${this.state}`);\r\n this.forceReconnect(reason);\r\n }\r\n\r\n /**\r\n * 소켓이 실제로 열려 있는지.\r\n *\r\n * <p>{@code state === CONNECTED} 만으로는 부족하다 — OS 가 백그라운드에서 소켓을 끊어도\r\n * STOMP 가 close 를 인지하기 전까지 상태는 CONNECTED 로 남는다. 반대로 readyState 가 OPEN 인데\r\n * 실제로는 죽은 경우는 STOMP 자체의 incoming heartbeat 감시가 닫아주고, 그 사이 유실분은\r\n * 갭 복구가 메운다.</p>\r\n *\r\n * @private\r\n */\r\n _isSocketAlive() {\r\n const socket = this.stompClient && this.stompClient.webSocket;\r\n if (!socket) return false;\r\n return socket.readyState === StompSocketState.OPEN;\r\n }\r\n\r\n /**\r\n * 강제 재연결 — 기존 클라이언트를 완전히 정리한 뒤 새로 활성화.\r\n *\r\n * <p>ERROR 상태({@link #_handleReconnect} 가 {@code deactivate} 로 STOMP 루프를 끈 상태)에서도\r\n * 되살아나야 하므로 {@code activate} 재호출이 필요하다.</p>\r\n *\r\n * @param {string} reason\r\n * @returns {Promise<void>}\r\n */\r\n async forceReconnect(reason) {\r\n if (this._intentionalDisconnect || this._terminalAuthError || this.state === ConnectionState.CONNECTING) {\r\n return;\r\n }\r\n\r\n // 단일 실행 — 복귀 시 visibilitychange / pageshow / online 이 거의 동시에 들어오는데,\r\n // deactivate() 를 await 하는 동안 state 는 아직 CONNECTED 라 재진입 가드가 없으면\r\n // 두 번째 resume 이 그대로 통과해 클라이언트를 이중으로 만든다.\r\n if (!this._forceReconnectPromise) {\r\n this._forceReconnectPromise = this._doForceReconnect(reason)\r\n .finally(() => { this._forceReconnectPromise = null; });\r\n }\r\n await this._forceReconnectPromise;\r\n // 외부 호출의 완료 의미는 유지하되, 다음 복구 묶음을 막는 실행 잠금은 이미 풀었다.\r\n try { await this._connectPromise; }\r\n catch { /* connect/error 이벤트가 종료 사유를 전달한다. */ }\r\n }\r\n\r\n /** @private */\r\n async _doForceReconnect(reason) {\r\n this._cancelReconnectRecovery();\r\n this.logger.info(`Force reconnect: ${reason}`);\r\n\r\n if (this.stompClient) {\r\n this._tearingDown = true;\r\n try {\r\n await this.stompClient.deactivate();\r\n } catch (error) {\r\n this.logger.warn('Error deactivating stomp client on force reconnect:', error);\r\n } finally {\r\n this._tearingDown = false;\r\n }\r\n this.stompClient = null;\r\n }\r\n\r\n // 구독 대기면 다시 붙지 않는다 — refresh 세션의 토큰 갱신(AuthSession 'access-token-renewed')·저빈도 복구·탭 복귀가 모두\r\n // 이리로 오고, 정리를 기다리는 동안 옛 클라이언트가 NOT_SUBSCRIBED 로 대기에 들어간 경우도 여기서 걸린다. 대기는 앱이 connect() 를\r\n // 다시 불러야 풀린다.\r\n if (this._intentionalDisconnect || this._terminalAuthError || this._subscriptionRequired) return;\r\n\r\n // connect() 가 CONNECTED/CONNECTING 이면 조기 반환하므로 상태를 먼저 내려둔다.\r\n this._setState(ConnectionState.DISCONNECTED);\r\n this.reconnectAttempts = 0;\r\n\r\n try {\r\n // 활성화까지만 직렬화한다. connect 대기는 장애 전체에 걸쳐 유지되므로\r\n // 여기서 await 하면 다음 저빈도 복구가 이 잠금에 막힌다.\r\n this.connect().catch(error => this.logger.warn(`Force reconnect failed (${reason}):`, error));\r\n } catch (error) {\r\n this.logger.warn(`Force reconnect failed (${reason}):`, error);\r\n }\r\n }\r\n\r\n /**\r\n * 연결 상태 변경\r\n * @private\r\n */\r\n _setState(state) {\r\n const prevState = this.state;\r\n this.state = state;\r\n this.emit('stateChange', { state, prevState });\r\n this.logger.info(`State changed: ${prevState} -> ${state}`);\r\n }\r\n\r\n /**\r\n * WebSocket URL 생성.\r\n *\r\n * <p>JWT 는 STOMP connectHeaders 의 Authorization 헤더로만 전달합니다.\r\n * URL query parameter 에 토큰을 넣지 않습니다 — 서버 로그, 프록시 로그,\r\n * 브라우저 히스토리에 JWT 가 평문 노출되는 것을 방지.</p>\r\n *\r\n * @private\r\n */\r\n _getWebSocketUrl() {\r\n const endpoint = this.useSockJS\r\n ? WebSocketPaths.SOCKJS_ENDPOINT\r\n : WebSocketPaths.NATIVE_ENDPOINT;\r\n\r\n return `${this.serverUrl}${endpoint}`;\r\n }\r\n\r\n /**\r\n * STOMP 클라이언트 생성\r\n * @private\r\n */\r\n _createStompClient() {\r\n const wsUrl = this._getWebSocketUrl();\r\n // 교체된 클라이언트(정지 뒤 새 토큰으로 다시 연결 등)의 늦은 콜백은 현재 연결 상태를 바꾸지 않는다.\r\n let stomp = null;\r\n const current = () => !this.stompClient || this.stompClient === stomp;\r\n\r\n const config = {\r\n beforeConnect: async (client) => {\r\n if (!this.getAccessToken) {\r\n this._connectionToken = this.jwtToken;\r\n return;\r\n }\r\n let attempt = 0;\r\n while (!this._intentionalDisconnect && (!client || client.active)) {\r\n try {\r\n const token = await this.getAccessToken({ forceRefresh: this._authRejected === true });\r\n if (this._intentionalDisconnect || (client && !client.active)) return;\r\n if (this._authRejected && (!token || token.replace(/^Bearer\\s+/i, '') === this._rejectedToken)) {\r\n this._stopForAuthError(new Error('Authentication rejected without a new access token'), client);\r\n return;\r\n }\r\n this.updateToken(token);\r\n this._connectionToken = token;\r\n this._authRejected = false;\r\n return;\r\n } catch (error) {\r\n this.emit('error', { type: ErrorTypes.CONNECTION_FAILED, message: error.message, error });\r\n // 거부된 access token과 갱신 요청의 일시 실패는 별개다.\r\n // 새 토큰/동일 토큰 판정은 위에서, 확정된 인증 종료만 여기서 처리한다.\r\n if (isTerminalAuthError(error)) {\r\n this._stopForAuthError(error, client);\r\n return;\r\n }\r\n // beforeConnect의 예외에는 STOMP 소켓 종료 백오프가 적용되지 않는다.\r\n const wait = Math.min(30000, 1000 * 2 ** Math.min(attempt++, 5)) * (0.8 + Math.random() * 0.4);\r\n await new Promise(resolve => {\r\n this._cancelAuthRetry = () => { clearTimeout(this._authRetryTimer); resolve(); };\r\n this._authRetryTimer = setTimeout(resolve, wait);\r\n this._authRetryTimer.unref?.();\r\n });\r\n this._cancelAuthRetry = null;\r\n }\r\n }\r\n },\r\n connectHeaders: {\r\n 'Authorization': this.jwtToken.startsWith('Bearer ')\r\n ? this.jwtToken\r\n : `Bearer ${this.jwtToken}`,\r\n 'X-API-KEY': this.apiKey,\r\n 'X-PROJECT-ID': this.projectId\r\n },\r\n heartbeatIncoming: this.heartbeatIncoming,\r\n heartbeatOutgoing: this.heartbeatOutgoing,\r\n // stompjs는 연결 성공 시 다음 지연을 저장한다. close 콜백보다 먼저 지터를 적용한다.\r\n reconnectDelay: this.reconnectDelay * (0.8 + Math.random() * 0.4),\r\n // 지수 백오프 — 고정 간격이면 서버 재시작 시 전 클라이언트가 같은 주기로 몰린다.\r\n // STOMP 7 내장 기능이라 직접 타이머를 굴리지 않는다.\r\n reconnectTimeMode: ReconnectionTimeMode.EXPONENTIAL,\r\n maxReconnectDelay: this.maxReconnectDelay,\r\n debug: (str) => {\r\n if (this.logger.level <= LogLevel.DEBUG) {\r\n this.logger.debug('STOMP:', str);\r\n }\r\n },\r\n onConnect: (frame) => { if (current()) this._onConnect(frame); },\r\n onDisconnect: (frame) => { if (current()) this._onDisconnect(frame); },\r\n onStompError: (frame) => { if (current()) this._onStompError(frame); },\r\n onWebSocketClose: (event) => { if (current()) this._onWebSocketClose(event); },\r\n onWebSocketError: (event) => { if (current()) this._onWebSocketError(event); }\r\n };\r\n\r\n if (this.useSockJS) {\r\n const SockJS = getSockJS();\r\n config.webSocketFactory = () => new SockJS(wsUrl);\r\n } else {\r\n // Native WebSocket — 토큰은 connectHeaders 로만 전달 (URL 노출 방지)\r\n const wsProtocol = this.serverUrl.startsWith('https') ? 'wss' : 'ws';\r\n const wsHost = this.serverUrl.replace(/^https?:\\/\\//, '');\r\n config.brokerURL = `${wsProtocol}://${wsHost}${WebSocketPaths.NATIVE_ENDPOINT}`;\r\n }\r\n\r\n const Client = getStompClient();\r\n stomp = new Client(config);\r\n return stomp;\r\n }\r\n\r\n /**\r\n * 연결\r\n * @returns {Promise<void>}\r\n */\r\n async connect() {\r\n if (this.state === ConnectionState.CONNECTED) {\r\n this.logger.warn('Already connected');\r\n return;\r\n }\r\n\r\n if (!this._connectPromise) {\r\n this._connectPromise = new Promise((resolve, reject) => {\r\n const clear = () => {\r\n this._connectPromise = null;\r\n this._connectResolve = this._connectReject = null;\r\n };\r\n this._connectResolve = () => { clear(); resolve(); };\r\n this._connectReject = error => { clear(); reject(error); };\r\n });\r\n }\r\n const connected = this._connectPromise;\r\n if (this.stompClient && (this.state === ConnectionState.CONNECTING || this.state === ConnectionState.RECONNECTING)) {\r\n return connected;\r\n }\r\n this._cancelReconnectRecovery();\r\n this._terminalAuthError = false;\r\n // 구독 대기는 앱이 connect() 를 다시 불러야 풀린다(결제 확인 뒤). 아직 구독이 없으면 서버가 다시 거부해 대기로 돌아온다.\r\n this._subscriptionRequired = false;\r\n this._setState(ConnectionState.CONNECTING);\r\n this.reconnectAttempts = 0;\r\n this._intentionalDisconnect = false;\r\n this._connectRequested = true;\r\n try {\r\n this.stompClient = this._createStompClient();\r\n this.stompClient.activate();\r\n } catch (error) {\r\n this._setState(ConnectionState.ERROR);\r\n this._drainPendingSubscriptions('Connection activation failed');\r\n this._connectReject?.(error);\r\n }\r\n return connected;\r\n }\r\n\r\n /**\r\n * 연결 성공 콜백\r\n * @private\r\n */\r\n _onConnect(frame) {\r\n this._authenticatedToken = this.jwtToken;\r\n // 재연결 여부 판정은 카운터 리셋 전에 — 갭 복구(S4)가 이 신호로 동기화를 건다.\r\n const attempts = this.reconnectAttempts;\r\n const isReconnect = attempts > 0 || this._hasConnectedBefore === true;\r\n\r\n this._setState(ConnectionState.CONNECTED);\r\n this.reconnectAttempts = 0;\r\n this._hasConnectedBefore = true;\r\n this._recoveryEligible = true;\r\n this._authRejected = false;\r\n this._rejectedToken = null;\r\n this._notifiedRejectedToken = null;\r\n this.logger.info('Connected to server', frame);\r\n\r\n // 기존 구독 재등록 (재연결 시)\r\n this._restoreSubscriptions();\r\n\r\n // 대기 중인 구독 처리\r\n this._processPendingSubscriptions();\r\n\r\n this.emit('connected', { frame });\r\n\r\n // 재연결 '성공' 신호 — 기존에는 성공 이벤트가 없어 connected 재발행으로만 유추해야 했다.\r\n if (isReconnect) {\r\n this.emit('reconnected', { attempts });\r\n }\r\n\r\n if (this._connectResolve) {\r\n this._connectResolve();\r\n this._connectResolve = null;\r\n this._connectReject = null;\r\n }\r\n }\r\n\r\n /**\r\n * 연결 해제 콜백\r\n * @private\r\n */\r\n _onDisconnect(frame) {\r\n this.logger.info('Disconnected from server', frame);\r\n this._setState(ConnectionState.DISCONNECTED);\r\n this.emit('disconnected', { frame });\r\n }\r\n\r\n /**\r\n * STOMP 에러 콜백\r\n * @private\r\n */\r\n _onStompError(frame) {\r\n let code = null;\r\n let serverMessage = null;\r\n try {\r\n const payload = JSON.parse(frame.body || frame.headers?.message || '{}');\r\n code = payload.code ?? null;\r\n serverMessage = typeof payload.message === 'string' ? payload.message : null;\r\n this._authRejected = ['ACCESS_UNAUTHORIZED', 'SESSION_REVOKED', 'TOKEN_INVALID'].includes(code);\r\n } catch { /* Non-authentication STOMP errors keep the normal reconnect policy. */ }\r\n // 회사 구독 없음 — 재연결해도 같은 거부다. 인증 거부로 기록하지 않고 «구독 대기» 로 멈춘다.\r\n // 구독 확인 일시 실패(SUBSCRIPTION_CHECK_UNAVAILABLE)는 아래 기본 경로 — 평소처럼 다시 붙는다.\r\n if (code === SUBSCRIPTION_REQUIRED_CODE) {\r\n this._stopForSubscription(serverMessage);\r\n return;\r\n }\r\n this.logger.error('STOMP error:', frame);\r\n this._setState(ConnectionState.ERROR);\r\n\r\n const error = {\r\n type: ErrorTypes.CONNECTION_FAILED,\r\n message: frame.headers?.message || 'STOMP error',\r\n frame\r\n };\r\n\r\n if (this._authRejected) this._recordAuthRejection();\r\n // 구독 확인 일시 실패는 첫 연결이라도 대기 중인 connect() 를 끝내지 않는다 — STOMP 가 곧 다시 붙는다. 여기서 reject 하면\r\n // 앱은 실패로 알고 방 목록 구독 등 후속 초기화를 건너뛰는데 연결은 붙어 «반쪽 연결» 이 된다.\r\n const terminal = code !== SUBSCRIPTION_CHECK_UNAVAILABLE_CODE && !this._canRecoverConnection();\r\n if (terminal) this._drainPendingSubscriptions(`STOMP error: ${error.message}`);\r\n\r\n this.emit('error', error);\r\n\r\n if (terminal && this._connectReject) {\r\n this._connectReject(new Error(error.message));\r\n this._connectResolve = null;\r\n this._connectReject = null;\r\n }\r\n }\r\n\r\n /**\r\n * WebSocket 종료 콜백\r\n * @private\r\n */\r\n _onWebSocketClose(event) {\r\n if (event?.code === 4001) this._recordAuthRejection();\r\n this.logger.warn('WebSocket closed:', event);\r\n\r\n // forceReconnect 의 의도적 teardown 이면 재연결 시도로 집계하지 않는다 —\r\n // 카운터가 오염되면 복귀 직후 다시 max 를 소진해버린다.\r\n if (this._tearingDown || this._intentionalDisconnect || this._terminalAuthError || this._subscriptionRequired) {\r\n this.logger.debug('WebSocket closed during intentional teardown — not counting as reconnect');\r\n return;\r\n }\r\n\r\n if (this.state !== ConnectionState.DISCONNECTED) {\r\n this._handleReconnect();\r\n }\r\n }\r\n\r\n /**\r\n * WebSocket 에러 콜백\r\n * @private\r\n */\r\n _onWebSocketError(event) {\r\n this.logger.error('WebSocket error:', event);\r\n\r\n // 이전에 연결된 클라이언트의 복구 중에는 원래 호출자의 대기를 유지한다.\r\n if (this._connectReject && !this._canRecoverConnection()) {\r\n this._drainPendingSubscriptions('WebSocket connection failed');\r\n this._connectReject(new Error('WebSocket connection failed'));\r\n this._connectResolve = null;\r\n this._connectReject = null;\r\n }\r\n }\r\n\r\n /**\r\n * 재연결 처리.\r\n *\r\n * <p>max 초과 시 STOMP 내부 재연결 루프(reconnectDelay) 를 차단하기 위해\r\n * stompClient 를 반드시 deactivate 하고 참조를 해제한다. state 변경만으로는\r\n * STOMP 자체가 소유한 타이머를 멈출 수 없음.</p>\r\n *\r\n * @private\r\n */\r\n _handleReconnect() {\r\n this.reconnectAttempts++;\r\n\r\n if (this.reconnectAttempts > this.maxReconnectAttempts) {\r\n const exhaustedClient = this.stompClient;\r\n this.stompClient = null;\r\n // 같은 STOMP 인스턴스의 타이머를 완전히 끈 뒤 저빈도 복구를 예약한다.\r\n // Promise 안에서 호출을 시작하면 stompjs가 close 이후 재시도를 먼저 예약할 수 있다.\r\n let deactivated;\r\n try {\r\n deactivated = exhaustedClient?.deactivate();\r\n } catch (error) {\r\n this.logger.warn('Error deactivating stomp client on max reconnect:', error);\r\n }\r\n Promise.resolve(deactivated).catch(error => {\r\n this.logger.warn('Error deactivating stomp client on max reconnect:', error);\r\n }).then(() => this._scheduleReconnectRecovery());\r\n\r\n // 복구 묶음 사이에서도 같은 connect/subscribe 요청을 유지한다.\r\n if (!this._canRecoverConnection()) {\r\n this._connectReject?.(new Error('Max reconnection attempts exceeded'));\r\n this._connectResolve = this._connectReject = null;\r\n this._drainPendingSubscriptions('Max reconnection attempts exceeded');\r\n }\r\n\r\n this._setState(ConnectionState.ERROR);\r\n this.emit('error', {\r\n type: ErrorTypes.CONNECTION_LOST,\r\n message: 'Max reconnection attempts exceeded'\r\n });\r\n return;\r\n }\r\n\r\n this._setState(ConnectionState.RECONNECTING);\r\n this.emit('reconnecting', { attempt: this.reconnectAttempts });\r\n this.logger.info(`Reconnecting... attempt ${this.reconnectAttempts}`);\r\n }\r\n\r\n /** 실제 거부된 자격 증명을 기록한다. 공급 함수가 있어도 정적 토큰일 수 있다. */\r\n _recordAuthRejection() {\r\n this._authRejected = true;\r\n this._rejectedToken = (this._connectionToken || this.jwtToken).replace(/^Bearer\\s+/i, '');\r\n if (!this.getAccessToken) this._stopForAuthError(new Error('STOMP error: authentication rejected'));\r\n }\r\n\r\n _stopForAuthError(error, client = this.stompClient) {\r\n // 같은 거부 토큰은 한 번만 알린다. 오류 처리기가 connect() 를 다시 불러도 즉시 정지 → 알림이 반복되지 않는다.\r\n const notify = !this._terminalAuthError && this._usesStaticToken() && this._rejectedToken !== this._notifiedRejectedToken;\r\n this._terminalAuthError = true;\r\n this._recoveryEligible = false;\r\n this._cancelReconnectRecovery();\r\n this._setState(ConnectionState.ERROR);\r\n this._connectReject?.(error);\r\n this._drainPendingSubscriptions(error.message);\r\n // beforeConnect 안에서도 호출된다. 여기서 deactivate 완료를 기다리지 않는다.\r\n Promise.resolve(client?.deactivate()).catch(failure => this.logger.warn('Auth teardown failed:', failure));\r\n // 정적 토큰 모드는 정지를 오류 이벤트(FE connectionError)로 알린다. 새 토큰은 updateToken() 으로 온다.\r\n // 정지 전이(이 함수와 호출한 _onStompError 의 나머지)를 마친 뒤 알린다. 처리기의 updateToken()·connect() 가 정리 중인 연결과 섞이지 않는다.\r\n if (notify) {\r\n this._notifiedRejectedToken = this._rejectedToken;\r\n Promise.resolve().then(() => this.emit('error', { type: ErrorTypes.CONNECTION_LOST, message: error.message, error }));\r\n }\r\n }\r\n\r\n /** 갱신 세션이 없는 정적 토큰(레거시) 모드. canRefreshToken 은 TalkFlowClient 가 설치한다. */\r\n _usesStaticToken() {\r\n return !this.getAccessToken || this.canRefreshToken?.() === false;\r\n }\r\n\r\n _canRecoverConnection() {\r\n return this._recoveryEligible && !this._intentionalDisconnect && !this._terminalAuthError\r\n && !this._subscriptionRequired && this.reconnectRecoveryDelay > 0;\r\n }\r\n\r\n /** 회사 구독 대기 중인가 — 서버가 NOT_SUBSCRIBED 로 거부해 연결을 멈췄다. 앱이 connect() 를 다시 부르면 풀린다. */\r\n isSubscriptionRequired() {\r\n return this._subscriptionRequired;\r\n }\r\n\r\n /**\r\n * «구독 대기» 로 멈춘다 (서버 구독 관문 설계 v3 §7).\r\n *\r\n * <p>인증 정지({@link #_stopForAuthError})와 같은 방식으로 STOMP 재연결 루프·저빈도 복구·탭 복귀 재연결을 끄되, 토큰은\r\n * 거부된 토큰으로 기록하지 않는다 — 토큰 갱신·로그인은 그대로 둔다. 대기 중인 connect()·구독은 reject 하고, 한 번 들어올 때\r\n * {@code subscriptionRequired} 를 알린다(일반 {@code error} 는 내지 않는다 — 연결 오류 안내가 결제 안내를 가리지 않게).\r\n * 연결 중에 해지되면 다음 SUBSCRIBE·SEND 의 ERROR 로 같은 경로에 온다.</p>\r\n *\r\n * @private\r\n */\r\n _stopForSubscription(serverMessage) {\r\n const entering = !this._subscriptionRequired;\r\n this._subscriptionRequired = true;\r\n this._cancelReconnectRecovery();\r\n this._setState(ConnectionState.ERROR);\r\n // 본문의 안내 문구 — message 헤더는 오류 JSON 전체가 이스케이프된 채 오므로 쓰지 않는다.\r\n const message = serverMessage || 'Company subscription required';\r\n const error = Object.assign(new Error('Company subscription required'),\r\n { code: SUBSCRIPTION_REQUIRED_CODE, type: ErrorTypes.SUBSCRIPTION_REQUIRED });\r\n this._connectReject?.(error);\r\n this._connectResolve = this._connectReject = null;\r\n this._drainPendingSubscriptions('Company subscription required');\r\n const client = this.stompClient;\r\n Promise.resolve(client?.deactivate()).catch(failure => this.logger.warn('Subscription teardown failed:', failure));\r\n this.logger.warn('Company subscription required — connection stopped until connect() is called again');\r\n if (entering) {\r\n // 정지 전이를 마친 뒤 알린다 — 처리기가 곧바로 connect() 를 불러도 정리 중인 연결과 섞이지 않는다.\r\n Promise.resolve().then(() => this.emit('subscriptionRequired', { code: SUBSCRIPTION_REQUIRED_CODE, message }));\r\n }\r\n }\r\n\r\n /** 기존에 연결됐던 클라이언트만 긴 장애 후 탭 이벤트 없이 다시 시도한다. */\r\n _scheduleReconnectRecovery() {\r\n if (!this._canRecoverConnection() || this.state !== ConnectionState.ERROR || this.stompClient) return;\r\n this._cancelReconnectRecovery();\r\n const delay = this.reconnectRecoveryDelay * (0.8 + Math.random() * 0.4);\r\n this._recoveryTimer = setTimeout(() => {\r\n this._recoveryTimer = null;\r\n if (!this._intentionalDisconnect && !this._terminalAuthError && this.state === ConnectionState.ERROR) {\r\n this.forceReconnect('retry-cooldown');\r\n }\r\n }, delay);\r\n this._recoveryTimer.unref?.();\r\n }\r\n\r\n _cancelReconnectRecovery() {\r\n clearTimeout(this._recoveryTimer);\r\n this._recoveryTimer = null;\r\n }\r\n\r\n /**\r\n * 기존 구독 재등록 (재연결 시)\r\n * @private\r\n */\r\n _restoreSubscriptions() {\r\n if (this.subscriptions.size === 0) return;\r\n\r\n this.logger.info(`Restoring ${this.subscriptions.size} subscriptions after reconnect`);\r\n\r\n const previousSubscriptions = new Map(this.subscriptions);\r\n this.subscriptions.clear();\r\n\r\n previousSubscriptions.forEach(({ callback, headers }, destination) => {\r\n this._subscribeInternal(destination, callback, headers);\r\n this.logger.debug(`Restored subscription: ${destination}`);\r\n });\r\n }\r\n\r\n /**\r\n * 대기 중인 구독 처리\r\n * @private\r\n */\r\n _processPendingSubscriptions() {\r\n while (this.pendingSubscriptions.length > 0) {\r\n const { destination, callback, headers, resolve } = this.pendingSubscriptions.shift();\r\n const subscription = this._subscribeInternal(destination, callback, headers);\r\n if (resolve) {\r\n resolve(subscription);\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * 내부 구독 처리\r\n * @private\r\n */\r\n _subscribeInternal(destination, callback, headers = {}) {\r\n const subscription = this.stompClient.subscribe(destination, (message) => {\r\n try {\r\n const body = JSON.parse(message.body);\r\n callback(body, message);\r\n } catch (error) {\r\n this.logger.error('Failed to parse message:', error);\r\n callback(message.body, message);\r\n }\r\n }, headers);\r\n\r\n // 재연결 시 복구를 위해 callback, headers도 함께 저장\r\n this.subscriptions.set(destination, { subscription, callback, headers });\r\n this.logger.debug(`Subscribed to: ${destination}`);\r\n\r\n return subscription;\r\n }\r\n\r\n /**\r\n * 채널 구독\r\n * @param {string} destination - 구독 경로\r\n * @param {Function} callback - 메시지 콜백\r\n * @param {Object} [headers] - 추가 헤더\r\n * @returns {Promise<Object>} - 구독 객체\r\n */\r\n subscribe(destination, callback, headers = {}) {\r\n // 이미 구독 중이면 기존 구독 해제 후 재구독\r\n if (this.subscriptions.has(destination)) {\r\n this.unsubscribe(destination);\r\n }\r\n\r\n if (this.state !== ConnectionState.CONNECTED) {\r\n return new Promise((resolve, reject) => {\r\n this.pendingSubscriptions.push({ destination, callback, headers, resolve, reject });\r\n this.logger.debug(`Queued subscription for: ${destination}`);\r\n });\r\n }\r\n\r\n return Promise.resolve(this._subscribeInternal(destination, callback, headers));\r\n }\r\n\r\n /**\r\n * 구독 해제\r\n * @param {string} destination - 구독 경로\r\n */\r\n unsubscribe(destination) {\r\n const entry = this.subscriptions.get(destination);\r\n if (entry) {\r\n entry.subscription.unsubscribe();\r\n this.subscriptions.delete(destination);\r\n this.logger.debug(`Unsubscribed from: ${destination}`);\r\n }\r\n }\r\n\r\n /**\r\n * 모든 구독 해제\r\n */\r\n unsubscribeAll() {\r\n this.subscriptions.forEach(({ subscription }, destination) => {\r\n subscription.unsubscribe();\r\n this.logger.debug(`Unsubscribed from: ${destination}`);\r\n });\r\n this.subscriptions.clear();\r\n }\r\n\r\n /**\r\n * 메시지 전송\r\n * @param {string} destination - 전송 경로\r\n * @param {Object} body - 메시지 본문\r\n * @param {Object} [headers] - 추가 헤더\r\n */\r\n send(destination, body, headers = {}) {\r\n if (this.state !== ConnectionState.CONNECTED) {\r\n this.logger.warn('Cannot send message: not connected');\r\n return false;\r\n }\r\n\r\n this.stompClient.publish({\r\n destination,\r\n body: JSON.stringify(body),\r\n headers\r\n });\r\n\r\n this.logger.debug(`Sent to ${destination}:`, body);\r\n return true;\r\n }\r\n\r\n /**\r\n * JWT 토큰 업데이트\r\n * @param {string} token\r\n */\r\n updateToken(token) {\r\n this.jwtToken = token;\r\n\r\n if (this.stompClient) {\r\n this.stompClient.connectHeaders = {\r\n ...this.stompClient.connectHeaders,\r\n 'Authorization': token.startsWith('Bearer ') ? token : `Bearer ${token}`\r\n };\r\n }\r\n\r\n // 정적 토큰 모드에서 거부된 토큰으로 정지했다면 새 토큰으로 다시 연결한다. 같은 토큰이면 정지를 유지한다.\r\n // connect() 는 진행 중인 연결을 공유한다. connect(jwt) 가 이어서 connect() 를 불러도 클라이언트는 하나다.\r\n if (this._terminalAuthError && !this._intentionalDisconnect && this._usesStaticToken()\r\n && token.replace(/^Bearer\\s+/i, '') !== this._rejectedToken) {\r\n this.connect().catch(error => this.logger.warn('Reconnect with updated token failed:', error));\r\n }\r\n }\r\n\r\n /**\r\n * 연결 해제.\r\n *\r\n * <p>대기 중 구독이 남아 있으면 각 Promise 를 reject 하고 큐를 비운다.\r\n * clear 만 하면 호출자 {@code await} 가 영원히 hang 된다.</p>\r\n */\r\n async disconnect() {\r\n // 명시적 해제 — 포그라운드 복귀가 이걸 되살리면 안 된다.\r\n this._intentionalDisconnect = true;\r\n this._recoveryEligible = false;\r\n this._subscriptionRequired = false;\r\n this._authRejected = false;\r\n this._rejectedToken = null;\r\n this._cancelReconnectRecovery();\r\n this._connectRequested = false;\r\n this._cancelAuthRetry?.();\r\n this._connectReject?.(Object.assign(new Error('Connection cancelled'), { code: 'AUTH_OPERATION_CANCELLED' }));\r\n this._connectResolve = this._connectReject = null;\r\n this._drainPendingSubscriptions('Disconnected before subscription completed');\r\n\r\n if (this.stompClient) {\r\n this.unsubscribeAll();\r\n await this.stompClient.deactivate();\r\n this.stompClient = null;\r\n }\r\n\r\n this._setState(ConnectionState.DISCONNECTED);\r\n this.logger.info('Disconnected');\r\n }\r\n\r\n /**\r\n * 대기 중 구독 Promise 일괄 reject + 큐 비움.\r\n * @private\r\n */\r\n _drainPendingSubscriptions(reason) {\r\n if (this.pendingSubscriptions.length === 0) return;\r\n\r\n const pending = this.pendingSubscriptions;\r\n this.pendingSubscriptions = [];\r\n\r\n pending.forEach(({ destination, reject }) => {\r\n if (reject) {\r\n reject(new Error(`${reason}: ${destination}`));\r\n }\r\n });\r\n\r\n this.logger.debug(`Drained ${pending.length} pending subscriptions: ${reason}`);\r\n }\r\n\r\n /**\r\n * 연결 상태 확인\r\n * @returns {boolean}\r\n */\r\n isConnected() {\r\n return this.state === ConnectionState.CONNECTED;\r\n }\r\n\r\n /**\r\n * 현재 상태 반환\r\n * @returns {string}\r\n */\r\n getState() {\r\n return this.state;\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n }\r\n\r\n /**\r\n * 리소스 정리.\r\n *\r\n * <p>{@link #disconnect} 가 async 이므로 await 필수. 최상위 {@link TalkFlowClient#destroy}\r\n * 는 별도 {@code await this.disconnect()} 선행으로 이미 완충되지만,\r\n * ConnectionManager 를 직접 소유한 사용자가 destroy 만 호출하는 경로도 안전해야 함.</p>\r\n */\r\n async destroy() {\r\n this._removeLifecycleListeners();\r\n await this.disconnect();\r\n this.removeAllListeners();\r\n this.logger.info('ConnectionManager destroyed');\r\n }\r\n}\r\n\r\nexport default ConnectionManager;\r\n","// noinspection JSValidateJSDoc\r\n\r\n/**\r\n * ChatClient\r\n * 채팅 기능 클라이언트\r\n */\r\n\r\nimport EventEmitter from '../utils/EventEmitter.js';\r\nimport Logger from '../utils/Logger.js';\r\nimport { WebSocketPaths, LogLevel, RoomListEventType, ChatRoomType, DefaultConfig, ConnectionState } from '../constants.js';\r\n\r\nconst MAX_FILES_PER_MESSAGE = 20;\r\n\r\n/**\r\n * 방 PM 프롬프트 레이어 본문 한도.\r\n * 서버 도메인 상수(PmPromptLayer.ROOM_CONTENT_MAX)의 미러 — 서버가 바뀌면 여기도 함께 올려야 한다.\r\n * 값이 뒤처지면 서버는 받아주는데 SDK 가 먼저 막아 \"저장이 안 된다\"로 보인다.\r\n */\r\nconst MAX_ROOM_PM_PROMPT_LENGTH = 20000;\r\n\r\n/**\r\n * 매직 시그니처가 없는 평문 문서 MIME (txt / md).\r\n * 서버 도메인 상수(PlainTextDocumentType)의 미러 — 서버가 평문 타입을 늘리면 여기도 함께 늘려야 한다.\r\n * 빠지면 이 MIME 들이 DOCUMENT 가 아닌 FILE 로 분류돼 그룹 수 예측이 서버와 어긋나고,\r\n * 결과적으로 다중 파일 전송의 messageId prediction 이 틀어진다.\r\n */\r\nconst PLAIN_TEXT_DOCUMENT_MIME_TYPES = new Set(['text/plain', 'text/markdown', 'text/x-markdown']);\r\n\r\nclass ChatClient extends EventEmitter {\r\n /**\r\n * @param {Object} options\r\n * @param {Object} options.connectionManager - ConnectionManager 인스턴스\r\n * @param {Object} options.apiClient - ApiClient 인스턴스\r\n * @param {string} options.userId - 사용자 ID\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options) {\r\n super();\r\n\r\n this.connectionManager = options.connectionManager;\r\n this.apiClient = options.apiClient;\r\n this.userId = options.userId;\r\n\r\n this.logger = new Logger(options.logLevel || LogLevel.WARN, 'ChatClient');\r\n\r\n // 구독 중인 채팅방\r\n this.subscribedRooms = new Map();\r\n\r\n // 채팅방 리스트 구독 상태 (카톡 스타일 리스트 실시간 업데이트)\r\n this.roomListSubscribed = false;\r\n\r\n // Gmail 메일함 변화 구독 상태 — 자동 구독하지 않는다(Gmail 을 연결한 화면에서 subscribeMail()).\r\n this.mailSubscribed = false;\r\n\r\n // 현재 보고 있는 채팅방 ID (이 방에서만 자동 읽음 처리)\r\n this._activeRoomId = null;\r\n\r\n // 타이핑 표시 debounce 타이머 (roomId → setTimeout ID)\r\n // 이 Map 은 outgoing 전용 — 사용자가 startTyping() 호출 시 N초 후 자동 stopTyping() 발신용.\r\n this._typingTimers = new Map();\r\n\r\n // 어시스턴트 응답 준비 중 typing=true 수신 후 client-side timeout (roomId → setTimeout ID).\r\n // 서버는 typing=false 를 발행하지 않으며 (멘션 N personas / 락 실패 / LLM 실패 / skip 등 종료 추적\r\n // 부담 회피), SDK 가 (1) assistant 메시지 도착 시 즉시 해제 (2) 본 timeout 만료 시 자동 해제 책임.\r\n // outgoing 타이머 (_typingTimers) 와 의미가 달라 별도 Map 유지.\r\n this._assistantTypingTimers = new Map();\r\n // 어시 typing 자동 해제 시한 (ms) — LLM 호출 최대 시간 (Sonnet 5-10초) + 부수 작업 + 여유.\r\n // 운영 환경 LLM 응답 지연 분포에 따라 조정 가능.\r\n this._assistantTypingTimeoutMs = 30000;\r\n // 어시 typing 의 generic sender 식별자 — 서버 ChatMessageSentAssistantListener 의\r\n // ASSISTANT_TYPING_USER_ID 와 정확히 일치해야 함. UI 가 roomId+userId 로 typing state 추적 시\r\n // typing=true / typing=false 가 같은 키로 매칭되도록 일관 유지 필수.\r\n this._assistantTypingUserId = '__assistant__';\r\n this._assistantTypingUserName = 'AI';\r\n\r\n // AI 진행 표시(생각중/검색중/작성중) — assistant-stream 채널의 PHASE/DELTA/DONE 수신 상태.\r\n //\r\n // roomId → Map(streamId → { personaId, phase, text, lastSeq, textAvailable, updatedAt, timer })\r\n //\r\n // ⚠️ v5.0.0 에서 <b>방 단위 1슬롯 → stream 단위 다중 슬롯</b>으로 바꿨다(설계 §7.1). 한 방에 동시\r\n // 스트림이 <b>실재</b>하기 때문이다 — 어시 리스너가 @Async 이고 방 단위 직렬화 락이 없어서, PM 방에\r\n // 트리거가 겹치면 각각 streamId 를 발급받아 동시에 산다. 1슬롯이면 나중 것이 앞 것을 덮어 한쪽이\r\n // 통째로 사라진다(그게 v4.x 의 \"stale 무시\" 가드가 하던 일이다).\r\n //\r\n // 자동 해제는 **2경로**다: (1) DONE 수신 (2) TTL(expiresAt) 만료.\r\n // ⚠️ 어시 메시지 도착은 해제 경로가 <b>아니다</b>(v4.2.0, 설계 §7.3) — 서버가 MESSAGE_CREATED 를\r\n // DONE 보다 먼저 보내 그 시점엔 messageId → streamId 를 알 수 없다.\r\n //\r\n // lastSeq/textAvailable = seq 가드용 커서(설계 §5.5.0). 커서 초기값은 -1 이다 — 첫 청크가 seq=0.\r\n this._assistantProgress = new Map();\r\n\r\n // 종료된 streamId 의 묘비 — roomId → Map(streamId → expiresAt).\r\n //\r\n // <b>다중 슬롯 전환의 대가다.</b> 1슬롯 시절엔 \"streamId 가 다르면 버린다\"가 늦은 이벤트를 막는\r\n // race 차단 장치였는데, 슬롯이 여러 개가 되면 그 가드가 사라진다. 그러면 DONE 으로 닫힌 스트림의\r\n // 늦은 DELTA 가 <b>새 슬롯을 만들어 유령 버블로 되살아난다</b>. 서버 gate 가 terminal 직후 제거를\r\n // 금지하고 TTL 로만 회수하는 것과 같은 이유이고 같은 해법이다(설계 §7.2).\r\n //\r\n // ⚠️ 등록 트리거는 <b>DONE 하나뿐</b>이다. 클라 TTL 만료는 \"표시가 낡았다\"이지 서버의 확정 종료가\r\n // 아니라서, 그걸로 묘비를 세우면 뒤늦게 오는 정상 DELTA 가 영영 막힌다.\r\n //\r\n // 회수는 <b>lazy</b> 다 — 만료분은 같은 방에 다음 DONE 이 오거나 그 streamId 이벤트가 올 때 지워진다.\r\n // 별도 타이머를 두지 않는 이유: 항목이 문자열+숫자뿐이고 방 구독을 끊으면 방 단위로 통째 삭제되므로\r\n // 상한이 자연히 걸린다. 타이머 하나를 더 관리하는 비용이 이득보다 크다.\r\n this._assistantStreamTombstones = new Map();\r\n this._assistantTombstoneTtlMs = 120000;\r\n // ⚠️ <b>상한이 아니라 경보 임계</b>다. 넘어도 유효한 묘비를 지우지 않는다(지우면 유령이 되살아난다).\r\n this._assistantTombstoneWarnThreshold = 32;\r\n /** 임계 초과 경보를 방마다 1회만 내기 위한 표식 — 넘은 동안 DONE 마다 짖지 않게. @private */\r\n this._assistantTombstoneWarned = new Set();\r\n\r\n // 슬롯 갱신 순번 — \"가장 최근에 갱신된 stream\" 판정용.\r\n // ⚠️ 벽시계(Date.now)로는 안 된다. 밀리초 해상도라 연속 갱신이 <b>동률</b>이 되고, 동률이면\r\n // 순회 순서가 승자를 정해 <b>다른 stream 의 본문이 방 버블에 뜬다</b>. 단조 카운터가 유일하게 안전하다.\r\n this._assistantSlotSeq = 0;\r\n\r\n // 방별 세대 카운터 — 구독/해제마다 증가. 스냅샷 응답이 \"아직 같은 세대인가\"를 판정하는 축이다\r\n // (늦게 도착한 응답이 이미 떠난 방의 슬롯을 되살리는 것을 막는다, 설계 §6.1-a).\r\n this._assistantRoomGeneration = new Map();\r\n\r\n // ── 스냅샷 재개 (설계 §6.1, R-4) ──\r\n //\r\n // 스트림은 Pub/Sub 라 <b>구독 시점 이후만</b> 온다. 재입장·중간입장 사용자가 앞부분을 받는 유일한\r\n // 경로가 서버 스냅샷이고, 그걸 라이브와 이어붙이는 순서가 이 세 자료구조에 걸려 있다.\r\n //\r\n // roomId → { generation, events: [] } : 스냅샷 도착 전 라이브 이벤트 보류함.\r\n //\r\n // ⚠️ <b>구독보다 먼저</b> 켜야 한다. 구독 호출과 보류 플래그 사이에 DELTA 가 오면 그건 보류되지\r\n // 않고 그대로 렌더돼, 스냅샷 병합 전에 화면이 잘린 본문으로 먼저 그려진다.\r\n //\r\n // ⚠️ 큐는 <b>방 단위 단일 배열</b>이다. stream 별로 쪼개면 A0→B0→A1 로 도착한 것이 A0→A1→B0 로\r\n // 풀려 \"가장 최근 stream\" 판정이 뒤집히고 방 집계 버블이 엉뚱한 본문을 보여준다 — R-3 이 단조\r\n // 카운터로 세운 정합성을 drain 이 다시 깨는 자리다.\r\n //\r\n // ⚠️ 세대를 함께 들고 있어야 한다. 안 그러면 이전 세대의 늦은 응답이 <b>현재 세대의 보류분을\r\n // 통째로 삭제</b>한다.\r\n this._assistantSnapshotPending = new Map();\r\n // roomId → { generation, task } : 진행 중인 스냅샷 요청. <b>같은 세대일 때만</b> 재사용한다 —\r\n // 다른 세대 요청을 재사용하면 그 응답이 세대 불일치로 버려지면서 이번 요청까지 함께 사라진다.\r\n this._assistantSnapshotInFlight = new Map();\r\n // 스냅샷 재개 사용 여부 — 서버가 구버전이면 404/빈 배열이라 자동으로 무해하지만, 끄고 싶을 때를 위해.\r\n this.assistantStreamReplay = options.assistantStreamReplay !== false;\r\n // phase → 기본 표시 라벨(설계 §2 — 서버는 의미만, 라벨 텍스트는 SDK 매핑). 소비자는 payload.phase 로 커스텀 가능.\r\n this._assistantPhaseLabels = {\r\n THINKING: '생각 중…',\r\n SEARCHING: '검색 중…',\r\n PLANNING: '기획 중…',\r\n DOCUMENT_ANALYSIS: '문서 분석 중…',\r\n VIDEO_ANALYSIS: '영상 분석 중…',\r\n WEB_READING: '페이지 읽는 중…',\r\n WRITING: '작성 중…'\r\n };\r\n // PHASE 의 expiresAt 누락/이상 시 fallback TTL (ms) — 서버 ttl(최소 30s) 보다 약간 길게.\r\n this._assistantProgressFallbackTtlMs = 35000;\r\n\r\n // 메시지 dedup 보호 — 같은 messageId 의 중복 수신 차단.\r\n // 서버측 멱등 race 복구 / 네트워크 재전송 / WebSocket 재연결 직후 등 다양한 중복 시나리오 커버.\r\n //\r\n // chat WebSocket (MESSAGE_CREATED) 와 roomList (MESSAGE_RECEIVED) 는 같은 messageId 의 별개 이벤트라\r\n // 각각 다른 키 공간 사용. 정상 흐름에서 두 이벤트가 거의 동시에 도착하므로 키를 공유하면 둘 중\r\n // 하나가 차단되는 부작용 발생.\r\n //\r\n // 구조: roomId → insertion-order Map<messageId, true> (LRU). size 초과 시 가장 오래된 항목 제거.\r\n this._seenChatMessageIdsByRoom = new Map();\r\n this._seenRoomListMessageIdsByRoom = new Map();\r\n\r\n // ── 갭 복구 (RECONNECT_SYNC_DESIGN §6.2 / §6.3) ──\r\n this.syncOnReconnect = options.syncOnReconnect !== false;\r\n this.rewindMillis = options.rewindMillis !== undefined\r\n ? options.rewindMillis\r\n : DefaultConfig.rewindMillis;\r\n this.maxSyncPages = options.maxSyncPages || DefaultConfig.maxSyncPages;\r\n this.syncPageSize = options.syncPageSize || DefaultConfig.syncPageSize;\r\n\r\n // 방별 서버 커서 워터마크 — Mongo _id 단독(anchor). sentAt 은 비교에 쓰지 않는다:\r\n // 같은 메시지라도 WS(서버 systemDefault epoch)와 REST(offset 없는 ISO → 브라우저 로컬 해석)의\r\n // sentAt 이 서로 다른 값이라, 두 출처를 섞어 비교하면 단조 규칙이 잘못된 방향으로 작동한다.\r\n this._anchor = new Map();\r\n // anchor 세대 — sync 진행 중 라이브 메시지가 앞서 갔는지 판정한다(generation guard).\r\n this._anchorGen = new Map();\r\n // 방별 sync 중복 실행 방지 (빠른 visible/hidden 토글).\r\n this._syncInFlight = new Set();\r\n // replay 중에는 개별 메시지로 anchor 를 전진시키지 않는다 — 완료 후 lastId 로 1회 커밋.\r\n this._replaying = false;\r\n\r\n // 방 하나당 dedup 대상 messageId 수. sync 상한보다 커야 되감기 구간의 중복이 새어나가지 않는다.\r\n // 상수로 두면 maxSyncPages 튜닝 시 조용히 어긋나므로 config 에서 파생시킨다 (설계 §7).\r\n this._maxSeenPerRoom = Math.max(200, this.maxSyncPages * this.syncPageSize + 100);\r\n\r\n this._reconnectSyncGeneration = 0;\r\n this._delayedReconnectSyncTimer = null;\r\n this._installSyncTriggers();\r\n }\r\n\r\n /**\r\n * 갭 복구 트리거 배선 — {@code reconnected} / {@code resumed} 양쪽 (설계 §6.1 이벤트 계약).\r\n *\r\n * <p>두 이벤트는 상호 배타적이다: 복귀가 재연결을 유발하면 {@code reconnected} 만, 연결이 살아 있으면\r\n * {@code resumed} 만 발행된다. 그래서 둘 다 들어도 한 번의 복귀에 sync 가 두 번 돌지 않는다.\r\n * 반대로 하나만 들으면 절반의 시나리오를 놓친다.</p>\r\n *\r\n * @private\r\n */\r\n _installSyncTriggers() {\r\n if (!this.connectionManager || typeof this.connectionManager.on !== 'function') return;\r\n\r\n // ⚠️ 어시 스트림 재개는 <b>메시지 갭 복구와 별도 경로</b>다(설계 §7.4). syncAfterReconnect 는\r\n // syncOnReconnect 옵션 + anchor 있는 방이라는 두 겹 필터가 걸려 있어서, 거기 얹으면 옵션이\r\n // 꺼져 있거나 그 방에서 메시지를 받은 적이 없을 때 replay 가 조용히 빠진다.\r\n this._onReconnectedForSync = ({ attempts } = {}) => {\r\n this._cancelDelayedReconnectSync();\r\n const generation = this._reconnectSyncGeneration;\r\n this.syncAfterReconnect(`reconnected:${attempts ?? 0}`)\r\n .catch((error) => this.logger.warn('Gap sync after reconnect failed:', error))\r\n .then((cursors) => {\r\n if (!this.syncOnReconnect || !cursors || generation !== this._reconnectSyncGeneration) return;\r\n // SUBSCRIBE 전송과 SimpleBroker 등록은 다른 시점이다. 첫 snapshot 뒤의 공백을 다시 읽는다.\r\n this._delayedReconnectSyncTimer = setTimeout(() => {\r\n this._delayedReconnectSyncTimer = null;\r\n this._supplementAfterReconnect(cursors, generation)\r\n .catch(error => this.logger.warn('Delayed gap sync failed:', error));\r\n }, 1000);\r\n this._delayedReconnectSyncTimer.unref?.();\r\n });\r\n this.resumeAssistantStreams(`reconnected:${attempts ?? 0}`)\r\n .catch((error) => this.logger.warn('Assistant stream replay after reconnect failed:', error));\r\n };\r\n this._onResumedForSync = ({ reason } = {}) => {\r\n this.syncAfterReconnect(`resumed:${reason ?? 'unknown'}`)\r\n .catch((error) => this.logger.warn('Gap sync after resume failed:', error));\r\n this.resumeAssistantStreams(`resumed:${reason ?? 'unknown'}`)\r\n .catch((error) => this.logger.warn('Assistant stream replay after resume failed:', error));\r\n };\r\n\r\n this._onStateChangeForSync = ({ state }) => {\r\n if (state !== ConnectionState.CONNECTED) this._cancelDelayedReconnectSync();\r\n };\r\n this.connectionManager.on('stateChange', this._onStateChangeForSync);\r\n this.connectionManager.on('reconnected', this._onReconnectedForSync);\r\n this.connectionManager.on('resumed', this._onResumedForSync);\r\n }\r\n\r\n /**\r\n * 방별 LRU dedup 체크 — 이미 본 messageId 면 {@code true} 반환 (중복).\r\n * 처음 보는 messageId 면 기록 후 {@code false} 반환.\r\n *\r\n * <p>Map 의 insertion-order 보존 특성을 이용한 가벼운 LRU. 외부 lib 없음.</p>\r\n *\r\n * @private\r\n * @param {Map<string, Map<string, true>>} bucket - 키 공간 (chat / roomList 별도)\r\n * @param {string} roomId\r\n * @param {string} messageId\r\n * @returns {boolean} 이미 본 메시지면 true (호출자가 무시)\r\n */\r\n _shouldDedupMessage(bucket, roomId, messageId) {\r\n if (!roomId || !messageId) return false;\r\n let seen = bucket.get(roomId);\r\n if (!seen) {\r\n seen = new Map();\r\n bucket.set(roomId, seen);\r\n }\r\n if (seen.has(messageId)) {\r\n return true;\r\n }\r\n seen.set(messageId, true);\r\n if (seen.size > this._maxSeenPerRoom) {\r\n // 가장 오래된 키 제거 (Map insertion-order 의 첫 항목).\r\n const oldestKey = seen.keys().next().value;\r\n seen.delete(oldestKey);\r\n }\r\n return false;\r\n }\r\n\r\n // ==================== REST API ====================\r\n\r\n /**\r\n * 내 채팅방 목록 조회\r\n * @param {Object} [params]\r\n * @param {number} [params.size=50] - 페이지 크기 (1-100)\r\n * @param {string} [params.lastId] - 마지막 채팅방 ID (커서)\r\n * @param {number} [params.lastSortValue] - 마지막 정렬값 (epoch millis)\r\n * @param {string} [params.type] - 채팅방 타입 필터 ('DIRECT' | 'GROUP'). 생략 시 전체.\r\n * 'GROUP' 은 \"그룹 탭\" 시맨틱 — PRIVATE_GROUP/TEAM 을 포함한 그룹 전체를 반환\r\n * (PRIVATE_GROUP/TEAM 값을 보내도 동일하게 그룹 전체 — 타입별 정확 필터 아님).\r\n * 특정 타입만 필요하면 응답의 {@code roomType} 필드로 클라이언트에서 거른다.\r\n * @returns {Promise<Object>}\r\n */\r\n async getRooms(params = {}) {\r\n const { size = 50, lastId, lastSortValue, type } = params;\r\n return this.apiClient.get('/api/v1/rooms/my', { size, lastId, lastSortValue, type });\r\n }\r\n\r\n /**\r\n * 채팅방 입장 (상세 조회).\r\n *\r\n * <p>서버 {@code markAllAsRead} 를 트리거하므로 방 안의 누적 unread 가 전부 읽음 처리된다.\r\n * 실시간 읽음 이벤트 보정 수신이 필요하면 {@link #enterRoom} 사용을 권장 — subscribe 를\r\n * {@link #getRoom} 보다 먼저 수행해서 \"응답 후, 구독 전\" 구간의 read 이벤트 유실을 막는다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async getRoom(roomId) {\r\n const genAtStart = this._anchorGen.get(roomId) || 0;\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}`);\r\n\r\n // 입장 응답의 초기 메시지(최신순)로 갭 복구 anchor 를 심는다 — 이 방에서 라이브 메시지를\r\n // 한 건도 받기 전에 백그라운드로 가는 경로를 덮기 위함(§6.2).\r\n // (타임스탬프 정규화는 기존 동작을 그대로 둔다 — 여기서 바꾸면 기존 소비자 계약이 흔들린다.)\r\n const detail = this._unwrapSuccessResponse(response);\r\n if (detail && Array.isArray(detail.messages)) {\r\n this._seedAnchorFromLatest(roomId, detail.messages, genAtStart);\r\n }\r\n return response;\r\n }\r\n\r\n /**\r\n * 채팅방 진입 — <b>권장 진입 경로</b>.\r\n *\r\n * <p>순서를 고정해서 race-free 진입을 보장:</p>\r\n * <ol>\r\n * <li><b>subscribeRoom</b> — WebSocket 구독 먼저. 서버가 발행하는 read/message/typing 이벤트의\r\n * 도달을 이 시점부터 보장.</li>\r\n * <li><b>setActiveRoom</b> — 이후 수신되는 신규 메시지 자동 읽음 처리 활성화.</li>\r\n * <li><b>getRoom</b> — 서버 {@code markAllAsRead} + 초기 메시지 50개 fetch. 이 시점에 서버가\r\n * 발행하는 read 이벤트는 (1) 의 구독으로 모두 수신됨.</li>\r\n * </ol>\r\n *\r\n * <p>구독이 {@code getRoom} 보다 먼저 완료돼야 \"서버에서 발행된 read 이벤트가 아직 구독 안 된\r\n * 클라에 유실\" 되는 케이스가 제거된다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>} {@link #getRoom} 의 응답 (방 상세 + 초기 메시지)\r\n *\r\n * @example\r\n * // 방 클릭 시\r\n * const detail = await client.chat.enterRoom('room-id');\r\n * renderRoom(detail);\r\n */\r\n async enterRoom(roomId) {\r\n // \"이 호출이 새로 만든 구독인지\" 기억 — getRoom 실패 시 rollback 시에 이전 구독은 유지.\r\n const wasAlreadySubscribed = this.subscribedRooms.has(roomId);\r\n\r\n // 1) WebSocket 구독을 먼저 — 이후 서버 read 이벤트 유실 방지\r\n if (!wasAlreadySubscribed) {\r\n await this.subscribeRoom(roomId);\r\n } else if (this.assistantStreamReplay) {\r\n // ⭐ <b>이미 구독 중인 방으로 재입장</b> — 구독을 유지한 채 방 화면만 나갔다 들어오는 흐름이다\r\n // (목록 화면에서 unread 를 받으려면 구독을 끊지 않는 것이 정상 구현이다). 이때 subscribeRoom 이\r\n // 통째로 건너뛰어져 <b>스냅샷 트리거가 하나도 돌지 않는다</b> — 재개 경로는 여기와 재연결\r\n // (`resumeAssistantStreams`) 둘뿐이기 때문이다.\r\n //\r\n // 진행 표시는 transient 이벤트라 화면이 언마운트되면 프론트에서 사라지는데, SDK 슬롯에만 남은\r\n // 그 상태를 다시 그려줄 신호가 없다 → 다음 PHASE 가 올 때까지(워커 heartbeat 간격 15초) 배지가\r\n // 안 뜬다. 스냅샷을 다시 받아 그 공백을 없앤다.\r\n //\r\n // await 하지 않는다 — 구독은 살아 있으므로 그 사이 도착분은 보류함에 쌓였다가 병합 후 순서대로\r\n // 풀린다(§6.1). 실패해도 보류분은 반드시 풀리므로 라이브만으로 계속 동작한다.\r\n const generation = this._armAssistantSnapshot(roomId);\r\n this._loadAssistantSnapshot(roomId, generation)\r\n .catch((error) => this.logger.warn(`Assistant snapshot on re-enter failed: room=${roomId}`, error));\r\n }\r\n // 2) 자동 읽음 처리 활성 방 지정\r\n this.setActiveRoom(roomId);\r\n\r\n try {\r\n // 3) 서버 입장 API — markAllAsRead + 초기 메시지 조회\r\n return await this.getRoom(roomId);\r\n } catch (e) {\r\n // getRoom 실패 시 half-open 상태 방지 — 이 호출이 켠 리소스만 되돌림.\r\n if (this.getActiveRoom() === roomId) {\r\n this.clearActiveRoom();\r\n }\r\n if (!wasAlreadySubscribed) {\r\n this.unsubscribeRoom(roomId);\r\n }\r\n throw e;\r\n }\r\n }\r\n\r\n /**\r\n * 채팅방 정보 조회 (입장하지 않고 정보만)\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async getRoomInfo(roomId) {\r\n return this.apiClient.get(`/api/v1/rooms/${roomId}/info`);\r\n }\r\n\r\n /**\r\n * 내 방 언어 설정 (다국어). 이 방에서 내가 볼 언어를 지정한다.\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string|null} language - BCP-47 코드(예 'ko','en','ja') / 'OFF'(원문 보기) /\r\n * null·''(오버라이드 해제 → 사용자 기본 언어로 복귀)\r\n * @returns {Promise<Object>} 갱신된 방 상세\r\n */\r\n async setMyRoomLanguage(roomId, language) {\r\n return this.apiClient.put(`/api/v1/rooms/${roomId}/my-language`, { language });\r\n }\r\n\r\n /**\r\n * 1:1 채팅방 생성/조회\r\n * @param {string} friendId - 상대방 사용자 ID (서버 내부 {@code users._id} — 참가자 목록의 userId)\r\n * @returns {Promise<Object>}\r\n */\r\n async createOneToOneRoom(friendId) {\r\n return this.apiClient.post(`/api/v1/rooms/direct/${friendId}`);\r\n }\r\n\r\n /**\r\n * 그룹 채팅방 생성.\r\n *\r\n * <p>방 타입 {@code GROUP} (공개) / {@code PRIVATE_GROUP} (비밀) / {@code TEAM} (팀) 지정 가능.\r\n * 비밀방인 경우 {@code password} 필수 (서버가 bcrypt 해시로 저장). 팀방은 비밀번호 금지 —\r\n * 초대 전용이라 비밀번호 개념이 없고, 멤버는 입장 시점과 무관하게 방 전체 히스토리를 본다.</p>\r\n *\r\n * <p>{@code invitedAssistantPersonaIds} 로 AI 어시스턴트 페르소나도 함께 추가 가능.\r\n * 페르소나 ID 는 {@link #getAssistants} 로 사전 조회. 서버가 해당 프로젝트에서 사용 가능한\r\n * 페르소나인지 검증 (글로벌 또는 자체/오버라이드). 활성 페르소나만 허용.</p>\r\n *\r\n * @param {Object} data\r\n * @param {string} data.roomName - 채팅방 이름 (필수, max 100자)\r\n * @param {string} [data.description] - 채팅방 설명 (max 500자)\r\n * @param {string[]} [data.invitedUserIds] - 초대할 사용자 ID 배열 (max 50명)\r\n * @param {string[]} [data.invitedAssistantPersonaIds] - 추가할 어시스턴트 ID 배열 (max 10개).\r\n * {@link #getAssistants} 로 조회한 어시스턴트의 {@code id} 를 전달.\r\n * @param {string} [data.roomType='GROUP'] - 방 타입 ('GROUP' | 'PRIVATE_GROUP' | 'TEAM'). 기본값 'GROUP'.\r\n * 'TEAM' = 초대 전용(직접 입장 불가) + 멤버 전체 히스토리 열람 + 비참여자에게 목록 비노출.\r\n * @param {string} [data.password] - 비밀번호 (roomType='PRIVATE_GROUP' 일 때 필수, 4~50자.\r\n * 'GROUP'/'TEAM' 에는 금지)\r\n * @param {number} [data.messageRetentionHours] - 메시지 보관 기간 (시간, 1~8760). 미설정 시 무제한.\r\n * 설정 시 메시지를 삭제하지 않고 조회 시 기간 필터링만 적용. 변경 시 즉시 반영.\r\n * @param {string} [data.assistantMode='GENERAL'] - 어시스턴트 참여 모드\r\n * ('GENERAL' | 'PEOPLE_ONLY' | 'CALL_ONLY'). 생략 시 서버 기본값 GENERAL.\r\n * @param {string} [data.roomAiType] - 방 AI 사용 방식 intent ('NONE' | 'PERSONA_MULTI' | 'PM_BACKSTAGE').\r\n * 생략 시 서버가 API 키 권한 + 첨부 페르소나로 파생 (레거시 호환). {@code 'NONE'} 이면\r\n * {@code invitedAssistantPersonaIds}/{@code assistantMode} 동시 지정 불가 (서버 400).\r\n * {@code 'PM_BACKSTAGE'} 는 PM 자동부착 — persona/assistantMode 대신 {@code engagementIntensity} 로 제어.\r\n * 권한 없는 값 지정 시 403.\r\n * <b>주의(함정):</b> PERSONA_MULTI 권한이 없는 PM 전용 키는 {@code roomAiType} 을 생략하면 서버 파생이\r\n * {@code 'NONE'}(사람 방)으로 떨어진다 (PM_BACKSTAGE 로 자동 승격되지 않음). {@link #getRoomAiMeta} 의\r\n * {@code availableRoomAiTypes} 로 확인한 타입을 반드시 명시 전송할 것.\r\n * @param {string} [data.engagementIntensity] - PM 개입 강도 ('QUIET' | 'NORMAL' | 'ACTIVE').\r\n * {@code 'PM_BACKSTAGE'} 방 전용 — 그 외 타입에 지정 시 서버 거절. 생략 시 서버 기본값 'NORMAL'.\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * // 공개 그룹방\r\n * await chat.createGroupRoom({\r\n * roomName: '개발팀 잡담방',\r\n * invitedUserIds: ['user-1', 'user-2']\r\n * });\r\n *\r\n * @example\r\n * // 어시스턴트 포함 그룹방\r\n * const assistants = await chat.getAssistants();\r\n * // UI 에 표시하고 사용자가 선택한 id 들을 그대로 전달\r\n * await chat.createGroupRoom({\r\n * roomName: '계약 검토방',\r\n * invitedUserIds: ['user-1', 'user-2'],\r\n * invitedAssistantPersonaIds: [/* 사용자가 선택한 id 배열 *\\/],\r\n * assistantMode: 'GENERAL'\r\n * });\r\n *\r\n * @example\r\n * // 비밀 그룹방\r\n * await chat.createGroupRoom({\r\n * roomName: '비공개 회의실',\r\n * invitedUserIds: ['user-1'],\r\n * roomType: ChatRoomType.PRIVATE_GROUP,\r\n * password: 'secret1234'\r\n * });\r\n *\r\n * @example\r\n * // 팀 채팅방 — 초대 전용 + 전체 히스토리 (중간에 들어와도 이전 대화 열람)\r\n * await chat.createGroupRoom({\r\n * roomName: '백엔드팀',\r\n * invitedUserIds: ['user-1', 'user-2'],\r\n * roomType: ChatRoomType.TEAM\r\n * });\r\n */\r\n async createGroupRoom(data) {\r\n const roomType = data.roomType || ChatRoomType.GROUP;\r\n\r\n // 클라이언트 측 사전 검증 (서버에서도 재검증됨)\r\n if (roomType === ChatRoomType.PRIVATE_GROUP) {\r\n if (!data.password || data.password.length < 4) {\r\n throw new Error('PRIVATE_GROUP requires a password of at least 4 characters');\r\n }\r\n } else if (roomType === ChatRoomType.GROUP) {\r\n if (data.password) {\r\n throw new Error('Public GROUP rooms cannot have a password');\r\n }\r\n } else if (roomType === ChatRoomType.TEAM) {\r\n if (data.password) {\r\n throw new Error('TEAM rooms cannot have a password (invite-only)');\r\n }\r\n }\r\n\r\n return this.apiClient.post('/api/v1/rooms/group', {\r\n roomName: data.roomName,\r\n description: data.description,\r\n invitedUserIds: data.invitedUserIds,\r\n roomType,\r\n password: data.password,\r\n messageRetentionHours: data.messageRetentionHours,\r\n invitedAssistantPersonaIds: data.invitedAssistantPersonaIds,\r\n assistantMode: data.assistantMode,\r\n roomAiType: data.roomAiType,\r\n engagementIntensity: data.engagementIntensity\r\n });\r\n }\r\n\r\n /**\r\n * 사용 가능한 어시스턴트 리스트 조회.\r\n *\r\n * <p>받은 리스트를 UI 에 표시하고, 사용자가 선택한 어시스턴트의 {@code id} 를\r\n * {@link #createGroupRoom} 의 {@code invitedAssistantPersonaIds} 로 전달.</p>\r\n *\r\n * @returns {Promise<Array<{id: string, displayName: string, mentionKey: string, role: string}>>}\r\n *\r\n * @example\r\n * const assistants = await chat.getAssistants();\r\n * // [{ id, displayName, mentionKey, role }, ...]\r\n */\r\n async getAssistants() {\r\n const response = await this.apiClient.get('/api/v1/assistants');\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 현재 API 키로 생성 가능한 방 AI 타입 조회 (discovery).\r\n *\r\n * <p>방 생성 UI 에서 {@link #createGroupRoom} 의 {@code roomAiType} 으로 만들 수 있는 타입만 노출하는 데 사용한다.\r\n * 키 권한(capability)으로 결정 — {@code 'NONE'}(사람 채팅)은 항상, {@code 'PERSONA_MULTI'}/{@code 'PM_BACKSTAGE'}\r\n * 는 권한 보유 시 포함.</p>\r\n *\r\n * <p><b>중요:</b> 여기서 고른 타입을 {@link #createGroupRoom} 의 {@code roomAiType} 으로 <b>반드시 명시 전송</b>해야 한다.\r\n * 생략하면 서버가 키 권한 + persona 로 파생하는데, PERSONA_MULTI 권한이 없는 PM 전용 키는 {@code 'NONE'}(사람 방)으로\r\n * 떨어진다 (PM_BACKSTAGE 자동 승격 없음).</p>\r\n *\r\n * @returns {Promise<{availableRoomAiTypes: string[]}>}\r\n *\r\n * @example\r\n * const meta = await chat.getRoomAiMeta();\r\n * // meta.availableRoomAiTypes 예: ['NONE', 'PM_BACKSTAGE']\r\n * // → UI 에서 이 타입들만 방 생성 옵션으로 노출, 고른 타입을 createGroupRoom 의 roomAiType 으로 명시 전송\r\n */\r\n async getRoomAiMeta() {\r\n const response = await this.apiClient.get('/api/v1/rooms/ai-meta');\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 어시스턴트 메시지 평점 등록 (1~5점).\r\n *\r\n * <p>AI 어시스턴트가 생성한 메시지({@code senderType === 'ASSISTANT'})에 대해서만 의미가 있다.\r\n * UI 는 어시 메시지이면서 삭제되지 않은 메시지에만 평점 버튼을 노출하는 것을 권장한다\r\n * (서버도 사람 메시지 / 삭제 메시지는 거부).</p>\r\n *\r\n * <p>같은 메시지를 다시 평가하면 갱신된다 (단, 서버에서 해당 평점이 아직 검토 전일 때만).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 평가할 어시스턴트 메시지의 {@code messageId}\r\n * @param {number} rating - 평점 (1~5 정수)\r\n * @param {string} [comment] - 자유 코멘트 (max 2000자)\r\n * @returns {Promise<void>}\r\n *\r\n * @example\r\n * await chat.rateAssistantMessage(roomId, msg.messageId, 5, '정확한 요약이었어요');\r\n */\r\n async rateAssistantMessage(roomId, messageId, rating, comment) {\r\n // 클라이언트 측 사전 검증 (서버에서도 @Min(1)@Max(5) 재검증됨)\r\n if (!Number.isInteger(rating) || rating < 1 || rating > 5) {\r\n throw new Error('rating must be an integer between 1 and 5');\r\n }\r\n\r\n await this.apiClient.post(\r\n `/api/v1/chat-messages/${roomId}/messages/${messageId}/rating`,\r\n { rating, comment }\r\n );\r\n }\r\n\r\n /**\r\n * 어시스턴트로 대화 요약 (버튼 진입점, 동기).\r\n *\r\n * <p>지정한 페르소나가 최근 메시지 범위를 요약해 방에 어시스턴트 메시지로 broadcast 한다.\r\n * HTTP 응답은 LLM 처리(수 초) 후 결과를 반환하며, 생성된 메시지는 일반 {@code chatMessage}\r\n * 이벤트로도 수신된다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} options\r\n * @param {string} options.personaId - 요약을 수행할 어시스턴트 ID ({@link #getAssistants} 의 {@code id}). 필수.\r\n * @param {string} [options.format] - 요약 포맷 ({@link SummarizeFormat}). 미지정 시 서버 기본값 {@code SHORT}.\r\n * @param {number} [options.messageCount] - 요약 대상 최근 메시지 수 (1~50). 미지정 시 서버 기본값.\r\n * @returns {Promise<{outcome: string, messageId: (string|null), detail: (string|null)}>}\r\n * {@code outcome}: {@code 'EMITTED'} (생성됨, {@code messageId} 존재) |\r\n * {@code 'SKIPPED'} / {@code 'FAILED'} ({@code detail} 에 사유).\r\n *\r\n * @example\r\n * const result = await chat.summarizeWithAssistant(roomId, {\r\n * personaId,\r\n * format: SummarizeFormat.MINUTES,\r\n * messageCount: 30\r\n * });\r\n * if (result.outcome !== 'EMITTED') console.warn(result.detail);\r\n */\r\n async summarizeWithAssistant(roomId, options = {}) {\r\n const { personaId, format, messageCount } = options;\r\n if (!personaId) {\r\n throw new Error('summarizeWithAssistant requires options.personaId');\r\n }\r\n\r\n const response = await this.apiClient.post(\r\n `/api/v1/rooms/${roomId}/assistant/tools/summarize`,\r\n { personaId, format, messageCount }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /** 영상 분석용 비공개 업로드. 반환한 assetId를 analyzeVideo에 전달한다. */\r\n async uploadVideoAsset(file, options = {}) {\r\n if (!file) throw new Error('file is required');\r\n return this._unwrapSuccessResponse(await this.apiClient.upload('/api/v1/video-assets', file, {\r\n onProgress: options.onProgress, signal: options.signal\r\n }));\r\n }\r\n\r\n /** 비공개 분석 접수. 네트워크 재시도에는 같은 idempotencyKey를 사용한다. */\r\n async analyzeVideo(analysis, options = {}) {\r\n if (typeof options.idempotencyKey !== 'string' || !options.idempotencyKey.trim()) {\r\n throw new Error('idempotencyKey is required for video analysis');\r\n }\r\n return this._unwrapSuccessResponse(await this.apiClient.post('/api/v1/video-analyses', analysis,\r\n undefined, { 'Idempotency-Key': options.idempotencyKey }));\r\n }\r\n\r\n /** 같은 분석 코어를 호출하고 결과를 지정한 방에 게시한다. */\r\n async analyzeVideoWithAssistant(roomId, request, options = {}) {\r\n if (!roomId || !request?.personaId || !request?.triggerMessageId) {\r\n throw new Error('roomId, personaId and triggerMessageId are required');\r\n }\r\n if (typeof options.idempotencyKey !== 'string' || !options.idempotencyKey.trim()) {\r\n throw new Error('idempotencyKey is required for video analysis');\r\n }\r\n return this._unwrapSuccessResponse(await this.apiClient.post(\r\n `/api/v1/rooms/${encodeURIComponent(roomId)}/assistant/tools/analyze-video`, request,\r\n undefined, { 'Idempotency-Key': options.idempotencyKey }));\r\n }\r\n\r\n async getVideoAnalysis(jobId) {\r\n if (!jobId) throw new Error('jobId is required');\r\n return this._unwrapSuccessResponse(await this.apiClient.get(`/api/v1/video-analyses/${encodeURIComponent(jobId)}`));\r\n }\r\n\r\n async cancelVideoAnalysis(jobId) {\r\n if (!jobId) throw new Error('jobId is required');\r\n return this._unwrapSuccessResponse(await this.apiClient.post(`/api/v1/video-analyses/${encodeURIComponent(jobId)}/cancel`));\r\n }\r\n\r\n async getVideoAnalysisCapabilities() {\r\n return this._unwrapSuccessResponse(await this.apiClient.get('/api/v1/video-analyses/capabilities'));\r\n }\r\n\r\n /**\r\n * 특정 메시지 온디맨드 번역 (버튼 진입점, 동기).\r\n *\r\n * <p>{@code sourceMessageId} 메시지(텍스트/이미지/문서)를 {@code targetLang} 으로 번역해 요청자에게만\r\n * 반환한다. 이미지는 이미지 속 텍스트를, 문서(PDF/DOCX/XLSX/PPTX 등)는 파일 안의 텍스트를 서버가 추출해\r\n * 번역한다. 방에 메시지를 저장하거나 {@code chatMessage} 이벤트를 broadcast 하지 않는다. 읽을 텍스트가\r\n * 없거나 문서가 너무 크면 에러를 반환한다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} options\r\n * @param {string} options.sourceMessageId - 번역 대상 메시지의 {@code messageId} (텍스트/이미지/문서). 필수.\r\n * @param {string} options.targetLang - 목표 언어 (예: {@code 'en'}, {@code '日本語'}). 필수.\r\n * @returns {Promise<{translatedText: string, sourceMessageId: string, targetLang: string}>}\r\n *\r\n * @example\r\n * const result = await chat.translateWithAssistant(roomId, {\r\n * sourceMessageId: msg.messageId,\r\n * targetLang: 'en'\r\n * });\r\n */\r\n async translateWithAssistant(roomId, options = {}) {\r\n const { targetLang, sourceMessageId } = options;\r\n if (!sourceMessageId) {\r\n throw new Error('translateWithAssistant requires options.sourceMessageId');\r\n }\r\n if (!targetLang) {\r\n throw new Error('translateWithAssistant requires options.targetLang');\r\n }\r\n\r\n const response = await this.apiClient.post(\r\n `/api/v1/rooms/${roomId}/assistant/tools/translate`,\r\n { targetLang, sourceMessageId }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * Rolling 누적 요약 갱신 트리거 (비동기).\r\n *\r\n * <p>방의 <b>누적 대화 요약</b>을 갱신한다 — {@link #summarizeWithAssistant}(페르소나가 최근 N개를 방에\r\n * 어시스턴트 메시지로 발행)와 달리, <b>포맷×언어별</b> 통합 요약본(rollup)을 마지막 요약 지점 이후 <b>새 대화만\r\n * 증분</b> 반영한다(같은 방을 여러 포맷·언어로 보관). 실제 요약은 서버 워커가 비동기로 수행하며, 완료/실패는\r\n * {@code summaryUpdate} 이벤트로 통지된다. 확정 내용은 {@link #getSummary} 로 조회한다(알림 payload 에는 내용이 없다).</p>\r\n *\r\n * <p><b>언어</b>: {@code lang} 미지정 시 서버가 요청자 언어(방 설정 ?? 사용자 선호)로 해소한다 — 자동번역으로\r\n * 원문이 섞여도 그 언어로만 통합된다. {@code 'OFF'} 는 원문 유지. 응답에 해소된 {@code format}/{@code language} 가\r\n * 담긴다.</p>\r\n *\r\n * <p>전체 히스토리를 보는 방(예: TEAM) 대상 기능이며, AI capability {@code TOOL_SUMMARIZE} 가 필요하다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} [options]\r\n * @param {string} [options.format] - 요약 포맷 ({@link SummarizeFormat}). 미지정/알 수 없는 값이면 서버가 {@code SHORT} 적용.\r\n * @param {string} [options.lang] - 출력 언어(BCP-47 예 {@code 'ko'}/{@code 'en'}, 또는 {@code 'OFF'}=원문). 미지정 시 요청자 언어로 해소.\r\n * @returns {Promise<{status: string, format?: string, language?: string}>} {@code status}: {@code 'QUEUED'} —\r\n * 방 단위 durable work item 에 저장·병합됐다. local worker 포화여도 요청은 버려지지 않으며\r\n * 최종 {@code READY|NO_CHANGE|NO_MESSAGES|FAILED} {@code summaryUpdate} 를 기다린다.\r\n * {@code format}/{@code language}=이번 호출이 work item 에 병합한 키.\r\n * ⚠️ 닫힌 목록이 아니다 — 분기에는 default 를 두고, 모르는 값에 스피너를 띄우지 마라.\r\n * @throws {Error} HTTP 503 {@code SUMMARY_REFRESH_UNAVAILABLE} — durable 저장 자체가 실패했다.\r\n * 간격을 두고 재시도하고 사용자에게는 일시 오류로 알린다.\r\n * {@code err.status}=503, {@code err.response.code}={@code 'SUMMARY_REFRESH_UNAVAILABLE'}.\r\n *\r\n * @fires summaryUpdate - { roomId, status: 'READY'|'NO_CHANGE'|'NO_MESSAGES'|'FAILED'|'INVALIDATED'|string, format, languages, updatedAt }.\r\n * fan-out 으로 여러 언어를 한 번에 생성하므로 {@code languages} 는 배열(§S1). {@code INVALIDATED} 는 refreshSummary 가\r\n * 아니라 방 원문 삭제/편집으로 발생(요약이 통째로 폐기됨).\r\n *\r\n * @example\r\n * const { status } = await chat.refreshSummary(roomId, { format: SummarizeFormat.MINUTES, lang: 'ko' });\r\n * if (status === 'QUEUED') showQueuedProgress(); // durable 접수 — summaryUpdate 대기\r\n * else showUnknownSummaryState(status); // 미래 서버 값은 추측하지 않는다\r\n * // 503(SUMMARY_REFRESH_UNAVAILABLE)은 durable 접수 실패 — try/catch로 받고 간격을 두고 재시도.\r\n */\r\n async refreshSummary(roomId, options = {}) {\r\n const { format, lang } = options;\r\n const response = await this.apiClient.post(\r\n `/api/v1/rooms/${roomId}/summary/refresh`,\r\n { format, lang }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * Rolling 누적 요약 조회 — 통합본(rollup) + 구간 부분요약(segments) 페이지. 저장된 요약의 단일 진실 소스.\r\n *\r\n * <p>terminal {@code summaryUpdate} 수신 후, 화면 진입/재접속 시, 그리고 {@code QUEUED} 뒤 앱이 정한 완료\r\n * timeout에 <b>한 번</b> 호출한다 — 알림은 transient(저장 안 됨)라 유실될 수 있으므로 GET 으로 동기화한다.\r\n * 주기적 polling은 하지 않는다. 아직 한 번도 요약된 적 없으면 {@code rollup} 이 {@code null} 이다.\r\n * segment 는 {@code afterSeq}/{@code size} 로 페이지네이션한다({@code hasNext}/{@code nextSeq}).</p>\r\n *\r\n * <p>같은 방을 여러 포맷×언어로 보관하므로 {@code options.format}/{@code options.lang} 으로 골라 조회한다.\r\n * {@code lang} 미지정 시 서버가 요청자 언어로 해소하고, 응답 {@code language} 로 <b>어느 언어 기준</b>인지\r\n * 알려준다. {@code availableFormats} 는 <b>그 언어에 존재하는</b> 포맷만 담는다(타 언어로 fallback 하지 않음).\r\n * 요청 포맷이 그 언어에 아직 없으면 {@code rollup=null}(refreshSummary 로 생성). {@code rollup.stale} 이\r\n * {@code true} 면 그 키가 최신 대화를 아직 반영 안 함(refresh 로 최신화).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} [options]\r\n * @param {string} [options.format] - 조회할 포맷({@link SummarizeFormat}). 미지정 시 그 언어의 기본(SHORT 우선). 미보유면 rollup=null.\r\n * @param {string} [options.lang] - 조회할 언어(BCP-47 예 {@code 'ko'}, 또는 {@code 'OFF'}=원문). 미지정 시 요청자 언어로 해소(응답 {@code language}).\r\n * @param {number} [options.afterSeq] - segment 페이지 커서(exclusive). 미지정 시 처음부터.\r\n * @param {number} [options.size=50] - segment 페이지 크기(서버 상한 50).\r\n * @returns {Promise<RoomSummaryView>} { language, roomStatus, rollup | null, availableFormats, segments, hasNext, nextSeq }\r\n *\r\n * @example\r\n * const view = await chat.getSummary(roomId, { format: 'timeline', lang: 'ko' });\r\n * console.log(view.language); // 'ko' (미지정 조회였다면 서버가 해소한 언어)\r\n * if (!view.rollup) return renderEmpty(); // 그 포맷×언어 아직 없음 (availableFormats 로 탭 렌더)\r\n * renderRollup(view.rollup, view.rollup.stale); // stale 이면 \"최신화\" 유도\r\n * renderTabs(view.availableFormats); // 이 언어에 보유한 포맷 탭\r\n */\r\n async getSummary(roomId, options = {}) {\r\n const { format, lang, afterSeq, size } = options;\r\n const response = await this.apiClient.get(\r\n `/api/v1/rooms/${roomId}/summary`,\r\n { format, lang, afterSeq, size }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 조회 — PM_BACKSTAGE 방 전용.\r\n *\r\n * <p>방 레이어는 전역 PM 프롬프트(+회사 레이어)에 <b>합성</b>되는 방 단위 추가 지시문이다 (교체 아님).\r\n * 등록된 적이 없으면 404 ({@code PM_PROMPT_LAYER_NOT_FOUND}) — \"아직 없음\" 상태로 처리할 것.</p>\r\n *\r\n * <p>권한: 방 참여자 전체 조회 가능. PM_BACKSTAGE 가 아닌 방이면 400\r\n * ({@code PM_PROMPT_ROOM_TYPE_UNSUPPORTED}).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>} PmPromptLayer — {@code {id, scope:'ROOM', projectId, roomId, content, active, activeVersion, editorType, updatedAt}}\r\n *\r\n * @example\r\n * try {\r\n * const layer = await chat.getRoomPmPrompt(roomId);\r\n * console.log(layer.active, layer.content);\r\n * } catch (e) {\r\n * if (e.status === 404) {\r\n * // 레이어 미등록 — 입력 UI 노출\r\n * }\r\n * }\r\n */\r\n async getRoomPmPrompt(roomId) {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/pm-prompt`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 등록/수정 (upsert) — <b>방 주인(OWNER) 전용</b>.\r\n *\r\n * <p>저장 시 새 버전이 자동 기록된다 ({@link #getRoomPmPromptVersions}). 본문 한도 20,000자.\r\n * 비주인 호출은 403 ({@code NOT_ROOM_ADMIN}).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} content - 레이어 본문 (1~20,000자). 방의 목적/맥락/금지사항 등 PM 에게 줄 추가 지시문.\r\n * @returns {Promise<Object>} 저장된 PmPromptLayer\r\n *\r\n * @example\r\n * await chat.upsertRoomPmPrompt(roomId, '이 방은 모바일앱 리뉴얼 TF. 결정사항은 표로 정리해줘.');\r\n */\r\n async upsertRoomPmPrompt(roomId, content) {\r\n if (typeof content !== 'string' || !content.trim()) {\r\n throw new Error('upsertRoomPmPrompt requires non-blank content');\r\n }\r\n if (content.length > MAX_ROOM_PM_PROMPT_LENGTH) {\r\n throw new Error(`upsertRoomPmPrompt content exceeds ${MAX_ROOM_PM_PROMPT_LENGTH} characters`);\r\n }\r\n\r\n const response = await this.apiClient.put(`/api/v1/rooms/${roomId}/pm-prompt`, { content });\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 합성 복귀 (활성화) — 방 주인(OWNER) 전용.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>} 변경된 PmPromptLayer ({@code active: true})\r\n */\r\n async activateRoomPmPrompt(roomId) {\r\n const response = await this.apiClient.patch(`/api/v1/rooms/${roomId}/pm-prompt/activate`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 합성 제외 (비활성화, soft delete) — 방 주인(OWNER) 전용.\r\n *\r\n * <p>본문/이력은 보존되고 PM 응답 합성에서만 빠진다. {@link #activateRoomPmPrompt} 로 복귀.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>} 변경된 PmPromptLayer ({@code active: false})\r\n */\r\n async deactivateRoomPmPrompt(roomId) {\r\n const response = await this.apiClient.patch(`/api/v1/rooms/${roomId}/pm-prompt/deactivate`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 버전 이력 조회 — 방 참여자 전체.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Array<Object>>} 버전 목록 — 각 항목 {@code {version, content, active, editorType, createdAt}} ({@code active}=현재 활성 버전)\r\n */\r\n async getRoomPmPromptVersions(roomId) {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/pm-prompt/versions`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 버전 활성화 — 방 주인(OWNER) 전용.\r\n *\r\n * <p>active 포인터 이동 방식 — 지정 버전을 활성으로 전환하고 라이브 본문을 그 버전으로 투영한다.\r\n * <b>새 버전을 만들지 않는다</b>(버전 수 불변, 글로벌/회사/방 PM 통일 모델). 존재하지 않는\r\n * 버전이면 404 ({@code PM_PROMPT_LAYER_VERSION_NOT_FOUND}).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {number} version - 활성화할 버전 번호 ({@link #getRoomPmPromptVersions} 의 {@code version})\r\n * @returns {Promise<Object>} 활성 버전이 반영된 PmPromptLayer\r\n */\r\n async activateRoomPmPromptVersion(roomId, version) {\r\n if (!Number.isInteger(version) || version < 1) {\r\n throw new Error('activateRoomPmPromptVersion requires a positive integer version');\r\n }\r\n\r\n const response = await this.apiClient.post(\r\n `/api/v1/rooms/${roomId}/pm-prompt/versions/${version}/activate`\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * PM 프롬프트 합성 미리보기 — 방 참여자 전체.\r\n *\r\n * <p>전역(+회사)(+방) 레이어가 합성된 최종 SYSTEM 본문을 반환한다. 컨텍스트 정책 등\r\n * 런타임 부착분은 미포함 — 레이어 합성 결과까지만.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<{composedPrompt: string}>}\r\n *\r\n * @example\r\n * const { composedPrompt } = await chat.previewRoomPmPrompt(roomId);\r\n */\r\n async previewRoomPmPrompt(roomId) {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/pm-prompt/preview`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 그룹 채팅방 입장.\r\n *\r\n * <p>비밀방 ({@code PRIVATE_GROUP}) 은 최초 입장 시 또는 나갔다 재입장 시 비밀번호 필요.\r\n * 이미 active 참가자라면 비밀번호 없이 재접근 가능 (서버의 fast path).</p>\r\n *\r\n * <p>팀방 ({@code TEAM}) 은 초대 전용 — 직접 입장 시도는 {@code TEAM_ROOM_INVITE_ONLY}(403).\r\n * {@link #inviteToGroupRoom} 으로 초대받아야 입장된다 (이미 멤버면 fast path 멱등 통과).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} [password] - 비밀방 입장 비밀번호 (비밀방 최초/재입장 시 필수)\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * // 공개방 입장\r\n * await chat.joinGroupRoom('room-id');\r\n *\r\n * @example\r\n * // 비밀방 입장\r\n * await chat.joinGroupRoom('room-id', 'secret1234');\r\n */\r\n async joinGroupRoom(roomId, password) {\r\n const body = password ? { password } : undefined;\r\n return this.apiClient.post(`/api/v1/rooms/group/${roomId}/join`, body);\r\n }\r\n\r\n /**\r\n * 채팅방 나가기 (구독도 자동 해제됨).\r\n *\r\n * <p>일반 참가자는 {@code leaveRoom(roomId)} 그대로. <b>방장</b>은 그냥 나갈 수 없다 — 나가면 방에 관리자가\r\n * 0명이 되기 때문이다. 방장은 둘 중 하나를 고른다:</p>\r\n * <ul>\r\n * <li>위임 후 나가기 — {@code leaveRoom(roomId, { newOwnerId })}</li>\r\n * <li>방 삭제 — {@link #deleteRoom}</li>\r\n * </ul>\r\n *\r\n * <p>서버 응답:</p>\r\n * <ul>\r\n * <li>409 {@code ROOM_OWNER_CANNOT_LEAVE} — 방장이 newOwnerId 없이 나가려 함 (선택 화면을 띄울 신호)</li>\r\n * <li>403 {@code NOT_ROOM_ADMIN} — 방장이 아닌데 newOwnerId 를 보냄</li>\r\n * <li>400 {@code CANNOT_TRANSFER_OWNERSHIP_TO_SELF} / {@code INVALID_OWNERSHIP_TARGET}, 404 {@code USER_NOT_IN_ROOM}\r\n * — 위임 대상 오류 ({@link #transferOwnership} 와 같다). 이때는 나가지도 않는다.</li>\r\n * </ul>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} [options]\r\n * @param {string} [options.newOwnerId] - 방장이 나갈 때 방장을 넘겨받을 사람 (방의 활성 사람 참가자 · 활성 계정)\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * try {\r\n * await chat.leaveRoom(roomId);\r\n * } catch (e) {\r\n * // 서버 코드는 e.response.code — 방장이면 «위임 후 나가기 / 방 삭제» 선택 화면\r\n * if (e.response?.code === 'ROOM_OWNER_CANNOT_LEAVE') openOwnerExitDialog(roomId);\r\n * }\r\n * // 위임 후 나가기\r\n * await chat.leaveRoom(roomId, { newOwnerId: 'user-2' });\r\n */\r\n async leaveRoom(roomId, options = {}) {\r\n const body = options.newOwnerId ? { newOwnerId: options.newOwnerId } : undefined;\r\n const result = await this.apiClient.post(`/api/v1/rooms/${roomId}/leave`, body);\r\n this.unsubscribeRoom(roomId);\r\n return result;\r\n }\r\n\r\n /**\r\n * 방장 위임 — 방장은 방에 남고 MEMBER 가 된다. 방장(OWNER) 전용.\r\n *\r\n * <p>나가면서 넘기려면 {@code leaveRoom(roomId, { newOwnerId })}. 방 참가자 전원에게\r\n * {@code roomListOwnerChanged} 가 간다(actorId = 이전 방장, members[0] = 새 방장).</p>\r\n *\r\n * <p>서버 응답:</p>\r\n * <ul>\r\n * <li>403 {@code NOT_ROOM_ADMIN} — 방장 아님 (DIRECT 방은 방장이 없다)</li>\r\n * <li>400 {@code CANNOT_TRANSFER_OWNERSHIP_TO_SELF} — 자기 자신에게 위임</li>\r\n * <li>404 {@code USER_NOT_IN_ROOM} — 대상이 현재 참가자가 아님</li>\r\n * <li>400 {@code INVALID_OWNERSHIP_TARGET} — 대상이 어시스턴트이거나 퇴사·차단 계정</li>\r\n * </ul>\r\n *\r\n * @param {string} roomId\r\n * @param {string} newOwnerId - 새 방장 userId\r\n * @returns {Promise<Object>} 갱신된 방 상세 (participants[].role 반영 — 봉투를 벗긴 data)\r\n */\r\n async transferOwnership(roomId, newOwnerId) {\r\n const response = await this.apiClient.put(`/api/v1/rooms/${roomId}/owner`, { newOwnerId });\r\n return response && typeof response === 'object' && 'data' in response\r\n ? response.data\r\n : response;\r\n }\r\n\r\n /**\r\n * 방 삭제 — 방장(OWNER) 전용. 참가자 전원이 나가게 되고 방은 목록에서 사라진다.\r\n * 이후 enterRoom·getRoomInfo 는 403(ACCESS_FORBIDDEN — 더는 참가자가 아님), joinGroupRoom 은 404(CHAT_ROOM_NOT_FOUND).\r\n *\r\n * <p>참가자 전원(방장 포함)에게 {@code roomListDeleted} 가 간다. 본인 구독은 자동 해제된다.\r\n * 메시지·첨부는 서버에 보존된다(소프트 삭제) — 되살리는 API 는 없다.</p>\r\n *\r\n * <p>서버 응답: 403 {@code NOT_ROOM_ADMIN} — 방장 아님.</p>\r\n *\r\n * @param {string} roomId\r\n * @returns {Promise<Object>}\r\n */\r\n async deleteRoom(roomId) {\r\n const result = await this.apiClient.delete(`/api/v1/rooms/${roomId}`);\r\n this.unsubscribeRoom(roomId);\r\n return result;\r\n }\r\n\r\n /**\r\n * 그룹 채팅방 설정 수정 (방장만).\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} data - 수정할 필드 (null/undefined 이면 변경 안 함)\r\n * @param {string} [data.roomName] - 방 이름\r\n * @param {string} [data.description] - 설명\r\n * @param {string} [data.roomType] - 공개/비공개 전환 ('GROUP' | 'PRIVATE_GROUP').\r\n * 'PRIVATE_GROUP' 전환 시 password 필수, 'GROUP' 전환 시 기존 비번 제거. 생략 시 변경 안 함.\r\n * @param {string} [data.password] - 비밀번호 (PRIVATE_GROUP). 공백만으로는 설정 불가, 4~50자.\r\n * @param {number} [data.messageRetentionHours] - 보관 기간 (시간)\r\n * @param {boolean} [data.anyoneCanInvite] - 누구나 초대 가능 여부\r\n * @param {string} [data.assistantMode] - 어시스턴트 참여 모드\r\n * ('GENERAL' | 'PEOPLE_ONLY' | 'CALL_ONLY'). PERSONA_MULTI 방 전용.\r\n * @param {string} [data.engagementIntensity] - PM 개입 강도 ('QUIET' | 'NORMAL' | 'ACTIVE').\r\n * PM_BACKSTAGE 방 전용 — PM 참여 레벨 변경. 키 PM_BACKSTAGE 권한 필요(없으면 403).\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * // PERSONA_MULTI 방 — 어시스턴트 모드 변경\r\n * await chat.updateGroupRoom('room-id', { assistantMode: 'CALL_ONLY' });\r\n *\r\n * @example\r\n * // PM_BACKSTAGE 방 — PM 개입 강도 변경\r\n * await chat.updateGroupRoom('room-id', { engagementIntensity: 'QUIET' });\r\n *\r\n * @example\r\n * // 공개 → 비공개 전환 (비밀번호 필수)\r\n * await chat.updateGroupRoom('room-id', { roomType: 'PRIVATE_GROUP', password: 'secret12' });\r\n *\r\n * @example\r\n * // 비공개 → 공개 전환 (기존 비밀번호 제거됨)\r\n * await chat.updateGroupRoom('room-id', { roomType: 'GROUP' });\r\n */\r\n async updateGroupRoom(roomId, data) {\r\n return this.apiClient.put(`/api/v1/rooms/group/${roomId}`, data);\r\n }\r\n\r\n /**\r\n * 그룹 채팅방에 회원 / 어시스턴트 추가 초대.\r\n *\r\n * <p>두 가지 호출 형식 지원 (backward-compat):</p>\r\n * <ul>\r\n * <li>{@code inviteToGroupRoom(roomId, ['user-1', 'user-2'])} — 회원만 초대 (기존)</li>\r\n * <li>{@code inviteToGroupRoom(roomId, { userIds, assistantPersonaIds })} — 회원 + 어시 (신규)</li>\r\n * </ul>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string[]|{userIds?: string[], assistantPersonaIds?: string[]}} userIdsOrData\r\n * 배열이면 회원 ID 목록, 객체면 {@code userIds} / {@code assistantPersonaIds}.\r\n * 둘 중 하나 이상 필수.\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * // 회원만\r\n * await chat.inviteToGroupRoom(roomId, ['user-1', 'user-2']);\r\n *\r\n * @example\r\n * // 회원 + 어시스턴트\r\n * await chat.inviteToGroupRoom(roomId, {\r\n * userIds: ['user-3'],\r\n * assistantPersonaIds: [/* 어시스턴트 id 배열 *\\/]\r\n * });\r\n */\r\n async inviteToGroupRoom(roomId, userIdsOrData) {\r\n const body = Array.isArray(userIdsOrData)\r\n ? { userIds: userIdsOrData }\r\n : {\r\n userIds: userIdsOrData?.userIds,\r\n assistantPersonaIds: userIdsOrData?.assistantPersonaIds\r\n };\r\n return this.apiClient.post(`/api/v1/rooms/group/${roomId}/invite`, body);\r\n }\r\n\r\n /**\r\n * 그룹 채팅방에서 참가자 단순 추방 — 방 관리자(OWNER/ADMIN) 만 호출 가능.\r\n *\r\n * <p>대상은 방에서 제거되지만 재입장 / 재초대 가능. 영구 차단이 필요하면\r\n * {@link #banMember} 사용.</p>\r\n *\r\n * <p>서버 응답:</p>\r\n * <ul>\r\n * <li>400 {@code CANNOT_KICK_SELF} — 자기 자신 추방 시도</li>\r\n * <li>403 {@code NOT_ROOM_ADMIN} — 방장 아님</li>\r\n * <li>404 {@code USER_NOT_IN_ROOM} — 대상이 현재 참가자 아님</li>\r\n * </ul>\r\n *\r\n * @param {string} roomId\r\n * @param {string} userId - 추방할 사용자 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async kickMember(roomId, userId) {\r\n return this.apiClient.delete(`/api/v1/rooms/${roomId}/members/${userId}`);\r\n }\r\n\r\n /**\r\n * 그룹 채팅방에서 참가자 추방 + 영구 차단 — 방 관리자(OWNER/ADMIN) 만 호출 가능.\r\n *\r\n * <p>{@link #kickMember} 와 동일하게 방에서 제거하되 {@code bannedUserIds} 에 추가되어\r\n * {@link #unbanMember} 호출 전까지 재입장 / 재초대가 거부된다. MVP 정책상 현재 또는\r\n * 과거 참가자만 차단 가능 (선제적 차단 불가 — 404 {@code USER_NOT_IN_ROOM}).</p>\r\n *\r\n * @param {string} roomId\r\n * @param {string} userId - 차단할 사용자 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async banMember(roomId, userId) {\r\n return this.apiClient.post(`/api/v1/rooms/${roomId}/banned-members`, { userId });\r\n }\r\n\r\n /**\r\n * 영구 차단 해제 — 방 관리자(OWNER/ADMIN) 만 호출 가능.\r\n *\r\n * <p>{@code bannedUserIds} 에서 제거만 수행. 실제 재입장은 별도 초대 / join 호출 필요.</p>\r\n *\r\n * @param {string} roomId\r\n * @param {string} userId - 해제할 사용자 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async unbanMember(roomId, userId) {\r\n return this.apiClient.delete(`/api/v1/rooms/${roomId}/banned-members/${userId}`);\r\n }\r\n\r\n /**\r\n * 방의 영구 차단 사용자 목록 조회 — 방 관리자(OWNER/ADMIN) 만 호출 가능.\r\n *\r\n * <p>응답 각 item: {@code { userId, nickname, profileUrl }}.\r\n * 일반 참가자가 호출 시 403 {@code NOT_ROOM_ADMIN}.</p>\r\n *\r\n * @param {string} roomId\r\n * @returns {Promise<Array<{userId: string, nickname: string, profileUrl: string}>>}\r\n */\r\n async getBannedMembers(roomId) {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/banned-members`);\r\n return response && typeof response === 'object' && 'data' in response\r\n ? response.data\r\n : response;\r\n }\r\n\r\n /**\r\n * 메시지 고정 (방장만).\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 고정할 메시지 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async pinMessage(roomId, messageId) {\r\n return this.apiClient.post(`/api/v1/rooms/group/${roomId}/pin/${messageId}`);\r\n }\r\n\r\n /**\r\n * 메시지 고정 해제 (방장만).\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async unpinMessage(roomId) {\r\n return this.apiClient.post(`/api/v1/rooms/group/${roomId}/unpin`);\r\n }\r\n\r\n // ── 투표 ────────────────────────────────────────────────────────────────\r\n //\r\n // 투표 서버는 클러스터 내부에만 있어 클라이언트가 직접 부를 수 없다. 모든 호출은 채팅 서버를 거치고,\r\n // 신원(userId/projectId)은 채팅 서버가 인증 결과로 채워 넣는다.\r\n //\r\n // 🔴 실패는 서버가 투표 서버의 code 를 <b>그대로</b> 내려준다 — {@code error.response.code} 로 분기한다\r\n // (VOTE_ALREADY_CLOSED / VOTE_NOT_EDITABLE / VOTE_MODIFY_FORBIDDEN / CONFLICT ...).\r\n // 409 는 세 종류이고 대응이 다르다: ALREADY_CLOSED·NOT_EDITABLE 은 재시도해도 같고,\r\n // CONFLICT 만 <b>재조회 후</b> 재전송하면 성공한다.\r\n //\r\n // 🔴 결과 마스킹은 <b>호출자마다</b> 계산된다. 목록에는 집계가 없고, 단건 조회/접수 응답에만 있다.\r\n // 같은 투표라도 다른 사용자가 보면 가려지므로 응답을 공유 캐시에 넣으면 안 된다.\r\n\r\n /**\r\n * 투표 생성.\r\n *\r\n * <p>🔴 <b>{@code idempotencyKey} 는 필수다.</b> SDK 가 대신 만들지 않는다 — 만들면 그 값이 호출자에게\r\n * 남지 않아, 응답만 유실된 타임아웃에서 <b>복구가 불가능</b>하다(다음 호출이 새 키를 만들어 투표가\r\n * 두 개 생긴다). 호출자가 키를 소유하고, 같은 시도의 재시도에는 <b>같은 키</b>를 넘긴다 —\r\n * 그러면 서버가 기존 투표를 돌려준다(응답도 201, {@code voteId} 도 같다).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} vote - 투표 폼\r\n * @param {string} vote.question - 질문 (최대 500자)\r\n * @param {'SINGLE'|'MULTIPLE'} vote.type\r\n * @param {number} vote.maxSelections - 양수. {@code SINGLE} 이면 1\r\n * @param {'TEXT'|'TEXT_WITH_IMAGE'} vote.optionType - {@code 'IMAGE'} 라는 값은 없다\r\n * @param {number} vote.closeAt - 마감 시각 (epoch millis). 과거·현재 불가 · 최대 30일 뒤\r\n * @param {Array<{id: string, content: string, order: number, imageUrl?: string}>} vote.options - 2~10개.\r\n * {@code imageUrl} 은 {@link #uploadVoteImage} 가 돌려준 값을 <b>가공 없이</b> 넣는다\r\n * @param {Object} options\r\n * @param {string} options.idempotencyKey - <b>필수.</b> 재시도에는 반드시 같은 값을 넘긴다\r\n * @returns {Promise<Object>} 생성된 투표 (집계 포함)\r\n * @throws {Error} {@code idempotencyKey} 가 없으면 서버를 호출하지 않고 즉시 실패한다\r\n * @example\r\n * const key = crypto.randomUUID();\r\n * const vote = await chat.createVote(roomId, {\r\n * question: '점심 뭐 먹지', type: 'SINGLE', optionType: 'TEXT', maxSelections: 1,\r\n * closeAt: Date.now() + 86_400_000,\r\n * options: [{ id: 'opt-1', content: '삼겹살', order: 0 }, { id: 'opt-2', content: '국밥', order: 1 }]\r\n * }, { idempotencyKey: key });\r\n */\r\n async createVote(roomId, vote, options = {}) {\r\n if (!roomId) {\r\n throw new Error('roomId is required');\r\n }\r\n const { idempotencyKey } = options;\r\n if (!idempotencyKey) {\r\n // SDK 가 만들어 주면 호출자는 그 값을 볼 수 없다 — 타임아웃으로 응답만 유실됐을 때\r\n // 같은 키로 재시도할 방법이 사라지고, 다음 호출이 투표를 하나 더 만든다.\r\n throw new Error(\r\n 'createVote: options.idempotencyKey is required — '\r\n + 'generate it once per vote and reuse the same value on retry '\r\n + '(재시도에 같은 키를 넘겨야 투표가 중복 생성되지 않는다).'\r\n );\r\n }\r\n const response = await this.apiClient.post(\r\n `/api/v1/rooms/${roomId}/votes`,\r\n vote,\r\n undefined,\r\n { 'X-Idempotency-Key': idempotencyKey }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방의 투표 목록 (최신순).\r\n *\r\n * <p>목록 항목에는 선택지별 집계도 내 선택도 없다 — 참여자 수({@code responseCount})만 있다.\r\n * 결과를 보려면 {@link #getVote} 를 쓴다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} [options]\r\n * @param {string} [options.cursor] - 이전 응답의 {@code nextCursor} 를 <b>그대로</b>. 불투명 문자열이라 파싱하지 않는다\r\n * @param {number} [options.limit=20] - 최대 100. 초과분은 서버가 조용히 100 으로 보정한다(에러 아님)\r\n * @returns {Promise<{items: Array<Object>, nextCursor: ?string}>} {@code items} 가 비면 끝\r\n */\r\n async getVotes(roomId, options = {}) {\r\n const { cursor, limit } = options;\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/votes`, { cursor, limit });\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 투표 단건 + 집계.\r\n *\r\n * <p>{@code revealed: false} 일 때의 {@code count}/{@code ratio} 0 은 「참여자가 없다」가 아니라\r\n * <b>서버가 가린 값</b>이다. 결과 공개 조건은 「내가 응답했다」 또는 「마감됐다」. 비공개 상태에서도\r\n * {@code myOptionIds} 는 채워진다(재투표 화면용).</p>\r\n *\r\n * <p>{@code ratio} 의 분모는 응답자 수이지 총 선택 수가 아니다 — 복수 선택에서 <b>합이 100을 넘는 것이\r\n * 정상</b>이고 화면이 「복수 응답」임을 표기해야 한다.</p>\r\n *\r\n * <p>마감 여부는 {@code status} 만으로 정해지지 않는다. 서버 자동 마감이 미구현이라\r\n * {@code status: 'OPEN'} 인데 {@code closeAt} 이 지난 투표가 있고 그건 마감된 것으로 동작한다 —\r\n * <b>{@code status} 와 {@code closeAt} 을 함께</b> 봐야 한다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} voteId - 투표 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async getVote(roomId, voteId) {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/votes/${voteId}`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 투표 수정 — <b>부분 수정이 아니다.</b> 등록 폼 전체를 보낸다({@link #createVote} 와 같은 타입).\r\n *\r\n * <p>{@code maxSelections}↔{@code type}, {@code optionType}↔{@code imageUrl} 이 서로 묶여 있어\r\n * 한쪽만 바꾸면 판독 불가능한 투표가 만들어지기 때문이다.</p>\r\n *\r\n * <p>조건 둘: <b>개설자만</b> · <b>참여자가 0명일 때만</b>. 표가 들어오면\r\n * {@code VOTE_NOT_EDITABLE}(409), 개설자가 아니면 {@code VOTE_MODIFY_FORBIDDEN}(403).\r\n * 선택지 {@code id} 를 이전 값과 맞출 필요는 없다 — 참여자가 0명이라 어긋날 응답이 없다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} voteId - 투표 ID\r\n * @param {Object} vote - 투표 폼 (생성과 동일)\r\n * @returns {Promise<Object>}\r\n */\r\n async updateVote(roomId, voteId, vote) {\r\n const response = await this.apiClient.patch(`/api/v1/rooms/${roomId}/votes/${voteId}`, vote);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 투표 삭제 — 개설자면 된다. 마감 여부도 참여자 유무도 보지 않는다.\r\n *\r\n * <p>채팅에 이미 게시된 투표 카드는 <b>그대로 남는다</b>(서버가 발행된 메시지를 고칠 수 없다).\r\n * 원본 카드를 누르면 404 가 오고, 이 404 를 「취소된 투표」로 표시하는 것은 화면 몫이다.\r\n * 삭제 사실은 별도의 취소 카드 메시지로도 방에 발행된다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} voteId - 투표 ID\r\n * @returns {Promise<void>} 본문 없음 (204)\r\n */\r\n async deleteVote(roomId, voteId) {\r\n await this.apiClient.delete(`/api/v1/rooms/${roomId}/votes/${voteId}`);\r\n }\r\n\r\n /**\r\n * 수동 마감 (개설자만).\r\n *\r\n * <p>본문이 없으므로 마감 후 결과를 보려면 {@link #getVote} 를 다시 호출한다.\r\n * 이미 마감이면 {@code VOTE_ALREADY_CLOSED}(409), 개설자가 아니면 {@code VOTE_CLOSE_FORBIDDEN}(403)\r\n * — 수정의 403 과 code 가 다르다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} voteId - 투표 ID\r\n * @returns {Promise<void>} 본문 없음 (204)\r\n */\r\n async closeVote(roomId, voteId) {\r\n await this.apiClient.patch(`/api/v1/rooms/${roomId}/votes/${voteId}/close`);\r\n }\r\n\r\n /**\r\n * 투표 접수 · 재투표 — <b>같은 메서드</b>다.\r\n *\r\n * <p>재투표는 새 자원을 만들지 않고 기존 응답을 덮어쓴다. 클라이언트가 최초/재투표를 구분해 보낼 필요가\r\n * 없고, 같은 값을 여러 번 보내도 결과가 같다. 응답에 <b>갱신된 집계가 함께</b> 오므로 별도 폴링이 필요 없다.</p>\r\n *\r\n * <p>빈 배열은 「투표 취소」로 해석되지 않는다 — 투표를 취소하는 경로는 없다(400).</p>\r\n *\r\n * <p>여러 규칙을 동시에 위반해도 서버는 <b>먼저 걸린 하나만</b> 응답한다. 하나를 고쳐 재시도하면\r\n * 다음 위반이 드러날 수 있어, 모든 오류를 한 번에 수집하는 화면은 성립하지 않는다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} voteId - 투표 ID\r\n * @param {string[]} optionIds - 고른 선택지. 빈 배열 불가\r\n * @returns {Promise<Object>} 갱신된 투표 (집계 포함)\r\n */\r\n async submitVote(roomId, voteId, optionIds) {\r\n const response = await this.apiClient.put(\r\n `/api/v1/rooms/${roomId}/votes/${voteId}/responses`,\r\n { optionIds }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 선택지 이미지 업로드.\r\n *\r\n * <p>돌려받은 {@code imageUrl} 을 <b>가공 없이 그대로</b> 생성·수정 본문의 {@code options[].imageUrl} 에\r\n * 넣는다 — 서버가 문자열로 비교하고, 이 API 가 발급한 URL 만 통과한다. <b>채팅 첨부 URL 은 쓸 수 없다</b>\r\n * (같은 버킷이라도 거절된다).</p>\r\n *\r\n * <p>형식은 매직넘버로 판정한다(PNG · JPEG · GIF · WEBP) — 신고된 Content-Type 과 확장자는 쓰이지 않으므로\r\n * {@code .png} 로 이름 붙인 텍스트 파일은 거절된다. 10MB 를 넘으면 {@code VOTE_IMAGE_TOO_LARGE}.</p>\r\n *\r\n * <p>{@code optionType: 'TEXT_WITH_IMAGE'} 는 「이미지를 쓸 수 있다」는 상한이지 「모두 이미지다」가 아니다 —\r\n * 이미지가 있는 선택지와 없는 선택지를 섞어도 되고 하나도 없어도 된다. 다만 {@code imageUrl} 이\r\n * <b>빈 문자열</b>이면 거절된다(없으면 키를 아예 빼거나 {@code null}).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {File|Blob} file - 이미지 파일\r\n * @param {Object} [options]\r\n * @param {Function} [options.onProgress] - {@code ({loaded, total, percent}) => void}\r\n * @param {AbortSignal} [options.signal] - 취소용\r\n * @returns {Promise<{imageUrl: string}>}\r\n */\r\n async uploadVoteImage(roomId, file, options = {}) {\r\n if (!roomId) {\r\n throw new Error('roomId is required');\r\n }\r\n if (!file) {\r\n throw new Error('file is required');\r\n }\r\n const response = await this.apiClient.upload(\r\n `/api/v1/rooms/${roomId}/votes/images`,\r\n file,\r\n { onProgress: options.onProgress, signal: options.signal }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 이모지 리액션 토글.\r\n * <p>한 사용자는 메시지당 하나의 이모지만 보유.\r\n * 같은 이모지 재전송 시 해제, 다른 이모지 전송 시 교체.</p>\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 메시지 ID\r\n * @param {string} emoji - 이모지 (예: \"👍\", \"😂\")\r\n * @returns {Promise<Object>}\r\n */\r\n async toggleReaction(roomId, messageId, emoji) {\r\n return this.apiClient.post(`/api/v1/chat-messages/${roomId}/messages/${messageId}/reaction`, { emoji });\r\n }\r\n\r\n /**\r\n * 참가 가능한 그룹 채팅방 목록 조회\r\n * @param {Object} [params]\r\n * @param {number} [params.size=50] - 페이지 크기\r\n * @param {string} [params.lastId] - 마지막 채팅방 ID\r\n * @param {number} [params.lastSortValue] - 마지막 정렬값\r\n * @returns {Promise<Object>}\r\n */\r\n async getAvailableGroupRooms(params = {}) {\r\n const { size = 50, lastId, lastSortValue } = params;\r\n return this.apiClient.get('/api/v1/rooms/groups/available', { size, lastId, lastSortValue });\r\n }\r\n\r\n /**\r\n * 모든 그룹 채팅방 목록 조회\r\n * @param {Object} [params]\r\n * @param {number} [params.size=50] - 페이지 크기\r\n * @param {string} [params.lastId] - 마지막 채팅방 ID\r\n * @param {number} [params.lastSortValue] - 마지막 정렬값\r\n * @returns {Promise<Object>}\r\n */\r\n async getAllGroupRooms(params = {}) {\r\n const { size = 50, lastId, lastSortValue } = params;\r\n return this.apiClient.get('/api/v1/rooms/groups/all', { size, lastId, lastSortValue });\r\n }\r\n\r\n /**\r\n * 메시지 목록 조회\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} [params]\r\n * @param {number} [params.size=50] - 페이지 크기 (1-100)\r\n * @param {string} [params.lastId] - 마지막 메시지 ID (커서)\r\n * @param {number} [params.lastSortValue] - 마지막 정렬값 (epoch millis)\r\n * @returns {Promise<Object>}\r\n */\r\n async getMessages(roomId, params = {}) {\r\n const { size = 50, lastId, lastSortValue } = params;\r\n // 커서가 없으면 \"최신 페이지\" — 이때만 anchor 를 심는다 (과거 페이지네이션은 워터마크를 뒤로 민다).\r\n // null 과 undefined 를 함께 잡는다(느슨한 비교) — 호출자가 명시적으로 null 을 넘겨도 서버에는\r\n // 최신 페이지로 나가므로, 엄격 비교로 두면 그 경로만 조용히 seed 를 건너뛴다.\r\n const isLatestPage = lastId == null && lastSortValue == null;\r\n const genAtStart = this._anchorGen.get(roomId) || 0;\r\n\r\n const response = await this.apiClient.get(\r\n `/api/v1/chat-messages/${roomId}/list`, { size, lastId, lastSortValue }\r\n );\r\n // REST 와 WebSocket 의 타임스탬프 직렬화가 다르다 (REST=ISO 문자열, WS=epoch number).\r\n // 선언된 타입(number)으로 통일 — 기존 응답 구조(envelope)는 그대로 둔다.\r\n const page = this._unwrapSuccessResponse(response);\r\n if (page && Array.isArray(page.content)) {\r\n page.content.forEach((m) => this._normalizeMessageTimestamps(m));\r\n if (isLatestPage) {\r\n this._seedAnchorFromLatest(roomId, page.content, genAtStart);\r\n }\r\n }\r\n return response;\r\n }\r\n\r\n /**\r\n * 초기 REST 로드로 anchor 를 심는다 (RECONNECT_SYNC_DESIGN §6.2).\r\n *\r\n * <p><b>없으면 가장 흔한 복귀 경로가 통째로 skip 된다</b> — 방에 들어와 초기 50건을 REST 로 본 뒤\r\n * 라이브 메시지를 한 건도 받기 전에 백그라운드로 가면 anchor 가 없어 갭 복구가 돌지 않는다.\r\n * 그게 바로 이 기능이 없애려는 증상이다.</p>\r\n *\r\n * <p><b>최신 페이지에서만</b> 호출해야 한다. 과거 방향 페이지네이션(무한 스크롤) 결과로 심으면\r\n * 워터마크가 뒤로 밀린다. 목록은 최신순(DESC)이라 {@code content[0]} 이 가장 최신이다.</p>\r\n *\r\n * <p>진행 중 라이브 메시지가 도착했으면(세대 변화) 그쪽이 더 최신이므로 덮어쓰지 않는다 —\r\n * {@code id} 는 문자열 비교로 시간 순서를 보장하지 못해 \"더 최신인지\" 를 직접 판별할 수 없기 때문에\r\n * sync 커밋과 동일한 generation guard 로 처리한다.</p>\r\n *\r\n * @private\r\n */\r\n _seedAnchorFromLatest(roomId, descendingMessages, genAtStart) {\r\n if (!Array.isArray(descendingMessages) || descendingMessages.length === 0) return false;\r\n const newest = descendingMessages[0];\r\n if (!newest || !newest.id) return false;\r\n return this._commitAnchorFromSync(roomId, newest.id, genAtStart);\r\n }\r\n\r\n /**\r\n * 갭 복구용 forward 조회 — 앵커 <b>이후</b> 메시지를 오름차순으로 가져온다.\r\n *\r\n * <p>커서는 메시지 {@code id}(Mongo {@code _id}) 하나뿐이다. 시간값은 보내지 않는다 —\r\n * 서버가 앵커를 조회해 {@code sentAt} 을 직접 읽으므로 timezone/정밀도 불일치가 생길 수 없다.</p>\r\n *\r\n * <p>읽음 부작용이 없다({@code /list} 와 동일). 백그라운드 방의 unread 를 건드리지 않는다.</p>\r\n *\r\n * @param {string} roomId\r\n * @param {Object} [params]\r\n * @param {string} [params.afterId] - 앵커 메시지의 Mongo _id. 없으면 방의 가장 오래된 것부터.\r\n * @param {number} [params.rewindMillis] - 시계 스큐 방어 되감기. <b>첫 페이지에만</b> 보낸다.\r\n * @param {number} [params.size]\r\n * @returns {Promise<Object>}\r\n */\r\n async syncMessages(roomId, params = {}) {\r\n const { afterId, rewindMillis, size = this.syncPageSize } = params;\r\n const response = await this.apiClient.get(\r\n `/api/v1/chat-messages/${roomId}/sync`, { afterId, rewindMillis, size }\r\n );\r\n const page = this._unwrapSuccessResponse(response);\r\n if (page && Array.isArray(page.content)) {\r\n page.content.forEach((m) => this._normalizeMessageTimestamps(m));\r\n }\r\n return response;\r\n }\r\n\r\n /**\r\n * 라이브 수신으로 anchor 전진 (+세대 증가).\r\n *\r\n * <p>{@code MESSAGE_CREATED} 에서만 호출한다. UPDATED/DELETED/REACTION 은 과거 메시지의 재발행이라\r\n * anchor 를 건드리면 워터마크가 뒤로 밀린다.</p>\r\n *\r\n * @private\r\n */\r\n _advanceAnchorFromLive(roomId, messageId) {\r\n if (!roomId || !messageId) return;\r\n this._anchor.set(roomId, messageId);\r\n this._anchorGen.set(roomId, (this._anchorGen.get(roomId) || 0) + 1);\r\n }\r\n\r\n /**\r\n * sync 완료 커밋 — 시작 시점 세대가 유지된 경우에만 anchor 를 덮어쓴다.\r\n *\r\n * <p>sync 진행 중 라이브 메시지가 들어와 anchor 가 더 앞서 갔을 수 있는데, 그걸 sync 결과로 덮으면\r\n * <b>워터마크가 후퇴</b>해 다음 sync 가 같은 구간을 다시 긁는다.</p>\r\n *\r\n * @private\r\n * @returns {boolean} 커밋 여부\r\n */\r\n _commitAnchorFromSync(roomId, lastId, genAtStart) {\r\n if (!lastId) return false;\r\n if ((this._anchorGen.get(roomId) || 0) !== genAtStart) {\r\n this.logger.debug(`Anchor commit skipped (live advanced): room=${roomId}`);\r\n return false;\r\n }\r\n this._anchor.set(roomId, lastId);\r\n return true;\r\n }\r\n\r\n /**\r\n * 복구한 메시지를 라이브와 <b>동일한 경로</b>로 재생한다.\r\n *\r\n * <p>반드시 {@link #_handleChatMessage} 하나로만 흘려야 한다 — dedup({@code _shouldDedupMessage})은\r\n * check 와 record 가 한 몸이라, 호출자가 미리 dedup 을 돌린 뒤 넘기면 {@code _handleChatMessage} 가\r\n * 같은 messageId 를 중복으로 판정해 <b>복구 메시지를 전부 삼킨다</b>.</p>\r\n *\r\n * <p>REST 응답에는 {@code eventType} 이 없으므로 {@code MESSAGE_CREATED} 를 합성한다. 삭제\r\n * placeholder 도 CREATED 로 흘린다 — dedup 대상이 CREATED 뿐이라, DELETED 로 보내면 재동기화 때마다\r\n * 중복 emit 된다. 삭제 표시는 payload 의 {@code deletedStatus} 가 담당한다.</p>\r\n *\r\n * @private\r\n */\r\n _replayMessages(roomId, messages) {\r\n if (!Array.isArray(messages) || messages.length === 0) return 0;\r\n\r\n let emitted = 0;\r\n this._replaying = true;\r\n try {\r\n messages.forEach((message) => {\r\n // 되감기 구간에는 이미 받은 메시지가 정상적으로 섞여 들어온다. dedup 에 걸려 화면에\r\n // 나가지 않은 건 복구 건수에서 빼야 syncCompleted 지표가 \"실제로 새로 들어온 수\" 가 된다.\r\n const handled = this._handleChatMessage(roomId, {\r\n ...message,\r\n eventType: message.eventType || 'MESSAGE_CREATED'\r\n });\r\n if (handled) emitted += 1;\r\n });\r\n } finally {\r\n this._replaying = false;\r\n }\r\n return emitted;\r\n }\r\n\r\n /**\r\n * 갭이 상한을 넘었을 때의 폴백 — 최신 페이지만 다시 싣는다.\r\n *\r\n * <p>{@code getRoom}/{@code enterRoom} 은 서버 {@code markAllAsRead} 를 트리거하므로 절대 쓰지 않는다.\r\n * 백그라운드 방의 누적 unread 가 통째로 지워진다. 읽음 부작용이 없는 {@code /list} 만 사용한다.</p>\r\n *\r\n * @private\r\n */\r\n async _reloadLatestPage(roomId, genAtStart) {\r\n const response = await this.getMessages(roomId, { size: this.syncPageSize });\r\n const page = this._unwrapSuccessResponse(response) || {};\r\n const content = Array.isArray(page.content) ? page.content : [];\r\n\r\n // /list 는 최신순(DESC) — 도착 순서대로 replay 하려면 뒤집는다.\r\n const ascending = content.slice().reverse();\r\n // 폴백 페이지에도 이미 본 메시지가 섞이므로, 지표는 조회 건수가 아니라 실제 emit 수여야 한다.\r\n const emitted = this._replayMessages(roomId, ascending);\r\n\r\n const newest = ascending.length ? ascending[ascending.length - 1].id : null;\r\n this._commitAnchorFromSync(roomId, newest, genAtStart);\r\n return emitted;\r\n }\r\n\r\n /** @private */\r\n _isCursorExpired(error) {\r\n return error?.status === 410 || error?.response?.code === 'SYNC_CURSOR_EXPIRED';\r\n }\r\n\r\n /**\r\n * 방 하나의 갭 복구 (RECONNECT_SYNC_DESIGN §6.3).\r\n *\r\n * <p><b>fetch 와 replay 를 분리</b>한다. 페이지마다 즉시 emit 하면 상한 초과를 알게 된 시점에 이미\r\n * 일부를 내보낸 뒤라 \"부분 replay 없음\" 이 성립하지 않는다.</p>\r\n *\r\n * @private\r\n * @returns {Promise<Object|null>} 복구 결과. 대상이 아니면 null.\r\n */\r\n async _syncRoom(roomId, anchor = this._anchor.get(roomId), quiet = false) {\r\n // 이 세션에서 아직 받은 메시지가 없는 방 — enterRoom 의 초기 로드가 담당한다.\r\n if (!anchor) return null;\r\n if (this._syncInFlight.has(roomId)) return null;\r\n\r\n this._syncInFlight.add(roomId);\r\n const genAtStart = this._anchorGen.get(roomId) || 0;\r\n const startedAt = Date.now();\r\n\r\n try {\r\n // ── Phase 1: fetch only (emit 없음) ──\r\n const buffer = [];\r\n let cursor = anchor;\r\n // 되감기는 워터마크 기준 첫 페이지에서 1회만 의미가 있다. 매 페이지 되감으면 같은 구간을 반복 조회한다.\r\n let rewindMillis = this.rewindMillis;\r\n let hasMore = true;\r\n let pages = 0;\r\n\r\n while (hasMore && pages < this.maxSyncPages) {\r\n const response = await this.syncMessages(roomId, { afterId: cursor, rewindMillis });\r\n const page = this._unwrapSuccessResponse(response) || {};\r\n const content = Array.isArray(page.content) ? page.content : [];\r\n\r\n buffer.push(...content);\r\n rewindMillis = 0;\r\n hasMore = !!page.hasNext;\r\n if (page.lastId) cursor = page.lastId;\r\n pages += 1;\r\n }\r\n\r\n // ── Phase 2: 판정 후 replay ──\r\n if (hasMore) {\r\n // 상한 초과 — 버퍼를 통째로 버리고 최신 페이지로 전환한다(부분 replay 금지).\r\n this.logger.info(`Gap too large, falling back to latest page: room=${roomId}`);\r\n if (!quiet) this.emit('gapTruncated', { roomId, fetched: buffer.length });\r\n const reloaded = await this._reloadLatestPage(roomId, genAtStart);\r\n return {\r\n roomId, cursor: this._anchor.get(roomId), recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt\r\n };\r\n }\r\n\r\n const recovered = this._replayMessages(roomId, buffer);\r\n this._commitAnchorFromSync(roomId, cursor, genAtStart);\r\n return { roomId, cursor, recovered, truncated: false, durationMs: Date.now() - startedAt };\r\n\r\n } catch (error) {\r\n if (this._isCursorExpired(error)) {\r\n // retention 정리 등으로 앵커가 사라짐 — 최신 페이지로 복구한다.\r\n this.logger.warn(`Sync cursor expired, reloading latest: room=${roomId}`);\r\n if (!quiet) this.emit('gapTruncated', { roomId, fetched: 0, reason: 'cursorExpired' });\r\n const reloaded = await this._reloadLatestPage(roomId, genAtStart);\r\n return {\r\n roomId, cursor: this._anchor.get(roomId), recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt\r\n };\r\n }\r\n throw error;\r\n } finally {\r\n this._syncInFlight.delete(roomId);\r\n }\r\n }\r\n\r\n /**\r\n * 구독 중인 모든 방의 갭 복구 + 방 목록 갱신.\r\n *\r\n * <p>재연결 성공({@code reconnected}) 과 복귀 감지({@code resumed}) 양쪽에서 호출된다. 두 이벤트는\r\n * 상호 배타적이라 중복 실행되지 않는다.</p>\r\n *\r\n * @param {string} [reason]\r\n * @returns {Promise<Map<string, string>|undefined>} 1차 조회 cursor — 내부 보충 조회용.\r\n */\r\n async syncAfterReconnect(reason = 'manual') {\r\n if (!this.syncOnReconnect) return;\r\n\r\n const roomIds = Array.from(this.subscribedRooms.keys()).filter((id) => this._anchor.has(id));\r\n if (roomIds.length === 0) {\r\n await this._refreshRoomList(reason);\r\n return;\r\n }\r\n\r\n this.emit('syncStarted', { roomIds, reason });\r\n const startedAt = Date.now();\r\n const rooms = [];\r\n const cursors = new Map();\r\n\r\n for (const roomId of roomIds) {\r\n try {\r\n const result = await this._syncRoom(roomId);\r\n if (result) {\r\n rooms.push(result);\r\n cursors.set(roomId, result.cursor);\r\n }\r\n } catch (error) {\r\n // 한 방의 실패가 나머지 방 복구를 막지 않게 한다.\r\n this.logger.warn(`Gap sync failed: room=${roomId}`, error);\r\n }\r\n }\r\n\r\n await this._refreshRoomList(reason);\r\n\r\n this.emit('syncCompleted', {\r\n rooms,\r\n totalRecovered: rooms.reduce((sum, r) => sum + (r.recovered || 0), 0),\r\n durationMs: Date.now() - startedAt,\r\n reason\r\n });\r\n return cursors;\r\n }\r\n\r\n /** 첫 조회가 끝난 cursor부터 조용히 보충한다. 목록·동기화 이벤트는 첫 조회의 책임이다. */\r\n async _supplementAfterReconnect(cursors, generation) {\r\n for (const [roomId, cursor] of cursors) {\r\n if (generation !== this._reconnectSyncGeneration) return;\r\n if (!this.subscribedRooms.has(roomId)) continue;\r\n try { await this._syncRoom(roomId, cursor, true); }\r\n catch (error) { this.logger.warn(`Supplemental gap sync failed: room=${roomId}`, error); }\r\n }\r\n }\r\n\r\n /**\r\n * 재연결 시 방 목록/unread 1회 갱신.\r\n *\r\n * <p>기존 {@code roomListUpdate} 로 흘리지 않는다 — 그건 단건 delta 계약이라 전체 스냅샷을 실으면\r\n * 기존 구독자가 형태가 다른 payload 를 delta 로 오해한다. 별도 {@code roomListSynced} 로 분리.</p>\r\n *\r\n * @private\r\n */\r\n async _refreshRoomList(reason) {\r\n if (!this.roomListSubscribed) return;\r\n try {\r\n const response = await this.getRooms({ size: 50 });\r\n const page = this._unwrapSuccessResponse(response) || {};\r\n this.emit('roomListSynced', { rooms: page.content || [], reason });\r\n } catch (error) {\r\n this.logger.warn('Room list refresh failed (non-fatal):', error);\r\n }\r\n }\r\n\r\n /**\r\n * 링크 미리보기 조회.\r\n *\r\n * <p>서버가 OG / Twitter Card / HTML fallback 을 파싱해 미리보기 카드를 반환합니다.\r\n * 외부 사이트 차단 등으로 HTML 을 못 가져오면 공개 DNS 로 확인되는 URL 에 한해\r\n * 도메인 + favicon 기반 최소 카드를 반환할 수 있습니다.\r\n * 이 경우 {@code previewSource} 는 {@code FAVICON_FALLBACK} 입니다.\r\n * 미리보기를 만들 수 없는 URL 이면 {@code null} 을 반환합니다.</p>\r\n *\r\n * <p>민감한 URL 이 query string 으로 남지 않도록 POST body 로 호출합니다.</p>\r\n *\r\n * @param {string} url - 미리보기를 조회할 URL\r\n * @returns {Promise<Object|null>} LinkPreview 또는 null\r\n */\r\n async fetchLinkPreview(url) {\r\n const normalizedUrl = typeof url === 'string' ? url.trim() : '';\r\n if (!normalizedUrl) {\r\n throw new Error('url is required');\r\n }\r\n\r\n const response = await this.apiClient.post('/api/v1/link-preview', {\r\n url: normalizedUrl\r\n });\r\n\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 메시지 전송 (REST API).\r\n *\r\n * <p>멱등 POST — SDK 가 내부에서 UUID 를 자동 생성하며, 호출자는 {@code messageId} 를\r\n * 지정할 수 없다. 같은 요청의 네트워크 재시도(SDK 내부 구현 시) 에도 같은 UUID 가 쓰이도록\r\n * 설계된다. 서버 Unique 제약 + pre-check 로 중복 저장이 차단되며, 재시도 히트 시\r\n * {@code duplicate: true} 로 기존 결과가 그대로 반환된다.</p>\r\n *\r\n * <p><b>전송 1회 = 메시지 1건</b>: 첨부가 몇 개든 한 메시지로 저장되며 응답 {@code messages} 는 길이 1이다.\r\n * {@code chatMessageType} 은 렌더러 힌트로, 첨부가 동질이면 그 카테고리, 혼재면 {@code MIXED} 다.</p>\r\n *\r\n * <p>답글: {@code data.replyToMessageId} 에 원본 메시지의 서버 {@code messageId} 를 지정.\r\n * 서버가 원본을 조회해서 snapshot 을 생성 후 저장하며, 응답/수신 메시지의 {@code replyTo}\r\n * 필드에 원본 요약이 포함됨. 원본이 존재하지 않으면 서버가 400 (REPLY_TARGET_NOT_FOUND) 반환.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} data\r\n * @param {string} [data.message] - 메시지 내용 (message 또는 fileInfos 중 하나 필수)\r\n * @param {Object[]} [data.fileInfos] - 파일 메타데이터 배열. 각 원소는 <b>UUID {@code fileId} 필수</b>\r\n * ({@link #uploadFile} 반환값은 이미 포함). 외부 스토리지 직접 업로드 연동은 직접 채워야 한다.\r\n * @param {string} [data.replyToMessageId] - 답글 대상 원본 메시지 ID (옵션)\r\n * @param {Object} [data.decisionResponse] - 의사결정 카드의 선택 — {@code { optionId }} (옵션).\r\n * 카드 버튼을 누르면 <b>평범한 답글</b>로 보낸다: {@code replyToMessageId} = 카드의 messageId,\r\n * {@code message} = 고른 선택지의 {@code label}, 여기에 {@code optionId}. 전용 endpoint 는 없다.\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n * - {@code duplicate}: 멱등 재시도로 기존 결과를 반환한 경우 {@code true}\r\n * - {@code messages}: 저장된 메시지 배열. {@code messageId} 는 서버가 부여한 ID.\r\n *\r\n * @example\r\n * // 일반 메시지\r\n * const { messages } = await chat.sendMessage('room-id', { message: '안녕하세요' });\r\n * console.log(messages[0].messageId); // 서버가 생성한 ID — 답글/삭제/리액션에 사용\r\n *\r\n * @example\r\n * // 답글\r\n * await chat.sendMessage('room-id', {\r\n * message: '네 동감이에요',\r\n * replyToMessageId: 'original-msg-id'\r\n * });\r\n *\r\n * @example\r\n * // 의사결정 카드의 선택 — 답글 하나로 나간다 (새 API 없음)\r\n * const option = card.decisionRequest.options[0];\r\n * await chat.sendMessage('room-id', {\r\n * message: option.label, // 폴백 가독성 — 카드를 못 그리는 화면에서도 대화가 읽힌다\r\n * replyToMessageId: card.messageId,\r\n * decisionResponse: { optionId: option.id }\r\n * });\r\n *\r\n * @example\r\n * // 첨부 3장 + 캡션 — 메시지 1건\r\n * const { messages } = await chat.sendMessage('room-id', {\r\n * message: '사진 보냅니다',\r\n * fileInfos: [f0, f1, f2] // uploadFile 결과 (fileId 포함)\r\n * });\r\n * // messages.length === 1 · messages[0].chatMessageType === 'IMAGE'\r\n * // messages[0].fileMetaDataList.length === 3\r\n */\r\n async sendMessage(roomId, data) {\r\n const messageId = this._generateMessageId();\r\n return this._sendMessageWithId(roomId, messageId, data);\r\n }\r\n\r\n /**\r\n * 메시지 전송 — Optimistic UI 버전.\r\n *\r\n * <p>{@link #sendMessage} 와 동일하게 서버에 메시지를 전송하지만, 호출 즉시\r\n * SDK 가 생성한 {@code baseMessageId} 와 응답 {@code messages} 배열과 1-1 매칭되는\r\n * 예측 {@code messageIds} 배열을 동기 반환한다. 호출자는 promise 가 resolve 되기 전에\r\n * 미리 placeholder 메시지를 UI 에 그려둘 수 있고, resolve 후 같은 ID 로 안전하게 교체할 수 있다.</p>\r\n *\r\n * <p>전송 1회 = 메시지 1건이므로 {@code messageIds} 는 <b>항상 {@code [baseMessageId]} 하나</b>다.\r\n * 서버 분류 규칙을 mirror 해 여러 ID 를 예측하던 로직은 제거됐다.</p>\r\n *\r\n * <p><b>전환 기간 주의</b>: 서버가 아직 병합 저장을 켜지 않았다면 한 요청이 여러 row 로 저장되어\r\n * {@code optimistic.predictionMatched} 가 {@code false} 가 된다. 오류가 아니라 전환 기간의 정상\r\n * 상태이며, 이때는 응답의 {@code actualMessageIds} 로 재구성해야 한다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} data - {@link #sendMessage} 와 동일한 페이로드\r\n * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}\r\n *\r\n * @example\r\n * const { messageIds, promise } = chat.sendMessageOptimistic('room-id', {\r\n * message: '안녕하세요'\r\n * });\r\n * messageIds.forEach(id => appendMessage({ messageId: id, status: 'sending' }));\r\n * try {\r\n * const { messages } = await promise;\r\n * messages.forEach(m => replaceMessage(m.messageId, m));\r\n * } catch (e) {\r\n * messageIds.forEach(id => markMessageFailed(id, e));\r\n * }\r\n */\r\n sendMessageOptimistic(roomId, data) {\r\n const baseMessageId = this._generateMessageId();\r\n // 전송 1회 = 메시지 1건 — 예측할 것이 없다. 서버 분류 규칙을 mirror 하던 예측기는 제거됐다.\r\n const messageIds = [baseMessageId];\r\n\r\n const promise = this._sendMessageWithId(roomId, baseMessageId, data)\r\n .then((result) => this._attachOptimisticMetadata(messageIds, result));\r\n\r\n return { baseMessageId, messageIds, promise };\r\n }\r\n\r\n /**\r\n * messageId 를 외부에서 주입받아 실제 send API 를 호출하는 내부 메서드.\r\n * <p>{@link #sendMessage} 와 {@link #sendMessageOptimistic} 가 공유하는 단일 코어 — base messageId 를\r\n * 양쪽이 같은 값으로 사용해야 optimistic 핸들의 예측 ID 가 서버 저장 ID 와 일치한다.</p>\r\n *\r\n * @private\r\n * @param {string} roomId\r\n * @param {string} messageId - SDK 가 생성한 base messageId (UUID)\r\n * @param {Object} data\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n */\r\n async _sendMessageWithId(roomId, messageId, data) {\r\n // 메시지 전송 시 타이핑 표시 자동 중단\r\n this.stopTyping(roomId);\r\n\r\n // separateFiles 는 더 이상 보내지 않는다 — 전송 1회 = 메시지 1건이 계약이다.\r\n // 서버는 이 필드를 병합 저장 플래그가 OFF 인 동안에만(legacy 재현용) 읽으며, 값이 없으면\r\n // 기본 true 로 동작한다. 즉 OFF 서버에서는 여전히 N건이 저장되고, 그 불일치는\r\n // optimistic 의 predictionMatched:false 로 드러난다(설계 §5).\r\n const response = await this.apiClient.post(`/api/v1/chat-messages/${roomId}/send`, {\r\n messageId,\r\n message: data.message,\r\n fileInfos: data.fileInfos,\r\n replyToMessageId: data.replyToMessageId,\r\n // 의사결정 카드의 선택. 🔴 이 줄이 없으면 호출자가 넘긴 값이 <b>조용히 유실</b>된다 —\r\n // 이 payload 는 화이트리스트라 data 에 있어도 전달되지 않는다.\r\n decisionResponse: data.decisionResponse\r\n });\r\n\r\n const result = this._unwrapSuccessResponse(response);\r\n if (result && Array.isArray(result.messages)) {\r\n result.messages.forEach((m) => this._normalizeMessageTimestamps(m));\r\n }\r\n return result;\r\n }\r\n\r\n /**\r\n * 텍스트 메시지 전송 (간편 버전).\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} message - 메시지 내용\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n *\r\n * @example\r\n * const { messages } = await chat.sendTextMessage('room-id', '안녕하세요');\r\n * console.log(messages[0].messageId);\r\n */\r\n async sendTextMessage(roomId, message) {\r\n return this.sendMessage(roomId, { message });\r\n }\r\n\r\n /**\r\n * 텍스트 메시지 전송 — Optimistic UI 버전.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} message - 메시지 내용\r\n * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}\r\n *\r\n * @example\r\n * const { baseMessageId, promise } = chat.sendTextMessageOptimistic('room-id', '안녕');\r\n * appendMessage({ messageId: baseMessageId, content: '안녕', status: 'sending' });\r\n * const { messages } = await promise;\r\n * replaceMessage(baseMessageId, messages[0]);\r\n */\r\n sendTextMessageOptimistic(roomId, message) {\r\n return this.sendMessageOptimistic(roomId, { message });\r\n }\r\n\r\n /**\r\n * 채팅 첨부 파일 업로드 (Low-level).\r\n *\r\n * <p>파일 바이트를 서버로 업로드하고 서버가 생성한 {@code FileMetaData} 를 반환.\r\n * 반환값은 {@link #sendMessage} 의 {@code fileInfos} 에 그대로 넣어 전송.</p>\r\n *\r\n * <p>업로드와 메시지 전송을 분리 호출해야 하는 케이스 (업로드 후 프리뷰 표시 →\r\n * 사용자가 전송 버튼 클릭 시 메시지 전송 등) 에 사용. 한 번에 처리하려면\r\n * {@link #sendFileMessage} 가 편리.</p>\r\n *\r\n * @param {string} roomId - 업로드 대상 채팅방 ID (권한 검증 + S3 namespace)\r\n * @param {File|Blob} file - 업로드할 파일\r\n * @param {Object} [options]\r\n * @param {Function} [options.onProgress] - {@code ({loaded, total, percent}) => void} — 진행률 콜백\r\n * @param {AbortSignal} [options.signal] - 업로드 취소용 AbortSignal\r\n * @returns {Promise<Object>} FileMetaData (fileId/fileUrl/fileName/fileType/fileSize/imageInfo/videoInfo/audioInfo/documentInfo)\r\n *\r\n * @example\r\n * const meta = await chat.uploadFile('room-id', fileInput.files[0], {\r\n * onProgress: ({ percent }) => console.log(`${percent}%`)\r\n * });\r\n * await chat.sendMessage('room-id', {\r\n * fileInfos: [meta],\r\n * message: '첨부합니다'\r\n * });\r\n */\r\n async uploadFile(roomId, file, options = {}) {\r\n if (!roomId) {\r\n throw new Error('roomId is required');\r\n }\r\n if (!file) {\r\n throw new Error('file is required');\r\n }\r\n\r\n const response = await this.apiClient.upload(\r\n `/api/v1/files/${roomId}/upload`,\r\n file,\r\n {\r\n onProgress: options.onProgress,\r\n signal: options.signal\r\n }\r\n );\r\n\r\n // 서버 SuccessResponse 래퍼 안의 data 가 FileMetaData\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 파일 업로드 + 메시지 전송 통합 (High-level).\r\n *\r\n * <p>내부적으로:</p>\r\n * <ol>\r\n * <li>각 파일을 {@link #uploadFile} 로 업로드 (배열이면 병렬)</li>\r\n * <li>수신된 {@code FileMetaData} 들을 모아 {@link #sendMessage} 로 전송</li>\r\n * </ol>\r\n *\r\n * <p>업로드 중 하나라도 실패하면 전체 rejected — 이미 업로드된 S3 객체는\r\n * 고아로 남으며 라이프사이클 정책으로 정리됨 (설계 결정).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {File|Blob|Array<File|Blob>} files - 단일 파일 또는 파일 배열\r\n * @param {Object} [options]\r\n * @param {string} [options.message] - 함께 보낼 텍스트 본문\r\n * @param {string} [options.replyToMessageId] - 답글 대상 원본 메시지의 서버 {@code messageId}\r\n * @param {Function} [options.onUploadProgress] - {@code ({loaded,total,percent,fileIndex}) => void} — 파일별 진행률\r\n * @param {AbortSignal} [options.signal] - 취소 시그널 — 업로드 중에만 유효\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n *\r\n * @example\r\n * // 단일 파일\r\n * await chat.sendFileMessage('room-id', file, {\r\n * message: '첨부',\r\n * onUploadProgress: ({ percent }) => console.log(`${percent}%`)\r\n * });\r\n *\r\n * @example\r\n * // 다중 파일 (병렬 업로드)\r\n * await chat.sendFileMessage('room-id', [file1, file2, file3], {\r\n * message: '여러 파일',\r\n * onUploadProgress: ({ fileIndex, percent }) =>\r\n * console.log(`파일 ${fileIndex}: ${percent}%`)\r\n * });\r\n */\r\n async sendFileMessage(roomId, files, options = {}) {\r\n const fileArray = this._normalizeFileArray(files);\r\n const metas = await this._uploadFiles(roomId, fileArray, options);\r\n const fileInfos = this._applyFileMetadata(metas, options.metadata);\r\n\r\n return this.sendMessage(roomId, {\r\n message: options.message,\r\n fileInfos,\r\n replyToMessageId: options.replyToMessageId\r\n });\r\n }\r\n\r\n /**\r\n * 파일 업로드 + 메시지 전송 — Optimistic UI 버전.\r\n *\r\n * <p>호출 즉시 {@code baseMessageId} 와 응답 {@code messages} 와 1-1 매칭되는 예측\r\n * {@code messageIds} 를 동기 반환한다. 호출자는 업로드 시작 전에 placeholder 메시지를\r\n * UI 에 그릴 수 있다.</p>\r\n *\r\n * <p><b>입력 검증은 동기 throw</b>: 빈 배열 / max 초과 등 {@link #_normalizeFileArray} 의\r\n * 검증 실패는 promise 가 만들어지기 전에 즉시 throw 되므로, 잘못된 입력에 대해 placeholder 가\r\n * 먼저 그려지는 일은 없다.</p>\r\n *\r\n * <p><b>업로드 실패 처리</b>: 업로드 단계에서 실패하면 promise 가 reject 된다. 호출자는 UI 의\r\n * placeholder 들을 모두 실패 상태로 표시하고, S3 에 부분 업로드된 객체는 라이프사이클 정책으로 정리된다.</p>\r\n *\r\n * @param {string} roomId\r\n * @param {File|Blob|Array<File|Blob>} files\r\n * @param {Object} [options] - {@link #sendFileMessage} 와 동일\r\n * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}\r\n *\r\n * @example\r\n * const { messageIds, promise } = chat.sendFileMessageOptimistic('room-id', files, {\r\n * message: '사진 보냅니다'\r\n * });\r\n * messageIds.forEach((id, i) => appendMessage({\r\n * messageId: id,\r\n * localFile: files[i] || null,\r\n * status: 'uploading'\r\n * }));\r\n * try {\r\n * const { messages } = await promise;\r\n * messages.forEach(m => replaceMessage(m.messageId, m));\r\n * } catch (e) {\r\n * messageIds.forEach(id => markMessageFailed(id, e));\r\n * }\r\n */\r\n sendFileMessageOptimistic(roomId, files, options = {}) {\r\n // validation 은 동기 — placeholder 가 그려지기 전에 throw 되어야 함\r\n const fileArray = this._normalizeFileArray(files);\r\n\r\n const baseMessageId = this._generateMessageId();\r\n const messageIds = [baseMessageId]; // 전송 1회 = 메시지 1건\r\n\r\n const promise = (async () => {\r\n const metas = await this._uploadFiles(roomId, fileArray, options);\r\n const fileInfos = this._applyFileMetadata(metas, options.metadata);\r\n const result = await this._sendMessageWithId(roomId, baseMessageId, {\r\n message: options.message,\r\n fileInfos,\r\n replyToMessageId: options.replyToMessageId\r\n });\r\n return this._attachOptimisticMetadata(messageIds, result);\r\n })();\r\n\r\n return { baseMessageId, messageIds, promise };\r\n }\r\n\r\n /**\r\n * 파일 입력 정규화 — 단일 파일 / 배열 양쪽 허용 + 개수 검증.\r\n * <p>{@link #sendFileMessage} 와 {@link #sendFileMessageOptimistic} 가 공유.</p>\r\n *\r\n * @private\r\n * @param {File|Blob|Array<File|Blob>} files\r\n * @returns {Array<File|Blob>}\r\n * @throws {Error} 빈 배열 또는 MAX_FILES_PER_MESSAGE 초과\r\n */\r\n _normalizeFileArray(files) {\r\n const fileArray = Array.isArray(files) ? files : [files];\r\n if (fileArray.length === 0) {\r\n throw new Error('At least one file is required');\r\n }\r\n if (fileArray.length > MAX_FILES_PER_MESSAGE) {\r\n throw new Error(`A maximum of ${MAX_FILES_PER_MESSAGE} files can be sent in one message`);\r\n }\r\n return fileArray;\r\n }\r\n\r\n /**\r\n * 파일 배열 병렬 업로드 — 각 파일의 진행률은 fileIndex 로 구분.\r\n * <p>{@link #sendFileMessage} 와 {@link #sendFileMessageOptimistic} 가 공유.</p>\r\n *\r\n * @private\r\n * @param {string} roomId\r\n * @param {Array<File|Blob>} fileArray\r\n * @param {Object} options - signal, onUploadProgress 사용\r\n * @returns {Promise<Object[]>} FileMetaData 배열\r\n */\r\n async _uploadFiles(roomId, fileArray, options = {}) {\r\n return Promise.all(\r\n fileArray.map((file, index) =>\r\n this.uploadFile(roomId, file, {\r\n signal: options.signal,\r\n onProgress: options.onUploadProgress\r\n ? (p) => options.onUploadProgress({ ...p, fileIndex: index })\r\n : undefined\r\n })\r\n )\r\n );\r\n }\r\n\r\n /**\r\n * 업로드된 FileMetaData 배열에 고객사 pass-through metadata 를 병합.\r\n *\r\n * <p>{@link #sendFileMessage} / {@link #sendFileMessageOptimistic} 가 공유.\r\n * 호출자가 {@code options.metadata} 를 안 주면 metas 가 그대로 반환되어 기존 동작과 100% 호환.</p>\r\n *\r\n * @private\r\n * @param {Object[]} metas - uploadFile 결과의 FileMetaData 배열\r\n * @param {Object|Array<Object|null|undefined>|null|undefined} metadata - 단일 객체(broadcast) 또는 index 별 배열\r\n * @returns {Object[]} metadata 가 병합된 새 배열 (기존 meta 객체는 수정하지 않음)\r\n */\r\n _applyFileMetadata(metas, metadata) {\r\n if (metadata === undefined || metadata === null) {\r\n return metas;\r\n }\r\n\r\n return metas.map((meta, index) => {\r\n const itemMetadata = Array.isArray(metadata)\r\n ? metadata[index]\r\n : metadata;\r\n\r\n if (itemMetadata === undefined || itemMetadata === null) {\r\n return meta;\r\n }\r\n\r\n return {\r\n ...meta,\r\n metadata: itemMetadata\r\n };\r\n });\r\n }\r\n\r\n /**\r\n * 답글 전송 (간편 버전) — 텍스트 답글 전용.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} message - 답글 내용\r\n * @param {string} replyToMessageId - 원본 메시지의 서버 {@code messageId}\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n *\r\n * @example\r\n * // \"안녕하세요\" 메시지에 답장\r\n * await chat.sendReply('room-id', '네 반가워요', 'original-msg-id');\r\n */\r\n async sendReply(roomId, message, replyToMessageId) {\r\n return this.sendMessage(roomId, { message, replyToMessageId });\r\n }\r\n\r\n /**\r\n * 답글 전송 — Optimistic UI 버전.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} message - 답글 내용\r\n * @param {string} replyToMessageId - 원본 메시지의 서버 {@code messageId}\r\n * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}\r\n */\r\n sendReplyOptimistic(roomId, message, replyToMessageId) {\r\n return this.sendMessageOptimistic(roomId, { message, replyToMessageId });\r\n }\r\n\r\n /**\r\n * 의사결정 카드의 선택 전송 (간편 버전).\r\n *\r\n * <p>세 가지를 <b>한 번에 맞춰</b> 보낸다 — 셋 중 하나만 빠져도 계약이 깨지므로 직접 조립하기보다\r\n * 이 메서드를 쓰는 편이 안전하다:</p>\r\n * <ul>\r\n * <li>{@code replyToMessageId} = 카드 messageId — 어느 질문에 답한 것인지</li>\r\n * <li>{@code message} = 고른 선택지의 {@code label} — <b>가독 폴백</b>. 빈 본문이면 대화에\r\n * 빈 칸이 남아 나중에 읽을 때 무엇을 골랐는지 알 수 없다</li>\r\n * <li>{@code decisionResponse.optionId} — 서버가 보는 <b>권위</b> 값</li>\r\n * </ul>\r\n *\r\n * <p>전용 endpoint 가 아니라 <b>평범한 답글</b>이다. 그래서 선택이 대화에 메시지로 남고, 카드를\r\n * 못 그리는 화면에서도 흐름이 읽힌다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} card - 카드 메시지 ({@code decisionRequest} 를 가진 그 메시지)\r\n * @param {Object} option - 사용자가 고른 {@code card.decisionRequest.options[i]}\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n *\r\n * @example\r\n * const card = messages.find(m => m.decisionRequest);\r\n * await chat.sendDecisionChoice('room-id', card, card.decisionRequest.options[1]);\r\n */\r\n async sendDecisionChoice(roomId, card, option) {\r\n if (!card || !card.messageId) {\r\n throw new Error('sendDecisionChoice: card with messageId is required');\r\n }\r\n if (!option || !option.id) {\r\n throw new Error('sendDecisionChoice: option with id is required');\r\n }\r\n return this.sendMessage(roomId, {\r\n message: option.label,\r\n replyToMessageId: card.messageId,\r\n decisionResponse: { optionId: option.id }\r\n });\r\n }\r\n\r\n /**\r\n * 메시지 삭제.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 메시지 ID\r\n * @param {string} [deleteType='ALL'] - 삭제 유형.\r\n * {@code 'ALL'} — 모두에게 삭제 (양쪽 \"삭제된 메시지입니다\" 표시).\r\n * {@code 'SELF'} — 나만 삭제 (내 화면에서만 숨김, 상대방은 원본 유지).\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * // 모두에게 삭제\r\n * await chat.deleteMessage('room-id', 'msg-id');\r\n *\r\n * @example\r\n * // 나만 삭제\r\n * await chat.deleteMessage('room-id', 'msg-id', 'SELF');\r\n */\r\n async deleteMessage(roomId, messageId, deleteType = 'ALL') {\r\n return this.apiClient.post(`/api/v1/chat-messages/${roomId}/messages/${messageId}/delete`, {\r\n deleteType\r\n });\r\n }\r\n\r\n /**\r\n * 메시지 수정.\r\n *\r\n * <p>본인 메시지만 수정 가능. 수정 후 '수정됨' 라벨이 표시됩니다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 메시지 ID\r\n * @param {string} message - 수정할 내용 (max 20000자)\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * await chat.editMessage('room-id', 'msg-id', '수정된 내용입니다');\r\n */\r\n async editMessage(roomId, messageId, message) {\r\n return this.apiClient.put(`/api/v1/chat-messages/${roomId}/messages/${messageId}`, {\r\n message\r\n });\r\n }\r\n\r\n /**\r\n * 서버의 {@code SendMessageService.hasTextMessage} mirror — null/공백 문자열을 모두 \"텍스트 없음\" 으로 취급.\r\n * <p>서버 기준 ({@code message != null && !message.isBlank()}) 과 동일해야 messageIds prediction 이 일치한다.</p>\r\n *\r\n * @private\r\n * @param {string|null|undefined} message\r\n * @returns {boolean}\r\n */\r\n _hasTextMessage(message) {\r\n return typeof message === 'string' && message.trim().length > 0;\r\n }\r\n\r\n /**\r\n * Content-Type 의 base MIME — {@code ;} 이후 파라미터(charset 등) 제거 + trim + 소문자.\r\n * <p>서버 {@code ChatFileCategory.baseMimeType} mirror. 'text/plain; charset=utf-8' → 'text/plain'.\r\n * 'text/plain-invalid' 같은 변형은 그대로 남아 정확 비교에서 걸러진다.</p>\r\n *\r\n * @private\r\n * @param {string} mimeType\r\n * @returns {string}\r\n */\r\n _baseMimeType(mimeType) {\r\n const raw = mimeType || '';\r\n const sep = raw.indexOf(';');\r\n return (sep >= 0 ? raw.slice(0, sep) : raw).trim().toLowerCase();\r\n }\r\n\r\n /**\r\n * MIME 타입 → {@code ChatMessageType} 카테고리 (서버 {@code AttachmentTypeClassifier} mirror).\r\n *\r\n * <p><b>공개 API</b> — legacy {@code GALLERY} 메시지의 실제 카테고리를 복원하는 데 쓴다\r\n * ({@link #resolveMessageCategory}). 서버 규칙이 바뀌면 본 메서드도 동기화해야 한다.</p>\r\n *\r\n * @param {string} mimeType\r\n * @returns {'IMAGE'|'VIDEO'|'AUDIO'|'DOCUMENT'|'FILE'}\r\n */\r\n classifyFileType(mimeType) {\r\n const mime = this._baseMimeType(mimeType);\r\n if (mime.startsWith('image/')) return 'IMAGE';\r\n if (mime.startsWith('video/')) return 'VIDEO';\r\n if (mime.startsWith('audio/')) return 'AUDIO';\r\n // 평문(txt/md)도 DOCUMENT — 서버가 온디맨드 번역·PM 문서 인식 대상으로 삼는 기준과 동일해야 한다.\r\n // pre-2007 Office(.doc/.xls/.ppt)도 서버가 OLE 매직까지 검증해 DOCUMENT 로 받는다.\r\n if (mime.includes('pdf') || mime.includes('document')\r\n || mime.includes('msword') || mime.includes('ms-excel') || mime.includes('ms-powerpoint')\r\n || PLAIN_TEXT_DOCUMENT_MIME_TYPES.has(mime)) {\r\n return 'DOCUMENT';\r\n }\r\n return 'FILE';\r\n }\r\n\r\n /**\r\n * 하위호환 별칭 — {@link #classifyFileType} 를 쓸 것.\r\n * @private\r\n * @deprecated\r\n */\r\n _classifyFileType(mimeType) {\r\n return this.classifyFileType(mimeType);\r\n }\r\n\r\n /**\r\n * 메시지의 <b>렌더 카테고리</b>를 해석한다 — legacy {@code GALLERY} 복원용 공개 헬퍼.\r\n *\r\n * <p>서버는 이제 첨부가 동질이면 그 카테고리를, 혼재면 {@code MIXED} 를 보낸다. 그러나 이 변경\r\n * <b>이전에 저장된 메시지는 복수 첨부면 타입 무관 전부 {@code GALLERY}</b> 다 — 동영상 2개도, PDF 2개도\r\n * {@code GALLERY} 다. 따라서 <b>{@code GALLERY} 를 {@code IMAGE} 로 매핑하면 안 된다.</b>\r\n * 이 메서드가 {@code fileMetaDataList} 의 MIME 으로 실제 카테고리를 복원한다.</p>\r\n *\r\n * @param {Object} message - 서버 메시지 ({@code chatMessageType} + {@code fileMetaDataList})\r\n * @returns {string} 'TEXT'|'IMAGE'|'VIDEO'|'AUDIO'|'DOCUMENT'|'FILE'|'MIXED'|'SYSTEM'\r\n *\r\n * @example\r\n * // 렌더러 선택 — GALLERY 분기를 따로 두지 않아도 된다\r\n * switch (client.resolveMessageCategory(message)) {\r\n * case 'IMAGE': return <ImageGrid files={message.fileMetaDataList} />;\r\n * case 'VIDEO': return <VideoList files={message.fileMetaDataList} />;\r\n * case 'MIXED': return <MixedAttachmentList files={message.fileMetaDataList} />;\r\n * default: return <FileList files={message.fileMetaDataList} />;\r\n * }\r\n */\r\n resolveMessageCategory(message) {\r\n const type = message && message.chatMessageType;\r\n const files = (message && message.fileMetaDataList) || [];\r\n // GALLERY 만 복원 대상 — 나머지는 서버가 이미 정확한 카테고리를 보냈다.\r\n if (type !== 'GALLERY') {\r\n return type || 'TEXT';\r\n }\r\n if (files.length === 0) {\r\n return 'FILE';\r\n }\r\n const categories = new Set(files.map(f => this.classifyFileType((f && f.fileType) || '')));\r\n return categories.size === 1 ? categories.values().next().value : 'MIXED';\r\n }\r\n\r\n /**\r\n * 예측 messageIds 와 서버 응답을 비교하여 result 에 optimistic metadata 첨부.\r\n * <p>서버 분류 규칙 drift 를 런타임에 감지하기 위한 방어 로직. mismatch 발생 시\r\n * warn 로그 + 응답에 {@code optimistic.predictionMatched: false} 를 실어 호출자가\r\n * 프로그래밍적으로 fallback reconcile 을 결정할 수 있게 한다.</p>\r\n *\r\n * <p>길이 mismatch 와 같은 길이의 값/순서 mismatch 모두 동일하게 {@code predictionMatched: false}\r\n * 로 표시된다. 호출자는 README \"Drift 방어\" 섹션의 fallback 패턴을 사용하면 된다.</p>\r\n *\r\n * @private\r\n * @param {string[]} predictedMessageIds\r\n * @param {{duplicate: boolean, messages: Array<{messageId: string}>}} result\r\n * @returns {{duplicate: boolean, messages: Array, optimistic: {predictedMessageIds: string[], actualMessageIds: string[], predictionMatched: boolean}}}\r\n */\r\n _attachOptimisticMetadata(predictedMessageIds, result) {\r\n const actualMessageIds = (result && Array.isArray(result.messages))\r\n ? result.messages.map(m => (m && m.messageId) || null)\r\n : [];\r\n\r\n const predictionMatched =\r\n actualMessageIds.length === predictedMessageIds.length &&\r\n predictedMessageIds.every((id, i) => id === actualMessageIds[i]);\r\n\r\n if (!predictionMatched) {\r\n this.logger.warn('Optimistic messageId prediction mismatch — server may have changed grouping rules. Use result.optimistic.actualMessageIds to reconcile.', {\r\n predicted: predictedMessageIds,\r\n actual: actualMessageIds\r\n });\r\n }\r\n\r\n return {\r\n ...result,\r\n optimistic: {\r\n predictedMessageIds,\r\n actualMessageIds,\r\n predictionMatched\r\n }\r\n };\r\n }\r\n\r\n /**\r\n * 메시지 ID 생성 (UUID v4 형식)\r\n * @private\r\n * @returns {string}\r\n */\r\n _generateMessageId() {\r\n // crypto.randomUUID() 우선 사용 (높은 엔트로피), 미지원 환경 fallback\r\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\r\n return crypto.randomUUID();\r\n }\r\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\r\n const r = Math.random() * 16 | 0;\r\n const v = c === 'x' ? r : (r & 0x3 | 0x8);\r\n return v.toString(16);\r\n });\r\n }\r\n\r\n /**\r\n * 서버 SuccessResponse 래퍼가 있으면 data 만 꺼내고, 아니면 원본 응답을 그대로 반환합니다.\r\n * data 가 null 이어도 그대로 null 을 반환해야 하므로 nullish coalescing 을 쓰지 않습니다.\r\n *\r\n * @private\r\n * @param {*} response\r\n * @returns {*}\r\n */\r\n _unwrapSuccessResponse(response) {\r\n return response\r\n && typeof response === 'object'\r\n && Object.prototype.hasOwnProperty.call(response, 'data')\r\n ? response.data\r\n : response;\r\n }\r\n\r\n /**\r\n * REST 메시지 응답의 타임스탬프를 epoch millis(number)로 정규화합니다 (in-place).\r\n *\r\n * <p>서버의 두 전달 경로가 서로 다르게 직렬화한다 — WebSocket 이벤트는 epoch number,\r\n * REST(목록/전송 응답)는 ISO 문자열(타임존 없는 LocalDateTime). 선언된 타입\r\n * ({@code ChatMessage.sentAt: number})으로 통일해 소비자 분기를 제거한다.</p>\r\n *\r\n * <p>⚠️ 한계: 서버 ISO 문자열에 타임존이 없어 브라우저 로컬 타임존으로 해석된다 —\r\n * 서버(KST)와 사용자 타임존이 다르면 오프셋만큼 어긋날 수 있다. 근본 해소는\r\n * 백엔드 직렬화 통일(후속 결정) 영역.</p>\r\n *\r\n * @private\r\n * @param {Object} message - REST 응답의 메시지 객체 (수정됨)\r\n * @returns {Object} 같은 객체\r\n */\r\n _normalizeMessageTimestamps(message) {\r\n if (!message || typeof message !== 'object') {\r\n return message;\r\n }\r\n message.sentAt = this._toEpochMillis(message.sentAt);\r\n message.editedAt = this._toEpochMillis(message.editedAt);\r\n if (message.replyTo && typeof message.replyTo === 'object') {\r\n message.replyTo.sentAt = this._toEpochMillis(message.replyTo.sentAt);\r\n }\r\n return message;\r\n }\r\n\r\n /**\r\n * ISO 문자열이면 epoch millis 로 변환, number/null/undefined 는 그대로 통과.\r\n * 파싱 불가 문자열은 원본 유지 (조용한 데이터 손실 방지).\r\n *\r\n * @private\r\n * @param {*} value\r\n * @returns {*}\r\n */\r\n _toEpochMillis(value) {\r\n if (typeof value !== 'string') {\r\n return value;\r\n }\r\n const parsed = Date.parse(value);\r\n return Number.isNaN(parsed) ? value : parsed;\r\n }\r\n\r\n // ==================== WebSocket 구독 ====================\r\n\r\n /**\r\n * 채팅방 구독.\r\n *\r\n * <p>구독 시 메시지, 읽음, 타이핑, 멤버 변경 이벤트를 자동으로 수신.\r\n * 멤버 변경은 roomList 구독 이벤트를 내부 변환하여\r\n * {@code memberJoined} / {@code memberLeft} 로 emit.</p>\r\n *\r\n * <p>자동 읽음 처리는 {@link #setActiveRoom} 으로 현재 보고 있는 방을 설정해야 동작합니다.\r\n * 구독만으로는 읽음 처리되지 않으며, 다른 방을 보고 있으면 안읽음 카운트가 유지됩니다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<void>}\r\n *\r\n * @fires memberJoined - { roomId, members: [{ userId, nickname, profileUrl, companyRole? }], participantCount, timestamp }\r\n * @fires memberLeft - { roomId, members: [{ userId, nickname, profileUrl, companyRole? }], participantCount, timestamp }\r\n */\r\n async subscribeRoom(roomId) {\r\n if (this.subscribedRooms.has(roomId)) {\r\n this.logger.warn(`Already subscribed to room: ${roomId}`);\r\n return;\r\n }\r\n\r\n // 메시지 구독\r\n const chatDestination = WebSocketPaths.getChatDestination(roomId);\r\n await this.connectionManager.subscribe(chatDestination, (message) => {\r\n this._handleChatMessage(roomId, message);\r\n });\r\n\r\n // 읽음 이벤트 구독\r\n const readDestination = WebSocketPaths.getChatReadDestination(roomId);\r\n await this.connectionManager.subscribe(readDestination, (message) => {\r\n this._handleReadEvent(roomId, message);\r\n });\r\n\r\n // 타이핑 이벤트 구독\r\n const typingDestination = WebSocketPaths.getChatTypingDestination(roomId);\r\n await this.connectionManager.subscribe(typingDestination, (event) => {\r\n this._handleTypingEvent(roomId, event);\r\n });\r\n\r\n // AI 진행 표시(생각중/검색중/작성중) 구독 — typing 과 별개 채널(가산 UX). 구버전 SDK 는 미구독 → 기존 typing 만.\r\n //\r\n // ⚠️ 보류함을 <b>구독보다 먼저</b> 연다(설계 §6.1 ①). 구독 호출과 보류 플래그 사이에 DELTA 가\r\n // 도착하면 그건 보류되지 않고 그대로 렌더돼, 스냅샷 병합 전에 잘린 본문이 화면에 먼저 그려진다.\r\n const snapshotGeneration = this.assistantStreamReplay ? this._armAssistantSnapshot(roomId) : null;\r\n const assistantStreamDestination = WebSocketPaths.getChatAssistantStreamDestination(roomId);\r\n await this.connectionManager.subscribe(assistantStreamDestination, (event) => {\r\n this._handleAssistantStreamEvent(roomId, event);\r\n });\r\n if (snapshotGeneration !== null) {\r\n // 구독 직후 스냅샷 — 응답을 기다리지 않는다. 구독은 이미 걸렸으므로 그 사이 이벤트는 보류함에 쌓이고,\r\n // 병합이 끝나면 순서대로 풀린다. 실패해도 보류분은 풀리므로 라이브만으로 계속 동작한다.\r\n this._loadAssistantSnapshot(roomId, snapshotGeneration)\r\n .catch((error) => this.logger.warn(`Assistant snapshot merge failed: room=${roomId}`, error));\r\n }\r\n\r\n // Rolling 누적 요약 완료 알림 구독 — refreshSummary 완료/실패를 summaryUpdate 이벤트로 통지(transient).\r\n const summaryDestination = WebSocketPaths.getChatSummaryDestination(roomId);\r\n await this.connectionManager.subscribe(summaryDestination, (event) => {\r\n this._handleSummaryEvent(roomId, event);\r\n });\r\n\r\n // 멤버 변경 이벤트 — roomList 이벤트를 방 수준으로 변환하여 자동 emit\r\n // 서버가 members 배열(입장/퇴장 사용자들) + participantCount(정확한 인원수) 포함\r\n const memberJoinedHandler = (event) => {\r\n if (event.roomId === roomId) {\r\n this.emit('memberJoined', {\r\n roomId,\r\n members: event.members || [],\r\n participantCount: event.activeParticipantCount,\r\n timestamp: event.timestamp\r\n });\r\n }\r\n };\r\n const memberLeftHandler = (event) => {\r\n if (event.roomId === roomId) {\r\n this.emit('memberLeft', {\r\n roomId,\r\n members: event.members || [],\r\n participantCount: event.activeParticipantCount,\r\n timestamp: event.timestamp\r\n });\r\n }\r\n };\r\n this.on('roomListJoined', memberJoinedHandler);\r\n this.on('roomListLeft', memberLeftHandler);\r\n\r\n this.subscribedRooms.set(roomId, {\r\n chatDestination,\r\n readDestination,\r\n typingDestination,\r\n assistantStreamDestination,\r\n summaryDestination,\r\n memberJoinedHandler,\r\n memberLeftHandler,\r\n subscribedAt: new Date()\r\n });\r\n\r\n this.logger.info(`Subscribed to room: ${roomId}`);\r\n this.emit('roomSubscribed', { roomId });\r\n }\r\n\r\n /**\r\n * 채팅방 구독 해제\r\n * @param {string} roomId - 채팅방 ID\r\n */\r\n unsubscribeRoom(roomId) {\r\n const subscription = this.subscribedRooms.get(roomId);\r\n if (!subscription) {\r\n this.logger.warn(`Not subscribed to room: ${roomId}`);\r\n return;\r\n }\r\n\r\n this.connectionManager.unsubscribe(subscription.chatDestination);\r\n this.connectionManager.unsubscribe(subscription.readDestination);\r\n if (subscription.typingDestination) {\r\n this.connectionManager.unsubscribe(subscription.typingDestination);\r\n }\r\n if (subscription.assistantStreamDestination) {\r\n this.connectionManager.unsubscribe(subscription.assistantStreamDestination);\r\n }\r\n if (subscription.summaryDestination) {\r\n this.connectionManager.unsubscribe(subscription.summaryDestination);\r\n }\r\n\r\n // 멤버 변경 리스너 해제\r\n if (subscription.memberJoinedHandler) {\r\n this.off('roomListJoined', subscription.memberJoinedHandler);\r\n }\r\n if (subscription.memberLeftHandler) {\r\n this.off('roomListLeft', subscription.memberLeftHandler);\r\n }\r\n\r\n // 타이핑 타이머 정리 (outgoing + incoming assistant 둘 다)\r\n this._clearTypingTimer(roomId);\r\n this._clearAssistantTypingTimer(roomId);\r\n // AI 진행 표시 상태/타이머/묘비 정리 + 세대 증가 (emit 없이 — 구독 해제 중)\r\n this._clearAssistantProgress(roomId);\r\n\r\n // 현재 보고 있는 방이면 activeRoom 해제\r\n if (this._activeRoomId === roomId) {\r\n this._activeRoomId = null;\r\n }\r\n\r\n this.subscribedRooms.delete(roomId);\r\n\r\n // 방별 dedup bucket 정리 — 장시간 세션에서 많은 방을 오갈 때 메모리 누수 차단.\r\n // 각 bucket 은 방당 최대 _maxSeenPerRoom(200) entries 라 개별은 작지만 방 수가 늘면 누적.\r\n this._seenChatMessageIdsByRoom.delete(roomId);\r\n this._seenRoomListMessageIdsByRoom.delete(roomId);\r\n\r\n this.logger.info(`Unsubscribed from room: ${roomId}`);\r\n this.emit('roomUnsubscribed', { roomId });\r\n }\r\n\r\n /**\r\n * 모든 채팅방 구독 해제\r\n */\r\n unsubscribeAllRooms() {\r\n this.subscribedRooms.forEach((_, roomId) => {\r\n this.unsubscribeRoom(roomId);\r\n });\r\n }\r\n\r\n /**\r\n * 현재 보고 있는 채팅방 설정.\r\n * 이 방에서 수신되는 다른 사용자의 메시지는 자동으로 읽음 처리됩니다.\r\n * 다른 방이나 리스트 화면으로 이동 시 {@link #clearActiveRoom} 호출.\r\n *\r\n * @param {string} roomId - 현재 보고 있는 채팅방 ID\r\n */\r\n setActiveRoom(roomId) {\r\n this._activeRoomId = roomId;\r\n this.logger.debug(`Active room set: ${roomId}`);\r\n }\r\n\r\n /**\r\n * 현재 보고 있는 채팅방 해제 (리스트 화면 등으로 이동 시).\r\n * 자동 읽음 처리가 중단됩니다.\r\n */\r\n clearActiveRoom() {\r\n this.logger.debug(`Active room cleared (was: ${this._activeRoomId})`);\r\n this._activeRoomId = null;\r\n }\r\n\r\n /**\r\n * 현재 보고 있는 채팅방 ID 반환.\r\n * @returns {string|null}\r\n */\r\n getActiveRoom() {\r\n return this._activeRoomId;\r\n }\r\n\r\n /**\r\n * 채팅방 리스트 실시간 업데이트 구독 (카톡 스타일).\r\n *\r\n * 리스트 화면에서 호출. 구독 후 다음 이벤트를 받을 수 있음:\r\n * - roomListUpdate — 모든 이벤트 (통합, eventType 으로 분기)\r\n * - roomListMessage — 새 메시지 수신 (MESSAGE_RECEIVED)\r\n * - roomListMessageDeleted — 현재 lastMessage 삭제 (MESSAGE_DELETED)\r\n * - roomListMessageUpdated — 현재 lastMessage 편집 (MESSAGE_UPDATED)\r\n * - roomListCreated — 새 방 생성 (ROOM_CREATED)\r\n * - roomListJoined — 방 입장 (ROOM_JOINED)\r\n * - roomListLeft — 방 퇴장 (ROOM_LEFT)\r\n * - roomListSelfLeft — 본인이 나간 경우 (리스트에서 제거 신호)\r\n * - roomListOwnerChanged — 방장 위임 (ROOM_OWNER_CHANGED, members[0] = 새 방장)\r\n * - roomListDeleted — 방장이 방을 삭제 (ROOM_DELETED, 리스트 제거 + 방 화면 닫기)\r\n *\r\n * @returns {Promise<void>}\r\n *\r\n * @example\r\n * await client.chat.subscribeRoomList();\r\n *\r\n * client.on('roomListMessage', (event) => {\r\n * // 해당 방을 리스트 최상단으로 이동\r\n * // event.actorId !== currentUserId 일 때만 unread +1\r\n * moveRoomToTop(event.roomId);\r\n * updateLastMessage(event.roomId, event.lastMessage, event.lastMessageAt);\r\n * if (event.actorId !== currentUserId) {\r\n * incrementUnread(event.roomId, event.unreadCountDelta);\r\n * }\r\n * });\r\n *\r\n * client.on('roomListSelfLeft', (event) => {\r\n * // 본인이 나간 방 → 리스트에서 제거\r\n * removeRoomFromList(event.roomId);\r\n * });\r\n */\r\n async subscribeRoomList() {\r\n if (this.roomListSubscribed) {\r\n this.logger.warn('Already subscribed to room list');\r\n return;\r\n }\r\n\r\n const destination = WebSocketPaths.ROOM_LIST_USER_DESTINATION;\r\n await this.connectionManager.subscribe(destination, (event) => {\r\n // noinspection JSCheckFunctionSignatures\r\n this._handleRoomListEvent(event);\r\n });\r\n\r\n this.roomListSubscribed = true;\r\n this.logger.info('Subscribed to room list');\r\n this.emit('roomListSubscribed', {});\r\n }\r\n\r\n /**\r\n * 채팅방 리스트 구독 해제.\r\n */\r\n unsubscribeRoomList() {\r\n if (!this.roomListSubscribed) {\r\n this.logger.warn('Not subscribed to room list');\r\n return;\r\n }\r\n\r\n this.connectionManager.unsubscribe(WebSocketPaths.ROOM_LIST_USER_DESTINATION);\r\n this.roomListSubscribed = false;\r\n\r\n this.logger.info('Unsubscribed from room list');\r\n this.emit('roomListUnsubscribed', {});\r\n }\r\n\r\n /**\r\n * 채팅방 리스트 구독 여부.\r\n * @returns {boolean}\r\n */\r\n isRoomListSubscribed() {\r\n return this.roomListSubscribed;\r\n }\r\n\r\n /**\r\n * Gmail 메일함 변화 구독 — {@code /user/queue/mail}. 받은 이벤트를 {@code mailChanged} 로 그대로 낸다.\r\n *\r\n * <p>새 메일 알림은 최상위 클라이언트의 {@code newMail} 을 쓴다 — 웹소켓 이벤트의 미리보기와 포그라운드 FCM 메일 푸시를\r\n * 합쳐 메일마다 한 번만 낸다. 재연결 때는 연결 관리자가 구독을 되살린다(이벤트는 최대 한 번 전달이라 재연결·창 포커스 때\r\n * 안읽은 수와 목록을 다시 읽는다).</p>\r\n */\r\n async subscribeMail() {\r\n if (this.mailSubscribed) {\r\n this.logger.debug('Already subscribed to mail');\r\n return;\r\n }\r\n\r\n await this.connectionManager.subscribe(WebSocketPaths.MAIL_USER_DESTINATION, (event) => {\r\n this._handleMailEvent(event);\r\n });\r\n\r\n this.mailSubscribed = true;\r\n this.logger.info('Subscribed to mail');\r\n this.emit('mailSubscribed', {});\r\n }\r\n\r\n /**\r\n * Gmail 메일함 변화 구독 해제.\r\n */\r\n unsubscribeMail() {\r\n if (!this.mailSubscribed) {\r\n return;\r\n }\r\n\r\n this.connectionManager.unsubscribe(WebSocketPaths.MAIL_USER_DESTINATION);\r\n this.mailSubscribed = false;\r\n\r\n this.logger.info('Unsubscribed from mail');\r\n this.emit('mailUnsubscribed', {});\r\n }\r\n\r\n /**\r\n * Gmail 메일함 변화 구독 여부.\r\n * @returns {boolean}\r\n */\r\n isMailSubscribed() {\r\n return this.mailSubscribed;\r\n }\r\n\r\n /** 메일 이벤트 — 보낸 사람·제목이 실리므로 내용은 로그에 남기지 않는다(연결 ID·건수만). */\r\n _handleMailEvent(event) {\r\n if (!event || event.eventType !== 'MAIL_CHANGED') {\r\n return;\r\n }\r\n this.logger.debug('Mail changed:', event.connectionId,\r\n Array.isArray(event.messages) ? event.messages.length : 0);\r\n this.emit('mailChanged', event);\r\n }\r\n\r\n /**\r\n * 메시지 읽음 처리\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 메시지 ID\r\n */\r\n markAsRead(roomId, messageId) {\r\n if (!this.connectionManager.isConnected()) {\r\n this.logger.warn('Cannot mark as read: not connected');\r\n return false;\r\n }\r\n\r\n return this.connectionManager.send(WebSocketPaths.CHAT_READ, {\r\n roomId,\r\n messageId\r\n });\r\n }\r\n\r\n // ==================== 이벤트 핸들러 ====================\r\n\r\n /**\r\n * 채팅 메시지 수신 처리.\r\n *\r\n * <p>서버는 동일 채널(/topic/chat/{roomId})로 신규/수정/삭제/리액션/OG 첨부\r\n * 이벤트를 모두 발행하고, 페이로드의 {@code eventType} 으로 구분한다.\r\n * 클라는 eventType 에 따라 별도 이벤트로 re-emit.</p>\r\n *\r\n * <ul>\r\n * <li>{@code MESSAGE_CREATED} → {@code message} + {@code newMessage} (상대 메시지면) + 자동 읽음</li>\r\n * <li>{@code MESSAGE_UPDATED} → {@code message} + {@code messageUpdated}</li>\r\n * <li>{@code MESSAGE_DELETED} → {@code message} + {@code messageDeleted}</li>\r\n * <li>{@code REACTION_CHANGED} → {@code message} + {@code reactionChanged}</li>\r\n * <li>{@code LINK_PREVIEW_ATTACHED} → {@code message} + {@code linkPreviewAttached}</li>\r\n * <li>{@code MESSAGE_TRANSLATED} → {@code message} + {@code messageTranslated} (translations 머지)</li>\r\n * </ul>\r\n *\r\n * <p>{@code message} 이벤트는 모든 케이스에서 발행되어 하위 호환을 유지한다 —\r\n * 기존 구독 코드는 messageId 로 merge 하면 계속 작동.</p>\r\n *\r\n * @private\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} message - 수신된 메시지 (eventType 필드 포함)\r\n */\r\n _handleChatMessage(roomId, message) {\r\n this.logger.debug(`Message received in room ${roomId} (${message.eventType}):`, message);\r\n\r\n // MESSAGE_CREATED 만 dedup 대상 — 다른 eventType (UPDATED/DELETED/REACTION/LINK_PREVIEW) 는\r\n // 같은 messageId 의 의도된 재발행이라 dedup 하면 안 됨.\r\n if (message.eventType === 'MESSAGE_CREATED'\r\n && this._shouldDedupMessage(this._seenChatMessageIdsByRoom, roomId, message.messageId)) {\r\n this.logger.debug(`Duplicate MESSAGE_CREATED suppressed: room=${roomId}, messageId=${message.messageId}`);\r\n return false;\r\n }\r\n\r\n // 갭 복구 워터마크 전진 — 라이브 수신분만. replay 중에는 개별 전진을 막고\r\n // sync 완료 시 응답 lastId 로 1회 커밋한다(중간 실패 시 어중간한 위치에 남는 것 방지).\r\n if (message.eventType === 'MESSAGE_CREATED' && !this._replaying) {\r\n this._advanceAnchorFromLive(roomId, message.id);\r\n }\r\n\r\n // 편집(MESSAGE_UPDATED) 시 서버가 번역을 무효화한다. payload 에 번역 필드가 없으면 generic emit 前에\r\n // 명시적 null 처리 — generic 'message' / 상위 'chatMessage' 소비자도 stale 번역을 즉시 지우도록 (다국어 §9).\r\n if (message.eventType === 'MESSAGE_UPDATED') {\r\n if (message.translations === undefined) message.translations = null;\r\n if (message.sourceLang === undefined) message.sourceLang = null;\r\n if (message.translationsOf === undefined) message.translationsOf = null;\r\n }\r\n\r\n // 모든 이벤트는 통합 이벤트로 pass-through (하위 호환)\r\n this.emit('message', { roomId, message });\r\n\r\n switch (message.eventType) {\r\n case 'MESSAGE_CREATED':\r\n // 어시 메시지 도착 = \"AI 응답 준비중\" 종료 신호. typing timer 즉시 해제 + typing=false emit.\r\n // 서버는 typing=false 를 발행하지 않고 client-side 가 책임 (assistant typing 설계).\r\n // userId / userName 은 서버 typing=true 발행과 정확히 일치하는 generic marker 사용 —\r\n // UI 가 roomId+userId 로 typing state 추적 시 true/false 가 같은 키로 매칭되어야 안 꺼지는\r\n // 케이스 방지. message.userId (페르소나 ID) 를 그대로 쓰면 키 불일치로 영구 표시 위험.\r\n if (message.senderType === 'ASSISTANT') {\r\n this._clearAssistantTypingTimer(roomId);\r\n this.emit('typing', {\r\n roomId,\r\n userId: this._assistantTypingUserId,\r\n userName: this._assistantTypingUserName,\r\n typing: false,\r\n senderType: 'ASSISTANT'\r\n });\r\n // ⚠️ AI 진행 표시(assistantProgress)는 <b>여기서 끄지 않는다</b>\r\n // (ASSISTANT_STREAM_REPLAY_DESIGN §7.3).\r\n //\r\n // 서버 발행 순서가 메시지 → DONE 이라(ChatMessagePublicationWorkflow 가 MESSAGE_CREATED 를\r\n // 먼저 내고, InterveneInChatService 가 그 뒤에 DONE(streamId, messageId) 을 낸다) 이 시점의\r\n // 클라는 <b>messageId → streamId 관계를 아직 모른다</b>. 그래서 \"이 메시지에 해당하는 stream 만\r\n // 끈다\" 가 불가능하고, 방 단위로 끄면 같은 방의 <b>다른 살아 있는 stream</b> 까지 사라진다.\r\n //\r\n // 종료 SSOT 는 DONE 이고, DONE 유실 대비 안전망은 TTL(_startAssistantProgressTimer) 이다.\r\n // DONE 은 메시지 post 직후에 나오므로 표시가 늦게 꺼지는 체감도 사실상 없다.\r\n }\r\n\r\n // 상대방 메시지일 때만 newMessage + 자동 읽음 처리\r\n if (message.userId !== this.userId) {\r\n this.emit('newMessage', { roomId, message });\r\n\r\n // 현재 보고 있는 방에서만 자동 읽음 처리\r\n if (this._activeRoomId === roomId && message.messageId) {\r\n this.markAsRead(roomId, message.messageId);\r\n }\r\n }\r\n break;\r\n\r\n case 'MESSAGE_UPDATED':\r\n // 번역 stale 클리어(번역 필드 null 보정)는 generic emit 前에 이미 수행됨 (_handleChatMessage 상단).\r\n this.emit('messageUpdated', { roomId, message });\r\n break;\r\n\r\n case 'MESSAGE_DELETED':\r\n this.emit('messageDeleted', { roomId, message });\r\n break;\r\n\r\n case 'REACTION_CHANGED':\r\n this.emit('reactionChanged', { roomId, message });\r\n break;\r\n\r\n case 'LINK_PREVIEW_ATTACHED':\r\n this.emit('linkPreviewAttached', { roomId, message });\r\n break;\r\n\r\n case 'MESSAGE_TRANSLATED':\r\n // 서버가 비동기 번역 완료 후 패치 — message.translations/sourceLang 채워져 옴.\r\n // 소비자는 messageId 로 기존 메시지에 머지 (linkPreviewAttached 와 동일 패턴).\r\n this.emit('messageTranslated', { roomId, message });\r\n break;\r\n\r\n default:\r\n // 미지의 eventType — 구버전 서버(eventType 없음)와 호환 유지.\r\n // 안전장치로 기존 동작(신규 메시지 알림) 수행.\r\n this.logger.warn(`Unknown eventType \"${message.eventType}\" — falling back to legacy behavior`);\r\n if (message.userId !== this.userId) {\r\n this.emit('newMessage', { roomId, message });\r\n if (this._activeRoomId === roomId && message.messageId) {\r\n this.markAsRead(roomId, message.messageId);\r\n }\r\n }\r\n }\r\n\r\n return true;\r\n }\r\n\r\n /**\r\n * 채팅방 리스트 업데이트 이벤트 수신 처리.\r\n * @private\r\n * @param {Object} event - 서버 RoomListEvent\r\n * @param {string} event.eventType - RoomListEventType\r\n * @param {string} event.roomId\r\n * @param {string} event.actorId - 이벤트 발생 사용자\r\n * @param {number} [event.unreadCountDelta]\r\n * @param {string} [event.lastMessage]\r\n * @param {string} [event.lastMessageType]\r\n * @param {string} [event.lastMessageAt]\r\n * @param {string} event.timestamp\r\n */\r\n _handleRoomListEvent(event) {\r\n this.logger.debug('Room list event:', event);\r\n\r\n // MESSAGE_RECEIVED 의 messageId 기반 dedup — chat WebSocket 측과 별도 키 공간.\r\n // 서버가 messageId 를 안 내려준 구버전 페이로드면 dedup 통과 (backward compatible).\r\n if (event.eventType === RoomListEventType.MESSAGE_RECEIVED\r\n && event.messageId\r\n && this._shouldDedupMessage(this._seenRoomListMessageIdsByRoom, event.roomId, event.messageId)) {\r\n this.logger.debug(`Duplicate roomList MESSAGE_RECEIVED suppressed: room=${event.roomId}, messageId=${event.messageId}`);\r\n return;\r\n }\r\n\r\n // 1. 통합 이벤트 (모든 타입)\r\n this.emit('roomListUpdate', event);\r\n\r\n // 2. 타입별 이벤트\r\n switch (event.eventType) {\r\n case RoomListEventType.MESSAGE_RECEIVED:\r\n this.emit('roomListMessage', event);\r\n break;\r\n case RoomListEventType.MESSAGE_DELETED:\r\n this.emit('roomListMessageDeleted', event);\r\n break;\r\n case RoomListEventType.MESSAGE_UPDATED:\r\n this.emit('roomListMessageUpdated', event);\r\n break;\r\n case RoomListEventType.ROOM_CREATED:\r\n this.emit('roomListCreated', event);\r\n break;\r\n case RoomListEventType.ROOM_JOINED:\r\n this.emit('roomListJoined', event);\r\n break;\r\n case RoomListEventType.ROOM_LEFT:\r\n this.emit('roomListLeft', event);\r\n // 본인이 나간 경우 편의 이벤트 (리스트에서 제거해야 함)\r\n if (event.actorId === this.userId) {\r\n this.emit('roomListSelfLeft', event);\r\n }\r\n break;\r\n case RoomListEventType.ROOM_KICKED:\r\n this.emit('roomListKicked', event);\r\n // 본인이 추방당한 경우 편의 이벤트 — UI 에서 \"추방되었습니다\" 표시 + 리스트 제거\r\n if (this._isSelfInMembers(event)) {\r\n this.emit('roomListSelfKicked', event);\r\n }\r\n break;\r\n case RoomListEventType.ROOM_OWNER_CHANGED:\r\n // 방장 위임 — actorId=이전 방장, members[0]=새 방장. 방장 표시·관리 메뉴를 갱신한다.\r\n this.emit('roomListOwnerChanged', event);\r\n break;\r\n case RoomListEventType.ROOM_DELETED:\r\n // 방장이 방을 삭제 — 모든 참가자가 리스트에서 제거하고 열린 방 화면을 닫는다.\r\n // 🔴 그 방 구독은 SDK 가 «먼저» 푼다. 남겨 두면 아무것도 못 받으면서 재연결 때마다 되살아나 서버에\r\n // 거부된다 — 그 거부로 연결을 끊던 서버 버전에서는 «재연결 → 거부 → 끊김» 이 반복돼 다른 방 실시간까지 멈췄다.\r\n if (this.subscribedRooms.has(event.roomId)) {\r\n this.unsubscribeRoom(event.roomId);\r\n }\r\n this.emit('roomListDeleted', event);\r\n break;\r\n case RoomListEventType.ROOM_BANNED:\r\n this.emit('roomListBanned', event);\r\n // 본인이 영구 차단당한 경우 편의 이벤트 — \"영구 차단되었습니다\" 표시 + 리스트 제거\r\n if (this._isSelfInMembers(event)) {\r\n this.emit('roomListSelfBanned', event);\r\n }\r\n break;\r\n case RoomListEventType.ROOM_UPDATED:\r\n this.emit('roomListRoomUpdated', event);\r\n break;\r\n case RoomListEventType.MESSAGE_RETENTION_CLEANUP:\r\n // noinspection JSUnresolvedReference\r\n this.emit('retentionCleanup', {\r\n roomId: event.roomId,\r\n cutoffTime: event.cutoffTime\r\n });\r\n break;\r\n case RoomListEventType.ROOM_UNREAD_UPDATED:\r\n // 다른(또는 이) 기기에서 내가 읽어 방 미독이 바뀜 → badge 를 절대값으로 세팅.\r\n // roomUnreadCount 는 delta 가 아니라 절대값, unreadVersion 은 순서 역전 판별용.\r\n this.emit('roomListUnreadUpdated', {\r\n roomId: event.roomId,\r\n roomUnreadCount: event.roomUnreadCount,\r\n unreadVersion: event.unreadVersion\r\n });\r\n break;\r\n default:\r\n this.logger.warn('Unknown room list event type:', event.eventType);\r\n }\r\n }\r\n\r\n /**\r\n * 읽음 이벤트 수신 처리\r\n * @private\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} event - 읽음 이벤트 (단일 또는 배치)\r\n */\r\n _handleReadEvent(roomId, event) {\r\n this.logger.debug(`Read event in room ${roomId}:`, event);\r\n\r\n const resolvedRoomId = event.roomId || roomId;\r\n\r\n // 배치 이벤트인 경우 (방 입장 시 일괄 읽음 처리)\r\n if (event.events && Array.isArray(event.events)) {\r\n event.events.forEach(e => {\r\n this.emit('messageRead', {\r\n roomId: resolvedRoomId,\r\n messageId: e.messageId,\r\n userId: event.userId,\r\n remainingUnreadCount: e.remainingUnreadCount\r\n });\r\n });\r\n }\r\n // 단일 이벤트인 경우 (실시간 개별 읽음 처리)\r\n else {\r\n this.emit('messageRead', {\r\n roomId: resolvedRoomId,\r\n messageId: event.messageId,\r\n userId: event.userId,\r\n remainingUnreadCount: event.remainingUnreadCount\r\n });\r\n }\r\n }\r\n\r\n // ==================== 타이핑 표시 ====================\r\n\r\n /**\r\n * 타이핑 시작 알림.\r\n *\r\n * <p>호출할 때마다 내부 타이머가 리셋됩니다 (debounce).\r\n * 3초간 추가 호출이 없으면 자동으로 {@link #stopTyping} 이 호출됩니다.\r\n * 메시지 전송 ({@link #sendMessage}) 시에도 자동 중단됩니다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n *\r\n * @example\r\n * // input 이벤트에 연결\r\n * inputField.addEventListener('input', () => {\r\n * client.chat.startTyping('room-id');\r\n * });\r\n */\r\n startTyping(roomId) {\r\n if (!this.connectionManager.isConnected()) return;\r\n\r\n const existingTimer = this._typingTimers.get(roomId);\r\n\r\n // throttle: 이미 typing 상태면 stop 타이머만 리셋 (STOMP 재전송 안 함)\r\n if (existingTimer) {\r\n clearTimeout(existingTimer);\r\n } else {\r\n // 최초 또는 stop 이후 첫 호출에만 STOMP 전송\r\n this.connectionManager.send(WebSocketPaths.CHAT_TYPING, {\r\n roomId,\r\n typing: true\r\n });\r\n }\r\n\r\n // 3초 후 자동 stopTyping\r\n const timer = setTimeout(() => {\r\n this.stopTyping(roomId);\r\n }, 3000);\r\n\r\n this._typingTimers.set(roomId, timer);\r\n }\r\n\r\n /**\r\n * 타이핑 중단 알림.\r\n *\r\n * <p>직접 호출하거나, {@link #startTyping} 의 3초 타이머 만료 시,\r\n * 또는 메시지 전송 시 자동 호출됩니다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n */\r\n stopTyping(roomId) {\r\n this._clearTypingTimer(roomId);\r\n\r\n if (!this.connectionManager.isConnected()) return;\r\n\r\n this.connectionManager.send(WebSocketPaths.CHAT_TYPING, {\r\n roomId,\r\n typing: false\r\n });\r\n }\r\n\r\n /**\r\n * 이벤트 members 배열에 본인이 포함돼 있는지 확인.\r\n * <p>ROOM_KICKED / ROOM_BANNED 이벤트에서 \"내가 추방당했는가\" 판별용. actorId (추방한 방장) 가 아니라\r\n * members 배열(추방된 사용자) 을 보는 이유는, 같은 이벤트를 방장 본인도 수신하기 때문.</p>\r\n * @private\r\n */\r\n _isSelfInMembers(event) {\r\n return Array.isArray(event.members)\r\n && event.members.some(m => m && m.userId === this.userId);\r\n }\r\n\r\n /**\r\n * 타이핑 이벤트 수신 처리.\r\n * <p>본인 이벤트는 필터링하여 emit 하지 않음.</p>\r\n * @private\r\n */\r\n _handleTypingEvent(roomId, event) {\r\n // 본인 이벤트 필터 — USER 타이핑만 적용 (어시는 본인 userId 매칭 불가, generic marker 사용)\r\n if (event.senderType !== 'ASSISTANT' && event.userId === this.userId) return;\r\n\r\n // 어시 typing=true 수신 시 client-side timeout 시작 — 서버 측 typing=false 발행이 없으므로\r\n // (1) 어시 메시지 도착 (_handleChatMessage) 또는 (2) 본 timeout 만료 중 먼저 도달 시 typing=false emit.\r\n if (event.senderType === 'ASSISTANT' && event.typing === true) {\r\n this._startAssistantTypingTimer(roomId);\r\n }\r\n\r\n this.emit('typing', {\r\n roomId,\r\n userId: event.userId,\r\n userName: event.userName,\r\n typing: event.typing,\r\n senderType: event.senderType || 'USER' // 구버전 서버 호환 — senderType 없으면 USER\r\n });\r\n }\r\n\r\n /**\r\n * 어시스턴트 typing 자동 해제 타이머 시작/재시작.\r\n * 같은 방에 이미 timer 있으면 reset (새로운 어시 응답 라운드).\r\n * @private\r\n */\r\n _startAssistantTypingTimer(roomId) {\r\n this._clearAssistantTypingTimer(roomId);\r\n const timer = setTimeout(() => {\r\n this._assistantTypingTimers.delete(roomId);\r\n // timeout 만료 = LLM 지연 또는 서버 실패 — UI 가 영구 \"답변 준비중\" 에 갇히지 않도록 해제 emit.\r\n // userId / userName 은 generic marker 일관 (typing=true 발행과 매칭 — P1 fix).\r\n this.emit('typing', {\r\n roomId,\r\n userId: this._assistantTypingUserId,\r\n userName: this._assistantTypingUserName,\r\n typing: false,\r\n senderType: 'ASSISTANT'\r\n });\r\n this.logger.debug(`Assistant typing auto-cleared (timeout): room=${roomId}`);\r\n }, this._assistantTypingTimeoutMs);\r\n this._assistantTypingTimers.set(roomId, timer);\r\n }\r\n\r\n /**\r\n * 어시스턴트 typing 자동 해제 타이머 취소.\r\n * 어시 메시지 도착 시 즉시 호출 (timeout 도달 전 해제).\r\n * @private\r\n */\r\n _clearAssistantTypingTimer(roomId) {\r\n const timer = this._assistantTypingTimers.get(roomId);\r\n if (timer) {\r\n clearTimeout(timer);\r\n this._assistantTypingTimers.delete(roomId);\r\n }\r\n }\r\n\r\n /**\r\n * 타이핑 타이머 정리.\r\n * @private\r\n */\r\n _clearTypingTimer(roomId) {\r\n const timer = this._typingTimers.get(roomId);\r\n if (timer) {\r\n clearTimeout(timer);\r\n this._typingTimers.delete(roomId);\r\n }\r\n }\r\n\r\n /**\r\n * AI 진행 표시(assistant-stream) 이벤트 수신 처리 — PHASE(단계)/DELTA(토큰 청크)/DONE(종료) 분기.\r\n *\r\n * <p>typing 과 별개의 가산 레이어 — UI 는 {@code active:true} 면 {@code text}(토큰 스트림 누적) 우선,\r\n * 없으면 {@code label}(단계) 표시, {@code active:false} 면 해제. PHASE→DELTA 흐름으로 \"작성 중\" 라벨이\r\n * 점진 렌더 텍스트로 자연 전환된다. 서버가 typing 도 이중발행하므로 구버전 SDK 는 본 채널 미구독 → 기존\r\n * typing 만으로 정상 동작(graceful).</p>\r\n *\r\n * <p><b>seq 가드</b>(ASSISTANT_STREAM_REPLAY_DESIGN §5.5.0/§7.1-a): 스트림은 Pub/Sub 라 <b>구독 시점\r\n * 이후만</b> 수신된다. 방을 나갔다 재입장하거나 WS 가 순단되면 앞부분을 받은 적이 없는데, 예전 구현은\r\n * 그 상태에서 도착한 청크를 그대로 누적본으로 렌더해 <b>잘린 본문</b>을 보여줬다. 이제 {@code seq} 로\r\n * \"앞이 비었다\"를 스스로 판정해 본문 대신 {@code label} 로 물러선다({@code textAvailable=false}).\r\n * 최종 메시지가 도착하면 어차피 본문이 확정되므로, 잘린 본문을 보여주는 것보다 라벨이 낫다.</p>\r\n * @private\r\n */\r\n _handleAssistantStreamEvent(roomId, event) {\r\n if (!event || !event.type) return;\r\n\r\n const streamId = event.streamId || null;\r\n if (!streamId) {\r\n // streamId 없는 이벤트는 슬롯을 키잉할 수 없다. 서버 계약상 always non-null 이라 도달하지 않는다.\r\n this.logger.debug(`Assistant stream event without streamId ignored: room=${roomId}, type=${event.type}`);\r\n return;\r\n }\r\n // 종료된 stream 의 늦은 이벤트 — 되살리지 않는다(§7.2). DONE 자체도 중복 도착하면 여기서 걸린다.\r\n if (this._isAssistantStreamClosed(roomId, streamId)) {\r\n this.logger.debug(`Assistant event for closed stream ignored: room=${roomId}, stream=${streamId}, type=${event.type}`);\r\n return;\r\n }\r\n // 스냅샷 대기 중이면 보류 — 병합 뒤에 seq 규칙으로 이어붙인다(§6.1). 렌더는 아직 하지 않는다.\r\n //\r\n // ⚠️ <b>DONE 만은 즉시 처리한다</b>(§6.2). 큐에 넣으면 스냅샷의 ACTIVE 가 먼저 적용돼\r\n // {@code active:true} 버블이 한 번 떴다가 곧바로 꺼진다. DONE 은 \"서버가 끝났다고 명시한\" 신호라\r\n // 지금 tombstone 을 세워야 스냅샷이 그 stream 을 되살리지 못한다. 같은 stream 의 이미 보류된\r\n // PHASE/DELTA 는 drain 때 그 tombstone 에 걸려 자연히 무시된다.\r\n const pending = this._assistantSnapshotPending.get(roomId);\r\n if (pending && event.type !== 'DONE') {\r\n pending.events.push(event);\r\n return;\r\n }\r\n\r\n if (event.type === 'PHASE') {\r\n // PHASE 에는 seq 가 없다 — <b>시각이 유일한 순서 축</b>이다. 스냅샷의 phaseUpdatedAt 보다\r\n // 오래된 pending PHASE 를 적용하면 라벨이 WRITING → SEARCHING 으로 역행한다(§6.2).\r\n const prevSlot = this._assistantSlot(roomId, streamId);\r\n const createdAt = typeof event.createdAt === 'number' ? event.createdAt : null;\r\n // ⚠️ 등호도 stale 이다. 계약이 \"더 최신일 때만 적용\"이라 같은 시각이면 적용하면 안 된다 —\r\n // 같은 밀리초에 발행된 pending PHASE 가 스냅샷 phase 를 덮어 라벨이 역행한다.\r\n if (prevSlot && createdAt !== null && createdAt <= (prevSlot.phaseUpdatedAt || 0)) {\r\n this.logger.debug(`Stale assistant PHASE ignored: room=${roomId}, stream=${streamId}, createdAt=${createdAt}, applied=${prevSlot.phaseUpdatedAt}`);\r\n return;\r\n }\r\n const phase = event.phase || null;\r\n const samePhase = Boolean(prevSlot && prevSlot.phase === phase);\r\n // 같은 phase의 일반 heartbeat에는 progress가 없다. 그 틱이 직전 실측 숫자를 지우지 않게\r\n // 보존하고, phase가 바뀔 때만 이전 작업의 숫자를 폐기한다.\r\n const progress = event.progress\r\n ? this._normalizeAssistantProgress(event.progress)\r\n : (samePhase && prevSlot ? prevSlot.progress : null);\r\n // 같은 stream 의 PHASE 재발행(B-3 synthesis heartbeat 등)이면 누적 상태를 보존한다.\r\n // ⚠️ 새 stream 의 커서는 <b>-1</b> 로 연다. DeltaCoalescer 의 첫 청크가 seq=0 이라 0 으로 열면\r\n // 첫 글자가 \"중복\"으로 버려진다 (설계 §5.5.0).\r\n const prev = prevSlot;\r\n const slot = this._setAssistantSlot(roomId, streamId, event, {\r\n phase,\r\n progress,\r\n phaseUpdatedAt: createdAt !== null ? createdAt : (prev ? prev.phaseUpdatedAt : 0),\r\n text: prev ? (prev.text || '') : '',\r\n lastSeq: prev ? this._seqOf(prev.lastSeq) : -1,\r\n textAvailable: prev ? prev.textAvailable !== false : true\r\n });\r\n this._emitAssistantStream(roomId, streamId, slot, {\r\n active: true,\r\n text: slot.textAvailable ? (slot.text || null) : null\r\n });\r\n return;\r\n }\r\n\r\n if (event.type === 'DELTA') {\r\n // 토큰 청크(B) — 누적 텍스트에 이어붙여 점진 렌더용으로 emit. WRITING 단계에서 흐른다.\r\n const prev = this._assistantSlot(roomId, streamId);\r\n const phase = (prev && prev.phase) || 'WRITING';\r\n const seq = typeof event.seq === 'number' ? event.seq : null;\r\n\r\n let lastSeq = prev ? this._seqOf(prev.lastSeq) : -1;\r\n let textAvailable = prev ? prev.textAvailable !== false : true;\r\n let text = prev ? (prev.text || '') : '';\r\n\r\n // ── seq 취급 규칙 (설계 §5.5.0 SSOT — 소비자 열. 서버 스냅샷 병합도 같은 표를 쓴다) ──\r\n // seq <= lastSeq : 중복·지연 도착. <b>장애가 아니다</b> — 조용히 무시.\r\n // seq == lastSeq + 1 : 정상 append.\r\n // seq > lastSeq + 1 : 실제 gap. 앞이 비었으니 본문을 포기하고 라벨로 물러선다.\r\n // 슬롯이 없는 상태의 late join(재입장 직후 seq>0)도 lastSeq=-1 기준으로 마지막 분기에 걸린다.\r\n //\r\n // ⚠️ <b>커서 판정은 degrade 여부와 무관하다.</b> textAvailable=false 가 멈추는 것은 <b>본문 누적\r\n // 뿐</b>이다. 커서까지 멈추면 (1) 이후 중복이 걸러지지 않아 같은 청크가 다시 emit 되고,\r\n // (2) lastSeq 가 옛 값에 갇혀 R-4 스냅샷 병합이 잘못된 지점에서 절단한다.\r\n if (seq === null) {\r\n // 서버가 seq 를 안 실은 경우(구버전 서버) — 판정 불가라 기존 동작(그대로 append)을 유지한다.\r\n if (textAvailable) text += event.delta || '';\r\n } else if (seq <= lastSeq) {\r\n this.logger.debug(`Duplicate assistant DELTA ignored: room=${roomId}, stream=${streamId}, seq=${seq}, lastSeq=${lastSeq}`);\r\n return;\r\n } else if (seq === lastSeq + 1) {\r\n // 포기한 슬롯이면 커서만 전진시키고 본문은 비운 채 둔다 — 베이스가 없는데 이어붙이면 오염이다.\r\n if (textAvailable) text += event.delta || '';\r\n lastSeq = seq;\r\n } else {\r\n this.logger.debug(`Assistant DELTA gap — text dropped: room=${roomId}, stream=${streamId}, seq=${seq}, lastSeq=${lastSeq}`);\r\n textAvailable = false;\r\n text = '';\r\n lastSeq = seq;\r\n }\r\n\r\n const slot = this._setAssistantSlot(roomId, streamId, event, {\r\n phase, progress: prev ? (prev.progress || null) : null, text, lastSeq, textAvailable\r\n });\r\n this._emitAssistantStream(roomId, streamId, slot, {\r\n active: true,\r\n text: slot.textAvailable ? slot.text : null, // 누적 전체(버블 렌더용)\r\n delta: slot.textAvailable ? (event.delta || null) : null, // 이번 청크(append 렌더 선호 시)\r\n seq\r\n });\r\n return;\r\n }\r\n\r\n if (event.type === 'DONE') {\r\n // 종료(성공·실패 공통) — <b>이 stream 만</b> 닫는다. 같은 방의 다른 stream 은 계속 산다.\r\n const slot = this._assistantSlot(roomId, streamId);\r\n if (slot && slot.timer) clearTimeout(slot.timer);\r\n this._deleteAssistantSlot(roomId, streamId);\r\n // 서버가 \"끝났다\"고 명시한 유일한 신호 — 묘비는 여기서만 세운다(§7.2).\r\n this._tombstoneAssistantStream(roomId, streamId);\r\n this._emitAssistantStream(roomId, streamId, slot, {\r\n active: false,\r\n personaId: event.personaId || (slot && slot.personaId) || null,\r\n phase: null,\r\n progress: null,\r\n label: null,\r\n status: event.status || null,\r\n messageId: event.messageId || null\r\n });\r\n }\r\n // 그 외 미지 type 은 무시(forward-compat).\r\n }\r\n\r\n /**\r\n * 진행 중 스트림 스냅샷을 받아 라이브와 병합한다 (설계 §6.1, R-4).\r\n *\r\n * <pre>\r\n * 0. gen 발급 + pending ON ← 구독보다 <b>먼저</b> (호출자가 한다)\r\n * 1. SUBSCRIBE ← 호출자가 한다\r\n * 2. GET /assistant/streams\r\n * 3. 세대 검증 — 다르면 응답 통째 폐기\r\n * 4. 병합: 스냅샷을 베이스로, 보류분을 §5.5.0 규칙으로 이어붙임\r\n * 5. pending OFF + 렌더\r\n * </pre>\r\n *\r\n * <p><b>왜 구독이 먼저인가</b>: 반대로 하면 스냅샷 생성과 구독 사이에 발행된 청크가 <b>유실</b>된다.\r\n * 구독을 먼저 걸면 최악이 중복인데, 중복은 {@code seq} 로 정확히 잘라낼 수 있다.</p>\r\n *\r\n * <p><b>실패해도 보류분은 반드시 푼다</b> — 안 그러면 라이브 이벤트가 큐에 갇혀 진행 표시가 통째로\r\n * 멈춘다. 스냅샷 실패는 \"복구 포기\"일 뿐 채팅을 막지 않는다(§6.2).</p>\r\n * @private\r\n */\r\n async _loadAssistantSnapshot(roomId, generation) {\r\n // 같은 <b>세대</b>의 요청이 이미 떠 있으면 재사용한다. 다른 세대 것은 재사용하면 안 된다 —\r\n // 그 응답은 세대 불일치로 폐기되는데, 재사용했다면 이번 세대의 요청까지 함께 사라진다(그리고\r\n // 이번 세대가 쌓아둔 보류분이 통째로 버려진다).\r\n const inFlight = this._assistantSnapshotInFlight.get(roomId);\r\n if (inFlight && inFlight.generation === generation) return inFlight.task;\r\n\r\n const task = (async () => {\r\n let streams = null;\r\n try {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/assistant/streams`);\r\n const data = this._unwrapSuccessResponse(response);\r\n streams = (data && Array.isArray(data.streams)) ? data.streams : [];\r\n } catch (error) {\r\n // 구버전 서버(404)·네트워크·5xx 모두 여기로 온다. 사용자에게 노출하지 않는다.\r\n this.logger.warn(`Assistant stream snapshot failed (live-only fallback): room=${roomId}`, error);\r\n }\r\n\r\n if (this._assistantGeneration(roomId) !== generation) {\r\n // 응답 전에 방을 떠났거나 재구독했다 — 이 스냅샷은 이미 다른 세대의 것이다.\r\n // ⚠️ <b>내 세대의 보류함만</b> 정리한다. 무조건 지우면 그 사이 열린 현재 세대의 보류분이\r\n // 통째로 사라져 라이브 이벤트가 증발한다.\r\n this.logger.debug(`Assistant snapshot discarded (stale generation): room=${roomId}`);\r\n this._deleteAssistantPendingIfGeneration(roomId, generation);\r\n return;\r\n }\r\n if (streams) this._applyAssistantSnapshot(roomId, streams);\r\n this._drainAssistantPending(roomId, generation);\r\n })();\r\n\r\n this._assistantSnapshotInFlight.set(roomId, { generation, task });\r\n try {\r\n await task;\r\n } finally {\r\n // 내가 등록한 항목일 때만 지운다 — 그 사이 새 세대가 자기 요청을 올려놨을 수 있다.\r\n const current = this._assistantSnapshotInFlight.get(roomId);\r\n if (current && current.generation === generation) {\r\n this._assistantSnapshotInFlight.delete(roomId);\r\n }\r\n }\r\n }\r\n\r\n /** @private */\r\n _deleteAssistantPendingIfGeneration(roomId, generation) {\r\n const pending = this._assistantSnapshotPending.get(roomId);\r\n if (pending && pending.generation === generation) {\r\n this._assistantSnapshotPending.delete(roomId);\r\n }\r\n }\r\n\r\n /** 스냅샷을 슬롯에 심는다 — 보류분을 풀기 <b>전에</b> 끝나야 베이스가 선다. @private */\r\n _applyAssistantSnapshot(roomId, streams) {\r\n streams.forEach((s) => {\r\n const streamId = s && s.streamId;\r\n if (!streamId) return;\r\n // 이미 DONE 을 라이브로 받아 닫은 stream — 스냅샷이 그걸 되살리면 안 된다(§6.2).\r\n if (this._isAssistantStreamClosed(roomId, streamId)) return;\r\n\r\n if (s.status === 'DONE') {\r\n // 유예 구간에 걸린 종료분. 진행 표시를 켜지 않고 최종 메시지로 바로 확정시킨다.\r\n this._tombstoneAssistantStream(roomId, streamId);\r\n this._emitAssistantStream(roomId, streamId, null, {\r\n active: false, phase: null, progress: null, label: null, personaId: s.personaId || null,\r\n status: s.doneStatus || null, messageId: s.messageId || null, resumed: true\r\n });\r\n return;\r\n }\r\n\r\n const textAvailable = s.textAvailable !== false;\r\n const slot = this._setAssistantSlot(roomId, streamId, {\r\n personaId: s.personaId, expiresAt: undefined\r\n }, {\r\n phase: s.phase || null,\r\n progress: this._normalizeAssistantProgress(s.progress),\r\n phaseUpdatedAt: typeof s.phaseUpdatedAt === 'number' ? s.phaseUpdatedAt : 0,\r\n text: textAvailable ? (s.text || '') : '',\r\n lastSeq: this._seqOf(s.lastSeq),\r\n textAvailable,\r\n resumed: true\r\n });\r\n this._emitAssistantStream(roomId, streamId, slot, {\r\n active: true,\r\n text: slot.textAvailable ? (slot.text || null) : null,\r\n resumed: true\r\n });\r\n });\r\n }\r\n\r\n /**\r\n * 보류분을 <b>도착 순서 그대로</b> 흘려보낸다. 이 시점부터 §5.5.0 규칙이 스냅샷 베이스 위에서 돈다.\r\n *\r\n * <p>순서가 중요하다 — stream 별로 몰아서 풀면 방 집계의 \"가장 최근 stream\" 판정이 실제 도착 순서와\r\n * 달라져 엉뚱한 본문이 버블에 뜬다.</p>\r\n * @private\r\n */\r\n _drainAssistantPending(roomId, generation) {\r\n const pending = this._assistantSnapshotPending.get(roomId);\r\n if (!pending) return;\r\n if (generation !== undefined && pending.generation !== generation) return;\r\n this._assistantSnapshotPending.delete(roomId); // 먼저 지워야 재귀로 다시 큐에 들어가지 않는다\r\n pending.events.forEach((event) => this._handleAssistantStreamEvent(roomId, event));\r\n }\r\n\r\n /** @private */\r\n _assistantGeneration(roomId) {\r\n return this._assistantRoomGeneration.get(roomId) || 0;\r\n }\r\n\r\n /** 세대를 넘기고 보류함을 연다 — <b>구독 직전</b>에 부른다(§6.1 ①). @private */\r\n _armAssistantSnapshot(roomId) {\r\n const generation = this._assistantGeneration(roomId) + 1;\r\n this._assistantRoomGeneration.set(roomId, generation);\r\n const previous = this._assistantSnapshotPending.get(roomId);\r\n // ⚠️ <b>보류분은 세대를 넘겨 이어받는다.</b> 세대는 <b>스냅샷 응답</b>의 유효성을 가르는 축이지\r\n // 큐 내용의 소유권이 아니다. 큐에 있는 건 아직 렌더되지 않은 <b>정상 라이브 이벤트</b>라, 새 세대를\r\n // 빈 배열로 열면 그대로 유실된다(GET 대기 중 재개가 한 번 더 불리면 그 사이 도착한 DELTA 가 통째로\r\n // 사라진다). 방을 떠날 때 버리는 건 별개 경로다({@link #_clearAssistantProgress}).\r\n this._assistantSnapshotPending.set(roomId, {\r\n generation,\r\n events: previous ? previous.events : []\r\n });\r\n return generation;\r\n }\r\n\r\n /**\r\n * 재연결·복귀 시 <b>구독 중인 모든 방</b>의 스냅샷을 다시 받는다 (설계 §7.4).\r\n *\r\n * <p>⚠️ {@link #syncAfterReconnect} 에 얹으면 안 된다. 그쪽은 {@code syncOnReconnect} 옵션과\r\n * <b>anchor 가 있는 방</b>이라는 두 겹 필터가 걸려 있어서, 옵션이 꺼져 있거나 그 방에서 메시지를 받은\r\n * 적이 없으면 replay 가 조용히 빠진다. 어시 재개는 메시지 갭 복구와 발동 조건이 다르다.</p>\r\n */\r\n async resumeAssistantStreams(reason = 'manual') {\r\n if (!this.assistantStreamReplay) return;\r\n const rooms = Array.from(this.subscribedRooms.keys());\r\n if (rooms.length === 0) return;\r\n this.logger.debug(`Assistant stream replay: rooms=${rooms.length}, reason=${reason}`);\r\n await Promise.all(rooms.map((roomId) => {\r\n const generation = this._armAssistantSnapshot(roomId);\r\n return this._loadAssistantSnapshot(roomId, generation)\r\n .catch((error) => this.logger.warn(`Assistant stream replay failed: room=${roomId}`, error));\r\n }));\r\n }\r\n\r\n /** 방의 stream 슬롯 맵 — 없으면 생성. @private */\r\n _assistantSlots(roomId) {\r\n let slots = this._assistantProgress.get(roomId);\r\n if (!slots) {\r\n slots = new Map();\r\n this._assistantProgress.set(roomId, slots);\r\n }\r\n return slots;\r\n }\r\n\r\n /** @private */\r\n _assistantSlot(roomId, streamId) {\r\n const slots = this._assistantProgress.get(roomId);\r\n return slots ? slots.get(streamId) : undefined;\r\n }\r\n\r\n /** @private */\r\n _deleteAssistantSlot(roomId, streamId) {\r\n const slots = this._assistantProgress.get(roomId);\r\n if (!slots) return;\r\n slots.delete(streamId);\r\n if (slots.size === 0) this._assistantProgress.delete(roomId);\r\n }\r\n\r\n /** 종료된 stream 인가 — 늦은 이벤트가 슬롯을 되살리는 것을 막는다(§7.2). @private */\r\n _isAssistantStreamClosed(roomId, streamId) {\r\n const graves = this._assistantStreamTombstones.get(roomId);\r\n if (!graves) return false;\r\n const expiresAt = graves.get(streamId);\r\n if (expiresAt === undefined) return false;\r\n if (expiresAt <= Date.now()) {\r\n graves.delete(streamId); // 만료된 묘비는 회수 — 그 뒤엔 새 stream 취급이 맞다.\r\n return false;\r\n }\r\n return true;\r\n }\r\n\r\n /**\r\n * 묘비 등록 — <b>회수는 시간으로만</b> 한다.\r\n *\r\n * <p>⚠️ 임계를 넘어도 <b>아직 유효한 묘비는 지우지 않는다.</b> 지우는 순간 그 stream 의 늦은 DELTA 가\r\n * 슬롯을 되살려 유령 버블이 되고, 그건 \"DONE 이후 절대 되살리지 않는다\"는 이 장치의 존재 이유 자체를\r\n * 깬다. 서버 gate 가 상한 초과 시에도 pruning 대신 경보만 내는 것과 <b>같은 규칙</b>이다 —\r\n * 계약 파손이 메모리보다 훨씬 비싸다(항목당 문자열+숫자다).</p>\r\n * @private\r\n */\r\n _tombstoneAssistantStream(roomId, streamId) {\r\n let graves = this._assistantStreamTombstones.get(roomId);\r\n if (!graves) {\r\n graves = new Map();\r\n this._assistantStreamTombstones.set(roomId, graves);\r\n }\r\n const now = Date.now();\r\n graves.forEach((expiresAt, id) => {\r\n if (expiresAt <= now) graves.delete(id); // 만료분만 회수(lazy)\r\n });\r\n graves.set(streamId, now + this._assistantTombstoneTtlMs);\r\n\r\n // 경보는 임계를 <b>넘는 순간 1회</b>만 — 넘은 동안 DONE 마다 짖으면 로그가 신호를 덮는다.\r\n if (graves.size > this._assistantTombstoneWarnThreshold) {\r\n if (!this._assistantTombstoneWarned.has(roomId)) {\r\n this._assistantTombstoneWarned.add(roomId);\r\n this.logger.warn(`Assistant tombstones above threshold — NOT pruning (TTL only): room=${roomId}, size=${graves.size}, threshold=${this._assistantTombstoneWarnThreshold}`);\r\n }\r\n } else {\r\n this._assistantTombstoneWarned.delete(roomId);\r\n }\r\n }\r\n\r\n /**\r\n * Rolling 누적 요약 완료 알림(summary 채널) 수신 처리 — status 를 그대로 {@code summaryUpdate} 로 emit.\r\n *\r\n * <p>서버 {@code SummaryStreamEvent}: {@code { status: 'READY'|'NO_CHANGE'|'NO_MESSAGES'|'FAILED'|'INVALIDATED'|string, roomId, format?, languages?, updatedAt? }}.\r\n * assistant-stream 과 달리 단계/토큰 누적이 없어 상태 관리 없이 그대로 통지한다. transient(저장 안 됨)라 UI 는\r\n * 이벤트를 신호로만 쓰고 확정 내용은 {@link #getSummary} 로 읽는다. {@code READY}=요약 갱신됨(→ getSummary),\r\n * {@code FAILED}=실패(재시도 가능), {@code NO_CHANGE}=durable 작업이 변경 없이 완료,\r\n * {@code NO_MESSAGES}=해당 키에 요약할 대화 없음,\r\n * {@code INVALIDATED}=방 원문 삭제/편집으로 요약이 통째로 버려짐(→ 요약 화면을 즉시 비우고, 필요 시 refreshSummary 로 재생성).</p>\r\n *\r\n * <p><b>키 식별(format+languages)</b>: 한 refresh 가 방 활성 언어들을 fan-out 으로 한 번에 생성하므로(§S1), 완료\r\n * 이벤트의 {@code languages} 에 내 언어가 있으면 {@code getSummary({ format, lang })} 로 읽는다.\r\n * {@code READY}·{@code NO_CHANGE}·{@code NO_MESSAGES}·{@code FAILED} 모두 언어 목록을 싣는다\r\n * ({@code INVALIDATED} 만 없음). 알 수 없는 미래 status도 그대로 전달하므로 소비자는 default 분기와\r\n * one-shot {@code getSummary} 재동기화를 둔다.</p>\r\n * @private\r\n */\r\n _handleSummaryEvent(roomId, event) {\r\n if (!event || !event.status) return;\r\n this.emit('summaryUpdate', {\r\n roomId,\r\n status: event.status, // known status + future server values (pass-through)\r\n format: event.format || null, // READY/NO_CHANGE/NO_MESSAGES/FAILED 포함\r\n languages: event.languages || null, // READY/NO_CHANGE/NO_MESSAGES/FAILED 처리 언어 목록\r\n updatedAt: event.updatedAt || null\r\n });\r\n }\r\n\r\n /**\r\n * seq 커서 정규화 — 값이 없으면 {@code -1}(DELTA 0건). 첫 청크가 {@code seq=0} 이라 기본값이 0 이면\r\n * 첫 글자를 중복으로 버린다(설계 §5.5.0).\r\n * @private\r\n */\r\n _seqOf(value) {\r\n return typeof value === 'number' ? value : -1;\r\n }\r\n\r\n /**\r\n * stream 슬롯 갱신 + TTL 타이머 재무장 — {@code expiresAt}(epochMillis) 기반, 누락/이상 시 fallback.\r\n *\r\n * <p>만료 = DONE 유실로 진행이 끊긴 것 → UI 가 영구 표시에 갇히지 않게 {@code active:false} 를 자동\r\n * emit 한다. ⚠️ 만료는 <b>묘비를 세우지 않는다</b> — \"표시가 낡았다\"는 것이지 서버의 확정 종료가\r\n * 아니라서, 뒤늦게 정상 DELTA 가 오면 다시 살아나야 맞다(§7.2).</p>\r\n * @private\r\n */\r\n _setAssistantSlot(roomId, streamId, event, fields) {\r\n const prev = this._assistantSlot(roomId, streamId);\r\n if (prev && prev.timer) clearTimeout(prev.timer);\r\n\r\n let ttl = this._assistantProgressFallbackTtlMs;\r\n if (typeof event.expiresAt === 'number') {\r\n // 비정상값 방어 — [5s, 120s] 로 clamp(과거 timestamp / 과대 TTL 모두 차단).\r\n ttl = Math.min(120000, Math.max(5000, event.expiresAt - Date.now()));\r\n }\r\n const timer = setTimeout(() => {\r\n this._expireAssistantStream(roomId, streamId);\r\n }, ttl);\r\n\r\n const slot = {\r\n personaId: event.personaId || (prev && prev.personaId) || null,\r\n phase: fields.phase,\r\n progress: fields.progress || null,\r\n // 마지막으로 <b>적용된</b> PHASE 의 발행 시각 — pending PHASE 역행 판정 커서(§6.2).\r\n phaseUpdatedAt: fields.phaseUpdatedAt !== undefined\r\n ? fields.phaseUpdatedAt\r\n : (prev ? (prev.phaseUpdatedAt || 0) : 0),\r\n text: fields.text || '',\r\n lastSeq: this._seqOf(fields.lastSeq),\r\n textAvailable: fields.textAvailable !== false,\r\n resumed: fields.resumed === true || (prev ? prev.resumed === true : false),\r\n updatedAt: Date.now(),\r\n order: ++this._assistantSlotSeq, // 최신 판정 축 — updatedAt 은 진단용일 뿐이다\r\n timer\r\n };\r\n this._assistantSlots(roomId).set(streamId, slot);\r\n return slot;\r\n }\r\n\r\n /** TTL 만료 — 이 stream 만 끈다. 묘비는 세우지 않는다. @private */\r\n _expireAssistantStream(roomId, streamId) {\r\n const slot = this._assistantSlot(roomId, streamId);\r\n if (!slot) return;\r\n if (slot.timer) clearTimeout(slot.timer);\r\n this._deleteAssistantSlot(roomId, streamId);\r\n this.logger.debug(`Assistant progress auto-cleared (timeout): room=${roomId}, stream=${streamId}`);\r\n this._emitAssistantStream(roomId, streamId, slot, {\r\n active: false, phase: null, progress: null, label: null, reason: 'timeout'\r\n });\r\n }\r\n\r\n /**\r\n * {@code assistantProgress}(stream 단위) + {@code assistantRoomProgress}(방 단위 집계)를 함께 발행한다.\r\n *\r\n * <p><b>왜 둘인가</b>: 진짜 상태는 stream 단위지만, 기존 프론트는 \"방마다 임시 버블 하나\"를 전제로\r\n * 짜여 있다. 그 코드에 stream 단위 {@code active:false} 가 들어가면 <b>같은 방의 다른 살아 있는\r\n * 버블까지 숨긴다</b>. 그래서 이행용 다리로 방 단위 집계를 별도 이벤트로 함께 낸다(설계 §7.5 (b)).\r\n * 프론트는 리스너 이름만 바꿔 기존 렌더를 유지하다가, 준비되면 stream 단위로 넘어오면 된다.</p>\r\n * @private\r\n */\r\n _emitAssistantStream(roomId, streamId, slot, overrides) {\r\n const phase = 'phase' in overrides ? overrides.phase : (slot ? slot.phase : null);\r\n const progress = 'progress' in overrides ? overrides.progress : (slot ? slot.progress : null);\r\n this.emit('assistantProgress', {\r\n roomId,\r\n streamId,\r\n personaId: slot ? slot.personaId : null,\r\n phase,\r\n progress,\r\n label: this._assistantPhaseLabel(phase, progress),\r\n textAvailable: slot ? slot.textAvailable : true,\r\n lastSeq: slot ? slot.lastSeq : -1,\r\n ...overrides\r\n });\r\n this._emitAssistantRoomProgress(roomId);\r\n }\r\n\r\n /**\r\n * 방 단위 집계 — 살아 있는 stream 이 하나라도 있으면 {@code active:true}.\r\n * 표시 내용은 <b>가장 최근에 갱신된</b> stream 을 따른다(단일 버블 UI 가 자연스럽게 최신을 보여준다).\r\n * @private\r\n */\r\n _emitAssistantRoomProgress(roomId) {\r\n // 이벤트 계약은 종전 그대로 둔다 — stream 목록은 getter 전용(이벤트로 매 틱 배열을 실어 보내면\r\n // DELTA 마다 방 전체 스트림을 다시 만들어 흘리는 셈이다).\r\n const { streams, ...event } = this._assistantRoomProgressPayload(roomId);\r\n this.emit('assistantRoomProgress', event);\r\n }\r\n\r\n /**\r\n * 방 단위 집계 payload 생성 — {@link #_emitAssistantRoomProgress} 와 {@link #getAssistantProgress} 의\r\n * <b>공통 SSOT</b>. 두 곳에서 각자 조립하면 \"이벤트로 본 상태\"와 \"물어봐서 본 상태\"가 갈린다.\r\n * @private\r\n */\r\n _assistantRoomProgressPayload(roomId) {\r\n const slots = this._assistantProgress.get(roomId);\r\n const alive = slots ? Array.from(slots.entries()) : [];\r\n if (alive.length === 0) {\r\n return {\r\n roomId, active: false, streamCount: 0,\r\n streamId: null, personaId: null, phase: null, label: null, text: null, textAvailable: true,\r\n progress: null,\r\n streams: []\r\n };\r\n }\r\n const [streamId, slot] = alive.reduce((a, b) => (b[1].order > a[1].order ? b : a));\r\n return {\r\n roomId,\r\n active: true,\r\n streamCount: alive.length,\r\n streamId,\r\n personaId: slot.personaId,\r\n phase: slot.phase,\r\n progress: slot.progress || null,\r\n label: this._assistantPhaseLabel(slot.phase, slot.progress),\r\n text: slot.textAvailable ? (slot.text || null) : null,\r\n textAvailable: slot.textAvailable,\r\n // stream 단위 UI(권장 (B))가 마운트 즉시 버블을 복원할 수 있도록 전부 싣는다.\r\n // 도착 순서(order)대로 — 소비자가 \"가장 최근\"을 다시 계산하지 않아도 되게 한다.\r\n streams: alive\r\n .sort((a, b) => a[1].order - b[1].order)\r\n .map(([id, s]) => ({\r\n streamId: id,\r\n personaId: s.personaId,\r\n phase: s.phase,\r\n progress: s.progress || null,\r\n label: this._assistantPhaseLabel(s.phase, s.progress),\r\n text: s.textAvailable ? (s.text || null) : null,\r\n textAvailable: s.textAvailable,\r\n resumed: s.resumed === true\r\n }))\r\n };\r\n }\r\n\r\n /** @private */\r\n _normalizeAssistantProgress(progress) {\r\n if (!progress || !['ANALYZING', 'SUMMARIZING'].includes(progress.stage)) return null;\r\n if (progress.stage === 'SUMMARIZING') {\r\n return { stage: 'SUMMARIZING', current: null, total: null, unit: null };\r\n }\r\n const current = Number.isInteger(progress.current) ? progress.current : null;\r\n const total = Number.isInteger(progress.total) ? progress.total : null;\r\n const unit = ['DOCUMENT', 'PAGE', 'TEXT_CHUNK'].includes(progress.unit) ? progress.unit : null;\r\n if (current === null || total === null || !unit || current < 0 || total < 1 || current > total) {\r\n return null;\r\n }\r\n return { stage: 'ANALYZING', current, total, unit };\r\n }\r\n\r\n /** @private */\r\n _assistantPhaseLabel(phase, progress) {\r\n if (!phase) return null;\r\n if (phase !== 'DOCUMENT_ANALYSIS') return this._assistantPhaseLabels[phase] || null;\r\n if (progress && progress.stage === 'SUMMARIZING') return '분석 결과 종합 중…';\r\n if (!progress || progress.stage !== 'ANALYZING') return this._assistantPhaseLabels[phase];\r\n const suffix = progress.unit === 'PAGE' ? '페이지'\r\n : progress.unit === 'TEXT_CHUNK' ? '구간' : '개';\r\n return `문서 분석 중… (${progress.current}/${progress.total}${suffix})`;\r\n }\r\n\r\n /**\r\n * 방의 <b>현재</b> AI 진행 상태를 즉시 반환 (네트워크 호출 없음).\r\n *\r\n * <p>{@code assistantProgress} / {@code assistantRoomProgress} 는 transient 이벤트라, 방 화면을 나갔다\r\n * 들어오면 프론트 상태가 비어 있는데 <b>이미 지나간 이벤트는 다시 오지 않는다</b>. 마운트 시점에 이걸\r\n * 한 번 읽으면 다음 이벤트를 기다리지 않고 바로 그릴 수 있다 — 재입장 스냅샷(1 RTT)보다도 빠르다.</p>\r\n *\r\n * <p>반환 형태는 {@code assistantRoomProgress} 이벤트와 동일하고, stream 단위 UI 를 위해 {@code streams}\r\n * 배열이 추가된다. 진행 중인 것이 없으면 {@code active:false} + 빈 배열.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Object} { roomId, active, streamCount, streamId, personaId, phase, label, text, textAvailable, streams[] }\r\n *\r\n * @example\r\n * // 방 화면 마운트 시 — 이벤트를 기다리지 않고 즉시 복원\r\n * const p = client.chat.getAssistantProgress(roomId);\r\n * if (p.active) renderBubble(p);\r\n */\r\n getAssistantProgress(roomId) {\r\n return this._assistantRoomProgressPayload(roomId);\r\n }\r\n\r\n /**\r\n * 방의 모든 stream 상태/타이머 정리 — 구독 해제·destroy 용. 조용히 지운다(emit 없음).\r\n *\r\n * <p>묘비도 함께 지운다. 방을 떠나면 되살아날 유령 자체가 없고, 재입장은 새 세대이기 때문이다.</p>\r\n * @private\r\n */\r\n _clearAssistantProgress(roomId) {\r\n const slots = this._assistantProgress.get(roomId);\r\n if (slots) {\r\n slots.forEach((slot) => slot.timer && clearTimeout(slot.timer));\r\n this._assistantProgress.delete(roomId);\r\n }\r\n this._assistantStreamTombstones.delete(roomId);\r\n // 경고 표식도 묘비와 <b>같은 수명</b>이어야 한다. 남겨두면 재구독 후 임계를 다시 넘어도\r\n // \"이미 알렸다\"로 판정해 경고가 영영 안 나오고, Set 에 죽은 roomId 가 쌓인다.\r\n this._assistantTombstoneWarned.delete(roomId);\r\n // 보류함도 비운다 — 방을 떠났는데 큐가 남아 있으면 재구독 때 옛 이벤트가 섞여 들어간다.\r\n // (진행 중 스냅샷 응답은 아래 세대 증가로 무효화되므로 별도 취소가 필요 없다.)\r\n this._assistantSnapshotPending.delete(roomId);\r\n // 진행 중이던 스냅샷 응답이 뒤늦게 도착해도 적용되지 않도록 세대를 넘긴다(§6.1-a, R-4 가 소비).\r\n this._assistantRoomGeneration.set(roomId, (this._assistantRoomGeneration.get(roomId) || 0) + 1);\r\n }\r\n\r\n // ==================== 유틸리티 ====================\r\n\r\n /**\r\n * 구독 중인 채팅방 목록\r\n * @returns {string[]}\r\n */\r\n getSubscribedRooms() {\r\n return Array.from(this.subscribedRooms.keys());\r\n }\r\n\r\n /**\r\n * 채팅방 구독 여부 확인\r\n * @param {string} roomId\r\n * @returns {boolean}\r\n */\r\n isSubscribed(roomId) {\r\n return this.subscribedRooms.has(roomId);\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n }\r\n\r\n _cancelDelayedReconnectSync() {\r\n this._reconnectSyncGeneration++;\r\n clearTimeout(this._delayedReconnectSyncTimer);\r\n this._delayedReconnectSyncTimer = null;\r\n }\r\n\r\n /** 구독, 예약된 복구, 방별 진행 표시를 정리한다. */\r\n destroy() {\r\n this._cancelDelayedReconnectSync();\r\n this.connectionManager.off?.('stateChange', this._onStateChangeForSync);\r\n this.unsubscribeAllRooms();\r\n if (this.roomListSubscribed) {\r\n this.unsubscribeRoomList();\r\n }\r\n\r\n this._typingTimers.forEach((timer) => clearTimeout(timer));\r\n this._typingTimers.clear();\r\n\r\n // 어시 typing 수신 타이머도 전체 cleanup — unsubscribeRoom 이 방별로 정리하지만 구독 목록에\r\n // 없는 roomId 의 타이머가 남아 있을 수 있어 명시 정리 (메모리 누수 차단).\r\n this._assistantTypingTimers.forEach((timer) => clearTimeout(timer));\r\n this._assistantTypingTimers.clear();\r\n\r\n // AI 진행 표시 상태/타이머도 전체 cleanup (구독 외 경로로 남은 roomId 방어).\r\n // 슬롯이 방 → stream 2단이라 안쪽까지 순회해야 타이머가 남지 않는다(v5.0.0).\r\n this._assistantProgress.forEach((slots) => slots.forEach((slot) => slot.timer && clearTimeout(slot.timer)));\r\n this._assistantProgress.clear();\r\n this._assistantStreamTombstones.clear();\r\n this._assistantTombstoneWarned.clear();\r\n this._assistantRoomGeneration.clear();\r\n this._assistantSnapshotPending.clear();\r\n this._assistantSnapshotInFlight.clear();\r\n\r\n // dedup bucket 전체 clear — unsubscribeRoom 이 방별로 정리하지만 구독 외 경로로 누적된 bucket\r\n // (예: 구독 안 한 방의 push/listener) 이 있을 수 있어 안전하게 명시 정리.\r\n this._seenChatMessageIdsByRoom.clear();\r\n this._seenRoomListMessageIdsByRoom.clear();\r\n\r\n // 갭 복구 트리거 해제 — connectionManager 는 ChatClient 보다 오래 살 수 있어(사용자 전환 등)\r\n // 리스너를 남기면 죽은 ChatClient 가 계속 깨어난다.\r\n if (this.connectionManager && typeof this.connectionManager.off === 'function') {\r\n if (this._onReconnectedForSync) {\r\n this.connectionManager.off('reconnected', this._onReconnectedForSync);\r\n }\r\n if (this._onResumedForSync) {\r\n this.connectionManager.off('resumed', this._onResumedForSync);\r\n }\r\n }\r\n this._anchor.clear();\r\n this._anchorGen.clear();\r\n this._syncInFlight.clear();\r\n\r\n this.removeAllListeners();\r\n this.logger.info('ChatClient destroyed');\r\n }\r\n}\r\n\r\nexport default ChatClient;\r\n","/**\r\n * MediaStreamManager\r\n * 로컬 미디어 스트림 관리 (카메라/마이크/화면공유)\r\n */\r\n\r\nimport EventEmitter from '../utils/EventEmitter.js';\r\nimport Logger from '../utils/Logger.js';\r\nimport { ErrorTypes, LogLevel } from '../constants.js';\r\n\r\nclass MediaStreamManager extends EventEmitter {\r\n /**\r\n * @param {Object} options\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options = {}) {\r\n super();\r\n\r\n this.localStream = null;\r\n this.screenStream = null;\r\n this.videoEnabled = true;\r\n this.audioEnabled = true;\r\n\r\n this.logger = new Logger(options.logLevel || LogLevel.WARN, 'MediaStreamManager');\r\n this._deviceChangeHandler = null;\r\n }\r\n\r\n /**\r\n * 사용자 미디어 가져오기\r\n * @param {Object} constraints - 미디어 제약조건\r\n * @returns {Promise<MediaStream>}\r\n */\r\n async getUserMedia(constraints = { video: true, audio: true }) {\r\n try {\r\n this.localStream = await navigator.mediaDevices.getUserMedia(constraints);\r\n this.videoEnabled = constraints.video !== false;\r\n this.audioEnabled = constraints.audio !== false;\r\n\r\n this.emit('streamStarted', { stream: this.localStream });\r\n this.logger.info('User media stream started');\r\n return this.localStream;\r\n } catch (error) {\r\n this.logger.error('Failed to get user media:', error);\r\n this.emit('error', {\r\n type: ErrorTypes.MEDIA_ACCESS_DENIED,\r\n message: this._getMediaErrorMessage(error),\r\n error\r\n });\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 미디어 에러 메시지 변환\r\n * @private\r\n * @param {Error} error - 에러 객체\r\n * @returns {string} 변환된 에러 메시지\r\n */\r\n _getMediaErrorMessage(error) {\r\n switch (error.name) {\r\n case 'NotAllowedError':\r\n return 'Camera/Microphone permission denied';\r\n case 'NotFoundError':\r\n return 'Camera/Microphone not found';\r\n case 'NotReadableError':\r\n return 'Camera/Microphone is already in use';\r\n case 'OverconstrainedError':\r\n return 'Camera/Microphone constraints cannot be satisfied';\r\n case 'AbortError':\r\n return 'Media access aborted';\r\n default:\r\n return error.message || 'Unknown media error';\r\n }\r\n }\r\n\r\n /**\r\n * 화면 공유 가져오기\r\n * @param {Object} [options] - 화면 공유 옵션\r\n * @returns {Promise<MediaStream>}\r\n */\r\n async getDisplayMedia(options = { video: true, audio: false }) {\r\n try {\r\n this.screenStream = await navigator.mediaDevices.getDisplayMedia(options);\r\n\r\n // 화면 공유 종료 감지\r\n this.screenStream.getVideoTracks()[0].onended = () => {\r\n this.emit('screenShareEnded', {});\r\n this.screenStream = null;\r\n };\r\n\r\n this.emit('screenShareStarted', { stream: this.screenStream });\r\n this.logger.info('Screen share stream started');\r\n return this.screenStream;\r\n } catch (error) {\r\n this.logger.error('Failed to get display media:', error);\r\n this.emit('error', {\r\n type: ErrorTypes.SCREEN_SHARE_DENIED,\r\n message: error.name === 'NotAllowedError'\r\n ? 'Screen share permission denied'\r\n : error.message,\r\n error\r\n });\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 비디오 토글\r\n * @returns {boolean} 현재 상태\r\n */\r\n toggleVideo() {\r\n if (!this.localStream) {\r\n this.logger.warn('No local stream available');\r\n return this.videoEnabled;\r\n }\r\n\r\n const videoTracks = this.localStream.getVideoTracks();\r\n if (videoTracks.length === 0) {\r\n this.logger.warn('No video track available');\r\n return this.videoEnabled;\r\n }\r\n\r\n this.videoEnabled = !this.videoEnabled;\r\n videoTracks.forEach(track => {\r\n track.enabled = this.videoEnabled;\r\n });\r\n\r\n this.emit('videoToggled', { enabled: this.videoEnabled });\r\n this.logger.debug(`Video toggled: ${this.videoEnabled}`);\r\n return this.videoEnabled;\r\n }\r\n\r\n /**\r\n * 오디오 토글\r\n * @returns {boolean} 현재 상태\r\n */\r\n toggleAudio() {\r\n if (!this.localStream) {\r\n this.logger.warn('No local stream available');\r\n return this.audioEnabled;\r\n }\r\n\r\n const audioTracks = this.localStream.getAudioTracks();\r\n if (audioTracks.length === 0) {\r\n this.logger.warn('No audio track available');\r\n return this.audioEnabled;\r\n }\r\n\r\n this.audioEnabled = !this.audioEnabled;\r\n audioTracks.forEach(track => {\r\n track.enabled = this.audioEnabled;\r\n });\r\n\r\n this.emit('audioToggled', { enabled: this.audioEnabled });\r\n this.logger.debug(`Audio toggled: ${this.audioEnabled}`);\r\n return this.audioEnabled;\r\n }\r\n\r\n /**\r\n * 비디오 상태 설정\r\n * @param {boolean} enabled\r\n */\r\n setVideoEnabled(enabled) {\r\n if (!this.localStream) {\r\n this.logger.warn('No local stream available');\r\n return;\r\n }\r\n\r\n const videoTracks = this.localStream.getVideoTracks();\r\n videoTracks.forEach(track => {\r\n track.enabled = enabled;\r\n });\r\n this.videoEnabled = enabled;\r\n this.emit('videoToggled', { enabled });\r\n }\r\n\r\n /**\r\n * 오디오 상태 설정\r\n * @param {boolean} enabled\r\n */\r\n setAudioEnabled(enabled) {\r\n if (!this.localStream) {\r\n this.logger.warn('No local stream available');\r\n return;\r\n }\r\n\r\n const audioTracks = this.localStream.getAudioTracks();\r\n audioTracks.forEach(track => {\r\n track.enabled = enabled;\r\n });\r\n this.audioEnabled = enabled;\r\n this.emit('audioToggled', { enabled });\r\n }\r\n\r\n /**\r\n * 로컬 스트림 가져오기\r\n * @returns {MediaStream|null}\r\n */\r\n getLocalStream() {\r\n return this.localStream;\r\n }\r\n\r\n /**\r\n * 화면 공유 스트림 가져오기\r\n * @returns {MediaStream|null}\r\n */\r\n getScreenStream() {\r\n return this.screenStream;\r\n }\r\n\r\n /**\r\n * 현재 비디오 상태\r\n * @returns {boolean}\r\n */\r\n isVideoEnabled() {\r\n return this.videoEnabled;\r\n }\r\n\r\n /**\r\n * 현재 오디오 상태\r\n * @returns {boolean}\r\n */\r\n isAudioEnabled() {\r\n return this.audioEnabled;\r\n }\r\n\r\n /**\r\n * 모든 트랙 중지\r\n */\r\n stopAll() {\r\n if (this.localStream) {\r\n this.localStream.getTracks().forEach(track => track.stop());\r\n this.localStream = null;\r\n this.emit('streamStopped', {});\r\n this.logger.info('All media tracks stopped');\r\n }\r\n\r\n if (this.screenStream) {\r\n this.screenStream.getTracks().forEach(track => track.stop());\r\n this.screenStream = null;\r\n }\r\n }\r\n\r\n /**\r\n * 특정 트랙 교체 (내부용 - switchDevice에서 호출)\r\n * @private\r\n * @param {MediaStreamTrack} oldTrack\r\n * @param {MediaStreamTrack} newTrack\r\n */\r\n replaceTrack(oldTrack, newTrack) {\r\n if (!this.localStream) {\r\n this.logger.warn('No local stream available');\r\n return;\r\n }\r\n\r\n this.localStream.removeTrack(oldTrack);\r\n this.localStream.addTrack(newTrack);\r\n oldTrack.stop();\r\n\r\n this.emit('trackReplaced', { oldTrack, newTrack });\r\n this.logger.debug(`Track replaced: ${oldTrack.kind}`);\r\n }\r\n\r\n /**\r\n * 특정 종류의 트랙 가져오기\r\n * @param {string} kind - 'video' or 'audio'\r\n * @returns {MediaStreamTrack|null}\r\n */\r\n getTrack(kind) {\r\n if (!this.localStream) return null;\r\n\r\n const tracks = kind === 'video'\r\n ? this.localStream.getVideoTracks()\r\n : this.localStream.getAudioTracks();\r\n\r\n return tracks.length > 0 ? tracks[0] : null;\r\n }\r\n\r\n /**\r\n * 디바이스 목록 가져오기\r\n * @returns {Promise<Object>}\r\n */\r\n async getDevices() {\r\n try {\r\n const devices = await navigator.mediaDevices.enumerateDevices();\r\n\r\n return {\r\n videoInputs: devices.filter(d => d.kind === 'videoinput'),\r\n audioInputs: devices.filter(d => d.kind === 'audioinput'),\r\n audioOutputs: devices.filter(d => d.kind === 'audiooutput')\r\n };\r\n } catch (error) {\r\n this.logger.error('Failed to enumerate devices:', error);\r\n this.emit('error', {\r\n type: ErrorTypes.ENUMERATE_DEVICES_FAILED,\r\n message: error.message,\r\n error\r\n });\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 특정 디바이스로 전환\r\n * @param {string} deviceId - 디바이스 ID\r\n * @param {string} kind - 'video' or 'audio'\r\n * @returns {Promise<MediaStreamTrack>}\r\n * @throws {Error} 스트림이 없거나 디바이스 전환 실패 시\r\n */\r\n async switchDevice(deviceId, kind) {\r\n if (!this.localStream) {\r\n throw new Error('No local stream available');\r\n }\r\n\r\n try {\r\n const constraints = kind === 'video'\r\n ? { video: { deviceId: { exact: deviceId } }, audio: false }\r\n : { video: false, audio: { deviceId: { exact: deviceId } } };\r\n\r\n const newStream = await navigator.mediaDevices.getUserMedia(constraints);\r\n const newTrack = newStream.getTracks()[0];\r\n\r\n const oldTrack = kind === 'video'\r\n ? this.localStream.getVideoTracks()[0]\r\n : this.localStream.getAudioTracks()[0];\r\n\r\n if (oldTrack) {\r\n this.replaceTrack(oldTrack, newTrack);\r\n\r\n this.emit('deviceSwitched', {\r\n kind,\r\n deviceId,\r\n newTrack\r\n });\r\n\r\n this.logger.info(`${kind} device switched to ${deviceId}`);\r\n }\r\n\r\n return newTrack;\r\n\r\n } catch (error) {\r\n this.logger.error(`Failed to switch ${kind} device:`, error);\r\n this.emit('error', {\r\n type: ErrorTypes.DEVICE_SWITCH_FAILED,\r\n message: error.message,\r\n error\r\n });\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 디바이스 변경 감지 시작\r\n */\r\n startDeviceChangeDetection() {\r\n if (this._deviceChangeHandler) {\r\n return;\r\n }\r\n\r\n this._deviceChangeHandler = async () => {\r\n try {\r\n const devices = await this.getDevices();\r\n this.emit('deviceChange', devices);\r\n this.logger.debug('Device change detected');\r\n } catch (error) {\r\n this.logger.warn('Failed to get devices on change:', error);\r\n }\r\n };\r\n\r\n navigator.mediaDevices.addEventListener('devicechange', this._deviceChangeHandler);\r\n this.logger.debug('Device change detection started');\r\n }\r\n\r\n /**\r\n * 디바이스 변경 감지 중지\r\n */\r\n stopDeviceChangeDetection() {\r\n if (this._deviceChangeHandler) {\r\n navigator.mediaDevices.removeEventListener('devicechange', this._deviceChangeHandler);\r\n this._deviceChangeHandler = null;\r\n this.logger.debug('Device change detection stopped');\r\n }\r\n }\r\n\r\n /**\r\n * 스트림 정보\r\n * @returns {Object}\r\n */\r\n getStreamInfo() {\r\n if (!this.localStream) {\r\n return {\r\n hasStream: false,\r\n videoEnabled: this.videoEnabled,\r\n audioEnabled: this.audioEnabled,\r\n tracks: []\r\n };\r\n }\r\n\r\n return {\r\n hasStream: true,\r\n videoEnabled: this.videoEnabled,\r\n audioEnabled: this.audioEnabled,\r\n tracks: this.localStream.getTracks().map(track => ({\r\n kind: track.kind,\r\n id: track.id,\r\n label: track.label,\r\n enabled: track.enabled,\r\n readyState: track.readyState,\r\n muted: track.muted\r\n }))\r\n };\r\n }\r\n\r\n /**\r\n * 비디오 해상도 변경\r\n * @param {Object} constraints - { width, height, frameRate }\r\n */\r\n async applyVideoConstraints(constraints) {\r\n if (!this.localStream) {\r\n throw new Error('No local stream available');\r\n }\r\n\r\n const videoTrack = this.localStream.getVideoTracks()[0];\r\n if (!videoTrack) {\r\n throw new Error('No video track available');\r\n }\r\n\r\n try {\r\n await videoTrack.applyConstraints(constraints);\r\n this.emit('videoConstraintsApplied', { constraints });\r\n this.logger.info('Video constraints applied:', constraints);\r\n } catch (error) {\r\n this.logger.error('Failed to apply video constraints:', error);\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 현재 비디오 설정 가져오기\r\n * @returns {MediaTrackSettings|null}\r\n */\r\n getVideoSettings() {\r\n if (!this.localStream) return null;\r\n\r\n const videoTrack = this.localStream.getVideoTracks()[0];\r\n return videoTrack ? videoTrack.getSettings() : null;\r\n }\r\n\r\n /**\r\n * 현재 오디오 설정 가져오기\r\n * @returns {MediaTrackSettings|null}\r\n */\r\n getAudioSettings() {\r\n if (!this.localStream) return null;\r\n\r\n const audioTrack = this.localStream.getAudioTracks()[0];\r\n return audioTrack ? audioTrack.getSettings() : null;\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n }\r\n\r\n /**\r\n * 리소스 정리\r\n */\r\n destroy() {\r\n this.stopAll();\r\n this.stopDeviceChangeDetection();\r\n this.removeAllListeners();\r\n this.logger.info('MediaStreamManager destroyed');\r\n }\r\n}\r\n\r\nexport default MediaStreamManager;\r\n","/**\r\n * PeerConnectionManager\r\n * WebRTC Peer 연결 관리 (Perfect Negotiation 패턴)\r\n */\r\n\r\nimport EventEmitter from '../utils/EventEmitter.js';\r\nimport Logger from '../utils/Logger.js';\r\nimport { ErrorTypes, DefaultConfig, LogLevel } from '../constants.js';\r\n\r\nclass PeerConnectionManager extends EventEmitter {\r\n /**\r\n * @param {Object} options\r\n * @param {Object[]} [options.iceServers] - ICE 서버 설정\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options = {}) {\r\n super();\r\n\r\n this.iceServers = options.iceServers || DefaultConfig.iceServers;\r\n this.logger = new Logger(options.logLevel || LogLevel.WARN, 'PeerConnectionManager');\r\n\r\n // 피어 연결 맵: peerId -> { connection, polite, makingOffer, ignoreOffer }\r\n this.peers = new Map();\r\n\r\n // 로컬 스트림\r\n this.localStream = null;\r\n }\r\n\r\n /**\r\n * 로컬 스트림 설정\r\n * @param {MediaStream} stream\r\n */\r\n setLocalStream(stream) {\r\n this.localStream = stream;\r\n\r\n // 기존 연결에 트랙 추가\r\n this.peers.forEach((peer) => {\r\n this._addTracksToConnection(peer.connection);\r\n });\r\n }\r\n\r\n /**\r\n * ICE 서버 설정\r\n * @param {Object[]} servers\r\n */\r\n setIceServers(servers) {\r\n this.iceServers = servers;\r\n }\r\n\r\n /**\r\n * 피어 연결 생성\r\n * @param {string} peerId - 피어 ID\r\n * @param {boolean} polite - Polite 피어 여부 (Perfect Negotiation)\r\n * @param {Object} [options] - 추가 옵션\r\n * @param {boolean} [options.skipAutoNegotiation=false] - 자동 negotiation 건너뛰기\r\n * @returns {RTCPeerConnection}\r\n */\r\n createPeerConnection(peerId, polite = false, options = {}) {\r\n if (this.peers.has(peerId)) {\r\n this.logger.warn(`Peer connection already exists: ${peerId}`);\r\n return this.peers.get(peerId).connection;\r\n }\r\n\r\n const config = {\r\n iceServers: this.iceServers,\r\n iceCandidatePoolSize: 10\r\n };\r\n\r\n const connection = new RTCPeerConnection(config);\r\n\r\n const peerState = {\r\n connection,\r\n polite,\r\n makingOffer: false,\r\n ignoreOffer: false,\r\n isSettingRemoteAnswerPending: false,\r\n skipAutoNegotiation: options.skipAutoNegotiation || false\r\n };\r\n\r\n this.peers.set(peerId, peerState);\r\n\r\n // 이벤트 핸들러 설정\r\n this._setupConnectionHandlers(peerId, connection, peerState);\r\n\r\n // 로컬 트랙 추가\r\n if (this.localStream) {\r\n this._addTracksToConnection(connection);\r\n }\r\n\r\n this.logger.info(`Peer connection created: ${peerId} (polite: ${polite}, skipAutoNegotiation: ${peerState.skipAutoNegotiation})`);\r\n return connection;\r\n }\r\n\r\n /** 그룹 시작 중 answer를 먼저 보낸 peer에 로컬 트랙을 보낼 협상을 재개한다. */\r\n async resumeAutoNegotiation(peerId) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer || peer.connection.signalingState !== 'stable') return false;\r\n peer.skipAutoNegotiation = false;\r\n await peer.connection.onnegotiationneeded();\r\n return true;\r\n }\r\n\r\n /**\r\n * 연결 이벤트 핸들러 설정\r\n * @private\r\n * @param {string} peerId - 피어 ID\r\n * @param {RTCPeerConnection} connection - 피어 연결\r\n * @param {Object} peerState - 피어 상태 객체\r\n */\r\n _setupConnectionHandlers(peerId, connection, peerState) {\r\n // ICE Candidate 이벤트\r\n connection.onicecandidate = (event) => {\r\n if (event.candidate) {\r\n this.emit('iceCandidate', {\r\n peerId,\r\n candidate: event.candidate\r\n });\r\n }\r\n };\r\n\r\n // ICE 연결 상태 변경\r\n connection.oniceconnectionstatechange = () => {\r\n this.logger.debug(`ICE connection state (${peerId}): ${connection.iceConnectionState}`);\r\n\r\n this.emit('iceConnectionStateChange', {\r\n peerId,\r\n state: connection.iceConnectionState\r\n });\r\n\r\n if (connection.iceConnectionState === 'failed') {\r\n this.emit('error', {\r\n type: ErrorTypes.ICE_CONNECTION_FAILED,\r\n peerId,\r\n message: 'ICE connection failed'\r\n });\r\n }\r\n };\r\n\r\n // 연결 상태 변경\r\n connection.onconnectionstatechange = () => {\r\n this.logger.debug(`Connection state (${peerId}): ${connection.connectionState}`);\r\n\r\n this.emit('connectionStateChange', {\r\n peerId,\r\n state: connection.connectionState\r\n });\r\n\r\n if (connection.connectionState === 'connected') {\r\n this.emit('peerConnected', { peerId });\r\n } else if (connection.connectionState === 'disconnected' || connection.connectionState === 'failed') {\r\n this.emit('peerDisconnected', { peerId });\r\n }\r\n };\r\n\r\n // 협상 필요 (Perfect Negotiation)\r\n connection.onnegotiationneeded = async () => {\r\n // 1:1 통화에서 수동으로 offer를 생성할 때는 자동 negotiation 건너뛰기\r\n if (peerState.skipAutoNegotiation || peerState.makingOffer || connection.signalingState !== 'stable') {\r\n this.logger.debug(`Skipping auto negotiation for ${peerId} (manual offer will be sent)`);\r\n return;\r\n }\r\n\r\n try {\r\n peerState.makingOffer = true;\r\n await connection.setLocalDescription();\r\n\r\n this.emit('negotiationNeeded', {\r\n peerId,\r\n description: connection.localDescription\r\n });\r\n\r\n } catch (error) {\r\n this.logger.error(`Negotiation error (${peerId}):`, error);\r\n } finally {\r\n peerState.makingOffer = false;\r\n }\r\n };\r\n\r\n // 원격 트랙 수신\r\n connection.ontrack = (event) => {\r\n this.logger.info(`Remote track received from ${peerId}:`, event.track.kind);\r\n\r\n this.emit('remoteTrack', {\r\n peerId,\r\n track: event.track,\r\n streams: event.streams\r\n });\r\n };\r\n\r\n // 데이터 채널 수신\r\n connection.ondatachannel = (event) => {\r\n this.logger.info(`Data channel received from ${peerId}`);\r\n this.emit('dataChannel', {\r\n peerId,\r\n channel: event.channel\r\n });\r\n };\r\n }\r\n\r\n /**\r\n * 연결에 트랙 추가\r\n * @private\r\n * @param {RTCPeerConnection} connection - 피어 연결\r\n */\r\n _addTracksToConnection(connection) {\r\n if (!this.localStream) return;\r\n\r\n const existingSenders = connection.getSenders();\r\n\r\n this.localStream.getTracks().forEach(track => {\r\n // 이미 추가된 트랙인지 확인\r\n const existingSender = existingSenders.find(sender =>\r\n sender.track && sender.track.kind === track.kind\r\n );\r\n\r\n if (!existingSender) {\r\n connection.addTrack(track, this.localStream);\r\n }\r\n });\r\n }\r\n\r\n /**\r\n * Offer 생성 및 전송\r\n * @param {string} peerId\r\n * @returns {Promise<RTCSessionDescription>}\r\n */\r\n async createOffer(peerId) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n throw new Error(`Peer not found: ${peerId}`);\r\n }\r\n\r\n try {\r\n const offer = await peer.connection.createOffer();\r\n await peer.connection.setLocalDescription(offer);\r\n\r\n this.logger.debug(`Offer created for ${peerId}`);\r\n return peer.connection.localDescription;\r\n\r\n } catch (error) {\r\n this.logger.error(`Failed to create offer for ${peerId}:`, error);\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * Answer 생성\r\n * @param {string} peerId\r\n * @returns {Promise<RTCSessionDescription>}\r\n */\r\n async createAnswer(peerId) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n throw new Error(`Peer not found: ${peerId}`);\r\n }\r\n\r\n try {\r\n const answer = await peer.connection.createAnswer();\r\n await peer.connection.setLocalDescription(answer);\r\n\r\n this.logger.debug(`Answer created for ${peerId}`);\r\n return peer.connection.localDescription;\r\n\r\n } catch (error) {\r\n this.logger.error(`Failed to create answer for ${peerId}:`, error);\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 원격 Description 설정 (Perfect Negotiation)\r\n * @param {string} peerId\r\n * @param {RTCSessionDescription} description\r\n */\r\n async handleRemoteDescription(peerId, description) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n throw new Error(`Peer not found: ${peerId}`);\r\n }\r\n\r\n const { connection, polite, makingOffer } = peer;\r\n\r\n // Perfect Negotiation: offer 충돌 감지\r\n const offerCollision = description.type === 'offer' &&\r\n (makingOffer || connection.signalingState !== 'stable');\r\n\r\n // Impolite는 충돌 시 상대 offer 무시\r\n peer.ignoreOffer = !polite && offerCollision;\r\n\r\n if (peer.ignoreOffer) {\r\n this.logger.debug(`Ignoring offer collision from ${peerId} (I am impolite)`);\r\n return;\r\n }\r\n\r\n try {\r\n // Perfect Negotiation: Polite가 충돌 시 자신의 offer를 roll back\r\n if (offerCollision && polite) {\r\n this.logger.debug(`Offer collision detected, rolling back local offer for ${peerId} (I am polite)`);\r\n await connection.setLocalDescription({ type: 'rollback' });\r\n }\r\n\r\n peer.isSettingRemoteAnswerPending = description.type === 'answer';\r\n await connection.setRemoteDescription(description);\r\n peer.isSettingRemoteAnswerPending = false;\r\n\r\n this.logger.debug(`Remote ${description.type} set for ${peerId}`);\r\n\r\n // Offer를 받았으면 Answer 생성\r\n if (description.type === 'offer') {\r\n const answer = await this.createAnswer(peerId);\r\n this.emit('answerCreated', { peerId, answer });\r\n }\r\n\r\n } catch (error) {\r\n this.logger.error(`Failed to set remote description for ${peerId}:`, error);\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * ICE Candidate 추가\r\n * @param {string} peerId\r\n * @param {RTCIceCandidate} candidate\r\n * @returns {Promise<boolean>} 추가 성공 여부 (false면 나중에 다시 시도 필요)\r\n */\r\n async addIceCandidate(peerId, candidate) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n this.logger.warn(`Peer not found for ICE candidate: ${peerId}`);\r\n return false;\r\n }\r\n\r\n // remoteDescription이 설정되지 않았으면 추가 불가\r\n if (!peer.connection.remoteDescription) {\r\n this.logger.debug(`Remote description not set yet for ${peerId}, ICE candidate queued`);\r\n return false;\r\n }\r\n\r\n try {\r\n await peer.connection.addIceCandidate(candidate);\r\n this.logger.debug(`ICE candidate added for ${peerId}`);\r\n return true;\r\n } catch (error) {\r\n if (!peer.ignoreOffer) {\r\n this.logger.error(`Failed to add ICE candidate for ${peerId}:`, error);\r\n }\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * 특정 피어 연결 가져오기\r\n * @param {string} peerId\r\n * @returns {RTCPeerConnection|null}\r\n */\r\n getPeerConnection(peerId) {\r\n const peer = this.peers.get(peerId);\r\n return peer ? peer.connection : null;\r\n }\r\n\r\n /**\r\n * 모든 피어 ID 목록\r\n * @returns {string[]}\r\n */\r\n getPeerIds() {\r\n return Array.from(this.peers.keys());\r\n }\r\n\r\n /**\r\n * 피어 연결 종료\r\n * @param {string} peerId\r\n */\r\n closePeerConnection(peerId) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n return;\r\n }\r\n\r\n peer.connection.close();\r\n this.peers.delete(peerId);\r\n\r\n this.emit('peerClosed', { peerId });\r\n this.logger.info(`Peer connection closed: ${peerId}`);\r\n }\r\n\r\n /**\r\n * 모든 피어 연결 종료\r\n */\r\n closeAllPeerConnections() {\r\n this.peers.forEach((peer, peerId) => {\r\n peer.connection.close();\r\n this.logger.debug(`Peer connection closed: ${peerId}`);\r\n });\r\n this.peers.clear();\r\n this.emit('allPeersClosed', {});\r\n }\r\n\r\n /**\r\n * 트랙 교체\r\n * @param {MediaStreamTrack} oldTrack - 교체할 기존 트랙\r\n * @param {MediaStreamTrack} newTrack - 새 트랙\r\n * @returns {Promise<void>}\r\n */\r\n async replaceTrack(oldTrack, newTrack) {\r\n const promises = [];\r\n\r\n this.peers.forEach((peer, peerId) => {\r\n const sender = peer.connection.getSenders().find(s =>\r\n s.track && s.track.kind === oldTrack.kind\r\n );\r\n\r\n if (sender) {\r\n promises.push(\r\n sender.replaceTrack(newTrack)\r\n .then(() => this.logger.debug(`Track replaced for ${peerId}`))\r\n .catch(error => this.logger.error(`Failed to replace track for ${peerId}:`, error))\r\n );\r\n }\r\n });\r\n\r\n await Promise.all(promises);\r\n }\r\n\r\n /**\r\n * 연결 통계 가져오기\r\n * @param {string} peerId\r\n * @returns {Promise<RTCStatsReport|null>}\r\n */\r\n async getStats(peerId) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n return null;\r\n }\r\n\r\n return peer.connection.getStats();\r\n }\r\n\r\n /**\r\n * 모든 연결 상태 요약\r\n * @returns {Object}\r\n */\r\n getConnectionSummary() {\r\n const summary = {};\r\n\r\n this.peers.forEach((peer, peerId) => {\r\n summary[peerId] = {\r\n connectionState: peer.connection.connectionState,\r\n iceConnectionState: peer.connection.iceConnectionState,\r\n signalingState: peer.connection.signalingState,\r\n polite: peer.polite\r\n };\r\n });\r\n\r\n return summary;\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n }\r\n\r\n /**\r\n * 리소스 정리\r\n */\r\n destroy() {\r\n this.closeAllPeerConnections();\r\n this.localStream = null;\r\n this.removeAllListeners();\r\n this.logger.info('PeerConnectionManager destroyed');\r\n }\r\n}\r\n\r\nexport default PeerConnectionManager;\r\n","/**\r\n * WebRTCClient\r\n * WebRTC 통화 기능 클라이언트\r\n */\r\n\r\nimport EventEmitter from '../utils/EventEmitter.js';\r\nimport Logger from '../utils/Logger.js';\r\nimport MediaStreamManager from './MediaStreamManager.js';\r\nimport PeerConnectionManager from './PeerConnectionManager.js';\r\nimport { WebSocketPaths, SignalTypes, ErrorTypes, DefaultConfig, LogLevel } from '../constants.js';\r\n\r\n/** 1:1 통화 제어 신호. 받는 쪽은 roomId 가 없어야 1:1 신호로 받는다(_acceptsSignal). */\r\nconst DIRECT_CONTROL_SIGNALS = new Set([\r\n SignalTypes.CALL_REQUEST, SignalTypes.CALL_ACCEPT, SignalTypes.CALL_REJECT,\r\n SignalTypes.CALL_CANCEL, SignalTypes.CALL_BUSY\r\n]);\r\n\r\n/** 받은 초대·내가 연 방은 응답 전까지만 의미가 있다. 오래된 것부터 잊는다. */\r\nconst MAX_REMEMBERED_CALL_ROOMS = 32;\r\n\r\nclass WebRTCClient extends EventEmitter {\r\n /**\r\n * @param {Object} options\r\n * @param {Object} options.connectionManager - ConnectionManager 인스턴스\r\n * @param {Object} options.apiClient - ApiClient 인스턴스\r\n * @param {string} options.userId - 사용자 ID\r\n * @param {Object[]} [options.iceServers] - ICE 서버 설정\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options) {\r\n super();\r\n\r\n this.connectionManager = options.connectionManager;\r\n this.apiClient = options.apiClient;\r\n this.userId = options.userId;\r\n this.logLevel = options.logLevel || LogLevel.WARN;\r\n\r\n this.logger = new Logger(this.logLevel, 'WebRTCClient');\r\n\r\n // 미디어 및 피어 연결 매니저\r\n this.mediaManager = new MediaStreamManager({ logLevel: this.logLevel });\r\n this.peerManager = new PeerConnectionManager({\r\n iceServers: options.iceServers || DefaultConfig.iceServers,\r\n logLevel: this.logLevel\r\n });\r\n\r\n // 현재 통화 상태\r\n this.currentRoom = null;\r\n this.isGroupCall = false;\r\n this.participants = new Map();\r\n this._callActive = false;\r\n this._callGeneration = 0;\r\n this._deferredGroupPeers = new Map();\r\n this._joinedGroupRoom = null;\r\n this._groupJoin = null;\r\n this._directCallPeer = null;\r\n this._incomingCallPeer = null;\r\n // 서버(REST)가 roomId 를 붙여 보내는 그룹 초대 취소·거절을 받을 방. callRoomId -> host / 나\r\n this._receivedInvitations = new Map();\r\n this._hostedCallRooms = new Map();\r\n // 서버는 생성 응답 전에 초대를 보낸다(CreateCallRoomService). 응답보다 먼저 온 거절은 응답을 받을 때까지 보관한다.\r\n this._pendingRoomCreations = 0;\r\n this._earlyRoomRejects = [];\r\n this._groupRecoveryTimer = null;\r\n this._groupRecoveryAttempts = 0;\r\n this._onReconnected = () => {\r\n this._recoverGroupParticipation().catch(error => this.logger.error('Call recovery failed:', error));\r\n };\r\n this.connectionManager.on?.('reconnected', this._onReconnected);\r\n\r\n // ICE 서버 초기화 상태\r\n this._iceServersFetched = false;\r\n\r\n // 1:1 통화 대기 상태 (CALL_REQUEST 전송 후 CALL_ACCEPT 대기)\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n\r\n // ICE candidate 큐 (peer connection 생성 전에 도착한 candidate 저장)\r\n this._pendingIceCandidates = new Map();\r\n\r\n // 이벤트 연결\r\n this._setupEventHandlers();\r\n }\r\n\r\n /**\r\n * ICE 서버 설정 초기화 (TURN credentials 가져오기)\r\n * 통화 시작 전 호출하면 더 빠른 연결 가능\r\n * @returns {Promise<void>}\r\n */\r\n async initializeIceServers() {\r\n if (this._iceServersFetched) {\r\n return;\r\n }\r\n\r\n try {\r\n const data = await this.getTurnCredentials();\r\n if (data && data.iceServers) {\r\n this.peerManager.setIceServers(data.iceServers);\r\n this._iceServersFetched = true;\r\n this.logger.info('ICE servers fetched successfully');\r\n }\r\n } catch (error) {\r\n this.logger.warn('Failed to fetch TURN credentials, using fallback ICE servers:', error.message);\r\n this._setFallbackIceServers();\r\n }\r\n }\r\n\r\n /**\r\n * 기본 ICE 서버 설정 (Fallback)\r\n * @private\r\n */\r\n _setFallbackIceServers() {\r\n this.peerManager.setIceServers([\r\n { urls: 'stun:stun.l.google.com:19302' },\r\n { urls: 'stun:stun1.l.google.com:19302' }\r\n ]);\r\n }\r\n\r\n /**\r\n * 이벤트 핸들러 설정\r\n * @private\r\n */\r\n _setupEventHandlers() {\r\n // 미디어 이벤트\r\n this.mediaManager.on('streamStarted', (data) => this.emit('localStreamStarted', data));\r\n this.mediaManager.on('streamStopped', (data) => this.emit('localStreamStopped', data));\r\n this.mediaManager.on('videoToggled', () => this._sendMediaState());\r\n this.mediaManager.on('audioToggled', () => this._sendMediaState());\r\n this.mediaManager.on('screenShareStarted', (data) => this.emit('screenShareStarted', data));\r\n this.mediaManager.on('screenShareEnded', (data) => this.emit('screenShareEnded', data));\r\n this.mediaManager.on('error', (error) => this.emit('error', error));\r\n\r\n // 피어 연결 이벤트\r\n this.peerManager.on('remoteTrack', (data) => this.emit('remoteTrack', data));\r\n this.peerManager.on('peerConnected', (data) => this.emit('peerConnected', data));\r\n this.peerManager.on('peerDisconnected', (data) => this.emit('peerDisconnected', data));\r\n this.peerManager.on('peerClosed', (data) => this.emit('peerClosed', data));\r\n this.peerManager.on('error', (error) => this.emit('error', error));\r\n\r\n // ICE Candidate 전송\r\n this.peerManager.on('iceCandidate', ({ peerId, candidate }) => {\r\n this._sendSignal({\r\n type: SignalTypes.ICE_CANDIDATE,\r\n receiverId: peerId,\r\n data: { candidate }\r\n });\r\n });\r\n\r\n // Negotiation (Offer 전송)\r\n this.peerManager.on('negotiationNeeded', ({ peerId, description }) => {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_OFFER,\r\n receiverId: peerId,\r\n data: { sdp: description }\r\n });\r\n });\r\n\r\n // Answer 전송\r\n this.peerManager.on('answerCreated', ({ peerId, answer }) => {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_ANSWER,\r\n receiverId: peerId,\r\n data: { sdp: answer }\r\n });\r\n });\r\n }\r\n\r\n // ==================== REST API ====================\r\n\r\n /**\r\n * TURN 서버 인증 정보 가져오기\r\n * @returns {Promise<Object>}\r\n */\r\n async getTurnCredentials() {\r\n const response = await this.apiClient.get('/api/v1/webrtc/turn-credentials');\r\n return response.data;\r\n }\r\n\r\n /**\r\n * 통화방 생성\r\n * @public\r\n * @param {Object} data\r\n * @param {string[]} [data.invitedUsers] - 초대할 사용자 ID\r\n * @param {string} [data.title] - 통화방 제목\r\n * @param {number} [data.maxParticipants] - 최대 참가자 수 (생략 시 서버 기본값)\r\n * @returns {Promise<Object>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async createCallRoom(data) {\r\n this._pendingRoomCreations++;\r\n try {\r\n const response = await this.apiClient.post('/api/v1/webrtc/rooms', data);\r\n const callRoomId = response?.data?.callRoomId;\r\n if (callRoomId) this._rememberCallRoom(this._hostedCallRooms, callRoomId, this.userId);\r\n return response;\r\n } finally {\r\n this._pendingRoomCreations--;\r\n // 보관한 거절을 같은 경로로 다시 판정한다. 연 방의 것은 전달되고, 진행 중인 생성이 남았으면 다시 보관되며, 아니면 버려진다.\r\n for (const message of this._earlyRoomRejects.splice(0)) {\r\n this._handleSignal(message).catch(error => this.logger.error('Early rejection handling failed:', error));\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * 통화방 정보 조회\r\n * @public\r\n * @param {string} roomId - 통화방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async getCallRoom(roomId) {\r\n return this.apiClient.get(`/api/v1/webrtc/rooms/${roomId}`);\r\n }\r\n\r\n /**\r\n * 통화방 참여\r\n * @public\r\n * @param {string} roomId - 통화방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async joinCallRoomApi(roomId) {\r\n // REST 응답보다 PEER_JOINED에 대한 offer가 먼저 도착할 수 있다.\r\n // 로컬에서 요청한 방만 허용하고, 실패·종료 뒤에는 이 권한을 남기지 않는다.\r\n const join = { roomId, generation: this._callGeneration };\r\n this._groupJoin = join;\r\n try {\r\n const response = await this.apiClient.post(`/api/v1/webrtc/rooms/${roomId}/join`);\r\n if (this._groupJoin === join && this._callGeneration === join.generation) this._joinedGroupRoom = roomId;\r\n return response;\r\n } catch (error) {\r\n if (this._groupJoin === join && this.currentRoom !== roomId) this._clearDeferredGroup(roomId);\r\n throw error;\r\n } finally {\r\n if (this._groupJoin === join) this._groupJoin = null;\r\n }\r\n }\r\n\r\n /**\r\n * 통화방 나가기\r\n * @public\r\n * @param {string} roomId - 통화방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async leaveCallRoomApi(roomId) {\r\n const response = await this.apiClient.post(`/api/v1/webrtc/rooms/${roomId}/leave`);\r\n if (this._joinedGroupRoom === roomId) this._joinedGroupRoom = null;\r\n if (this._groupJoin?.roomId === roomId) this._groupJoin = null;\r\n if (this.currentRoom !== roomId) this._clearDeferredGroup(roomId);\r\n return response;\r\n }\r\n\r\n // ==================== 통화 수신 대기 ====================\r\n\r\n /**\r\n * 통화 수신 대기 활성화\r\n * connect() 후 자동 호출되지만, 수동으로도 호출 가능\r\n * 개인 WebRTC 채널(/user/queue/webrtc)을 구독하여 초대 알림 수신\r\n * @public\r\n * @returns {Promise<void>}\r\n */\r\n async enableIncomingCalls() {\r\n if (this._incomingCallsEnabled) {\r\n this.logger.debug('Incoming calls already enabled');\r\n return;\r\n }\r\n\r\n await this._subscribeToDirectSignaling();\r\n this._incomingCallsEnabled = true;\r\n this.logger.info('Incoming calls enabled - now listening for call invitations');\r\n }\r\n\r\n /**\r\n * 통화 수신 대기 비활성화\r\n * @public\r\n */\r\n disableIncomingCalls() {\r\n if (!this._incomingCallsEnabled) {\r\n return;\r\n }\r\n\r\n const directDestination = WebSocketPaths.getWebRTCUserDestination();\r\n this.connectionManager.unsubscribe(directDestination);\r\n this._incomingCallsEnabled = false;\r\n this.logger.info('Incoming calls disabled');\r\n }\r\n\r\n /**\r\n * 통화 수신 대기 상태 확인\r\n * @public\r\n * @returns {boolean}\r\n */\r\n isIncomingCallsEnabled() {\r\n return this._incomingCallsEnabled || false;\r\n }\r\n\r\n // ==================== 통화 기능 ====================\r\n\r\n /**\r\n * 통화 시작 (미디어 및 구독)\r\n * @param {Object} options\r\n * @param {string} options.roomId - 통화방 ID\r\n * @param {boolean} [options.isGroup=false] - 그룹 통화 여부\r\n * @param {Object} [options.mediaConstraints] - 미디어 제약조건\r\n * @returns {Promise<MediaStream>}\r\n */\r\n async startCall(options) {\r\n const { roomId, isGroup = false, mediaConstraints = { video: true, audio: true } } = options;\r\n if (this._pendingStart) throw new Error('A call is already starting');\r\n // 조기 offer의 peer 존재만으로 막지 않는다. 이미 소유한 통화만 보호한다.\r\n if (this.currentRoom !== null || this._callActive || this._waitingForCallAccept) {\r\n throw new Error('A call is already active');\r\n }\r\n this._callGeneration++;\r\n this._cancelGroupRecoveryRetry();\r\n this._groupRecoveryAttempts = 0;\r\n this._groupRecovery = null;\r\n const start = { cancelled: false };\r\n this._pendingStart = start;\r\n const checkCancelled = () => { if (start.cancelled) throw new Error('Call start cancelled'); };\r\n\r\n try {\r\n this.currentRoom = roomId;\r\n this.isGroupCall = isGroup;\r\n for (const deferredRoom of new Set(this._deferredGroupPeers.values())) {\r\n if (!isGroup || deferredRoom !== roomId) this._clearDeferredGroup(deferredRoom);\r\n }\r\n // 그룹 참가 임대를 먼저 연결한다. 권한 팝업/TURN 조회가 90초를 넘어도 갱신된다.\r\n await this._subscribeToSignaling(roomId, isGroup);\r\n checkCancelled();\r\n await this.initializeIceServers();\r\n checkCancelled();\r\n const localStream = await this.mediaManager.getUserMedia(mediaConstraints);\r\n checkCancelled();\r\n // 먼저 수신한 offer의 peer에도 트랙을 추가하고 재협상한다.\r\n // 권한 대기 중 시그널을 버리면 상대의 기존 peer가 offer를 다시 보내지 않을 수 있다.\r\n this.peerManager.setLocalStream(localStream);\r\n for (const peerId of this._deferredGroupPeers.keys()) {\r\n await this._resumeGroupNegotiation(peerId);\r\n checkCancelled();\r\n }\r\n this._callActive = true;\r\n\r\n // 입장 시그널 전송\r\n this._sendSignal({\r\n type: SignalTypes.JOIN_ROOM,\r\n roomId\r\n });\r\n\r\n this.logger.info(`Call started in room: ${roomId}`);\r\n this.emit('callStarted', { roomId, isGroup, localStream });\r\n\r\n return localStream;\r\n\r\n } catch (error) {\r\n if (isGroup) this.connectionManager.unsubscribe(WebSocketPaths.getWebRTCDestination(roomId));\r\n this._unsubscribeFromSignaling();\r\n this.mediaManager.stopAll();\r\n this.peerManager.closeAllPeerConnections();\r\n this.peerManager.setLocalStream(null);\r\n this.participants.clear();\r\n this._pendingIceCandidates.clear();\r\n this._deferredGroupPeers.clear();\r\n this._callActive = false;\r\n this.currentRoom = null;\r\n this.isGroupCall = false;\r\n this._joinedGroupRoom = null;\r\n this._groupJoin = null;\r\n this.logger.error('Failed to start call:', error);\r\n this.emit('error', {\r\n type: ErrorTypes.PEER_CONNECTION_FAILED,\r\n message: error.message,\r\n error\r\n });\r\n throw error;\r\n } finally {\r\n this._pendingStart = null;\r\n }\r\n }\r\n\r\n /**\r\n * 1:1 통화 요청\r\n * @param {string} targetUserId - 상대방 사용자 ID\r\n * @param {Object} [mediaConstraints] - 미디어 제약조건\r\n */\r\n async callUser(targetUserId, mediaConstraints = { video: true, audio: true }) {\r\n try {\r\n // TURN 서버 설정 초기화\r\n await this.initializeIceServers();\r\n\r\n // 로컬 미디어 획득\r\n const localStream = await this.mediaManager.getUserMedia(mediaConstraints);\r\n this.peerManager.setLocalStream(localStream);\r\n\r\n // 1:1 통화 구독\r\n await this._subscribeToDirectSignaling();\r\n\r\n // CALL_ACCEPT 대기 상태 설정\r\n // PeerConnection은 CALL_ACCEPT를 받은 후에 생성하여 자동 negotiation 방지\r\n this._callActive = true;\r\n this._waitingForCallAccept = true;\r\n this._pendingCallTarget = targetUserId;\r\n this._incomingCallPeer = null;\r\n\r\n // 통화 요청 시그널\r\n this._sendSignal({\r\n type: SignalTypes.CALL_REQUEST,\r\n receiverId: targetUserId\r\n });\r\n\r\n this.currentRoom = null;\r\n this.isGroupCall = false;\r\n\r\n this.emit('callRequested', { targetUserId, localStream });\r\n\r\n } catch (error) {\r\n this.logger.error('Failed to call user:', error);\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 통화 수락\r\n * @param {string} callerId - 발신자 ID\r\n * @param {Object} [mediaConstraints] - 미디어 제약조건\r\n */\r\n async acceptCall(callerId, mediaConstraints = { video: true, audio: true }) {\r\n try {\r\n // TURN 서버 설정 초기화\r\n await this.initializeIceServers();\r\n\r\n // 로컬 미디어 획득 (수신자도 미디어 필요)\r\n const localStream = await this.mediaManager.getUserMedia(mediaConstraints);\r\n this.peerManager.setLocalStream(localStream);\r\n\r\n // 피어 연결 생성 (Polite - 수신자)\r\n // skipAutoNegotiation: true - 수신자는 offer를 받아서 answer를 보내므로 자동 negotiation 불필요\r\n this.peerManager.createPeerConnection(callerId, true, { skipAutoNegotiation: true });\r\n this._directCallPeer = callerId;\r\n this._incomingCallPeer = null;\r\n\r\n // 수락 시그널\r\n this._sendSignal({\r\n type: SignalTypes.CALL_ACCEPT,\r\n receiverId: callerId\r\n });\r\n\r\n this._callActive = true;\r\n this.emit('callAccepted', { callerId });\r\n\r\n } catch (error) {\r\n this.logger.error('Failed to accept call:', error);\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 통화 거절\r\n * @param {string} callerId - 발신자 ID\r\n */\r\n rejectCall(callerId) {\r\n if (this._incomingCallPeer === callerId) this._incomingCallPeer = null;\r\n this._sendSignal({\r\n type: SignalTypes.CALL_REJECT,\r\n receiverId: callerId\r\n });\r\n\r\n this.emit('callRejected', { callerId });\r\n }\r\n\r\n /**\r\n * 통화 취소 (발신자가 수신자 응답 전 취소)\r\n */\r\n cancelCall() {\r\n if (this._waitingForCallAccept && this._pendingCallTarget) {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_CANCEL,\r\n receiverId: this._pendingCallTarget\r\n });\r\n\r\n this.emit('callCancelled', { targetUserId: this._pendingCallTarget });\r\n\r\n // 대기 상태 초기화\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n\r\n // 미디어 정리\r\n this._callActive = false;\r\n this.mediaManager.stopAll();\r\n }\r\n }\r\n\r\n /**\r\n * 통화 종료\r\n */\r\n endCall() {\r\n this._endCall(true);\r\n }\r\n\r\n _endCall(notifyPeers) {\r\n this._callGeneration++;\r\n this._cancelGroupRecoveryRetry();\r\n this._groupRecoveryAttempts = 0;\r\n this._groupRecovery = null;\r\n this._callActive = false;\r\n this._deferredGroupPeers.clear();\r\n this._joinedGroupRoom = null;\r\n this._groupJoin = null;\r\n this._incomingCallPeer = null;\r\n if (this._pendingStart) this._pendingStart.cancelled = true;\r\n // 대기 중인 통화가 있으면 취소 처리\r\n if (notifyPeers && this._waitingForCallAccept && this._pendingCallTarget) {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_CANCEL,\r\n receiverId: this._pendingCallTarget\r\n });\r\n }\r\n\r\n // 대기 상태 초기화\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n\r\n if (this.currentRoom) {\r\n // 퇴장 시그널 전송\r\n if (notifyPeers) this._sendSignal({\r\n type: SignalTypes.LEAVE_ROOM,\r\n roomId: this.currentRoom\r\n });\r\n\r\n // 구독 해제\r\n this._unsubscribeFromSignaling();\r\n }\r\n\r\n // 모든 피어에게 종료 시그널\r\n if (notifyPeers) this.peerManager.getPeerIds().forEach(peerId => {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_END,\r\n receiverId: peerId\r\n });\r\n });\r\n // 1:1 상대에게 가는 CALL_END 에 그룹 roomId 가 붙지 않도록 보낸 뒤에 지운다(_groupRoomFor).\r\n this._directCallPeer = null;\r\n\r\n // 정리\r\n this.peerManager.closeAllPeerConnections();\r\n this.peerManager.setLocalStream(null);\r\n this.mediaManager.stopAll();\r\n this.participants.clear();\r\n this._pendingIceCandidates.clear();\r\n\r\n const roomId = this.currentRoom;\r\n this.currentRoom = null;\r\n this.isGroupCall = false;\r\n\r\n this.emit('callEnded', { roomId });\r\n this.logger.info('Call ended');\r\n }\r\n\r\n // ==================== 미디어 제어 ====================\r\n\r\n /**\r\n * 비디오 토글\r\n * @returns {boolean}\r\n */\r\n toggleVideo() {\r\n return this.mediaManager.toggleVideo();\r\n }\r\n\r\n /**\r\n * 오디오 토글\r\n * @returns {boolean}\r\n */\r\n toggleAudio() {\r\n return this.mediaManager.toggleAudio();\r\n }\r\n\r\n /**\r\n * 화면 공유 시작\r\n * @returns {Promise<MediaStream>}\r\n */\r\n async startScreenShare() {\r\n const screenStream = await this.mediaManager.getDisplayMedia();\r\n\r\n // 피어들에게 화면 공유 트랙 전송\r\n const videoTrack = screenStream.getVideoTracks()[0];\r\n const localVideoTrack = this.mediaManager.getTrack('video');\r\n\r\n if (localVideoTrack && videoTrack) {\r\n await this.peerManager.replaceTrack(localVideoTrack, videoTrack);\r\n }\r\n\r\n // 화면 공유 종료 시 원복 — 복원 시점의 현재 카메라 트랙 사용 (공유 중 switchDevice 대응)\r\n videoTrack.onended = async () => {\r\n const currentVideoTrack = this.mediaManager.getTrack('video');\r\n if (currentVideoTrack) {\r\n await this.peerManager.replaceTrack(videoTrack, currentVideoTrack);\r\n }\r\n this.emit('screenShareEnded', {});\r\n };\r\n\r\n return screenStream;\r\n }\r\n\r\n /**\r\n * 화면 공유 중지\r\n */\r\n stopScreenShare() {\r\n const screenStream = this.mediaManager.getScreenStream();\r\n if (screenStream) {\r\n screenStream.getTracks().forEach(track => track.stop());\r\n }\r\n }\r\n\r\n /**\r\n * 로컬 스트림 가져오기\r\n * @public\r\n * @returns {MediaStream|null}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n getLocalStream() {\r\n return this.mediaManager.getLocalStream();\r\n }\r\n\r\n /**\r\n * 디바이스 목록 가져오기\r\n * @returns {Promise<Object>}\r\n */\r\n async getDevices() {\r\n return this.mediaManager.getDevices();\r\n }\r\n\r\n /**\r\n * 디바이스 전환\r\n * @param {string} deviceId\r\n * @param {string} kind - 'video' or 'audio'\r\n */\r\n async switchDevice(deviceId, kind) {\r\n // switchDevice가 localStream을 교체하므로, 교체 전 기존 트랙을 먼저 저장\r\n const oldTrack = this.mediaManager.getTrack(kind);\r\n const newTrack = await this.mediaManager.switchDevice(deviceId, kind);\r\n\r\n // 피어들에게 트랙 교체\r\n if (oldTrack) {\r\n await this.peerManager.replaceTrack(oldTrack, newTrack);\r\n }\r\n\r\n return newTrack;\r\n }\r\n\r\n /**\r\n * 비디오 상태 직접 설정\r\n * @public\r\n * @param {boolean} enabled\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n setVideoEnabled(enabled) {\r\n this.mediaManager.setVideoEnabled(enabled);\r\n this._sendMediaState();\r\n }\r\n\r\n /**\r\n * 오디오 상태 직접 설정\r\n * @public\r\n * @param {boolean} enabled\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n setAudioEnabled(enabled) {\r\n this.mediaManager.setAudioEnabled(enabled);\r\n this._sendMediaState();\r\n }\r\n\r\n /**\r\n * 디바이스 변경 감지 시작\r\n * @public\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n startDeviceChangeDetection() {\r\n this.mediaManager.startDeviceChangeDetection();\r\n this.mediaManager.on('deviceChange', (devices) => {\r\n this.emit('deviceChange', devices);\r\n });\r\n }\r\n\r\n /**\r\n * 디바이스 변경 감지 중지\r\n * @public\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n stopDeviceChangeDetection() {\r\n this.mediaManager.stopDeviceChangeDetection();\r\n }\r\n\r\n /**\r\n * 비디오 해상도/프레임레이트 변경\r\n * @public\r\n * @param {Object} constraints - { width, height, frameRate }\r\n * @returns {Promise<void>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async applyVideoConstraints(constraints) {\r\n return this.mediaManager.applyVideoConstraints(constraints);\r\n }\r\n\r\n /**\r\n * 현재 비디오 설정 가져오기\r\n * @public\r\n * @returns {MediaTrackSettings|null}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n getVideoSettings() {\r\n return this.mediaManager.getVideoSettings();\r\n }\r\n\r\n /**\r\n * 현재 오디오 설정 가져오기\r\n * @public\r\n * @returns {MediaTrackSettings|null}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n getAudioSettings() {\r\n return this.mediaManager.getAudioSettings();\r\n }\r\n\r\n // ==================== 시그널링 ====================\r\n\r\n /**\r\n * 시그널링 구독\r\n * @private\r\n */\r\n async _subscribeToSignaling(roomId, isGroup) {\r\n if (isGroup) {\r\n // 그룹 통화: /topic/webrtc/{roomId}\r\n const destination = WebSocketPaths.getWebRTCDestination(roomId);\r\n await this.connectionManager.subscribe(destination, (message) => {\r\n this._handleSignal(message);\r\n });\r\n }\r\n\r\n // 1:1 또는 그룹 모두에서 개인 큐 구독\r\n await this._subscribeToDirectSignaling();\r\n }\r\n\r\n /**\r\n * 1:1 시그널링 구독\r\n * @private\r\n */\r\n async _subscribeToDirectSignaling() {\r\n const destination = WebSocketPaths.getWebRTCUserDestination();\r\n await this.connectionManager.subscribe(destination, (message) => {\r\n this._handleSignal(message);\r\n });\r\n }\r\n\r\n /**\r\n * 시그널링 구독 해제\r\n * @private\r\n */\r\n _unsubscribeFromSignaling() {\r\n if (this.currentRoom && this.isGroupCall) {\r\n const destination = WebSocketPaths.getWebRTCDestination(this.currentRoom);\r\n this.connectionManager.unsubscribe(destination);\r\n }\r\n\r\n // 수신 대기가 활성화된 경우 direct 채널 구독 유지 (그룹 통화 종료 후에도 수신 가능)\r\n if (!this._incomingCallsEnabled) {\r\n const directDestination = WebSocketPaths.getWebRTCUserDestination();\r\n this.connectionManager.unsubscribe(directDestination);\r\n }\r\n }\r\n\r\n /**\r\n * 시그널 전송\r\n * @private\r\n */\r\n _sendSignal(signal) {\r\n const payload = {\r\n type: signal.type,\r\n roomId: signal.roomId || this._groupRoomFor(signal),\r\n receiverId: signal.receiverId,\r\n data: signal.data\r\n };\r\n\r\n this.connectionManager.send(WebSocketPaths.WEBRTC_SIGNAL, payload);\r\n this.logger.debug('Signal sent:', payload);\r\n }\r\n\r\n /**\r\n * 그룹 roomId 는 그룹 상대·그룹 방 신호에만 붙인다.\r\n * 1:1 신호에 붙으면 상대가 방 신호로 보고 버린다(그룹 통화 중 자동 BUSY 유실).\r\n * @private\r\n */\r\n _groupRoomFor({ type, receiverId }) {\r\n if (DIRECT_CONTROL_SIGNALS.has(type)) return undefined;\r\n if (receiverId && receiverId === this._directCallPeer) return undefined;\r\n return this.currentRoom || this._deferredGroupPeers.get(receiverId);\r\n }\r\n\r\n /**\r\n * 미디어 상태 전송\r\n * @private\r\n */\r\n _sendMediaState() {\r\n if (!this.currentRoom && this.peerManager.getPeerIds().length === 0) {\r\n return;\r\n }\r\n\r\n const mediaState = {\r\n videoEnabled: this.mediaManager.isVideoEnabled(),\r\n audioEnabled: this.mediaManager.isAudioEnabled()\r\n };\r\n\r\n this._sendSignal({\r\n type: SignalTypes.VIDEO_STATE_CHANGED,\r\n data: mediaState\r\n });\r\n\r\n this.emit('mediaStateChanged', mediaState);\r\n }\r\n\r\n /**\r\n * 시그널 처리\r\n * @private\r\n */\r\n async _handleSignal(message) {\r\n const { type, senderId, data } = message;\r\n\r\n // 자신의 시그널 무시\r\n if (senderId === this.userId) {\r\n return;\r\n }\r\n\r\n if (this._holdsEarlyRoomReject(message)) return;\r\n if (!this._acceptsSignal(message)) return;\r\n\r\n this.logger.debug(`Signal received: ${type} from ${senderId}`);\r\n\r\n switch (type) {\r\n case SignalTypes.JOIN_ROOM:\r\n case SignalTypes.PEER_JOINED:\r\n await this._handleUserJoined(senderId, message.roomId);\r\n break;\r\n\r\n case SignalTypes.LEAVE_ROOM:\r\n case SignalTypes.PEER_LEFT:\r\n this._handleUserLeft(senderId);\r\n break;\r\n\r\n case SignalTypes.CALL_OFFER:\r\n await this._handleOffer(senderId, data.sdp, message.roomId);\r\n break;\r\n\r\n case SignalTypes.CALL_ANSWER:\r\n await this._handleAnswer(senderId, data.sdp);\r\n break;\r\n\r\n case SignalTypes.ICE_CANDIDATE:\r\n await this._handleIceCandidate(senderId, data.candidate);\r\n break;\r\n\r\n case SignalTypes.VIDEO_STATE_CHANGED:\r\n case SignalTypes.AUDIO_STATE_CHANGED:\r\n this._handleMediaState(senderId, data);\r\n break;\r\n\r\n case SignalTypes.CALL_REQUEST:\r\n // 통화 중이거나 통화 요청 대기 중이면 BUSY 응답\r\n if (this.isInCall() || this._waitingForCallAccept) {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_BUSY,\r\n receiverId: senderId\r\n });\r\n this.emit('incomingCallWhileBusy', { callerId: senderId });\r\n } else {\r\n this._incomingCallPeer = senderId;\r\n this.emit('incomingCall', { callerId: senderId });\r\n }\r\n break;\r\n\r\n case SignalTypes.CALL_BUSY:\r\n // 상대방이 통화 중\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n this._callActive = false;\r\n this.mediaManager.stopAll();\r\n this.emit('callBusy', { userId: senderId });\r\n break;\r\n\r\n case SignalTypes.CALL_INVITATION: {\r\n // 그룹 통화 초대 (createCallRoom으로 초대받은 경우)\r\n const callRoomId = data?.callRoomId || message.roomId;\r\n // 취소 수락 기준은 서버가 채운 roomId·senderId(host)다. data 는 보낸 쪽이 정한 값이라 기억 키로 쓰지 않는다.\r\n if (message.roomId) this._rememberCallRoom(this._receivedInvitations, message.roomId, senderId);\r\n this.emit('callInvitation', {\r\n callRoomId,\r\n title: data?.title,\r\n hostUserId: data?.hostUserId || senderId,\r\n maxParticipants: data?.maxParticipants,\r\n createdAt: data?.createdAt\r\n });\r\n break;\r\n }\r\n\r\n case SignalTypes.CALL_ACCEPT:\r\n await this._handleCallAccepted(senderId);\r\n break;\r\n\r\n case SignalTypes.CALL_REJECT:\r\n if (message.roomId) {\r\n // 그룹 초대 거절(서버 REST 발신). host 의 통화·미디어는 그대로 둔다.\r\n this.emit('callRejected', { userId: senderId, roomId: message.roomId });\r\n break;\r\n }\r\n // 대기 상태 초기화\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n this._callActive = false;\r\n // 발신자 미디어 정리 (거절당했으므로 통화 불가)\r\n this.mediaManager.stopAll();\r\n this.emit('callRejected', { userId: senderId });\r\n break;\r\n\r\n case SignalTypes.CALL_CANCEL:\r\n if (message.roomId) {\r\n // 그룹 초대 취소(서버 REST 발신). 1:1 대기 상태는 건드리지 않는다.\r\n this._receivedInvitations.delete(message.roomId);\r\n this.emit('callCancelled', { userId: senderId, roomId: message.roomId });\r\n break;\r\n }\r\n this._incomingCallPeer = null;\r\n // 대기 상태 초기화\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n this.emit('callCancelled', { userId: senderId });\r\n break;\r\n\r\n case SignalTypes.CALL_END:\r\n if (data?.callEndScope === 'ROOM') {\r\n this._endCall(false); // _acceptsSignal에서 현재/참가 요청한 그룹 방 일치를 확인했다.\r\n } else {\r\n this._handleCallEnded(senderId);\r\n }\r\n break;\r\n\r\n default:\r\n this.logger.warn(`Unknown signal type: ${type}`);\r\n }\r\n }\r\n\r\n /** 미디어를 공유할 상대/방은 로컬 참가·발신·수락으로만 결정한다. */\r\n _acceptsSignal({ type, senderId, roomId }) {\r\n if (!senderId) return false;\r\n if (type === SignalTypes.CALL_INVITATION) return true;\r\n if (type === SignalTypes.CALL_REQUEST) return !roomId;\r\n // 그룹 초대 거절·취소는 서버가 roomId 를 붙여 보낸다. 내가 연 방·받은 초대의 host 것만 받는다.\r\n if (roomId && type === SignalTypes.CALL_REJECT) return this._hostedCallRooms.has(roomId);\r\n if (roomId && type === SignalTypes.CALL_CANCEL) return this._receivedInvitations.get(roomId) === senderId;\r\n if ([SignalTypes.CALL_ACCEPT, SignalTypes.CALL_BUSY, SignalTypes.CALL_REJECT].includes(type)) {\r\n return !roomId && this._waitingForCallAccept && senderId === this._pendingCallTarget;\r\n }\r\n if (type === SignalTypes.CALL_CANCEL) return !roomId && senderId === this._incomingCallPeer;\r\n // 수락한 1:1 상대의 신호는 roomId 없이 온다. 그룹 통화 중에 수락했어도 받는다.\r\n if (!roomId && senderId === this._directCallPeer) return true;\r\n const groupRoom = this.isGroupCall ? this.currentRoom\r\n : (!this._callActive && !this._waitingForCallAccept ? this._groupJoin?.roomId || this._joinedGroupRoom : null);\r\n if (groupRoom) return roomId === groupRoom;\r\n return !roomId && senderId === this._pendingCallTarget;\r\n }\r\n\r\n /** 방 생성 응답 전에 온 그룹 거절은 응답의 방 ID 로 판정할 수 있을 때까지 보관한다. */\r\n _holdsEarlyRoomReject(message) {\r\n const { type, roomId } = message;\r\n if (type !== SignalTypes.CALL_REJECT || !roomId || this._pendingRoomCreations === 0\r\n || this._hostedCallRooms.has(roomId)) return false;\r\n this._earlyRoomRejects.push(message);\r\n if (this._earlyRoomRejects.length > MAX_REMEMBERED_CALL_ROOMS) this._earlyRoomRejects.shift();\r\n return true;\r\n }\r\n\r\n _rememberCallRoom(rooms, roomId, userId) {\r\n rooms.delete(roomId);\r\n rooms.set(roomId, userId);\r\n if (rooms.size > MAX_REMEMBERED_CALL_ROOMS) rooms.delete(rooms.keys().next().value);\r\n }\r\n\r\n _clearDeferredGroup(roomId) {\r\n for (const [peerId, peerRoom] of this._deferredGroupPeers) {\r\n if (peerRoom !== roomId) continue;\r\n this.peerManager.closePeerConnection(peerId);\r\n this.participants.delete(peerId);\r\n this._pendingIceCandidates.delete(peerId);\r\n this._deferredGroupPeers.delete(peerId);\r\n }\r\n }\r\n\r\n /**\r\n * 사용자 입장 처리\r\n * @private\r\n */\r\n async _handleUserJoined(userId, roomId) {\r\n this.participants.set(userId, { joinedAt: new Date() });\r\n\r\n // userId 비교로 Polite/Impolite 결정 (작은 쪽이 Polite)\r\n // Perfect Negotiation에서 충돌 시 한 쪽만 양보하도록 보장\r\n const polite = this.userId < userId;\r\n const defer = !!roomId && !this.peerManager.localStream;\r\n this.peerManager.createPeerConnection(userId, polite, { skipAutoNegotiation: defer });\r\n if (defer) this._deferredGroupPeers.set(userId, roomId);\r\n\r\n this.emit('userJoined', { userId });\r\n this.logger.info(`User joined: ${userId} (I am ${polite ? 'polite' : 'impolite'})`);\r\n }\r\n\r\n /**\r\n * 사용자 퇴장 처리\r\n * @private\r\n */\r\n _handleUserLeft(userId) {\r\n this.participants.delete(userId);\r\n this.peerManager.closePeerConnection(userId);\r\n\r\n this.emit('userLeft', { userId });\r\n this.logger.info(`User left: ${userId}`);\r\n }\r\n\r\n /**\r\n * Offer 처리\r\n * @private\r\n */\r\n async _handleOffer(senderId, sdp, roomId = this.isGroupCall ? this.currentRoom : null) {\r\n // 피어 연결이 없으면 생성\r\n if (!this.peerManager.getPeerConnection(senderId)) {\r\n // userId 비교로 Polite/Impolite 결정\r\n const polite = this.userId < senderId;\r\n // skipAutoNegotiation: true - offer를 먼저 처리한 후 answer 전송\r\n // 트랙 추가 시 자동 negotiation이 발생하면 offer 충돌 발생\r\n this.peerManager.createPeerConnection(senderId, polite, { skipAutoNegotiation: true });\r\n // REST join의 PEER_JOINED는 startCall보다 먼저 올 수 있다.\r\n // 메시지의 roomId로 그룹을 식별하되, 1:1 수동 협상에는 적용하지 않는다.\r\n if (roomId && !this.peerManager.localStream) this._deferredGroupPeers.set(senderId, roomId);\r\n }\r\n\r\n await this.peerManager.handleRemoteDescription(senderId, sdp);\r\n await this._resumeGroupNegotiation(senderId);\r\n\r\n // remoteDescription 설정 후 대기 중인 ICE candidates 처리\r\n await this._processPendingIceCandidates(senderId);\r\n }\r\n\r\n async _resumeGroupNegotiation(peerId) {\r\n if (!this.isGroupCall || this._deferredGroupPeers.get(peerId) !== this.currentRoom\r\n || !this.peerManager.localStream) return;\r\n if (await this.peerManager.resumeAutoNegotiation(peerId)) this._deferredGroupPeers.delete(peerId);\r\n }\r\n\r\n /** 재구독은 참가 이력을 복구하지 않는다. 동일 사용자의 다른 기기 참가도 이 조회에 반영된다. */\r\n async _recoverGroupParticipation() {\r\n if (!this.isGroupCall || !this.currentRoom || this._groupRecovery) return;\r\n this._cancelGroupRecoveryRetry();\r\n const roomId = this.currentRoom;\r\n const generation = this._callGeneration;\r\n const stillCurrent = () => this.currentRoom === roomId && this._callGeneration === generation;\r\n const recovery = (async () => {\r\n try {\r\n const response = await this.apiClient.get(`/api/v1/webrtc/rooms/${roomId}/participation`);\r\n if (!stillCurrent()) return;\r\n if (!response.data.participating) {\r\n await this.joinCallRoomApi(roomId);\r\n if (!stillCurrent()) return;\r\n this._sendSignal({ type: SignalTypes.JOIN_ROOM, roomId });\r\n }\r\n this._groupRecoveryAttempts = 0;\r\n } catch (error) {\r\n if (!stillCurrent()) return;\r\n if ([403, 404, 409].includes(error?.status ?? error?.response?.status)) {\r\n // 확정 실패만 로컬 종료한다. 다른 기기 참가를 지우는 LEAVE는 보내지 않는다.\r\n this._endCall(false);\r\n this.emit('callRecoveryFailed', { roomId, error });\r\n } else {\r\n // 일시 오류 동안 미디어·peer·구독을 유지하고 1s→2s→…→30s 간격으로 재시도한다.\r\n const delay = Math.min(30000, 1000 * 2 ** Math.min(this._groupRecoveryAttempts++, 5));\r\n this._groupRecoveryTimer = setTimeout(() => {\r\n this._groupRecoveryTimer = null;\r\n if (stillCurrent()) this._onReconnected();\r\n }, delay);\r\n this._groupRecoveryTimer.unref?.();\r\n this.logger.warn('Call participation recovery will retry:', error);\r\n }\r\n }\r\n })();\r\n this._groupRecovery = recovery;\r\n try { await recovery; }\r\n finally { if (this._groupRecovery === recovery) this._groupRecovery = null; }\r\n }\r\n\r\n _cancelGroupRecoveryRetry() {\r\n clearTimeout(this._groupRecoveryTimer);\r\n this._groupRecoveryTimer = null;\r\n }\r\n\r\n /**\r\n * Answer 처리\r\n * @private\r\n */\r\n async _handleAnswer(senderId, sdp) {\r\n await this.peerManager.handleRemoteDescription(senderId, sdp);\r\n // remoteDescription 설정 후 대기 중인 ICE candidates 처리\r\n await this._processPendingIceCandidates(senderId);\r\n }\r\n\r\n /**\r\n * ICE Candidate 처리\r\n * @private\r\n */\r\n async _handleIceCandidate(senderId, candidate) {\r\n // peer connection이 아직 없으면 큐에 저장\r\n if (!this.peerManager.getPeerConnection(senderId)) {\r\n this._queueIceCandidate(senderId, candidate);\r\n return;\r\n }\r\n\r\n // addIceCandidate가 false 반환하면 (remoteDescription 미설정) 큐에 저장\r\n const added = await this.peerManager.addIceCandidate(senderId, new RTCIceCandidate(candidate));\r\n if (!added) {\r\n this._queueIceCandidate(senderId, candidate);\r\n }\r\n }\r\n\r\n /**\r\n * ICE candidate를 큐에 저장\r\n * @private\r\n */\r\n _queueIceCandidate(peerId, candidate) {\r\n if (!this._pendingIceCandidates.has(peerId)) {\r\n this._pendingIceCandidates.set(peerId, []);\r\n }\r\n this._pendingIceCandidates.get(peerId).push(candidate);\r\n this.logger.debug(`ICE candidate queued for ${peerId}`);\r\n }\r\n\r\n /**\r\n * 대기 중인 ICE candidate 처리\r\n * @private\r\n */\r\n async _processPendingIceCandidates(peerId) {\r\n const candidates = this._pendingIceCandidates.get(peerId);\r\n if (!candidates || candidates.length === 0) {\r\n return;\r\n }\r\n\r\n this.logger.debug(`Processing ${candidates.length} pending ICE candidates for ${peerId}`);\r\n\r\n for (const candidate of candidates) {\r\n await this.peerManager.addIceCandidate(peerId, new RTCIceCandidate(candidate));\r\n }\r\n\r\n this._pendingIceCandidates.delete(peerId);\r\n }\r\n\r\n /**\r\n * 미디어 상태 처리\r\n * @private\r\n */\r\n _handleMediaState(userId, state) {\r\n this.emit('participantMediaState', {\r\n userId,\r\n videoEnabled: state.videoEnabled,\r\n audioEnabled: state.audioEnabled\r\n });\r\n }\r\n\r\n /**\r\n * 통화 수락 처리 (발신자 측에서 CALL_ACCEPT 수신 시)\r\n * @private\r\n */\r\n async _handleCallAccepted(userId) {\r\n this._directCallPeer = userId;\r\n // 대기 상태 초기화\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n\r\n // 이제 PeerConnection 생성 (Impolite - 발신자)\r\n // skipAutoNegotiation: true - 트랙 추가 시 자동 negotiation 건너뛰고 수동으로 offer 전송\r\n this.peerManager.createPeerConnection(userId, false, { skipAutoNegotiation: true });\r\n\r\n // Offer 생성 및 전송\r\n const offer = await this.peerManager.createOffer(userId);\r\n this._sendSignal({\r\n type: SignalTypes.CALL_OFFER,\r\n receiverId: userId,\r\n data: { sdp: offer }\r\n });\r\n\r\n this.emit('callAccepted', { userId });\r\n }\r\n\r\n /**\r\n * 통화 종료 처리\r\n * @private\r\n */\r\n _handleCallEnded(userId) {\r\n this.peerManager.closePeerConnection(userId);\r\n this.participants.delete(userId);\r\n // 끝난 1:1 상대는 더 이상 선택한 상대가 아니다. 그룹 통화가 남아도 그의 roomId 없는 신호를 받지 않는다.\r\n if (userId === this._directCallPeer) this._directCallPeer = null;\r\n\r\n this.emit('participantLeft', { userId });\r\n\r\n // 모든 참여자가 나갔으면 통화 종료\r\n if (this.peerManager.getPeerIds().length === 0) {\r\n this.endCall();\r\n }\r\n }\r\n\r\n // ==================== 유틸리티 ====================\r\n\r\n /**\r\n * 현재 통화 중인지 확인\r\n * @returns {boolean}\r\n */\r\n isInCall() {\r\n return this.currentRoom !== null || this.peerManager.getPeerIds().length > 0;\r\n }\r\n\r\n /**\r\n * 현재 통화방 ID\r\n * @returns {string|null}\r\n */\r\n getCurrentRoom() {\r\n return this.currentRoom;\r\n }\r\n\r\n /**\r\n * 참여자 목록\r\n * @returns {string[]}\r\n */\r\n getParticipants() {\r\n return Array.from(this.participants.keys());\r\n }\r\n\r\n /**\r\n * 연결 상태 요약\r\n * @public\r\n * @returns {Object}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n getConnectionSummary() {\r\n return this.peerManager.getConnectionSummary();\r\n }\r\n\r\n /**\r\n * 특정 피어의 연결 통계 가져오기 (디버깅/모니터링용)\r\n * @public\r\n * @param {string} peerId - 피어 ID\r\n * @returns {Promise<RTCStatsReport|null>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async getStats(peerId) {\r\n return this.peerManager.getStats(peerId);\r\n }\r\n\r\n /**\r\n * 미디어 상태\r\n * @returns {Object}\r\n */\r\n getMediaState() {\r\n return {\r\n videoEnabled: this.mediaManager.isVideoEnabled(),\r\n audioEnabled: this.mediaManager.isAudioEnabled(),\r\n streamInfo: this.mediaManager.getStreamInfo()\r\n };\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n this.mediaManager.setLogLevel(level);\r\n this.peerManager.setLogLevel(level);\r\n }\r\n\r\n /**\r\n * 리소스 정리\r\n */\r\n destroy() {\r\n this.connectionManager.off?.('reconnected', this._onReconnected);\r\n this.endCall();\r\n this.mediaManager.destroy();\r\n this.peerManager.destroy();\r\n this.removeAllListeners();\r\n this.logger.info('WebRTCClient destroyed');\r\n }\r\n}\r\n\r\nexport default WebRTCClient;\r\n","/**\r\n * PushManager\r\n * FCM 웹 푸시 알림 관리 (Firebase 초기화, 서비스 워커, 권한, 토큰)\r\n */\r\n\r\nimport Logger from '../utils/Logger.js';\r\nimport { LogLevel } from '../constants.js';\r\n\r\n/**\r\n * 푸시 활성화 실패 사유 분류.\r\n *\r\n * <p>호출자가 {@code error.message} 문자열 매칭 대신 {@code error.code} 로 분기할 수 있도록\r\n * 표준화된 enum 값을 제공한다.</p>\r\n *\r\n * <ul>\r\n * <li>{@code UNSUPPORTED_BROWSER} — Notification / Service Worker 미지원 환경</li>\r\n * <li>{@code FIREBASE_NOT_INSTALLED} — firebase 패키지 동적 import 실패</li>\r\n * <li>{@code SW_REGISTER_FAILED} — 서비스 워커 등록 실패 (firebase-messaging-sw.js 누락 등)</li>\r\n * <li>{@code PERMISSION_DENIED} — 브라우저 알림 권한 거부 (이미 거부 상태 또는 이번 요청에서 거부)</li>\r\n * <li>{@code TOKEN_FAILED} — FCM 토큰 발급 실패</li>\r\n * <li>{@code SERVER_REGISTER_FAILED} — TalkFlow 서버에 디바이스 토큰 등록 실패</li>\r\n * </ul>\r\n */\r\nexport const PushErrorCode = Object.freeze({\r\n UNSUPPORTED_BROWSER: 'UNSUPPORTED_BROWSER',\r\n FIREBASE_NOT_INSTALLED: 'FIREBASE_NOT_INSTALLED',\r\n SW_REGISTER_FAILED: 'SW_REGISTER_FAILED',\r\n PERMISSION_DENIED: 'PERMISSION_DENIED',\r\n TOKEN_FAILED: 'TOKEN_FAILED',\r\n SERVER_REGISTER_FAILED: 'SERVER_REGISTER_FAILED'\r\n});\r\n\r\n/**\r\n * 푸시 활성화 과정에서 발생한 분류된 에러.\r\n *\r\n * <p>일반 {@link Error} 와 달리 {@code code} 필드로 분류되어 있어\r\n * 호출자가 사유별로 다른 UX (권한 거부 → 설정 가이드, 토큰 실패 → 재시도 등) 를 적용할 수 있다.</p>\r\n */\r\nexport class PushError extends Error {\r\n /**\r\n * @param {string} code - {@link PushErrorCode} 값 중 하나\r\n * @param {string} message - 사람이 읽는 메시지\r\n * @param {Error} [cause] - 원인 에러 (네트워크 실패 등)\r\n */\r\n constructor(code, message, cause) {\r\n super(message);\r\n this.name = 'PushError';\r\n this.code = code;\r\n if (cause !== undefined) {\r\n this.cause = cause;\r\n }\r\n }\r\n}\r\n\r\n// TalkFlow 기본 Firebase 설정 (고객별 분리 시 서버에서 내려받는 구조로 확장 가능)\r\nconst DEFAULT_FIREBASE_CONFIG = {\r\n apiKey: \"AIzaSyB8VhRg6rSvUI7K3Ua7h6sBpLvaQGmIkRc\",\r\n authDomain: \"chatting-c3e5d.firebaseapp.com\",\r\n projectId: \"chatting-c3e5d\",\r\n storageBucket: \"chatting-c3e5d.firebasestorage.app\",\r\n messagingSenderId: \"1020496565673\",\r\n appId: \"1:1020496565673:web:5039167257fd83f5ce20b8\"\r\n};\r\n\r\nconst DEFAULT_VAPID_KEY = 'BGP8qaSm1ntjWd4n9pc0lX_rw4BmMxm9u4pvRIANCitbmYaV0iy-gn05suTKBo88kUBejxdxM8sb6x2nt3avu8c';\r\nconst PENDING_NAVIGATION_DB_NAME = 'talkflowPush';\r\nconst PENDING_NAVIGATION_STORE_NAME = 'pendingNavigation';\r\n// SW 와 동일한 key 규칙 사용 — 멀티테넌트에서 다른 projectId 의 pending 을 건드리지 않도록 한다.\r\nconst PENDING_NAVIGATION_KEY_PREFIX = 'pending-room';\r\nconst PENDING_NAVIGATION_LEGACY_KEY = 'pending-room';\r\n// Gmail 메일 알림 클릭 — 방과 다른 key 라 consumePendingRoom 과 섞이지 않는다. 프로젝트별로만 저장한다(레거시 전역 key 없음).\r\nconst PENDING_MAIL_KEY_PREFIX = 'pending-mail';\r\n// 앱이 이 시간 안에 꺼내지 않은 메일 이동은 버린다 — 며칠 뒤 앱을 열 때 엉뚱하게 메일 화면으로 가지 않게.\r\nconst PENDING_MAIL_MAX_AGE_MS = 10 * 60 * 1000;\r\n\r\n// deviceId 저장 — 같은 IndexedDB 의 별도 store 사용 (XSS 방어 + 기존 인프라 재사용).\r\n// DB 버전은 2 로 bump — v1 에서 pendingNavigation 만 있던 상태에서 deviceInfo store 추가.\r\nconst DEVICE_STORE_NAME = 'deviceInfo';\r\nconst DEVICE_ID_KEY = 'device-id';\r\nconst DEVICE_ID_DB_VERSION = 2;\r\n// 기존 localStorage 에 저장된 deviceId 를 IndexedDB 로 옮긴 뒤 제거하기 위한 legacy key.\r\nconst LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY = 'talkflow_device_id';\r\n\r\nfunction buildPendingNavigationKey(projectId) {\r\n if (projectId && typeof projectId === 'string' && projectId.trim() !== '') {\r\n return PENDING_NAVIGATION_KEY_PREFIX + ':' + projectId;\r\n }\r\n return PENDING_NAVIGATION_LEGACY_KEY;\r\n}\r\n\r\nfunction openPendingNavigationDb() {\r\n if (typeof indexedDB === 'undefined') {\r\n return Promise.resolve(null);\r\n }\r\n\r\n return new Promise((resolve, reject) => {\r\n const request = indexedDB.open(PENDING_NAVIGATION_DB_NAME, DEVICE_ID_DB_VERSION);\r\n\r\n request.onupgradeneeded = () => {\r\n const db = request.result;\r\n // v1 → v2: deviceInfo store 추가.\r\n // 기존 pendingNavigation store 는 그대로 유지 (contains 체크로 멱등).\r\n if (!db.objectStoreNames.contains(PENDING_NAVIGATION_STORE_NAME)) {\r\n db.createObjectStore(PENDING_NAVIGATION_STORE_NAME, { keyPath: 'id' });\r\n }\r\n if (!db.objectStoreNames.contains(DEVICE_STORE_NAME)) {\r\n db.createObjectStore(DEVICE_STORE_NAME, { keyPath: 'id' });\r\n }\r\n };\r\n\r\n request.onsuccess = () => resolve(request.result);\r\n request.onerror = () => reject(request.error || new Error('IndexedDB open failed'));\r\n });\r\n}\r\n\r\n/**\r\n * IndexedDB 에서 deviceId 를 읽는다.\r\n * @returns {Promise<string|null>} 저장된 deviceId, 없으면 null. IndexedDB 사용 불가 시 null.\r\n */\r\nfunction readDeviceIdFromIndexedDb() {\r\n return openPendingNavigationDb().then(db => {\r\n if (!db) return null;\r\n return new Promise((resolve, reject) => {\r\n const tx = db.transaction(DEVICE_STORE_NAME, 'readonly');\r\n const store = tx.objectStore(DEVICE_STORE_NAME);\r\n const request = store.get(DEVICE_ID_KEY);\r\n request.onsuccess = () => {\r\n const record = request.result;\r\n resolve(record && record.value ? record.value : null);\r\n };\r\n request.onerror = () => reject(request.error || new Error('IndexedDB read failed'));\r\n tx.oncomplete = () => db.close();\r\n tx.onerror = () => db.close();\r\n tx.onabort = () => db.close();\r\n });\r\n });\r\n}\r\n\r\n/**\r\n * IndexedDB 에 deviceId 를 저장한다.\r\n *\r\n * <p><b>반환값 의미</b>:</p>\r\n * <ul>\r\n * <li>{@code true} — transaction 이 complete 까지 성공적으로 완료됨.</li>\r\n * <li>{@code false} — open / transaction 실패, IDB 사용 불가, 기타 예외.</li>\r\n * </ul>\r\n *\r\n * <p>호출자는 반환값이 {@code true} 일 때만 \"영속성 확보\" 로 간주해야 한다.\r\n * 특히 legacy localStorage 정리는 read-back 검증까지 거친 뒤 수행.</p>\r\n *\r\n * @param {string} deviceId\r\n * @returns {Promise<boolean>}\r\n */\r\nfunction writeDeviceIdToIndexedDb(deviceId) {\r\n return openPendingNavigationDb().then(db => {\r\n if (!db) return false;\r\n return new Promise(resolve => {\r\n try {\r\n const tx = db.transaction(DEVICE_STORE_NAME, 'readwrite');\r\n tx.objectStore(DEVICE_STORE_NAME).put({ id: DEVICE_ID_KEY, value: deviceId });\r\n tx.oncomplete = () => { db.close(); resolve(true); };\r\n tx.onerror = () => { db.close(); resolve(false); };\r\n tx.onabort = () => { db.close(); resolve(false); };\r\n } catch (e) {\r\n try { db.close(); } catch (_) { /* ignore */ }\r\n resolve(false);\r\n }\r\n });\r\n }).catch(() => false);\r\n}\r\n\r\n/**\r\n * 신규 deviceId 생성.\r\n */\r\nfunction generateDeviceId() {\r\n const random = (typeof crypto !== 'undefined' && crypto.randomUUID)\r\n ? crypto.randomUUID()\r\n : Date.now().toString(36) + Math.random().toString(36).substring(2);\r\n return 'web-' + random;\r\n}\r\n\r\n/**\r\n * SW 가 저장한 메일 이동(알림 클릭 → 열린 창 없음)을 꺼내고 지운다. 같은 프로젝트·10분 안의 것만.\r\n *\r\n * @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}\r\n */\r\nasync function consumePendingMailFromIndexedDB(projectId) {\r\n if (!projectId || typeof projectId !== 'string' || projectId.trim() === '') {\r\n return null;\r\n }\r\n const db = await openPendingNavigationDb();\r\n if (!db) {\r\n return null;\r\n }\r\n const key = PENDING_MAIL_KEY_PREFIX + ':' + projectId;\r\n return new Promise((resolve, reject) => {\r\n const transaction = db.transaction(PENDING_NAVIGATION_STORE_NAME, 'readwrite');\r\n const store = transaction.objectStore(PENDING_NAVIGATION_STORE_NAME);\r\n let pending = null;\r\n const request = store.get(key);\r\n request.onsuccess = () => {\r\n const record = request.result;\r\n if (!record) {\r\n return;\r\n }\r\n store.delete(key);\r\n const fresh = typeof record.savedAt === 'number' && Date.now() - record.savedAt <= PENDING_MAIL_MAX_AGE_MS;\r\n if (fresh && record.projectId === projectId && record.connectionId) {\r\n pending = { connectionId: record.connectionId, messageId: record.messageId || null };\r\n }\r\n };\r\n request.onerror = () => {\r\n reject(request.error || new Error('IndexedDB read failed'));\r\n };\r\n transaction.oncomplete = () => {\r\n db.close();\r\n resolve(pending);\r\n };\r\n transaction.onerror = () => {\r\n db.close();\r\n reject(transaction.error || new Error('IndexedDB transaction failed'));\r\n };\r\n transaction.onabort = () => {\r\n db.close();\r\n reject(transaction.error || new Error('IndexedDB transaction aborted'));\r\n };\r\n });\r\n}\r\n\r\nasync function consumePendingRoomFromIndexedDB(projectId) {\r\n const db = await openPendingNavigationDb();\r\n if (!db) {\r\n return null;\r\n }\r\n\r\n // 1차: projectId 별 key 를 조회한다.\r\n // 2차 (mixed rollout 호환): projectId 가 있지만 1차 miss 인 경우,\r\n // legacy 전역 key 도 fallback 으로 조회한다. 서버 배포 전 또는 구버전 SW 에서 발생한\r\n // projectId 없는 payload 는 legacy key (pending-room) 에 저장되어 있을 수 있다.\r\n // legacy record 는 반드시 record.projectId 가 비어있어야 안전하게 소비한다 —\r\n // 다른 프로젝트의 값이 섞일 여지를 원천 차단.\r\n const primaryKey = buildPendingNavigationKey(projectId);\r\n const shouldTryLegacyFallback = !!projectId && primaryKey !== PENDING_NAVIGATION_LEGACY_KEY;\r\n\r\n return new Promise((resolve, reject) => {\r\n const transaction = db.transaction(PENDING_NAVIGATION_STORE_NAME, 'readwrite');\r\n const store = transaction.objectStore(PENDING_NAVIGATION_STORE_NAME);\r\n\r\n let pendingRoomId = null;\r\n let finalizedKey = null;\r\n\r\n const primaryRequest = store.get(primaryKey);\r\n\r\n primaryRequest.onsuccess = () => {\r\n const record = primaryRequest.result;\r\n // 저장된 record 의 projectId 와 요청한 projectId 가 일치할 때만 소비.\r\n // key 분리가 정상 작동하면 이 체크는 리던던트이지만, 레거시 전역 key 로 저장된\r\n // 레코드를 다른 프로젝트가 실수로 소비하는 것을 막기 위한 방어.\r\n if (record && record.roomId) {\r\n const recordProjectId = record.projectId || null;\r\n const requestedProjectId = projectId || null;\r\n if (recordProjectId === requestedProjectId) {\r\n pendingRoomId = record.roomId;\r\n finalizedKey = primaryKey;\r\n store.delete(primaryKey);\r\n return;\r\n }\r\n }\r\n\r\n // 1차 miss — legacy fallback 시도 (mixed rollout 호환 경로).\r\n if (!shouldTryLegacyFallback) {\r\n return;\r\n }\r\n\r\n const legacyRequest = store.get(PENDING_NAVIGATION_LEGACY_KEY);\r\n legacyRequest.onsuccess = () => {\r\n const legacyRecord = legacyRequest.result;\r\n // legacy record 는 projectId 가 null/undefined 인 경우에만 안전하게 소비.\r\n // SW v1 또는 구버전 서버가 projectId 없이 저장한 경우에 해당한다.\r\n if (\r\n legacyRecord &&\r\n legacyRecord.roomId &&\r\n !legacyRecord.projectId\r\n ) {\r\n pendingRoomId = legacyRecord.roomId;\r\n finalizedKey = PENDING_NAVIGATION_LEGACY_KEY;\r\n store.delete(PENDING_NAVIGATION_LEGACY_KEY);\r\n }\r\n };\r\n // legacyRequest.onerror 는 별도 처리하지 않는다 — primary 가 성공했다면 transaction 은\r\n // 이미 유효하고, 실패하면 transaction.onerror 로 넘어간다.\r\n };\r\n\r\n primaryRequest.onerror = () => {\r\n reject(primaryRequest.error || new Error('IndexedDB read failed'));\r\n };\r\n\r\n transaction.oncomplete = () => {\r\n db.close();\r\n // finalizedKey 를 쓰진 않지만, 추후 디버깅/로그 확장 시 어느 key 를 소비했는지 구분 가능.\r\n void finalizedKey;\r\n resolve(pendingRoomId);\r\n };\r\n\r\n transaction.onerror = () => {\r\n db.close();\r\n reject(transaction.error || new Error('IndexedDB transaction failed'));\r\n };\r\n\r\n transaction.onabort = () => {\r\n db.close();\r\n reject(transaction.error || new Error('IndexedDB transaction aborted'));\r\n };\r\n });\r\n}\r\n\r\nclass PushManager {\r\n /**\r\n * @param {Object} options\r\n * @param {Object} options.apiClient - ApiClient 인스턴스\r\n * @param {string} [options.projectId] - 프로젝트 ID (멀티테넌트 환경에서 알림 라우팅 격리용)\r\n * @param {Object} [options.firebaseConfig] - Firebase 설정 (기본: TalkFlow 설정)\r\n * @param {string} [options.vapidKey] - VAPID Key (기본: TalkFlow 키)\r\n * @param {string} [options.serviceWorkerPath] - 서비스 워커 경로 (기본: '/firebase-messaging-sw.js')\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options) {\r\n this.apiClient = options.apiClient;\r\n this.projectId = options.projectId || null;\r\n this.firebaseConfig = options.firebaseConfig || DEFAULT_FIREBASE_CONFIG;\r\n this.vapidKey = options.vapidKey || DEFAULT_VAPID_KEY;\r\n this.serviceWorkerPath = options.serviceWorkerPath || '/firebase-messaging-sw.js';\r\n this.logger = new Logger(options.logLevel || LogLevel.WARN, 'PushManager');\r\n\r\n this._messaging = null;\r\n this._currentToken = null;\r\n this._enabled = false;\r\n this._enablePromise = null;\r\n this._foregroundMessageUnsubscribe = null;\r\n this._swRegistration = null;\r\n this._projectRegisteredToSW = false;\r\n this._visibilityHandler = null;\r\n this._deviceIdCache = null; // IndexedDB 조회 결과 메모리 캐시 (세션 단위)\r\n }\r\n\r\n /**\r\n * 저장된 pending roomId 를 소비한다.\r\n *\r\n * @param {string} [projectId] - 소비할 projectId. 지정 시 해당 projectId 로 저장된 pending 만 소비.\r\n * 미지정 시 레거시 전역 key 만 조회 (구버전 호환).\r\n * @returns {Promise<string|null>}\r\n */\r\n static async consumePendingRoom(projectId) {\r\n return consumePendingRoomFromIndexedDB(projectId);\r\n }\r\n\r\n /**\r\n * 서비스 워커가 cold start 시 저장한 메일 이동(Gmail 메일 알림 클릭)을 꺼내고 지운다.\r\n * 앱 부팅 때 호출해 메일 화면으로 라우팅한다 — {@code messageId} 가 있으면 그 메일 상세, 없으면(묶음 알림) 받은메일함.\r\n *\r\n * @param {string} projectId\r\n * @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}\r\n */\r\n static async consumePendingMail(projectId) {\r\n return consumePendingMailFromIndexedDB(projectId);\r\n }\r\n\r\n /**\r\n * 현재 브라우저의 푸시 권한 상태를 조회한다.\r\n *\r\n * <p>{@link PushManager} 인스턴스가 없어도 (즉, {@code enablePushNotifications()} 호출 전) 호출 가능하다.\r\n * 호출자가 enable 호출 <b>전에</b> UI 분기를 결정할 때 사용한다.</p>\r\n *\r\n * <p>반환값:</p>\r\n * <ul>\r\n * <li>{@code 'granted'} — 이미 허용. enable 호출 시 권한창 안 뜨고 토큰 발급으로 즉시 진행.</li>\r\n * <li>{@code 'default'} — 아직 묻지 않음. enable 호출 시 권한창 표시.</li>\r\n * <li>{@code 'denied'} — 거부됨. enable 호출 시 즉시 PERMISSION_DENIED 로 실패 (브라우저 정책상 권한창 안 뜸).</li>\r\n * <li>{@code 'unsupported'} — Notification 또는 Service Worker 미지원 환경 (Safari iOS 등).</li>\r\n * </ul>\r\n *\r\n * @returns {'granted'|'denied'|'default'|'unsupported'}\r\n */\r\n static getPermissionState() {\r\n if (typeof window === 'undefined' || typeof Notification === 'undefined') {\r\n return 'unsupported';\r\n }\r\n if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {\r\n return 'unsupported';\r\n }\r\n return Notification.permission; // 'granted' | 'denied' | 'default'\r\n }\r\n\r\n /**\r\n * 푸시 알림 활성화.\r\n * Firebase 초기화 → 서비스 워커 등록 → 권한 요청 → 토큰 획득 → 서버 등록.\r\n *\r\n * <p>토큰은 SDK 내부에서만 관리되므로 호출자는 반환값을 받을 필요가 없습니다.\r\n * 상태 확인은 {@link #isEnabled} 로, 에러는 throw 로 전달됩니다.</p>\r\n *\r\n * @returns {Promise<void>}\r\n * @throws {Error} 브라우저 미지원, 권한 거부, Firebase 미설치 등\r\n */\r\n async enable() {\r\n if (this._enabled) {\r\n this.logger.debug('푸시 알림이 이미 활성화되어 있습니다.');\r\n return;\r\n }\r\n\r\n if (this._enablePromise) {\r\n this.logger.debug('푸시 알림 활성화가 이미 진행 중입니다.');\r\n return this._enablePromise;\r\n }\r\n\r\n this._enablePromise = this._enableInternal();\r\n\r\n try {\r\n return await this._enablePromise;\r\n } finally {\r\n this._enablePromise = null;\r\n }\r\n }\r\n\r\n async _enableInternal() {\r\n // 브라우저 환경 체크\r\n if (typeof window === 'undefined' || !('Notification' in window)) {\r\n throw new PushError(\r\n PushErrorCode.UNSUPPORTED_BROWSER,\r\n '푸시 알림은 브라우저 환경에서만 사용 가능합니다'\r\n );\r\n }\r\n\r\n if (!('serviceWorker' in navigator)) {\r\n throw new PushError(\r\n PushErrorCode.UNSUPPORTED_BROWSER,\r\n '이 브라우저는 서비스 워커를 지원하지 않습니다'\r\n );\r\n }\r\n\r\n // 권한이 이미 거부된 상태면 requestPermission() 은 즉시 'denied' 를 반환하고\r\n // 권한창은 뜨지 않는다. 명확한 분류 에러로 즉시 throw 하여 호출자가\r\n // \"브라우저 설정에서 풀어달라\" 가이드를 띄울 수 있도록 한다.\r\n if (Notification.permission === 'denied') {\r\n throw new PushError(\r\n PushErrorCode.PERMISSION_DENIED,\r\n '알림 권한이 이미 거부되어 있습니다. 브라우저 사이트 설정에서 허용해주세요.'\r\n );\r\n }\r\n\r\n // Firebase 동적 로드\r\n let firebaseApp, firebaseMessaging;\r\n try {\r\n firebaseApp = await import('firebase/app');\r\n firebaseMessaging = await import('firebase/messaging');\r\n } catch (e) {\r\n throw new PushError(\r\n PushErrorCode.FIREBASE_NOT_INSTALLED,\r\n 'firebase 패키지가 설치되지 않았습니다. npm install firebase 를 실행하세요.',\r\n e\r\n );\r\n }\r\n\r\n // Firebase 초기화 (이미 초기화된 경우 기존 앱 사용)\r\n // 고객 앱의 기존 Firebase와 충돌 방지 — named app으로 분리\r\n let app;\r\n try {\r\n app = firebaseApp.getApp('talkflow');\r\n } catch {\r\n app = firebaseApp.initializeApp(this.firebaseConfig, 'talkflow');\r\n }\r\n\r\n // 서비스 워커 등록\r\n const registration = await this._registerServiceWorker();\r\n\r\n // 알림 권한 요청\r\n // - 'default' 상태: 브라우저가 권한창을 표시하고 사용자 응답을 대기\r\n // - 'granted' 상태: 재요청하지 않고 즉시 통과 (아래 주의사항 참고)\r\n // - 'denied' 상태: 위 가드에서 이미 throw 했으므로 여기 도달 불가\r\n //\r\n // !! Safari/WebKit 주의 !!\r\n // Chromium 은 이미 granted 인 상태에서 requestPermission() 을 호출하면 창 없이 즉시\r\n // 'granted' 를 반환하지만, Safari/WebKit 은 이 API 를 '사용자 제스처 안에서만' 허용한다.\r\n // 따라서 연결 직후 자동 활성화처럼 제스처가 없는 경로에서는 이미 허용된 상태여도 거부로 반환되어\r\n // PERMISSION_DENIED 로 throw 되고, 그 아래의 토큰 발급/서버 등록/포그라운드 onMessage 등록까지\r\n // 모두 건너뛰게 된다(iOS 홈 화면 앱에서 새로고침마다 권한 차단 배너 + 포그라운드 메시지 미수신).\r\n // 이미 허용된 상태에서는 재요청 자체가 불필요하므로 호출을 건너뛴다.\r\n const permission =\r\n Notification.permission === 'granted'\r\n ? 'granted'\r\n : await Notification.requestPermission();\r\n if (permission !== 'granted') {\r\n throw new PushError(\r\n PushErrorCode.PERMISSION_DENIED,\r\n '알림 권한이 거부되었습니다. 브라우저 설정에서 허용해주세요.'\r\n );\r\n }\r\n\r\n // FCM 토큰 획득\r\n this._messaging = firebaseMessaging.getMessaging(app);\r\n\r\n const tokenOptions = { serviceWorkerRegistration: registration };\r\n if (this.vapidKey) {\r\n tokenOptions.vapidKey = this.vapidKey;\r\n }\r\n\r\n try {\r\n this._currentToken = await firebaseMessaging.getToken(this._messaging, tokenOptions);\r\n } catch (e) {\r\n throw new PushError(\r\n PushErrorCode.TOKEN_FAILED,\r\n 'FCM 토큰 획득 중 오류 발생: ' + (e?.message || e),\r\n e\r\n );\r\n }\r\n\r\n if (!this._currentToken) {\r\n throw new PushError(\r\n PushErrorCode.TOKEN_FAILED,\r\n 'FCM 토큰 획득 실패 (응답이 비어있음)'\r\n );\r\n }\r\n\r\n // 서버에 토큰 등록\r\n await this._registerTokenToServer(this._currentToken);\r\n\r\n // 포그라운드 메시지 수신 핸들러\r\n if (this._foregroundMessageUnsubscribe) {\r\n this._foregroundMessageUnsubscribe();\r\n }\r\n\r\n this._foregroundMessageUnsubscribe = firebaseMessaging.onMessage(this._messaging, (payload) => {\r\n this.logger.debug('포그라운드 푸시 수신:', payload);\r\n this._onForegroundMessage(payload);\r\n });\r\n\r\n // SW 등록 객체를 보관 — 이후 projectId 재등록에 사용한다.\r\n this._swRegistration = registration;\r\n\r\n // SW 에 projectId 등록.\r\n // 멀티테넌트 환경에서 notificationclick 시 SW 가 \"어느 창이 어느 project 인지\"를 알아야\r\n // 다른 프로젝트 창에 NAVIGATE_TO_ROOM 을 보내지 않는다.\r\n await this._registerProjectToSW();\r\n\r\n // SW terminate 로 Map 이 소실될 수 있으므로 visibility 복귀 시 재등록.\r\n this._installVisibilityReRegister();\r\n\r\n // SW 업데이트 체크 (CDN handler 갱신 전파)\r\n try {\r\n await registration.update();\r\n } catch (e) {\r\n this.logger.debug('SW 업데이트 체크 실패 (무시):', e.message);\r\n }\r\n\r\n // SW 버전 감지 — 구버전 경고 용도로만 사용.\r\n // 이 값으로 서버/SDK 가 기능 분기(image 지원 여부 등)를 판단하면 안 된다.\r\n const swVersion = await this._getSWVersion(registration);\r\n if (!swVersion) {\r\n this.logger.warn(\r\n '[TalkFlow] 서비스 워커가 구버전(v1)입니다. ' +\r\n '이미지 미리보기 등 리치 알림 기능을 사용하려면 firebase-messaging-sw.js 를 ' +\r\n 'v2 로 업데이트하세요. 가이드: https://docs.talkflow.ai/push/sw-upgrade'\r\n );\r\n } else {\r\n this.logger.debug('SW 버전:', swVersion);\r\n }\r\n\r\n this._enabled = true;\r\n this.logger.info('푸시 알림 활성화 완료');\r\n }\r\n\r\n /**\r\n * 서비스 워커 등록.\r\n * updateViaCache: 'none' 으로 등록하여 registration.update() 시\r\n * importScripts 로 로드하는 CDN handler 까지 캐시 없이 갱신되도록 한다.\r\n * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register\r\n * @private\r\n */\r\n async _registerServiceWorker() {\r\n try {\r\n const registration = await navigator.serviceWorker.register(\r\n this.serviceWorkerPath,\r\n { updateViaCache: 'none' }\r\n );\r\n this.logger.debug('서비스 워커 등록 완료:', this.serviceWorkerPath);\r\n return registration;\r\n } catch (error) {\r\n throw new PushError(\r\n PushErrorCode.SW_REGISTER_FAILED,\r\n `서비스 워커 등록 실패: ${this.serviceWorkerPath}\\n` +\r\n '프로젝트 public 폴더에 firebase-messaging-sw.js 파일을 배치하세요.',\r\n error\r\n );\r\n }\r\n }\r\n\r\n /**\r\n * 서버에 디바이스 토큰 등록\r\n * @private\r\n */\r\n async _registerTokenToServer(token) {\r\n try {\r\n const deviceId = await this._getDeviceId();\r\n this.apiClient?.authProvider?.setDeviceId(deviceId);\r\n await this.apiClient.post('/api/v1/push/devices', {\r\n deviceToken: token,\r\n deviceType: 'WEB',\r\n deviceId\r\n });\r\n this.logger.info('디바이스 토큰 서버 등록 완료');\r\n } catch (error) {\r\n this.logger.error('디바이스 토큰 서버 등록 실패:', error);\r\n // PushError 가 아닌 경우 (네트워크 / 서버 5xx 등) SERVER_REGISTER_FAILED 로 wrap.\r\n // 이미 PushError 면 그대로 전파 (분류 보존).\r\n if (error instanceof PushError) {\r\n throw error;\r\n }\r\n throw new PushError(\r\n PushErrorCode.SERVER_REGISTER_FAILED,\r\n '디바이스 토큰 서버 등록 실패: ' + (error?.message || error),\r\n error\r\n );\r\n }\r\n }\r\n\r\n /**\r\n * 브라우저 고유 식별자 조회/생성 (IndexedDB 기반).\r\n *\r\n * <p>순서:</p>\r\n * <ol>\r\n * <li>IndexedDB 에서 기존 deviceId 조회</li>\r\n * <li>없으면 localStorage 에 있는 legacy 값 있는지 확인 (기존 SDK 버전 설치 이력)</li>\r\n * <li>legacy 있으면 IndexedDB 로 이관 + localStorage 키 제거</li>\r\n * <li>둘 다 없으면 신규 생성 + IndexedDB 저장</li>\r\n * </ol>\r\n *\r\n * <p>IndexedDB 사용 불가 환경 (private mode 등) 에서는 in-memory fallback — 세션 단위\r\n * 새 deviceId 사용. 완벽한 영속성은 없지만 기본 기능은 유지.</p>\r\n *\r\n * @private\r\n * @returns {Promise<string>}\r\n */\r\n async _getDeviceId() {\r\n // 메모리 캐시 — 동일 세션 내 반복 호출 방지.\r\n if (this._deviceIdCache) {\r\n return this._deviceIdCache;\r\n }\r\n\r\n try {\r\n // 1) IndexedDB 에서 조회\r\n let deviceId = await readDeviceIdFromIndexedDb();\r\n\r\n // 2) 없으면 localStorage legacy 값 확인 + 이관\r\n if (!deviceId && typeof localStorage !== 'undefined') {\r\n const legacy = localStorage.getItem(LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY);\r\n if (legacy) {\r\n deviceId = legacy;\r\n // IDB 저장 성공 + read-back 검증 둘 다 통과한 경우에만 localStorage 정리.\r\n // 실패하거나 검증 miss 면 legacy 값 보존해 다음 방문에 재시도 가능.\r\n const saved = await writeDeviceIdToIndexedDb(deviceId);\r\n if (saved) {\r\n const readBack = await readDeviceIdFromIndexedDb();\r\n if (readBack === deviceId) {\r\n try {\r\n localStorage.removeItem(LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY);\r\n this.logger.debug('deviceId migrated from localStorage to IndexedDB');\r\n } catch (_) { /* quota/private mode 예외 무시 — legacy 남아도 다음에 재이관 시도 */ }\r\n } else {\r\n // 저장 tx 는 complete 됐는데 read-back 은 다르게 나오는 극단 케이스.\r\n // localStorage 보존 — 다음 방문에 재시도.\r\n this.logger.warn('deviceId IDB read-back 불일치, localStorage 보존');\r\n }\r\n } else {\r\n this.logger.warn('deviceId IDB 저장 실패 (private mode / quota 등), localStorage 보존');\r\n }\r\n }\r\n }\r\n\r\n // 3) 그래도 없으면 신규 생성 + 저장 시도.\r\n // 저장 실패해도 in-memory 캐시로 세션 단위 유효하므로 푸시 등록은 진행됨.\r\n // (영속화 실패는 다음 방문에 새 deviceId 생성 → 서버 쪽 device 매핑 갱신은 불가피)\r\n if (!deviceId) {\r\n deviceId = generateDeviceId();\r\n const saved = await writeDeviceIdToIndexedDb(deviceId);\r\n if (!saved) {\r\n this.logger.warn('신규 deviceId IDB 저장 실패, 이번 세션만 유효');\r\n }\r\n }\r\n\r\n this._deviceIdCache = deviceId;\r\n this.apiClient?.authProvider?.setDeviceId(deviceId);\r\n return deviceId;\r\n } catch (error) {\r\n // IndexedDB 장애 (open / read / transaction throw 등) 시 fallback.\r\n // IDB 가 현재 실패하므로 localStorage 를 삭제하지 않는다 — 다음 방문에 IDB 회복되면\r\n // try 블록의 이관 경로가 다시 시도됨.\r\n this.logger.warn('IndexedDB deviceId 조회/저장 실패, fallback:', error);\r\n if (!this._deviceIdCache) {\r\n // 1) localStorage legacy 우선 확인 — 기존 사용자 device 연속성 유지.\r\n // IDB 가 실패해도 localStorage 가 읽히면 기존 deviceId 재사용.\r\n let fallbackId = null;\r\n if (typeof localStorage !== 'undefined') {\r\n try {\r\n fallbackId = localStorage.getItem(LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY);\r\n } catch (_) { /* private mode 등 — localStorage 도 접근 불가 */ }\r\n }\r\n // 2) legacy 도 없으면 신규 생성 (in-memory 단일 세션).\r\n this._deviceIdCache = fallbackId || generateDeviceId();\r\n }\r\n return this._deviceIdCache;\r\n }\r\n }\r\n\r\n /**\r\n * 포그라운드 메시지 수신 콜백.\r\n * TalkFlowClient에서 오버라이드하여 activeRoom 기반 suppress 처리.\r\n * @param {Object} payload - FCM 메시지 페이로드\r\n */\r\n _onForegroundMessage(payload) {\r\n // TalkFlowClient에서 오버라이드\r\n }\r\n\r\n /**\r\n * 서비스 워커가 cold start 시 저장한 pending roomId 를 소비하고 삭제한다.\r\n * 앱 부팅 시 호출하여 알림 클릭으로 열려야 할 방으로 라우팅할 때 사용한다.\r\n *\r\n * <p>내부 projectId 가 설정되어 있으면 해당 프로젝트의 pending 만 소비한다.\r\n * 다른 프로젝트의 pending 은 건드리지 않아 그 프로젝트가 나중에 꺼낼 수 있다.</p>\r\n *\r\n * @returns {Promise<string|null>} pending roomId 또는 null\r\n */\r\n async consumePendingRoom() {\r\n return PushManager.consumePendingRoom(this.projectId);\r\n }\r\n\r\n /**\r\n * 서비스 워커가 cold start 시 저장한 메일 이동을 꺼내고 지운다 — {@link PushManager.consumePendingMail}.\r\n *\r\n * @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}\r\n */\r\n async consumePendingMail() {\r\n return PushManager.consumePendingMail(this.projectId);\r\n }\r\n\r\n /**\r\n * 현재 푸시 상태를 정리한다.\r\n * 로그아웃 또는 다른 사용자로 전환할 때 foreground listener 와 활성화 상태를 초기화한다.\r\n * SW 에 등록된 projectId 도 해제한다.\r\n */\r\n reset() {\r\n if (this._foregroundMessageUnsubscribe) {\r\n try {\r\n this._foregroundMessageUnsubscribe();\r\n } catch (error) {\r\n this.logger.debug('포그라운드 푸시 리스너 해제 실패 (무시):', error?.message || error);\r\n }\r\n }\r\n\r\n // visibility 재등록 핸들러 제거 — reset 이후 새 enable 전까지 자동 재등록이 일어나지 않도록.\r\n this._uninstallVisibilityReRegister();\r\n\r\n // SW 의 projectId 등록도 해제 (best-effort).\r\n this._unregisterProjectFromSW();\r\n\r\n this._foregroundMessageUnsubscribe = null;\r\n this._enablePromise = null;\r\n this._messaging = null;\r\n this._currentToken = null;\r\n this._enabled = false;\r\n this._swRegistration = null;\r\n this._projectRegisteredToSW = false;\r\n }\r\n\r\n /**\r\n * 활성 SW 에 현재 projectId 를 등록한다.\r\n * SW 는 Client.id → projectId Map 을 유지해 notificationclick 시 올바른 창을 선택한다.\r\n * @private\r\n */\r\n async _registerProjectToSW() {\r\n if (!this.projectId) {\r\n return;\r\n }\r\n try {\r\n const readyRegistration = await navigator.serviceWorker.ready;\r\n const sw =\r\n readyRegistration.active ||\r\n (this._swRegistration && this._swRegistration.active);\r\n if (!sw) {\r\n this.logger.debug('SW active worker 없음 — projectId 등록 스킵');\r\n return;\r\n }\r\n sw.postMessage({ type: 'TALKFLOW_REGISTER_PROJECT', projectId: this.projectId });\r\n this._projectRegisteredToSW = true;\r\n } catch (error) {\r\n this.logger.debug('SW projectId 등록 실패 (무시):', error?.message || error);\r\n }\r\n }\r\n\r\n /**\r\n * SW 에 등록된 projectId 해제 (best-effort).\r\n * reset 시 호출되며, navigator.serviceWorker 접근이 실패해도 예외를 삼킨다.\r\n * @private\r\n */\r\n _unregisterProjectFromSW() {\r\n if (!this._projectRegisteredToSW) {\r\n return;\r\n }\r\n try {\r\n if (typeof navigator === 'undefined' || !navigator.serviceWorker) {\r\n return;\r\n }\r\n const controller = navigator.serviceWorker.controller;\r\n if (!controller) {\r\n return;\r\n }\r\n controller.postMessage({ type: 'TALKFLOW_UNREGISTER_PROJECT' });\r\n } catch (error) {\r\n this.logger.debug('SW projectId 해제 실패 (무시):', error?.message || error);\r\n }\r\n }\r\n\r\n /**\r\n * visibility 복귀 시 SW 에 projectId 를 재등록한다.\r\n * SW 는 idle 시 terminate 되며 이때 clientProjectMap 이 소실되므로,\r\n * 창이 다시 visible 상태가 될 때 방어적으로 재등록한다.\r\n * @private\r\n */\r\n _installVisibilityReRegister() {\r\n if (typeof document === 'undefined' || this._visibilityHandler) {\r\n return;\r\n }\r\n this._visibilityHandler = () => {\r\n if (document.visibilityState === 'visible' && this._enabled && this.projectId) {\r\n // best-effort — 재등록 실패는 다음 visibility 이벤트에 다시 시도된다.\r\n this._registerProjectToSW().catch(() => {});\r\n }\r\n };\r\n document.addEventListener('visibilitychange', this._visibilityHandler);\r\n }\r\n\r\n /**\r\n * @private\r\n */\r\n _uninstallVisibilityReRegister() {\r\n if (typeof document === 'undefined' || !this._visibilityHandler) {\r\n return;\r\n }\r\n try {\r\n document.removeEventListener('visibilitychange', this._visibilityHandler);\r\n } catch (error) {\r\n // 무시\r\n }\r\n this._visibilityHandler = null;\r\n }\r\n\r\n // noinspection JSUnusedGlobalSymbols\r\n /**\r\n * 현재 FCM 토큰\r\n * @returns {string|null}\r\n */\r\n getToken() {\r\n return this._currentToken;\r\n }\r\n\r\n /**\r\n * 푸시 활성화 여부\r\n * @returns {boolean}\r\n */\r\n isEnabled() {\r\n return this._enabled;\r\n }\r\n\r\n /**\r\n * 현재 사용자의 등록된 디바이스 목록 조회 (푸시 on/off UI 용).\r\n *\r\n * <p>서버가 내려주는 필드: {@code deviceId}, {@code deviceType}, {@code enabled}, {@code createdAt}.\r\n * {@code deviceToken} 은 보안상 응답에 포함되지 않는다.</p>\r\n *\r\n * @returns {Promise<Array<{deviceId: string, deviceType: string, enabled: boolean, createdAt: string}>>}\r\n */\r\n async getMyDevices() {\r\n const response = await this.apiClient.get('/api/v1/push/devices/me');\r\n return response && typeof response === 'object' && 'data' in response\r\n ? response.data\r\n : response;\r\n }\r\n\r\n /**\r\n * 특정 디바이스의 푸시 수신 여부 토글.\r\n *\r\n * <p>본인 소유 디바이스만 수정 가능 (서버가 {@code (projectId, userId, deviceId)} 조합으로 검증).\r\n * 소유자 불일치 또는 미등록 디바이스 시 서버가 404 로 응답 → 호출자에 throw.</p>\r\n *\r\n * @param {string} deviceId - 대상 디바이스 ID (현재 디바이스면 {@link #setCurrentDeviceEnabled} 사용 권장)\r\n * @param {boolean} enabled - true 면 수신, false 면 수신 중단\r\n * @returns {Promise<void>}\r\n */\r\n async setDeviceEnabled(deviceId, enabled) {\r\n await this.apiClient.patch('/api/v1/push/devices/me/enabled', {\r\n deviceId,\r\n enabled\r\n });\r\n this.logger.info(`디바이스 푸시 enabled=${enabled}: deviceId=${deviceId}`);\r\n }\r\n\r\n /**\r\n * 현재 디바이스의 푸시 수신 여부 토글 (편의 메서드).\r\n *\r\n * <p>SDK 가 관리하는 현재 브라우저의 deviceId 로 {@link #setDeviceEnabled} 를 호출한다.\r\n * {@link #enable} 이 선행되지 않아도 호출 가능 — 서버에 디바이스가 등록돼 있으면 동작.</p>\r\n *\r\n * @param {boolean} enabled\r\n * @returns {Promise<void>}\r\n */\r\n async setCurrentDeviceEnabled(enabled) {\r\n const deviceId = await this._getDeviceId();\r\n await this.setDeviceEnabled(deviceId, enabled);\r\n }\r\n\r\n /**\r\n * 등록된 서비스 워커의 TalkFlow 버전을 조회.\r\n *\r\n * navigator.serviceWorker.ready 를 기다려 active worker 가 보장된 상태에서,\r\n * readyRegistration → register() 반환 registration 양쪽의\r\n * waiting → installing → active 순으로 SW 를 찾는다.\r\n * v1→v2 교체 직후 새 SW 가 waiting/installing 상태에 있을 때\r\n * 구 active(v1) 대신 신규(v2)를 우선 감지하여 거짓 경고를 방지한다.\r\n * 같은 scope 에서는 두 객체가 동일 참조이지만, 타이밍 차이에 대한 방어 코드.\r\n *\r\n * v1(구버전) SW 는 메시지 핸들러가 없으므로 2초 timeout 후 null 반환.\r\n *\r\n * @param {ServiceWorkerRegistration} registration - register() 가 반환한 등록 객체\r\n * @returns {Promise<string|null>} 버전 문자열 또는 null (구버전/미응답)\r\n * @private\r\n */\r\n async _getSWVersion(registration) {\r\n try {\r\n const readyRegistration = await navigator.serviceWorker.ready;\r\n\r\n const sw =\r\n readyRegistration.waiting ||\r\n readyRegistration.installing ||\r\n readyRegistration.active ||\r\n registration.waiting ||\r\n registration.installing ||\r\n registration.active;\r\n if (!sw) return null;\r\n\r\n return new Promise((resolve) => {\r\n const timeout = setTimeout(() => resolve(null), 2000);\r\n\r\n const channel = new MessageChannel();\r\n channel.port1.onmessage = (event) => {\r\n clearTimeout(timeout);\r\n resolve(event.data?.version || null);\r\n };\r\n\r\n try {\r\n sw.postMessage({ type: 'TALKFLOW_SW_VERSION' }, [channel.port2]);\r\n } catch (e) {\r\n clearTimeout(timeout);\r\n resolve(null);\r\n }\r\n });\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n }\r\n}\r\n\r\nexport default PushManager;\r\n","/**\r\n * Gmail 새 메일 알림 — 웹소켓 {@code MAIL_CHANGED} 의 미리보기와 포그라운드 FCM 메일 푸시를 합쳐 메일마다 한 번만\r\n * {@code newMail} 을 낸다 (서버 GMAIL_MAILBOX_FEATURES_DESIGN.md §6-7).\r\n *\r\n * <p>같은 메일이 두 길로 온다: 서버는 앱 접속 여부와 상관없이 FCM 을 보내고(채팅과 같음), 화면이 떠 있으면 웹소켓\r\n * 이벤트도 받는다. 중복 판정 키는 {@code projectId:connectionId:messageId} — 어느 쪽이 먼저 와도 두 번째는 버린다.\r\n * 묶음 푸시({@code kind=SUMMARY})도 묶은 메시지 ID({@code data.messageIds})로 같은 판정을 하고, 처음 보는 메일이 있을 때만\r\n * 그 수로 {@code newMailSummary} 를 낸다(같은 묶음이 다시 와도, 개별 알림을 이미 받았어도 다시 띄우지 않는다).</p>\r\n *\r\n * <p>보낸 사람·제목은 로그에 남기지 않는다.</p>\r\n */\r\n\r\n/** 같은 메일을 다시 보지 않는 시간 — 서버 푸시 수명(수신 후 30분)보다 길게. */\r\nconst DEDUP_TTL_MS = 60 * 60 * 1000;\r\n/** 기억하는 메일 수 상한 — 넘으면 오래된 것부터 잊는다. */\r\nconst DEDUP_MAX = 1000;\r\n\r\nexport function createMailNotificationDeduper({ ttlMs = DEDUP_TTL_MS, max = DEDUP_MAX, now = () => Date.now() } = {}) {\r\n const seen = new Map();\r\n return {\r\n /** 처음 보는 키면 true(그리고 기억), 이미 본 키면 false. */\r\n accept(key) {\r\n const at = now();\r\n for (const [seenKey, seenAt] of seen) {\r\n if (at - seenAt < ttlMs) {\r\n break; // Map 은 넣은 순서 — 첫 살아 있는 항목부터는 다 살아 있다\r\n }\r\n seen.delete(seenKey);\r\n }\r\n if (seen.has(key)) {\r\n return false;\r\n }\r\n seen.set(key, at);\r\n while (seen.size > max) {\r\n seen.delete(seen.keys().next().value);\r\n }\r\n return true;\r\n },\r\n size() {\r\n return seen.size;\r\n }\r\n };\r\n}\r\n\r\nfunction deduperOf(client) {\r\n if (!client._mailNotificationDeduper) {\r\n client._mailNotificationDeduper = createMailNotificationDeduper();\r\n }\r\n return client._mailNotificationDeduper;\r\n}\r\n\r\nfunction projectIdOf(client) {\r\n return (client.options && client.options.projectId) || null;\r\n}\r\n\r\nfunction keyOf(projectId, connectionId, messageId) {\r\n return `${projectId || ''}:${connectionId}:${messageId}`;\r\n}\r\n\r\nfunction senderOf(from) {\r\n if (!from || typeof from !== 'object') {\r\n return null;\r\n }\r\n return (typeof from.name === 'string' && from.name.trim() !== '') ? from.name : (from.email || null);\r\n}\r\n\r\n/**\r\n * 웹소켓 {@code MAIL_CHANGED} → 새 메일마다 {@code newMail}. 읽음·보관 같은 변화(미리보기 없음)는 아무것도 내지 않는다.\r\n */\r\nexport function emitNewMailFromSocket(client, event) {\r\n if (!event || event.eventType !== 'MAIL_CHANGED' || !Array.isArray(event.messages) || !event.connectionId) {\r\n return;\r\n }\r\n const projectId = projectIdOf(client);\r\n const deduper = deduperOf(client);\r\n for (const preview of event.messages) {\r\n if (!preview || !preview.messageId) {\r\n continue;\r\n }\r\n if (!deduper.accept(keyOf(projectId, event.connectionId, preview.messageId))) {\r\n continue;\r\n }\r\n client.emit('newMail', {\r\n projectId,\r\n connectionId: event.connectionId,\r\n messageId: preview.messageId,\r\n threadId: preview.threadId || null,\r\n sender: senderOf(preview.from),\r\n from: preview.from || null,\r\n subject: preview.subject || null,\r\n receivedAt: preview.receivedAt || null,\r\n source: 'socket'\r\n });\r\n }\r\n}\r\n\r\n/**\r\n * 포그라운드 FCM 메일 푸시({@code data.target === 'MAIL'}) → {@code newMail}(한 통) 또는 {@code newMailSummary}(묶음).\r\n * 메일 푸시면 {@code true} — 호출자는 {@code pushNotification} 을 내지 않는다(같은 알림을 두 번 띄우지 않게).\r\n */\r\nexport function handleMailPush(client, payload) {\r\n const data = (payload && payload.data) || {};\r\n if (data.target !== 'MAIL') {\r\n return false;\r\n }\r\n const projectId = projectIdOf(client);\r\n if (!data.connectionId) {\r\n return true;\r\n }\r\n if (data.kind === 'SUMMARY') {\r\n const ids = typeof data.messageIds === 'string'\r\n ? data.messageIds.split(',').map((id) => id.trim()).filter(Boolean)\r\n : [];\r\n if (ids.length === 0) {\r\n // 메시지 ID 없는 묶음(구버전 서버) — 거를 기준이 없어 건수 그대로 낸다.\r\n const count = Number.parseInt(data.count, 10);\r\n client.emit('newMailSummary', {\r\n projectId,\r\n connectionId: data.connectionId,\r\n count: Number.isFinite(count) ? count : null,\r\n messageIds: [],\r\n source: 'push'\r\n });\r\n return true;\r\n }\r\n const deduper = deduperOf(client);\r\n const unseen = ids.filter((id) => deduper.accept(keyOf(projectId, data.connectionId, id)));\r\n if (unseen.length > 0) {\r\n client.emit('newMailSummary', {\r\n projectId,\r\n connectionId: data.connectionId,\r\n count: unseen.length,\r\n messageIds: unseen,\r\n source: 'push'\r\n });\r\n }\r\n return true;\r\n }\r\n if (!data.messageId || !deduperOf(client).accept(keyOf(projectId, data.connectionId, data.messageId))) {\r\n return true;\r\n }\r\n client.emit('newMail', {\r\n projectId,\r\n connectionId: data.connectionId,\r\n messageId: data.messageId,\r\n threadId: data.threadId || null,\r\n sender: (payload.notification && payload.notification.title) || data.title || null,\r\n from: null,\r\n subject: (payload.notification && payload.notification.body) || data.body || null,\r\n receivedAt: null,\r\n source: 'push'\r\n });\r\n return true;\r\n}\r\n","import { emitNewMailFromSocket } from './mailNotifications.js';\r\n\r\nconst CONNECTION_EVENT_MAP = [\r\n ['connected', 'connected'],\r\n ['disconnected', 'disconnected'],\r\n ['reconnecting', 'reconnecting'],\r\n // 재연결 '성공' + 포그라운드 복귀 감지 (RECONNECT_SYNC_DESIGN §6.1/§6.4)\r\n ['reconnected', 'reconnected'],\r\n ['resumed', 'resumed'],\r\n // 회사 구독 대기(서버 구독 관문) — 연결 오류와 따로 알린다. 결제 뒤 connect() 를 다시 부르면 풀린다.\r\n ['subscriptionRequired', 'subscriptionRequired'],\r\n ['error', 'connectionError']\r\n];\r\n\r\nconst CHAT_EVENT_MAP = [\r\n // 갭 복구 (RECONNECT_SYNC_DESIGN §6.4) — 기본 메시지 수신 코드는 변경 불필요, 선택 구독용.\r\n ['syncStarted', 'syncStarted'],\r\n ['syncCompleted', 'syncCompleted'],\r\n ['gapTruncated', 'gapTruncated'],\r\n ['roomListSynced', 'roomListSynced'],\r\n ['message', 'chatMessage'],\r\n ['newMessage', 'newChatMessage'],\r\n ['messageUpdated', 'messageUpdated'],\r\n ['messageDeleted', 'messageDeleted'],\r\n ['reactionChanged', 'reactionChanged'],\r\n ['linkPreviewAttached', 'linkPreviewAttached'],\r\n ['messageTranslated', 'messageTranslated'],\r\n ['messageRead', 'messageRead'],\r\n ['typing', 'typing'],\r\n ['assistantProgress', 'assistantProgress'],\r\n ['assistantRoomProgress', 'assistantRoomProgress'],\r\n ['summaryUpdate', 'summaryUpdate'],\r\n ['memberJoined', 'memberJoined'],\r\n ['memberLeft', 'memberLeft'],\r\n ['roomSubscribed', 'roomSubscribed'],\r\n ['roomUnsubscribed', 'roomUnsubscribed'],\r\n ['roomListSubscribed', 'roomListSubscribed'],\r\n ['roomListUnsubscribed', 'roomListUnsubscribed'],\r\n ['roomListUpdate', 'roomListUpdate'],\r\n ['roomListMessage', 'roomListMessage'],\r\n ['roomListMessageDeleted', 'roomListMessageDeleted'],\r\n ['roomListMessageUpdated', 'roomListMessageUpdated'],\r\n ['roomListCreated', 'roomListCreated'],\r\n ['roomListJoined', 'roomListJoined'],\r\n ['roomListLeft', 'roomListLeft'],\r\n ['roomListSelfLeft', 'roomListSelfLeft'],\r\n ['roomListKicked', 'roomListKicked'],\r\n ['roomListSelfKicked', 'roomListSelfKicked'],\r\n ['roomListBanned', 'roomListBanned'],\r\n ['roomListSelfBanned', 'roomListSelfBanned'],\r\n // 방장 위임 · 방 삭제 — 타입에 선언한 이벤트는 여기 없으면 최상위 client.on 에서 안 울린다.\r\n ['roomListOwnerChanged', 'roomListOwnerChanged'],\r\n ['roomListDeleted', 'roomListDeleted'],\r\n ['roomListRoomUpdated', 'roomListRoomUpdated'],\r\n ['roomListUnreadUpdated', 'roomListUnreadUpdated'],\r\n // Gmail 메일함 — mailChanged 는 받은 그대로. 새 메일 알림(newMail)은 아래에서 푸시와 합쳐 따로 낸다.\r\n ['mailSubscribed', 'mailSubscribed'],\r\n ['mailUnsubscribed', 'mailUnsubscribed'],\r\n ['mailChanged', 'mailChanged'],\r\n ['retentionCleanup', 'retentionCleanup']\r\n];\r\n\r\nconst WEBRTC_EVENT_MAP = [\r\n ['localStreamStarted', 'localStreamStarted'],\r\n ['localStreamStopped', 'localStreamStopped'],\r\n ['remoteTrack', 'remoteTrack'],\r\n ['screenShareStarted', 'screenShareStarted'],\r\n ['screenShareEnded', 'screenShareEnded'],\r\n ['deviceChange', 'deviceChange'],\r\n ['mediaStateChanged', 'mediaStateChanged'],\r\n ['callStarted', 'callStarted'],\r\n ['callEnded', 'callEnded'],\r\n ['callRecoveryFailed', 'callRecoveryFailed'],\r\n ['callRequested', 'callRequested'],\r\n ['callAccepted', 'callAccepted'],\r\n ['callRejected', 'callRejected'],\r\n ['callCancelled', 'callCancelled'],\r\n ['callBusy', 'callBusy'],\r\n ['incomingCall', 'incomingCall'],\r\n ['incomingCallWhileBusy', 'incomingCallWhileBusy'],\r\n ['callInvitation', 'callInvitation'],\r\n ['userJoined', 'userJoined'],\r\n ['userLeft', 'userLeft'],\r\n ['participantLeft', 'participantLeft'],\r\n ['participantMediaState', 'participantMediaState'],\r\n ['peerConnected', 'peerConnected'],\r\n ['peerDisconnected', 'peerDisconnected'],\r\n ['peerClosed', 'peerClosed'],\r\n ['error', 'webrtcError']\r\n];\r\n\r\nfunction forwardMappedEvents(source, target, eventMap) {\r\n eventMap.forEach(([sourceEvent, targetEvent]) => {\r\n source.on(sourceEvent, (data) => {\r\n target.emit(targetEvent, data);\r\n });\r\n });\r\n}\r\n\r\nexport function setupEventForwarding(client) {\r\n if (!client.connectionManager) {\r\n return;\r\n }\r\n\r\n client.connectionManager.on('stateChange', ({ state, prevState }) => {\r\n client._state = state;\r\n client.emit('stateChange', { state, prevState });\r\n });\r\n\r\n forwardMappedEvents(client.connectionManager, client, CONNECTION_EVENT_MAP);\r\n\r\n if (client.chat) {\r\n forwardMappedEvents(client.chat, client, CHAT_EVENT_MAP);\r\n client.chat.on('mailChanged', (event) => emitNewMailFromSocket(client, event));\r\n }\r\n\r\n if (client.webrtc) {\r\n forwardMappedEvents(client.webrtc, client, WEBRTC_EVENT_MAP);\r\n }\r\n}\r\n","const CHAT_DELEGATE_METHODS = [\r\n 'getRooms',\r\n 'getRoom',\r\n 'getRoomInfo',\r\n 'setMyRoomLanguage',\r\n 'createOneToOneRoom',\r\n 'createGroupRoom',\r\n 'joinGroupRoom',\r\n 'leaveRoom',\r\n // 방장 위임(방에 남음) · 방 삭제 — 방장의 «위임 후 나가기» 는 leaveRoom(roomId, { newOwnerId }).\r\n 'transferOwnership',\r\n 'deleteRoom',\r\n 'updateGroupRoom',\r\n 'inviteToGroupRoom',\r\n 'kickMember',\r\n 'banMember',\r\n 'unbanMember',\r\n 'getBannedMembers',\r\n 'getAvailableGroupRooms',\r\n 'getAllGroupRooms',\r\n 'enterRoom',\r\n 'getMessages',\r\n 'fetchLinkPreview',\r\n 'sendMessage',\r\n 'sendMessageOptimistic',\r\n 'sendTextMessage',\r\n 'sendTextMessageOptimistic',\r\n 'sendReply',\r\n 'sendReplyOptimistic',\r\n // AI 의사결정 카드의 선택 — 답글·라벨 본문·optionId 를 한 번에 맞춰 보낸다.\r\n 'sendDecisionChoice',\r\n 'uploadFile',\r\n 'sendFileMessage',\r\n 'sendFileMessageOptimistic',\r\n // 첨부 분류 — legacy GALLERY 복원용 공개 헬퍼. 타입 선언이 최상위 클라이언트에도 있으므로\r\n // 여기에 없으면 talkflow.resolveMessageCategory() 가 런타임 undefined 가 된다(타입이 거짓말).\r\n 'classifyFileType',\r\n 'resolveMessageCategory',\r\n 'editMessage',\r\n 'deleteMessage',\r\n 'markAsRead',\r\n 'pinMessage',\r\n 'unpinMessage',\r\n 'toggleReaction',\r\n 'subscribeRoom',\r\n 'unsubscribeRoom',\r\n 'unsubscribeAllRooms',\r\n 'setActiveRoom',\r\n 'clearActiveRoom',\r\n 'getActiveRoom',\r\n 'subscribeRoomList',\r\n 'unsubscribeRoomList',\r\n 'isRoomListSubscribed',\r\n 'subscribeMail',\r\n 'unsubscribeMail',\r\n 'isMailSubscribed',\r\n 'getSubscribedRooms',\r\n 'isSubscribed',\r\n 'startTyping',\r\n 'stopTyping',\r\n // AI 진행 표시 — 마운트 즉시 복원용 동기 조회 + 수동 재개. 여기 없으면 최상위 클라이언트에서\r\n // 런타임 undefined 가 된다(타입 선언만 있고 배선이 없으면 타입이 거짓말을 한다).\r\n 'getAssistantProgress',\r\n 'resumeAssistantStreams',\r\n 'getAssistants',\r\n 'getRoomAiMeta',\r\n 'rateAssistantMessage',\r\n 'summarizeWithAssistant',\r\n 'translateWithAssistant',\r\n 'uploadVideoAsset',\r\n 'analyzeVideo',\r\n 'analyzeVideoWithAssistant',\r\n 'getVideoAnalysis',\r\n 'cancelVideoAnalysis',\r\n 'getVideoAnalysisCapabilities',\r\n 'refreshSummary',\r\n 'getSummary',\r\n 'getRoomPmPrompt',\r\n 'upsertRoomPmPrompt',\r\n 'activateRoomPmPrompt',\r\n 'deactivateRoomPmPrompt',\r\n 'getRoomPmPromptVersions',\r\n 'activateRoomPmPromptVersion',\r\n 'previewRoomPmPrompt',\r\n // 투표 — 투표 서버는 클러스터 내부에만 있어 클라이언트가 직접 부를 수 없다. 전부 채팅 서버 경유.\r\n 'createVote',\r\n 'getVotes',\r\n 'getVote',\r\n 'updateVote',\r\n 'deleteVote',\r\n 'closeVote',\r\n 'submitVote',\r\n 'uploadVoteImage'\r\n];\r\n\r\nconst WEBRTC_DELEGATE_METHODS = [\r\n 'initializeIceServers',\r\n 'getTurnCredentials',\r\n 'createCallRoom',\r\n 'getCallRoom',\r\n 'joinCallRoomApi',\r\n 'leaveCallRoomApi',\r\n 'enableIncomingCalls',\r\n 'disableIncomingCalls',\r\n 'isIncomingCallsEnabled',\r\n 'startCall',\r\n 'callUser',\r\n 'acceptCall',\r\n 'rejectCall',\r\n 'cancelCall',\r\n 'endCall',\r\n 'toggleVideo',\r\n 'toggleAudio',\r\n 'setVideoEnabled',\r\n 'setAudioEnabled',\r\n 'startScreenShare',\r\n 'stopScreenShare',\r\n 'getLocalStream',\r\n 'getDevices',\r\n 'switchDevice',\r\n 'startDeviceChangeDetection',\r\n 'stopDeviceChangeDetection',\r\n 'applyVideoConstraints',\r\n 'getVideoSettings',\r\n 'getAudioSettings',\r\n 'isInCall',\r\n 'getCurrentRoom',\r\n 'getParticipants',\r\n 'getMediaState',\r\n 'getConnectionSummary',\r\n 'getStats'\r\n];\r\n\r\nfunction defineDelegates(prototype, targetKey, methods) {\r\n methods.forEach((method) => {\r\n prototype[method] = function delegatedMethod(...args) {\r\n return this[targetKey][method](...args);\r\n };\r\n });\r\n}\r\n\r\nexport function applyDelegatedMethods(TalkFlowClient) {\r\n defineDelegates(TalkFlowClient.prototype, 'chat', CHAT_DELEGATE_METHODS);\r\n defineDelegates(TalkFlowClient.prototype, 'webrtc', WEBRTC_DELEGATE_METHODS);\r\n}\r\n","/**\r\n * TalkFlowClient\r\n * 채팅 + WebRTC 통합 클라이언트\r\n */\r\n\r\nimport EventEmitter from './utils/EventEmitter.js';\r\nimport Logger from './utils/Logger.js';\r\nimport ApiClient from './utils/ApiClient.js';\r\nimport AuthSession, { tokenClaims } from './auth/AuthSession.js';\r\nimport { extractUserIdFromJWT } from './utils/jwtUtils.js';\r\nimport { ConnectionState, ErrorTypes, DefaultConfig, LogLevel, Environment, getServerUrl } from './constants.js';\r\nimport { initializeSubClients, applySessionMethods } from './talkflow/session.js';\r\nimport { setupEventForwarding } from './talkflow/eventForwarding.js';\r\nimport { applyDelegatedMethods } from './talkflow/delegates.js';\r\n\r\n// noinspection JSUnresolvedReference\r\nclass TalkFlowClient extends EventEmitter {\r\n /**\r\n * TalkFlowClient 생성\r\n *\r\n * <p>JWT 토큰은 고객사 backend 에서 발급받아서 SDK 에 전달해야 합니다.\r\n * 브라우저에서 직접 JWT 를 발급하는 기능은 제공하지 않습니다 (보안상).\r\n * 자세한 내용은 README \"프로덕션 인증 플로우\" 섹션 참고.</p>\r\n *\r\n * @param {Object} options - 설정 옵션\r\n * @param {string} options.apiKey - Client API 키 (필수, 브라우저 노출 안전)\r\n * @param {string} options.projectId - 프로젝트 ID (필수)\r\n * @param {string} options.jwtToken - JWT 토큰 (필수, 고객사 backend 에서 발급)\r\n * @param {string} [options.env='production'] - 환경 ('development' | 'staging' | 'production')\r\n * @param {string} [options.serverUrl] - 서버 URL (직접 지정 시 env 무시)\r\n * @param {boolean} [options.useSockJS=true] - SockJS 사용 여부\r\n * @param {number} [options.reconnectDelay=5000] - 재연결 지연 (ms)\r\n * @param {number} [options.maxReconnectAttempts=10] - 최대 재연결 시도\r\n * @param {Object[]} [options.iceServers] - ICE 서버 설정\r\n * @param {boolean} [options.autoSubscribeRoomList=true] - connect() 시 채팅방 리스트 자동 구독 여부 (카톡 스타일)\r\n * @param {number} [options.logLevel=LogLevel.WARN] - 로그 레벨\r\n *\r\n * @example\r\n * // 1단계: 로그인 — 클라이언트 생성 «전» 에 부른다. ck/projectId/JWT 를 한 번에 받는다.\r\n * // const { clientKey, projectId, accessToken } = await TalkFlowClient.loginUser({\r\n * // serverUrl, email, password });\r\n * // (회사 계정이 둘 이상이면 먼저 workspaceSelectionRequired 결과 → projectId 를 실어 다시 부른다)\r\n *\r\n * // 2단계: 받은 값으로 SDK 초기화\r\n * const client = new TalkFlowClient({\r\n * apiKey: 'CLIENT_KEY', // 브라우저에 노출되는 Client Key\r\n * projectId: 'your-project-id',\r\n * jwtToken: receivedJwt, // 고객사 backend 로부터 받은 JWT\r\n * env: 'production'\r\n * });\r\n *\r\n * await client.connect();\r\n */\r\n constructor(options) {\r\n super();\r\n\r\n // 필수 옵션 검증\r\n this._validateOptions(options);\r\n\r\n // 환경에 따른 서버 URL 결정\r\n // serverUrl이 직접 지정되면 우선 사용, 아니면 env에 따라 자동 결정\r\n const env = options.env || DefaultConfig.environment;\r\n const serverUrl = (options.serverUrl || getServerUrl(env)).replace(/\\/$/, '');\r\n\r\n this.options = {\r\n serverUrl,\r\n env,\r\n apiKey: options.apiKey,\r\n projectId: options.projectId,\r\n jwtToken: options.jwtToken || null,\r\n sessionMode: options.sessionMode || (tokenClaims(options.jwtToken || '').sid ? 'refresh' : 'legacy'),\r\n useSockJS: options.useSockJS !== false,\r\n reconnectDelay: options.reconnectDelay || DefaultConfig.reconnectDelay,\r\n maxReconnectDelay: options.maxReconnectDelay || DefaultConfig.maxReconnectDelay,\r\n maxReconnectAttempts: options.maxReconnectAttempts || DefaultConfig.maxReconnectAttempts,\r\n reconnectRecoveryDelay: options.reconnectRecoveryDelay ?? DefaultConfig.reconnectRecoveryDelay,\r\n heartbeatIncoming: options.heartbeatIncoming || DefaultConfig.heartbeatIncoming,\r\n heartbeatOutgoing: options.heartbeatOutgoing || DefaultConfig.heartbeatOutgoing,\r\n // 탭 복귀 / 네트워크 복구 시 자동 재연결. false 로 꺼야 하므로 !== false 로 보존 —\r\n // options 에 담지 않으면 undefined 가 전달되어 ConnectionManager 기본값 true 로 되살아난다.\r\n resumeOnVisibility: options.resumeOnVisibility !== false,\r\n // 갭 복구 (재연결/복귀 후 끊긴 구간 메시지 재조회). false 로 꺼야 하므로 !== false 로 보존.\r\n syncOnReconnect: options.syncOnReconnect !== false,\r\n // 진행 중 AI 스트림 재개 (구독/재연결 시 서버 스냅샷으로 앞부분 이어받기). 갭 복구와 별개 축.\r\n // ⚠️ 여기 담지 않으면 ChatClient 기본값 true 로 되살아나 롤백 스위치가 동작하지 않는다.\r\n assistantStreamReplay: options.assistantStreamReplay !== false,\r\n rewindMillis: options.rewindMillis !== undefined\r\n ? options.rewindMillis\r\n : DefaultConfig.rewindMillis,\r\n maxSyncPages: options.maxSyncPages || DefaultConfig.maxSyncPages,\r\n syncPageSize: options.syncPageSize || DefaultConfig.syncPageSize,\r\n iceServers: options.iceServers || DefaultConfig.iceServers,\r\n // connect() 시 채팅방 리스트 자동 구독 여부 (카톡 스타일).\r\n // 기본 true — 앱 어디서든 리스트 이벤트를 놓치지 않음 (배지/알림 등).\r\n // false 로 설정 시 수동으로 client.chat.subscribeRoomList() 호출 필요.\r\n autoSubscribeRoomList: options.autoSubscribeRoomList !== false,\r\n logLevel: options.logLevel !== undefined ? options.logLevel : LogLevel.WARN\r\n };\r\n\r\n // 로거 초기화\r\n this.logger = new Logger(this.options.logLevel, 'TalkFlowClient');\r\n\r\n // 상태\r\n this._initialized = false;\r\n this._state = ConnectionState.DISCONNECTED;\r\n\r\n // public 읽기 전용 surface 의 backing field.\r\n // types/index.d.ts 는 chat/webrtc/pushManager/userId 를 readonly 로 선언 —\r\n // 외부 계약(readonly) 과 내부 재할당 필요성을 동시에 만족시키기 위해 private\r\n // backing field 에 저장하고 동명 getter 로 공개한다.\r\n this._userId = null;\r\n\r\n // JWT가 있으면 userId 추출\r\n if (this.options.jwtToken) {\r\n this._userId = extractUserIdFromJWT(this.options.jwtToken);\r\n }\r\n\r\n // API 클라이언트 초기화 (JWT 없이도 동작)\r\n this.apiClient = new ApiClient({\r\n baseUrl: this.options.serverUrl,\r\n apiKey: this.options.apiKey,\r\n projectId: this.options.projectId,\r\n jwtToken: this.options.jwtToken,\r\n logLevel: this.options.logLevel\r\n });\r\n\r\n // ConnectionManager, ChatClient, WebRTCClient는 지연 초기화\r\n this.authSession = null;\r\n this._authConnectionRequested = false;\r\n this.connectionManager = null;\r\n this._chat = null;\r\n this._webrtc = null;\r\n this._pushManager = null;\r\n this._pushEnablePromise = null;\r\n\r\n // JWT가 있으면 즉시 서브 클라이언트 초기화\r\n if (this.options.jwtToken && this._userId) {\r\n this._initializeSubClients();\r\n }\r\n\r\n this._initialized = true;\r\n if (this.options.sessionMode === 'refresh') this._enableRefreshSession();\r\n this.logger.info('TalkFlowClient initialized', {\r\n userId: this._userId,\r\n hasToken: !!this.options.jwtToken\r\n });\r\n }\r\n\r\n // ==================== Public readonly surface (getter) ====================\r\n\r\n /** @returns {ChatClient|null} */\r\n get chat() { return this._chat; }\r\n\r\n /** @returns {WebRTCClient|null} */\r\n get webrtc() { return this._webrtc; }\r\n\r\n /** @returns {PushManager|null} */\r\n get pushManager() { return this._pushManager; }\r\n\r\n /** @returns {string|null} */\r\n get userId() { return this._userId; }\r\n\r\n /**\r\n * 옵션 검증\r\n * @private\r\n * @param {Object} options - 설정 옵션\r\n * @throws {Error} 필수 옵션이 없는 경우\r\n */\r\n _validateOptions(options) {\r\n if (!options) {\r\n throw new Error('Options are required');\r\n }\r\n if (!options.apiKey && options.sessionMode !== 'refresh') {\r\n throw new Error('apiKey is required');\r\n }\r\n if (!options.projectId && options.sessionMode !== 'refresh') {\r\n throw new Error('projectId is required');\r\n }\r\n\r\n // Server Key (sk-) 는 SDK 가 쓸 일이 없다 — 유일한 용도였던 registerUser(/users/auth) 가\r\n // 사라졌다. 그래도 들어오면 잘못된 키를 넣은 것이므로 알린다.\r\n if (options.apiKey?.startsWith('sk-')) {\r\n console.error(\r\n '🚨 [TalkFlow] Server Key (sk-) 로 SDK 를 초기화했습니다. ' +\r\n 'SDK 는 Client Key (ck-) 만 사용합니다 — loginUser() 응답의 clientKey 를 그대로 쓰세요.'\r\n );\r\n }\r\n }\r\n\r\n /**\r\n * 서브 클라이언트 초기화\r\n * @private\r\n */\r\n _initializeSubClients() {\r\n initializeSubClients(this);\r\n }\r\n\r\n /**\r\n * 이벤트 포워딩 설정\r\n * @private\r\n */\r\n _setupEventForwarding() {\r\n setupEventForwarding(this);\r\n }\r\n\r\n /**\r\n * 연결 상태 확인\r\n * @returns {boolean}\r\n */\r\n isConnected() {\r\n return this.connectionManager ? this.connectionManager.isConnected() : false;\r\n }\r\n\r\n /**\r\n * 회사 구독 대기 중인가 — 서버가 웹소켓을 NOT_SUBSCRIBED 로 거부해 연결을 멈췄다(결제 전·해지 회사).\r\n * 결제가 확인되면(`GET /api/v1/company/billing` 의 `subscription === 'ACTIVE'`) 같은 토큰으로 {@link #connect} 를 다시 부른다.\r\n * @returns {boolean}\r\n */\r\n isSubscriptionRequired() {\r\n return this.connectionManager ? this.connectionManager.isSubscriptionRequired() : false;\r\n }\r\n\r\n _enableRefreshSession() {\r\n if (this.authSession) return;\r\n this.options.sessionMode = 'refresh';\r\n this.authSession = AuthSession.forServer(this.options.serverUrl);\r\n this.authSession.attach(this);\r\n this.apiClient.authProvider = this.authSession;\r\n this._authUnsubscribe = this.authSession.on('authStateChanged', event => this.emit('authStateChanged', event));\r\n this._authErrorUnsubscribe = this.authSession.on('authError', error => this.emit('authError', error));\r\n if (!this.authSession.data && this.options.jwtToken && tokenClaims(this.options.jwtToken).sid) {\r\n this.authSession.data = { accessToken: this.options.jwtToken, projectId: this.options.projectId,\r\n clientKey: this.options.apiKey, user: { userId: this.userId } };\r\n this.authSession._setState('authenticated');\r\n }\r\n this._authReady = Promise.resolve();\r\n if (this.authSession.data) this._authReady = this._acceptAuthSession(this.authSession.data).catch(error => {\r\n this._authReady = Promise.resolve();\r\n throw error;\r\n });\r\n }\r\n\r\n async _acceptAuthSession(data) {\r\n if (this.options.projectId && this.options.projectId !== data.projectId && this.connectionManager) {\r\n await this._clearAuthSession();\r\n }\r\n this.options.apiKey = data.clientKey;\r\n this.options.projectId = data.projectId;\r\n this.apiClient.apiKey = data.clientKey;\r\n this.apiClient.projectId = data.projectId;\r\n await this.setToken(data.accessToken);\r\n }\r\n\r\n async _clearAuthSession() {\r\n this._authConnectionRequested = false;\r\n this.options.jwtToken = null;\r\n this._userId = null;\r\n this.apiClient.setJwtToken(null);\r\n await this.disconnect();\r\n this._chat?.destroy();\r\n this._webrtc?.destroy();\r\n await this.connectionManager?.destroy();\r\n this._chat = null;\r\n this._webrtc = null;\r\n this.connectionManager = null;\r\n this.pushManager?.reset();\r\n this._pushManager = null;\r\n this._pushEnablePromise = null;\r\n this.emit('loggedOut');\r\n }\r\n\r\n getAuthState() {\r\n return this.authSession?.getAuthState()\r\n || (Number(tokenClaims(this.options.jwtToken || '').exp || 0) * 1000 > Date.now() ? 'authenticated' : 'unauthenticated');\r\n }\r\n isLoggedIn() { return this.getAuthState() === 'authenticated'; }\r\n async getAccessToken(options = {}) {\r\n if (this.authSession) { await this._authReady; return this.authSession.getAccessToken(options); }\r\n if (!this.isLoggedIn()) { const error = new Error('Sign in is required'); error.code = 'SESSION_REVOKED'; throw error; }\r\n return this.options.jwtToken;\r\n }\r\n async restoreSession() {\r\n this._enableRefreshSession();\r\n await this._authReady;\r\n return this.authSession.restoreSession();\r\n }\r\n static async restoreSession({ serverUrl }) {\r\n if (!serverUrl) throw new Error('serverUrl is required');\r\n return AuthSession.forServer(serverUrl).restoreSession();\r\n }\r\n\r\n /** FE의 관리/캘린더 직접 호출도 동일한 인증·1회 재시도 경로를 사용한다. */\r\n async request(method, path, options = {}) {\r\n if (!path.startsWith('/') || path.startsWith('//')) throw new Error('Use an API-relative path');\r\n await this._authReady;\r\n return this.apiClient.request(method, path, options);\r\n }\r\n\r\n /**\r\n * 현재 연결 상태\r\n * @returns {string}\r\n */\r\n getState() {\r\n return this._state;\r\n }\r\n\r\n /**\r\n * 토큰 보유 여부\r\n * @returns {boolean}\r\n */\r\n hasToken() {\r\n return !!this.options.jwtToken;\r\n }\r\n\r\n // ==================== 사용자 API ====================\r\n\r\n /**\r\n * 회원 로그인 — 이메일/비밀번호로 채팅 접속 일체를 받는다. **클라이언트 생성 전에 호출한다.**\r\n *\r\n * <p>Server Key 도 고객사 BFF 도 필요 없다. 응답의\r\n * {@code clientKey}/{@code projectId}/{@code accessToken} 이 그대로 SDK 초기화 입력이다.</p>\r\n *\r\n * <p><b>회사 선택</b>: 같은 이메일이 여러 회사에 계정을 가질 수 있다(다른 회사의 게스트 등). 비밀번호가 맞는 계정이\r\n * 둘 이상이면 토큰 없이 {@code {workspaceSelectionRequired: true, workspaces: [{projectId, companyName, companyRole}]}}\r\n * 를 돌려준다 — 저장된 세션은 그대로다. 고른 {@code projectId} 를 실어 같은 이메일·비밀번호로 다시 부른다.\r\n * 계정이 하나면 지금처럼 바로 로그인된다.</p>\r\n *\r\n * @param {Object} options\r\n * @param {string} options.serverUrl - 채팅 서버 base URL\r\n * @param {string} options.email\r\n * @param {string} options.password\r\n * @param {string} [options.projectId] - 회사 선택 응답에서 고른 회사\r\n * @returns {Promise<Object>} `{accessToken, tokenType, expiresIn, projectId, clientKey, user}` 또는 회사 선택 결과\r\n *\r\n * @example\r\n * let login = await TalkFlowClient.loginUser({\r\n * serverUrl: 'https://chat.apiorbit.net',\r\n * email: 'dan@example.com',\r\n * password: '********'\r\n * });\r\n * if (login.workspaceSelectionRequired) {\r\n * const picked = await showWorkspacePicker(login.workspaces); // 화면에서 고른 회사\r\n * login = await TalkFlowClient.loginUser({ serverUrl, email, password, projectId: picked.projectId });\r\n * }\r\n *\r\n * const client = new TalkFlowClient({\r\n * apiKey: login.clientKey,\r\n * projectId: login.projectId,\r\n * jwtToken: login.accessToken\r\n * });\r\n * await client.connect();\r\n */\r\n static async loginUser({ serverUrl, email, password, projectId }) {\r\n if (!serverUrl) throw new Error('serverUrl is required');\r\n if (!email) throw new Error('email is required');\r\n if (!password) throw new Error('password is required');\r\n\r\n // 로그인은 키를 «받기 위해» 부르는 경로라 X-API-KEY/X-PROJECT-ID 가 없다(서버에서 필터 제외).\r\n return AuthSession.forServer(serverUrl).login({ email, password, projectId });\r\n }\r\n\r\n /**\r\n * 회원 로그인 (인스턴스) — 성공 시 JWT 를 자동 설정한다.\r\n *\r\n * <p>이미 클라이언트가 있는 상태의 재로그인·계정 전환용이다. 최초 진입은 클라이언트 생성 전에\r\n * 부르는 정적 {@link TalkFlowClient.loginUser} 를 쓴다 — 그쪽이 {@code clientKey} 까지 준다.</p>\r\n *\r\n * <p>회사 선택 응답이면 토큰을 설정하지 않고 그 결과를 돌려준다 — 정적 {@link TalkFlowClient.loginUser} 와 같다.</p>\r\n *\r\n * @param {Object} credentials\r\n * @param {string} credentials.email\r\n * @param {string} credentials.password\r\n * @param {string} [credentials.projectId] - 회사 선택 응답에서 고른 회사\r\n * @returns {Promise<Object>} 로그인 응답 또는 회사 선택 결과\r\n */\r\n async loginUser({ email, password, projectId }) {\r\n if (!email || !password) throw new Error('email and password are required');\r\n this._enableRefreshSession();\r\n await this._authReady;\r\n return this.authSession.login({ email, password, projectId });\r\n }\r\n\r\n /**\r\n * 내 정보 조회.\r\n *\r\n * `metadata` 를 볼 수 있는 **유일한** 곳이다. 고치는 문은 {@link #updateMyProfile} 하나뿐이고\r\n * 그쪽은 **전체 교체**라, 부분 수정하려면 여기서 먼저 읽어 병합한 뒤 통째로 보내야 한다:\r\n *\r\n * ```js\r\n * const me = await client.getMyProfile();\r\n * await client.updateMyProfile({ metadata: { ...me.metadata, statusMessage: '회의 중' } });\r\n * ```\r\n *\r\n * @returns {Promise<Object>} `{userId, email, name, phone, profileUrl, companyRole,\r\n * orgNodeId, orgName, preferredLanguage, metadata, mustChangePassword, ...}`\r\n */\r\n async getMyProfile() {\r\n this._checkToken();\r\n const response = await this.apiClient.get('/api/v1/me');\r\n return (response && response.data) ? response.data : response;\r\n }\r\n\r\n /**\r\n * 내 정보 수정 — 이름·전화번호·프로필 이미지.\r\n *\r\n * <p>서버가 <b>새 JWT</b> 를 함께 준다: {@code name}/{@code profileUrl} 이 토큰 claim 이라\r\n * 갱신하지 않으면 옛 값이 토큰 만료(7일)까지 계속 나간다. 여기서 자동 채택한다.</p>\r\n *\r\n * <p>이메일·조직·역할은 바꿀 수 없다 — 이메일은 로그인 키, 조직은 파트너가 정하고,\r\n * 역할은 본인이 승급할 수 없어야 한다.</p>\r\n *\r\n * <p><b>내 정보를 고치는 유일한 메서드다.</b> {@code updateMyInfo} 와\r\n * {@code PUT /api/v1/users/update} 는 v7.0.0 에서 걷어냈다 — 같은 칸(이름·프로필 이미지)을\r\n * 고치는 문이 둘이라 한 화면에서 순서 없이 두 번 부르면 나중 호출이 앞을 덮었고, 어느 쪽도\r\n * 혼자서는 부족했다(그쪽엔 {@code phone} 이, 이쪽엔 나머지 셋이 없었다).</p>\r\n *\r\n * <p>서버는 갱신된 프로필이 담긴 <b>새 JWT</b> 를 함께 준다. 채택하지 않으면 옛 토큰의\r\n * name/profileUrl 클레임이 최대 7일(토큰 TTL) 동안 계속 나간다. 같은 사용자의 토큰 교체라\r\n * {@code setToken} 은 재연결을 일으키지 않는다 — STOMP connectHeaders 만 갱신된다.</p>\r\n *\r\n * @param {Object} profile — 넘기지 않은 필드는 «변경 안 함»\r\n * @param {string} [profile.name] - 이름 (max 100자)\r\n * @param {string} [profile.phone] - 전화번호 (max 30자)\r\n * @param {string} [profile.profileUrl] - 프로필 이미지 URL (max 500자)\r\n * @param {Object} [profile.metadata] - 메타데이터 (**전체 교체** — 부분 수정하려면\r\n * {@link #getMyProfile} 로 현재 값을 읽어 병합한 뒤 통째로 보내라. 서버는 병합하지 않는다)\r\n * @param {string|null} [profile.preferredLanguage] - 기본 선호 언어 (BCP-47 / 'OFF'(원문) / null)\r\n * @param {string} [profile.timeZone] - IANA zone id. 어시가 대화 시각을 이 기준으로 렌더한다.\r\n * `Intl.DateTimeFormat().resolvedOptions().timeZone` 값을 그대로 보내면 된다.\r\n * @returns {Promise<Object>} 갱신된 내 정보 (accessToken 포함)\r\n */\r\n async updateMyProfile(profile) {\r\n this._checkToken();\r\n const response = await this.apiClient.put('/api/v1/me', profile);\r\n const data = (response && response.data) ? response.data : response;\r\n\r\n if (data && data.accessToken) {\r\n // 토큰 교체 실패로 수정 자체를 실패시키지 않는다 — 서버 반영은 이미 끝났고 옛 토큰도\r\n // 유효(낡았을 뿐)하므로 여기서 throw 하면 호출자가 이미 적용된 수정을 다시 시도한다.\r\n try {\r\n await this.setToken(data.accessToken);\r\n } catch (error) {\r\n this.logger.warn('Profile updated but token refresh failed; keeping the old token', error);\r\n }\r\n }\r\n return data;\r\n }\r\n\r\n /**\r\n * 비밀번호 변경 — 현재 비밀번호를 재확인한다.\r\n *\r\n * <p>토큰만 탈취해도 비밀번호를 바꿀 수 있으면 계정을 통째로 빼앗긴다. 비밀번호는 바뀌어도\r\n * 현재 클라이언트는 새 세션을 받아 유지하고, 다른 세션은 종료한다.</p>\r\n *\r\n * @param {Object} params\r\n * @param {string} params.currentPassword\r\n * @param {string} params.newPassword - 8자 이상\r\n */\r\n async changeMyPassword({ currentPassword, newPassword }) {\r\n this._checkToken();\r\n if (this.authSession) {\r\n return this.authSession.changePassword(() => this.apiClient.request('PUT', '/api/v1/me/password', {\r\n body: { currentPassword, newPassword }, skipAuth: true\r\n }));\r\n }\r\n const response = await this.apiClient.put('/api/v1/me/password', { currentPassword, newPassword });\r\n const data = response?.data ?? response;\r\n if (data?.accessToken && data?.refreshToken) {\r\n this._enableRefreshSession();\r\n this.authSession._newMarker(false);\r\n await this.authSession._accept(data);\r\n }\r\n return response;\r\n }\r\n\r\n /**\r\n * 내 기본 선호 언어 설정 (전 방 공통, 다국어). 명시 변경 — 방별 오버라이드는 {@link #setMyRoomLanguage}.\r\n * @param {string|null} language - BCP-47 코드(예 'ko','en') / 'OFF'(원문) / null·''(미설정)\r\n * @returns {Promise<Object>}\r\n */\r\n async setPreferredLanguage(language) {\r\n return this.updateMyProfile({ preferredLanguage: language });\r\n }\r\n\r\n /**\r\n * 브라우저 locale 감지 — {@code navigator.language} 를 BCP-47 소문자로 정규화하고 지역 변형을 primary 로\r\n * 축약한다({@code ko-KR → 'ko'}, {@code en-US → 'en'}). 단 {@code zh} 계열은 간체/번체 구분을 위해\r\n * 변형을 보존한다({@code zh-TW → 'zh-tw'}). 비브라우저/미가용 시 null.\r\n *\r\n * <p>축약 이유(값공간 통일): 서버 저장·번역·요약 키는 짧은 코드({@code 'ko'}/{@code 'en'}) 기준이다. 시드가\r\n * 지역 변형을 그대로 보내면 명시 요청({@code lang: 'en'})과 키가 분열되어 같은 언어 이중 생성·조회 miss 가\r\n * 발생한다. 서버 {@code LanguageCode} 도 동일 규칙으로 축약하므로 어느 쪽으로 들어와도 같은 값이 된다.</p>\r\n *\r\n * <p>예전에는 {@code registerUser} 가 이 값을 자동으로 실어 보냈는데 그 경로가 사라졌다.\r\n * 지금은 호출자가 직접 감지해 {@link #setPreferredLanguage} (또는 {@link #updateMyProfile} 의\r\n * {@code preferredLanguage}) 로 넣어야 한다 — 넣지 않으면 사용자 기본 언어가 미설정으로 남아\r\n * 자동 번역이 서버 기본값을 쓴다.</p>\r\n * @returns {string|null}\r\n */\r\n static detectBrowserLanguage() {\r\n if (typeof navigator === 'undefined' || !navigator.language) {\r\n return null;\r\n }\r\n return TalkFlowClient.normalizeLanguageCode(navigator.language);\r\n }\r\n\r\n /**\r\n * 언어 코드 canonical 정규화 — 소문자 + 지역 변형 축약(zh 계열 보존). 서버 {@code LanguageCode} 규칙의 미러.\r\n * <p>{@code 'OFF'}(원문 sentinel)는 대문자 그대로 유지한다. 빈 값은 null.</p>\r\n * @param {string|null|undefined} code - BCP-47 코드(예 'ko-KR') / 'OFF' / null\r\n * @returns {string|null}\r\n */\r\n static normalizeLanguageCode(code) {\r\n if (!code || typeof code !== 'string') {\r\n return null;\r\n }\r\n const trimmed = code.trim();\r\n if (!trimmed) {\r\n return null;\r\n }\r\n if (trimmed.toUpperCase() === 'OFF') {\r\n return 'OFF';\r\n }\r\n const lower = trimmed.toLowerCase();\r\n const dash = lower.indexOf('-');\r\n if (dash < 0) {\r\n return lower;\r\n }\r\n const primary = lower.slice(0, dash);\r\n return primary === 'zh' ? lower : primary;\r\n }\r\n\r\n /**\r\n * 표시 텍스트 헬퍼 (다국어) — 내 언어 번역이 있으면 그것, 없으면 원문.\r\n * <p>{@code myLang} 은 내 effectiveLanguage (방 participant.language ?? user.preferredLanguage).\r\n * {@code messageTranslated} 이벤트로 갱신된 message 에 적용.</p>\r\n * @param {Object} message - 메시지 (translations 맵 포함 가능)\r\n * @param {string} [myLang] - 내 언어 코드\r\n * @returns {string|null}\r\n */\r\n static displayText(message, myLang) {\r\n // myLang 도 canonical 로 정규화 — 앱이 'ko-KR' 처럼 지역 변형을 넘겨도 서버 저장 키('ko')와 일치시킨다.\r\n const lang = TalkFlowClient.normalizeLanguageCode(myLang);\r\n if (message && message.translations && lang && message.translations[lang]) {\r\n return message.translations[lang];\r\n }\r\n return message ? message.content : null;\r\n }\r\n\r\n /**\r\n * 회사 조직도 + 인원 — <b>방 만들 때 «조직으로 초대»</b> 가 쓴다.\r\n *\r\n * <p>조직을 고르면 <b>하위 조직 인원까지</b> 초대된다. 그래서 각 노드의 {@code memberCount} 는\r\n * 자손을 포함한 누적이고, {@code members} 는 그 노드 <b>직속</b>만이다 — 누적 목록이 필요하면\r\n * {@code children} 을 따라 내려가며 모은다(상위마다 중복해 실으면 응답이 제곱으로 커진다).</p>\r\n *\r\n * <p>부서가 없는 회원은 {@code unassignedMembers} 에 있다. 퇴사·차단 회원은 어디에도 없다.</p>\r\n *\r\n * @returns {Promise<Object>} `{tree, unassignedMembers, totalMemberCount, membersOmitted}`\r\n *\r\n * @example\r\n * const dir = await client.getCompanyDirectory();\r\n * // 조직 하나를 고르면 그 자손까지 전부 모은다\r\n * const collect = (node) => [...node.members, ...node.children.flatMap(collect)];\r\n */\r\n async getCompanyDirectory() {\r\n this._checkToken();\r\n return this.apiClient.get('/api/v1/company/directory');\r\n }\r\n\r\n /**\r\n * 사용자 목록 조회\r\n *\r\n * <p>응답 행에 {@code email}/{@code phone}/{@code orgNodeId}/{@code orgName} 이 포함된다 —\r\n * 부서명은 저장값이 아니라 조회 시점에 조직 마스터에서 해소한 값이다.</p>\r\n *\r\n * <p>{@code email} 은 <b>연락용</b>이다. 로그인 키인 {@code loginEmail} 은 어떤 목록에도\r\n * 실리지 않는다 — 둘은 다른 값이고, 퇴사 시 {@code loginEmail} 만 비워진다.</p>\r\n *\r\n * <p>퇴사·차단된 회원은 목록·검색 어디에도 나오지 않는다.</p>\r\n *\r\n * @param {Object} [params]\r\n * @param {number} [params.size=50] - 페이지 크기 (1-100)\r\n * @param {string} [params.lastId] - 마지막 사용자 ID\r\n * @param {number} [params.lastSortValue] - 마지막 정렬값\r\n * @returns {Promise<Object>}\r\n */\r\n async getUsers(params = {}) {\r\n this._checkToken();\r\n const { size = 50, lastId, lastSortValue } = params;\r\n return this.apiClient.get('/api/v1/users', { size, lastId, lastSortValue });\r\n }\r\n\r\n /**\r\n * 사용자 검색\r\n * @param {Object} params\r\n * @param {string} params.keyword - 검색 키워드 (필수, max 50자)\r\n * @param {number} [params.limit=20] - 결과 수 (1-50)\r\n * @returns {Promise<Object>}\r\n */\r\n async searchUsers(params) {\r\n this._checkToken();\r\n const { keyword, limit = 20 } = params;\r\n return this.apiClient.get('/api/v1/users/search', { keyword, limit });\r\n }\r\n\r\n /**\r\n * 토큰 필요 여부 확인\r\n * @private\r\n * @throws {Error} 토큰이 없는 경우\r\n */\r\n _checkToken() {\r\n if (!this.options.jwtToken) {\r\n throw new Error(\r\n 'JWT token is required for this operation. Sign in with ' +\r\n 'TalkFlowClient.loginUser({ serverUrl, email, password }) and pass the ' +\r\n 'returned accessToken via setToken(). See README \"로그인\".'\r\n );\r\n }\r\n }\r\n\r\n // ==================== 유틸리티 ====================\r\n\r\n /**\r\n * 현재 사용자 ID\r\n * @returns {string|null}\r\n */\r\n getUserId() {\r\n return this.userId;\r\n }\r\n\r\n /**\r\n * 초기화 상태 확인\r\n * @returns {boolean}\r\n */\r\n isInitialized() {\r\n return this._initialized;\r\n }\r\n\r\n /**\r\n * 서브 클라이언트 초기화 상태 확인\r\n * @returns {boolean}\r\n */\r\n isReady() {\r\n return this._initialized && !!this.connectionManager;\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level - LogLevel 값\r\n */\r\n setLogLevel(level) {\r\n this.options.logLevel = level;\r\n this.logger.setLevel(level);\r\n this.apiClient.logger?.setLevel(level);\r\n\r\n if (this.connectionManager) {\r\n this.connectionManager.setLogLevel(level);\r\n }\r\n if (this.chat) {\r\n this.chat.setLogLevel(level);\r\n }\r\n if (this.webrtc) {\r\n this.webrtc.setLogLevel(level);\r\n }\r\n }\r\n\r\n /**\r\n * 전체 상태 요약\r\n * @returns {Object}\r\n */\r\n getStatus() {\r\n return {\r\n initialized: this._initialized,\r\n ready: this.isReady(),\r\n hasToken: this.hasToken(),\r\n connectionState: this._state,\r\n isConnected: this.isConnected(),\r\n userId: this.userId,\r\n chat: this.chat ? {\r\n subscribedRooms: this.chat.getSubscribedRooms()\r\n } : null,\r\n webrtc: this.webrtc ? {\r\n isInCall: this.webrtc.isInCall(),\r\n currentRoom: this.webrtc.getCurrentRoom(),\r\n participants: this.webrtc.getParticipants(),\r\n mediaState: this.webrtc.getMediaState()\r\n } : null\r\n };\r\n }\r\n\r\n /**\r\n * 리소스 정리\r\n */\r\n async destroy() {\r\n await this.disconnect();\r\n this._authUnsubscribe?.();\r\n this._authErrorUnsubscribe?.();\r\n this.authSession?.detach(this);\r\n\r\n if (this.chat) {\r\n this.chat.destroy();\r\n }\r\n if (this.webrtc) {\r\n this.webrtc.destroy();\r\n }\r\n if (this.connectionManager) {\r\n await this.connectionManager.destroy();\r\n }\r\n\r\n this.removeAllListeners();\r\n\r\n this._initialized = false;\r\n this.logger.info('TalkFlowClient destroyed');\r\n }\r\n}\r\n\r\n// 정적 속성으로 상수 노출\r\nTalkFlowClient.ConnectionState = ConnectionState;\r\nTalkFlowClient.ErrorTypes = ErrorTypes;\r\nTalkFlowClient.LogLevel = LogLevel;\r\nTalkFlowClient.Environment = Environment;\r\n\r\napplySessionMethods(TalkFlowClient);\r\napplyDelegatedMethods(TalkFlowClient);\r\n\r\nexport default TalkFlowClient;\r\n","import { validateAndParseJWT, isJWTExpired } from '../utils/jwtUtils.js';\r\nimport ConnectionManager from '../core/ConnectionManager.js';\r\nimport ChatClient from '../chat/ChatClient.js';\r\nimport WebRTCClient from '../webrtc/WebRTCClient.js';\r\nimport PushManager from '../push/PushManager.js';\r\nimport { handleMailPush } from './mailNotifications.js';\r\n\r\nexport function initializeSubClients(client) {\r\n if (client.connectionManager) {\r\n return;\r\n }\r\n\r\n if (!client.userId) {\r\n throw new Error('userId is required to initialize sub-clients. Please set JWT token first.');\r\n }\r\n\r\n client.connectionManager = new ConnectionManager({\r\n serverUrl: client.options.serverUrl,\r\n jwtToken: client.options.jwtToken,\r\n apiKey: client.options.apiKey,\r\n projectId: client.options.projectId,\r\n useSockJS: client.options.useSockJS,\r\n reconnectDelay: client.options.reconnectDelay,\r\n maxReconnectDelay: client.options.maxReconnectDelay,\r\n maxReconnectAttempts: client.options.maxReconnectAttempts,\r\n reconnectRecoveryDelay: client.options.reconnectRecoveryDelay,\r\n heartbeatIncoming: client.options.heartbeatIncoming,\r\n heartbeatOutgoing: client.options.heartbeatOutgoing,\r\n resumeOnVisibility: client.options.resumeOnVisibility,\r\n logLevel: client.options.logLevel\r\n });\r\n client.connectionManager.getAccessToken = async ({ forceRefresh = false } = {}) => {\r\n if (!client.authSession) return client.options.jwtToken;\r\n return client.authSession.getAccessToken({ forceRefresh, rejectedToken: client.connectionManager?.jwtToken });\r\n };\r\n // 레거시 모드의 공급자는 정적 토큰을 돌려줄 뿐이다. 새 토큰은 updateToken() 으로만 온다.\r\n client.connectionManager.canRefreshToken = () => !!client.authSession;\r\n\r\n client._chat = new ChatClient({\r\n connectionManager: client.connectionManager,\r\n apiClient: client.apiClient,\r\n userId: client._userId,\r\n syncOnReconnect: client.options.syncOnReconnect,\r\n assistantStreamReplay: client.options.assistantStreamReplay,\r\n rewindMillis: client.options.rewindMillis,\r\n maxSyncPages: client.options.maxSyncPages,\r\n syncPageSize: client.options.syncPageSize,\r\n logLevel: client.options.logLevel\r\n });\r\n\r\n client._webrtc = new WebRTCClient({\r\n connectionManager: client.connectionManager,\r\n apiClient: client.apiClient,\r\n userId: client._userId,\r\n iceServers: client.options.iceServers,\r\n logLevel: client.options.logLevel\r\n });\r\n\r\n client._setupEventForwarding();\r\n client.logger.debug('Sub-clients initialized');\r\n}\r\n\r\nexport function applySessionMethods(TalkFlowClient) {\r\n TalkFlowClient.prototype.connect = async function connect(jwt, options = {}) {\r\n const { enablePush = false } = options;\r\n\r\n if (jwt) {\r\n await this.setToken(jwt);\r\n }\r\n\r\n if (this.authSession) {\r\n await this._authReady;\r\n this._authConnectionRequested = true;\r\n const token = await this.authSession.getAccessToken();\r\n await this.setToken(token);\r\n this.authSession.schedule();\r\n }\r\n\r\n if (!this.options.jwtToken) {\r\n throw new Error(\r\n 'JWT token is required. Sign in with TalkFlowClient.loginUser({ serverUrl, ' +\r\n 'email, password }) and pass the returned accessToken to the SDK via the ' +\r\n 'jwtToken option, setToken(), or connect(jwt). See README \"로그인\".'\r\n );\r\n }\r\n\r\n if (!this.authSession && isJWTExpired(this.options.jwtToken)) {\r\n throw new Error('JWT token has expired. Please update the token before connecting.');\r\n }\r\n\r\n if (!this.connectionManager) {\r\n this._initializeSubClients();\r\n }\r\n\r\n await this.connectionManager.connect();\r\n\r\n if (this.webrtc) {\r\n await this.webrtc.enableIncomingCalls();\r\n }\r\n\r\n if (this.chat && this.options.autoSubscribeRoomList) {\r\n try {\r\n await this.chat.subscribeRoomList();\r\n } catch (error) {\r\n this.logger.warn('Failed to auto-subscribe room list (non-fatal):', error);\r\n }\r\n }\r\n\r\n if (enablePush) {\r\n this.enablePushNotifications();\r\n }\r\n\r\n this.logger.info('Connected to server');\r\n };\r\n\r\n TalkFlowClient.prototype.disconnect = async function disconnect() {\r\n this._authConnectionRequested = false;\r\n this.authSession?.schedule();\r\n if (!this.connectionManager) {\r\n return;\r\n }\r\n\r\n if (this.webrtc && this.webrtc.isInCall()) {\r\n this.webrtc.endCall();\r\n }\r\n\r\n if (this.webrtc) {\r\n this.webrtc.disableIncomingCalls();\r\n }\r\n\r\n if (this.chat) {\r\n this.chat.unsubscribeAllRooms();\r\n this.chat.unsubscribeRoomList();\r\n this.chat.unsubscribeMail();\r\n }\r\n\r\n await this.connectionManager.disconnect();\r\n this.logger.info('Disconnected from server');\r\n };\r\n\r\n TalkFlowClient.prototype.logout = async function logout() {\r\n if (this.authSession) return this.authSession.logout();\r\n try {\r\n await this.apiClient.post('/v1/auth/signout');\r\n this.logger.info('Logged out from server');\r\n } catch (error) {\r\n this.logger.warn('Server logout failed (proceeding with local cleanup):', error.message);\r\n }\r\n\r\n await this.disconnect();\r\n\r\n if (this.pushManager) {\r\n this.pushManager.reset();\r\n this._pushEnablePromise = null;\r\n }\r\n\r\n this.options.jwtToken = null;\r\n this._userId = null;\r\n this.apiClient.setJwtToken(null);\r\n\r\n this.emit('loggedOut');\r\n };\r\n\r\n /**\r\n * 웹 푸시 알림을 활성화한다.\r\n *\r\n * <p>전체 흐름: Firebase 초기화 → 서비스 워커 등록 → 권한 요청 → FCM 토큰 발급 → 서버 등록 →\r\n * 포그라운드 메시지 핸들러 등록 → SW 에 projectId 등록.</p>\r\n *\r\n * <h3>호출 패턴</h3>\r\n * <ul>\r\n * <li><b>자동 활성화 (비차단)</b> — {@code client.enablePushNotifications();} 처럼\r\n * await 없이 호출하면 fire-and-forget 으로 동작한다. 메인 흐름 (UI 렌더링 등) 을 차단하지 않는다.\r\n * 이 경우 결과는 {@code 'pushEnabled'} / {@code 'pushFailed'} 이벤트로 받는다.\r\n * <b>중요</b>: 이 패턴을 쓸 때는 호출 <b>전에</b> 이벤트 리스너를 등록해야 한다 — 그렇지 않으면\r\n * 즉시 emit 되는 실패 이벤트를 놓칠 수 있다.</li>\r\n * <li><b>사용자 액션 (await)</b> — 버튼 클릭 등 사용자 명시적 액션에서는\r\n * {@code const result = await client.enablePushNotifications();} 으로 결과를 즉시 받아서\r\n * UI 분기에 사용한다. throw 하지 않으므로 try/catch 불필요.</li>\r\n * </ul>\r\n *\r\n * <p>이 메서드는 throw 하지 않는다. 실패는 {@code { ok: false, reason, error }} 형태의 객체로 반환된다.\r\n * 따라서 fire-and-forget 으로 호출해도 unhandled rejection 이 발생하지 않는다.</p>\r\n *\r\n * @param {Object} [options]\r\n * @param {Object} [options.firebaseConfig] - 커스텀 Firebase 설정 (테스트/검증용)\r\n * @param {string} [options.vapidKey] - 커스텀 VAPID 키\r\n * @param {string} [options.serviceWorkerPath] - 서비스 워커 경로\r\n * @returns {Promise<{ok: true, alreadyEnabled?: boolean} | {ok: false, reason: string, error: Error}>}\r\n * 성공 시 {@code { ok: true }}, 실패 시 {@code { ok: false, reason, error }}.\r\n * {@code reason} 은 {@link PushErrorCode} 값 또는 {@code 'NO_TOKEN'}.\r\n */\r\n TalkFlowClient.prototype.enablePushNotifications = async function enablePushNotifications(options = {}) {\r\n // JWT 토큰 미설정 시 명확한 분류 에러로 통보 (throw 하지 않고 결과 객체 반환).\r\n if (!this.options.jwtToken) {\r\n const error = new Error('JWT token is required to enable push notifications.');\r\n this.emit('pushFailed', { reason: 'NO_TOKEN', error });\r\n return { ok: false, reason: 'NO_TOKEN', error };\r\n }\r\n\r\n if (!this.pushManager) {\r\n this._pushManager = new PushManager({\r\n apiClient: this.apiClient,\r\n projectId: this.options.projectId,\r\n firebaseConfig: options.firebaseConfig,\r\n vapidKey: options.vapidKey,\r\n serviceWorkerPath: options.serviceWorkerPath,\r\n logLevel: this.options.logLevel\r\n });\r\n\r\n // 포그라운드 메시지 콜백 — projectId 필터링 + activeRoom suppress + messageId dedup 후 client emit.\r\n // PushManager 자체는 EventEmitter 가 아니므로 콜백 오버라이드로 우회한다.\r\n //\r\n // dedup: 같은 messageId 의 푸시가 서버측 멱등 race 복구 / FCM 재전송 등으로 중복 도착할 수 있어\r\n // 5분 TTL Map 으로 한 번만 emit. setTimeout 으로 자동 cleanup.\r\n const PUSH_DEDUP_TTL_MS = 5 * 60 * 1000;\r\n this.pushManager._onForegroundMessage = (payload) => {\r\n const data = payload.data || {};\r\n\r\n if (data.projectId && data.projectId !== this.options.projectId) {\r\n this.logger.debug('다른 프로젝트 푸시 무시:', data.projectId);\r\n return;\r\n }\r\n\r\n // Gmail 메일 푸시 — 웹소켓 미리보기와 합쳐 newMail/newMailSummary 로만 낸다(pushNotification 아님).\r\n if (handleMailPush(this, payload)) {\r\n return;\r\n }\r\n\r\n const activeRoomId = this.chat?.getActiveRoom();\r\n\r\n if (activeRoomId && activeRoomId === data.roomId) {\r\n this.logger.debug('포그라운드 푸시 suppress (현재 방):', data.roomId);\r\n return;\r\n }\r\n\r\n // messageId 기반 dedup — 키가 없는 시스템 푸시 등은 통과.\r\n if (data.messageId) {\r\n if (!this._recentPushMessageIds) {\r\n this._recentPushMessageIds = new Map();\r\n }\r\n if (this._recentPushMessageIds.has(data.messageId)) {\r\n this.logger.debug('포그라운드 푸시 dedup:', data.messageId);\r\n return;\r\n }\r\n const timer = setTimeout(() => {\r\n this._recentPushMessageIds.delete(data.messageId);\r\n }, PUSH_DEDUP_TTL_MS);\r\n this._recentPushMessageIds.set(data.messageId, timer);\r\n }\r\n\r\n this.emit('pushNotification', {\r\n title: payload.notification?.title || data.title,\r\n body: payload.notification?.body || data.body,\r\n data\r\n });\r\n };\r\n }\r\n\r\n // 가드 1: 이미 활성화됨 — 중복 호출은 성공으로 간주.\r\n if (this.pushManager.isEnabled()) {\r\n this.logger.debug('웹 푸시가 이미 활성화되어 있어 중복 호출을 건너뜁니다.');\r\n return { ok: true, alreadyEnabled: true };\r\n }\r\n\r\n // 가드 2: 진행 중 — 같은 Promise 를 공유해 두 호출자가 동일 결과를 받도록 한다.\r\n if (this._pushEnablePromise) {\r\n this.logger.debug('웹 푸시 활성화가 이미 진행 중입니다.');\r\n return this._pushEnablePromise;\r\n }\r\n\r\n // 본 활성화 흐름 — throw 하지 않고 결과 객체로 통일.\r\n // try/catch 안에서 emit + return 모두 처리하여 fire-and-forget 호출자도 안전하게 한다.\r\n const promise = (async () => {\r\n try {\r\n await this.pushManager.enable();\r\n this.emit('pushEnabled');\r\n return { ok: true };\r\n } catch (error) {\r\n // PushError 면 분류 코드 사용, 아니면 'UNKNOWN'.\r\n const reason = (error && error.code) ? error.code : 'UNKNOWN';\r\n this.logger.warn('Failed to enable push notifications:', reason, error?.message);\r\n this.emit('pushFailed', { reason, error });\r\n return { ok: false, reason, error };\r\n }\r\n })();\r\n\r\n this._pushEnablePromise = promise;\r\n\r\n // self-clear — 본인이 현재 진행 중인 Promise 일 때만 null 처리 (race 방지).\r\n promise.finally(() => {\r\n if (this._pushEnablePromise === promise) {\r\n this._pushEnablePromise = null;\r\n }\r\n });\r\n\r\n return promise;\r\n };\r\n\r\n TalkFlowClient.prototype.consumePendingRoom = async function consumePendingRoom() {\r\n return PushManager.consumePendingRoom(this.options.projectId);\r\n };\r\n\r\n TalkFlowClient.prototype.consumePendingMail = async function consumePendingMail() {\r\n return PushManager.consumePendingMail(this.options.projectId);\r\n };\r\n\r\n TalkFlowClient.prototype.setCurrentDeviceEnabled = function setCurrentDeviceEnabled(enabled) {\r\n return this.pushManager?.setCurrentDeviceEnabled(enabled);\r\n };\r\n\r\n TalkFlowClient.prototype.setDeviceEnabled = function setDeviceEnabled(deviceId, enabled) {\r\n return this.pushManager?.setDeviceEnabled(deviceId, enabled);\r\n };\r\n\r\n TalkFlowClient.prototype.getMyDevices = function getMyDevices() {\r\n return this.pushManager?.getMyDevices();\r\n };\r\n\r\n /**\r\n * 현재 브라우저의 푸시 권한 상태를 조회한다.\r\n *\r\n * <p>{@code enablePushNotifications()} 호출 <b>전에도</b> 사용 가능 — pushManager 인스턴스 불필요.\r\n * UI 분기 (granted → 자동 enable / default → 버튼 노출 / denied → 설정 가이드 / unsupported → 숨김) 에 사용한다.</p>\r\n *\r\n * @returns {'granted'|'denied'|'default'|'unsupported'}\r\n */\r\n TalkFlowClient.prototype.getPushPermissionState = function getPushPermissionState() {\r\n return PushManager.getPermissionState();\r\n };\r\n\r\n /**\r\n * SDK 내부 푸시 활성화 상태 (FCM 토큰 발급 + 서버 등록까지 완료 여부).\r\n *\r\n * <p>주의: 이 값은 SDK 인스턴스의 메모리 상태일 뿐, 브라우저 권한 상태와 다르다.\r\n * 페이지 새로고침 후에는 enable 호출 전까지 false. 권한 상태는 {@link #getPushPermissionState} 사용.</p>\r\n *\r\n * @returns {boolean}\r\n */\r\n TalkFlowClient.prototype.isPushEnabled = function isPushEnabled() {\r\n return this.pushManager?.isEnabled() ?? false;\r\n };\r\n\r\n /**\r\n * 현재 발급된 FCM 토큰. enable 호출 후에만 유효, 그 외에는 null.\r\n * @returns {string|null}\r\n */\r\n TalkFlowClient.prototype.getPushToken = function getPushToken() {\r\n return this.pushManager?.getToken() ?? null;\r\n };\r\n\r\n /**\r\n * 푸시 상태 초기화 — 포그라운드 리스너 / FCM 토큰 / SW projectId 등록을 해제한다.\r\n * 로그아웃 또는 사용자 전환 시 호출. (logout() 내부에서도 자동 호출되므로 일반 흐름에선 불필요.)\r\n */\r\n TalkFlowClient.prototype.resetPush = function resetPush() {\r\n this.pushManager?.reset();\r\n this._pushEnablePromise = null;\r\n };\r\n\r\n TalkFlowClient.prototype.setToken = async function setToken(token) {\r\n const { userId } = validateAndParseJWT(token, { validateExpiry: false });\r\n const needReinitialize = this.userId && this.userId !== userId;\r\n\r\n this.options.jwtToken = token;\r\n this._userId = userId;\r\n this.apiClient.setJwtToken(token);\r\n\r\n if (needReinitialize && this.connectionManager) {\r\n await this.disconnect();\r\n this._chat?.destroy();\r\n this._webrtc?.destroy();\r\n // destroy() 로 버려야 lifecycle listener(visibilitychange/pageshow/online)가 해제된다.\r\n // disconnect() 만 하고 참조를 끊으면 죽은 매니저가 window 에 매달린 채 남아,\r\n // 사용자 전환이 잦은 앱에서 복귀 이벤트마다 유령 매니저가 함께 깨어난다.\r\n await this.connectionManager.destroy();\r\n if (this.pushManager) {\r\n this.pushManager.reset();\r\n this._pushEnablePromise = null;\r\n this._pushManager = null;\r\n }\r\n this.connectionManager = null;\r\n this._chat = null;\r\n this._webrtc = null;\r\n\r\n this._initializeSubClients();\r\n } else if (this.connectionManager) {\r\n this.connectionManager.updateToken(token);\r\n } else {\r\n this._initializeSubClients();\r\n }\r\n\r\n this.logger.info('JWT token set', { userId });\r\n this.authSession?.replaceAccessToken(token);\r\n this.emit('tokenSet', { userId });\r\n };\r\n\r\n TalkFlowClient.prototype.updateToken = async function updateToken(newToken) {\r\n await this.setToken(newToken);\r\n };\r\n}\r\n","/**\r\n * 방별 미독 카운트 계산기 (READ_SYNC_DESIGN v2.1 §6).\r\n *\r\n * <p>SDK 는 방 목록 상태를 소유하지 않는다(페이징·필터·프레임워크 상태와 충돌). 대신 순서 역전에\r\n * 안전한 \"카운트 계산\"만 이 헬퍼로 제공하고, 앱이 결과를 자신의 room store 에 반영한다.</p>\r\n *\r\n * <p><b>왜 단순 절대값 세팅이 안 되나</b>: 서로 다른 발신 흐름에서 나온 이벤트는 도착 순서가 논리적\r\n * 발생 순서와 다를 수 있다. 예컨대 내가 방을 읽어 나간 {@code ROOM_UNREAD_UPDATED(count=0, v=13)} 이\r\n * 신규 {@code MESSAGE_RECEIVED(+1, v=12)} 보다 먼저 도착할 수 있다. 단순히 절대값을 덮으면 뒤늦게 온\r\n * v12 delta 가 badge 를 1 로 되돌린다. 그래서 방마다 스냅샷(마지막 절대값)과 그 이후의 delta 를 분리\r\n * 보관하고, version 으로 이미 반영된 delta 를 정리한다.</p>\r\n *\r\n * <p><b>방별 상태</b>: {@code snapshotCount}/{@code snapshotVersion}(마지막 절대값) +\r\n * {@code pendingDeltas}(messageId → {version, delta}). 표시값 = snapshotCount + Σ pending.delta.</p>\r\n *\r\n * <p><b>버전 전제</b>: 순서 정합성은 서버가 이벤트/응답에 {@code version} 을 실어줄 때만 보장된다.\r\n * version 이 아예 없는 구서버 구간에서는 순서 역전을 판별할 수 없다 — 이 경우 이미 versioned baseline 이\r\n * 있으면 비교 불가한 legacy 스냅샷을 무시해 최신 pair 를 지키고, 최종 정합은 REST 재조회에 의존한다.</p>\r\n *\r\n * @example\r\n * const reconciler = new RoomUnreadReconciler(client.userId); // JWT 에서 SDK 가 추출한 내 userId\r\n * // 초기 REST 페이지를 baseline 으로 투입\r\n * rooms.forEach(r => reconciler.applySnapshot(r.id, r.unreadCount, r.unreadVersion));\r\n * client.on('roomListMessage', e => { reconciler.applyMessageReceived(e); render(e.roomId); });\r\n * client.on('roomListUnreadUpdated', e => { reconciler.applySnapshot(e.roomId, e.roomUnreadCount, e.unreadVersion); render(e.roomId); });\r\n * function render(roomId) { store.setBadge(roomId, reconciler.getCount(roomId)); } // null 이면 REST 로 표시\r\n */\r\nexport class RoomUnreadReconciler {\r\n /**\r\n * @param {string} currentUserId - 내 사용자 ID. SDK 가 JWT 에서 추출한 값을 {@code client.userId} 로\r\n * 얻는다(별도 관리 불필요). 내가 보낸 메시지의 MESSAGE_RECEIVED 가 내 badge 를 올리지 않도록 걸러내는\r\n * 데만 쓰는 <b>표시용 값</b>이라 보안 결정에 관여하지 않는다. (서버도 발신자를 unread 대상에서 제외하지만,\r\n * 방 리스트 이벤트는 발신자에게도 전달되므로 클라에서 한 번 더 거른다.)\r\n */\r\n constructor(currentUserId) {\r\n this._currentUserId = currentUserId;\r\n /** @type {Map<string, {snapshotCount: (number|null), snapshotVersion: (number|null), pendingDeltas: Map<string, {version: (number|null), delta: number}>}>} */\r\n this._rooms = new Map();\r\n }\r\n\r\n _room(roomId) {\r\n let state = this._rooms.get(roomId);\r\n if (!state) {\r\n state = { snapshotCount: null, snapshotVersion: null, pendingDeltas: new Map() };\r\n this._rooms.set(roomId, state);\r\n }\r\n return state;\r\n }\r\n\r\n /**\r\n * MESSAGE_RECEIVED(delta +1) 반영.\r\n * @param {{roomId: string, messageId: string, actorId?: string, unreadVersion?: (number|null)}} event\r\n */\r\n applyMessageReceived(event) {\r\n if (!event || !event.roomId || !event.messageId) return;\r\n // 발신자 본인 메시지 → 내 badge 증가 금지.\r\n if (event.actorId != null && event.actorId === this._currentUserId) return;\r\n\r\n const state = this._room(event.roomId);\r\n // 같은 메시지 중복 수신(서버측 멱등 race 복구 등) → 한 번만.\r\n if (state.pendingDeltas.has(event.messageId)) return;\r\n\r\n const version = event.unreadVersion == null ? null : event.unreadVersion;\r\n // 이미 baseline 에 반영된 delta(스냅샷 version 이하) → 무시.\r\n if (state.snapshotVersion != null && version != null && version <= state.snapshotVersion) return;\r\n\r\n state.pendingDeltas.set(event.messageId, { version, delta: 1 });\r\n }\r\n\r\n /**\r\n * 절대값 스냅샷 반영 — REST 응답 또는 ROOM_UNREAD_UPDATED.\r\n * @param {string} roomId\r\n * @param {(number|null|undefined)} count 방별 내 미독 절대값. null/undefined 면 무시(미계산 방).\r\n * @param {(number|null|undefined)} version 그 카운트가 확정된 방 version.\r\n */\r\n applySnapshot(roomId, count, version) {\r\n if (!roomId || count == null) return;\r\n const v = version == null ? null : version;\r\n const state = this._room(roomId);\r\n\r\n // 이미 versioned baseline 이 있는데 version 없는(구서버) 스냅샷이 오면 비교 불가 → 무시.\r\n // 그대로 count 를 덮으면 snapshotVersion 은 최신인데 count 는 옛 값인 (옛 count, 최신 version) 조합이\r\n // 되어, 백엔드가 원자 read 로 막은 순서 훼손이 SDK 에서 재현된다.\r\n if (state.snapshotVersion != null && v == null) return;\r\n\r\n // 더 오래된 스냅샷은 무시(stale).\r\n if (state.snapshotVersion != null && v != null && v < state.snapshotVersion) return;\r\n\r\n state.snapshotCount = count;\r\n if (v != null) state.snapshotVersion = v;\r\n\r\n // 이 스냅샷에 이미 반영된 pending 제거, 더 최신 pending 은 보존(v13 유실 방지).\r\n // version 이 있을 때만 정리 가능 — version 없는 스냅샷(구서버)은 pending 을 건드리지 않는다.\r\n if (v != null) {\r\n for (const [messageId, d] of state.pendingDeltas) {\r\n if (d.version == null || d.version <= v) {\r\n state.pendingDeltas.delete(messageId);\r\n }\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * 방의 현재 표시 카운트.\r\n * @param {string} roomId\r\n * @returns {(number|null)} baseline(스냅샷)이 아직 없으면 null — 앱은 REST 조회 결과로 표시한다.\r\n */\r\n getCount(roomId) {\r\n const state = this._rooms.get(roomId);\r\n if (!state || state.snapshotCount == null) return null;\r\n let sum = state.snapshotCount;\r\n for (const d of state.pendingDeltas.values()) sum += d.delta;\r\n return sum;\r\n }\r\n\r\n /** 방을 상태에서 제거(퇴장/추방 등). */\r\n removeRoom(roomId) {\r\n this._rooms.delete(roomId);\r\n }\r\n\r\n /** 전체 초기화(로그아웃/재연결 리셋 등). */\r\n clear() {\r\n this._rooms.clear();\r\n }\r\n}\r\n"],"names":["EventEmitter","constructor","this","_events","Map","on","event","listener","has","set","Set","get","add","off","once","onceWrapper","args","apply","_originalListener","listeners","l","delete","size","emit","data","forEach","error","console","removeAllListeners","clear","listenerCount","eventNames","Array","from","keys","ConnectionState","DISCONNECTED","CONNECTING","CONNECTED","RECONNECTING","ERROR","ErrorTypes","CONNECTION_FAILED","CONNECTION_LOST","CONNECTION_TIMEOUT","SUBSCRIPTION_REQUIRED","JWT_INVALID","JWT_EXPIRED","JWT_PARSE_FAILED","UNAUTHORIZED","API_ERROR","API_TIMEOUT","CHAT_ROOM_NOT_FOUND","CHAT_MESSAGE_FAILED","CHAT_SUBSCRIPTION_FAILED","MEDIA_ACCESS_DENIED","SCREEN_SHARE_DENIED","PEER_CONNECTION_FAILED","ICE_CONNECTION_FAILED","SIGNALING_FAILED","DEVICE_SWITCH_FAILED","ENUMERATE_DEVICES_FAILED","CALL_ROOM_NOT_FOUND","INVALID_STATE","UNKNOWN_ERROR","SignalTypes","CALL_OFFER","CALL_ANSWER","ICE_CANDIDATE","JOIN_ROOM","LEAVE_ROOM","PEER_JOINED","PEER_LEFT","VIDEO_STATE_CHANGED","AUDIO_STATE_CHANGED","SCREEN_SHARE_STARTED","SCREEN_SHARE_ENDED","CALL_REQUEST","CALL_ACCEPT","CALL_REJECT","CALL_CANCEL","CALL_END","CALL_INVITATION","CALL_BUSY","ChatRoomType","DIRECT","GROUP","PRIVATE_GROUP","TEAM","RoomListEventType","MESSAGE_RECEIVED","MESSAGE_DELETED","MESSAGE_UPDATED","ROOM_CREATED","ROOM_JOINED","ROOM_LEFT","ROOM_UPDATED","ROOM_KICKED","ROOM_BANNED","ROOM_OWNER_CHANGED","ROOM_DELETED","MESSAGE_RETENTION_CLEANUP","ROOM_UNREAD_UPDATED","WebSocketPaths","SOCKJS_ENDPOINT","NATIVE_ENDPOINT","APP_PREFIX","TOPIC_PREFIX","QUEUE_PREFIX","USER_PREFIX","getChatDestination","roomId","getChatReadDestination","getChatTypingDestination","getChatAssistantStreamDestination","getChatSummaryDestination","ROOM_LIST_USER_DESTINATION","MAIL_USER_DESTINATION","getWebRTCDestination","getWebRTCUserDestination","CHAT_SEND","CHAT_READ","CHAT_TYPING","WEBRTC_SIGNAL","Environment","DEVELOPMENT","STAGING","PRODUCTION","Endpoints","serverUrl","wsEndpoint","DefaultConfig","environment","reconnectDelay","maxReconnectDelay","maxReconnectAttempts","reconnectRecoveryDelay","heartbeatIncoming","heartbeatOutgoing","resumeOnVisibility","syncOnReconnect","rewindMillis","maxSyncPages","syncPageSize","apiTimeout","iceServers","urls","logLevel","getServerUrl","env","LogLevel","DEBUG","INFO","WARN","NONE","Logger","level","prefix","setLevel","setPrefix","_format","Date","toISOString","debug","info","warn","ApiClient","options","baseUrl","replace","apiKey","projectId","jwtToken","timeout","logger","setJwtToken","token","_getHeaders","headers","startsWith","request","method","path","authProvider","skipAuth","_request","getAccessToken","sentToken","generation","status","includes","response","code","forceRefresh","rejectedToken","retryToken","retryGeneration","retryError","authenticationFailed","body","params","url","searchParams","URLSearchParams","Object","entries","key","value","append","queryString","toString","controller","AbortController","timeoutId","setTimeout","abort","fetch","credentials","JSON","stringify","undefined","signal","clearTimeout","name","timeoutError","Error","contentType","json","text","ok","message","post","put","patch","upload","file","_upload","fieldName","onProgress","Promise","resolve","reject","xhr","XMLHttpRequest","form","FormData","open","setRequestHeader","onprogress","e","lengthComputable","loaded","total","percent","Math","round","onAbort","aborted","addEventListener","cleanupSignal","removeEventListener","onload","parsed","responseText","getResponseHeader","parse","onerror","err","ontimeout","onabort","send","sessions","TERMINAL_CODES","isTerminalAuthError","tokenClaims","split","atob","AuthSession","forServer","super","state","clients","refreshPromise","timer","peerWaiters","markerKey","marker","_readMarker","serverClock","channel","storageListener","stored","revision","logout","_clear","catch","globalThis","window","BroadcastChannel","onmessage","_receive","unref","localStorage","getItem","_storageError","_writeMarker","setItem","_newMarker","pending","previous","crypto","randomUUID","String","now","random","deviceId","pendingRefreshToken","refreshToken","setDeviceId","_monotonic","performance","_serverNow","server","max","mono","wall","_shareData","serverTime","_publish","type","postMessage","_setState","force","getAuthState","isLoggedIn","_fresh","accessToken","Number","exp","refreshMargin","attach","client","detach","schedule","_enqueue","work","operation","transition","then","_accept","publish","async","_cancelled","clientKey","user","userId","identityChanged","refreshRequestId","isFinite","_roundTripMillis","min","all","map","_acceptAuthSession","_reconnectClients","_authConnectionRequested","isConnected","manager","connectionManager","_authenticatedToken","forceReconnect","_clearAuthSession","requestId","session","incoming","current","sid","_peerToken","done","_locked","locks","navigator","httpQueue","_newRefreshRequestId","bytes","Uint8Array","getRandomValues","byte","padStart","join","started","defineProperty","login","email","password","previousRefreshToken","workspaceSelectionRequired","workspaces","isArray","length","some","workspace","companyName","companyRole","workspaceSelection","restoreSession","_loggedOut","peer","finally","cleanup","result","Authorization","changePassword","reauthenticationRequired","replaceAccessToken","claims","sub","expiresIn","delay","wait","before","filter","destroy","close","stripBearer","base64UrlDecode","str","base64","padding","repeat","binaryString","c","charCodeAt","TextDecoder","decode","validateAndParseJWT","bufferSeconds","validateExpiry","validateIssuedAt","parts","payload","originalError","expiryTime","bufferTime","expiredAt","iat","clockSkew","user_id","extractUserIdFromJWT","isJWTExpired","SUBSCRIPTION_REQUIRED_CODE","getSockJS","SockJS","ConnectionManager","useSockJS","stompClient","reconnectAttempts","subscriptions","pendingSubscriptions","_intentionalDisconnect","_tearingDown","_connectRequested","_forceReconnectPromise","_lifecycleHandlers","_recoveryTimer","_terminalAuthError","_recoveryEligible","_subscriptionRequired","_installLifecycleListeners","onVisibilityChange","document","visibilityState","_onResume","onPageShow","onOnline","_removeLifecycleListeners","reason","_isSocketAlive","socket","webSocket","readyState","StompSocketState","OPEN","_doForceReconnect","_connectPromise","_cancelReconnectRecovery","deactivate","connect","prevState","_getWebSocketUrl","endpoint","_createStompClient","wsUrl","stomp","config","beforeConnect","_connectionToken","attempt","active","_authRejected","_rejectedToken","updateToken","_stopForAuthError","_cancelAuthRetry","_authRetryTimer","connectHeaders","reconnectTimeMode","ReconnectionTimeMode","EXPONENTIAL","onConnect","frame","_onConnect","onDisconnect","_onDisconnect","onStompError","_onStompError","onWebSocketClose","_onWebSocketClose","onWebSocketError","_onWebSocketError","webSocketFactory","wsProtocol","wsHost","brokerURL","Client","StompJs","StompClient","_connectResolve","_connectReject","connected","activate","_drainPendingSubscriptions","attempts","isReconnect","_hasConnectedBefore","_notifiedRejectedToken","_restoreSubscriptions","_processPendingSubscriptions","serverMessage","_stopForSubscription","_recordAuthRejection","terminal","_canRecoverConnection","_handleReconnect","exhaustedClient","deactivated","_scheduleReconnectRecovery","notify","_usesStaticToken","failure","canRefreshToken","isSubscriptionRequired","entering","assign","previousSubscriptions","callback","destination","_subscribeInternal","shift","subscription","subscribe","unsubscribe","push","entry","unsubscribeAll","disconnect","getState","setLogLevel","PLAIN_TEXT_DOCUMENT_MIME_TYPES","ChatClient","apiClient","subscribedRooms","roomListSubscribed","mailSubscribed","_activeRoomId","_typingTimers","_assistantTypingTimers","_assistantTypingTimeoutMs","_assistantTypingUserId","_assistantTypingUserName","_assistantProgress","_assistantStreamTombstones","_assistantTombstoneTtlMs","_assistantTombstoneWarnThreshold","_assistantTombstoneWarned","_assistantSlotSeq","_assistantRoomGeneration","_assistantSnapshotPending","_assistantSnapshotInFlight","assistantStreamReplay","_assistantPhaseLabels","THINKING","SEARCHING","PLANNING","DOCUMENT_ANALYSIS","VIDEO_ANALYSIS","WEB_READING","WRITING","_assistantProgressFallbackTtlMs","_seenChatMessageIdsByRoom","_seenRoomListMessageIdsByRoom","_anchor","_anchorGen","_syncInFlight","_replaying","_maxSeenPerRoom","_reconnectSyncGeneration","_delayedReconnectSyncTimer","_installSyncTriggers","_onReconnectedForSync","_cancelDelayedReconnectSync","syncAfterReconnect","cursors","_supplementAfterReconnect","resumeAssistantStreams","_onResumedForSync","_onStateChangeForSync","_shouldDedupMessage","bucket","messageId","seen","oldestKey","next","getRooms","lastId","lastSortValue","getRoom","genAtStart","detail","_unwrapSuccessResponse","messages","_seedAnchorFromLatest","enterRoom","wasAlreadySubscribed","_armAssistantSnapshot","_loadAssistantSnapshot","subscribeRoom","setActiveRoom","getActiveRoom","clearActiveRoom","unsubscribeRoom","getRoomInfo","setMyRoomLanguage","language","createOneToOneRoom","friendId","createGroupRoom","roomType","roomName","description","invitedUserIds","messageRetentionHours","invitedAssistantPersonaIds","assistantMode","roomAiType","engagementIntensity","getAssistants","getRoomAiMeta","rateAssistantMessage","rating","comment","isInteger","summarizeWithAssistant","personaId","format","messageCount","uploadVideoAsset","analyzeVideo","analysis","idempotencyKey","trim","analyzeVideoWithAssistant","triggerMessageId","encodeURIComponent","getVideoAnalysis","jobId","cancelVideoAnalysis","getVideoAnalysisCapabilities","translateWithAssistant","targetLang","sourceMessageId","refreshSummary","lang","getSummary","afterSeq","getRoomPmPrompt","upsertRoomPmPrompt","content","activateRoomPmPrompt","deactivateRoomPmPrompt","getRoomPmPromptVersions","activateRoomPmPromptVersion","version","previewRoomPmPrompt","joinGroupRoom","leaveRoom","newOwnerId","transferOwnership","deleteRoom","updateGroupRoom","inviteToGroupRoom","userIdsOrData","userIds","assistantPersonaIds","kickMember","banMember","unbanMember","getBannedMembers","pinMessage","unpinMessage","createVote","vote","getVotes","cursor","limit","getVote","voteId","updateVote","deleteVote","closeVote","submitVote","optionIds","uploadVoteImage","toggleReaction","emoji","getAvailableGroupRooms","getAllGroupRooms","getMessages","isLatestPage","page","m","_normalizeMessageTimestamps","descendingMessages","newest","id","_commitAnchorFromSync","syncMessages","afterId","_advanceAnchorFromLive","_replayMessages","emitted","_handleChatMessage","eventType","_reloadLatestPage","ascending","slice","reverse","_isCursorExpired","_syncRoom","anchor","quiet","startedAt","buffer","hasMore","pages","hasNext","fetched","reloaded","recovered","truncated","durationMs","roomIds","_refreshRoomList","rooms","totalRecovered","reduce","sum","r","fetchLinkPreview","normalizedUrl","sendMessage","_generateMessageId","_sendMessageWithId","sendMessageOptimistic","baseMessageId","messageIds","promise","_attachOptimisticMetadata","stopTyping","fileInfos","replyToMessageId","decisionResponse","sendTextMessage","sendTextMessageOptimistic","uploadFile","sendFileMessage","files","fileArray","_normalizeFileArray","metas","_uploadFiles","_applyFileMetadata","metadata","sendFileMessageOptimistic","index","onUploadProgress","p","fileIndex","meta","itemMetadata","sendReply","sendReplyOptimistic","sendDecisionChoice","card","option","label","optionId","deleteMessage","deleteType","editMessage","_hasTextMessage","_baseMimeType","mimeType","raw","sep","indexOf","toLowerCase","classifyFileType","mime","_classifyFileType","resolveMessageCategory","chatMessageType","fileMetaDataList","categories","f","fileType","values","predictedMessageIds","actualMessageIds","predictionMatched","every","i","predicted","actual","optimistic","prototype","hasOwnProperty","call","sentAt","_toEpochMillis","editedAt","replyTo","isNaN","chatDestination","readDestination","_handleReadEvent","typingDestination","_handleTypingEvent","snapshotGeneration","assistantStreamDestination","_handleAssistantStreamEvent","summaryDestination","_handleSummaryEvent","memberJoinedHandler","members","participantCount","activeParticipantCount","timestamp","memberLeftHandler","subscribedAt","_clearTypingTimer","_clearAssistantTypingTimer","_clearAssistantProgress","unsubscribeAllRooms","_","subscribeRoomList","_handleRoomListEvent","unsubscribeRoomList","isRoomListSubscribed","subscribeMail","_handleMailEvent","unsubscribeMail","isMailSubscribed","connectionId","markAsRead","translations","sourceLang","translationsOf","senderType","userName","typing","actorId","_isSelfInMembers","cutoffTime","roomUnreadCount","unreadVersion","resolvedRoomId","events","remainingUnreadCount","startTyping","existingTimer","_startAssistantTypingTimer","streamId","_isAssistantStreamClosed","prevSlot","_assistantSlot","createdAt","phaseUpdatedAt","phase","samePhase","Boolean","progress","_normalizeAssistantProgress","prev","slot","_setAssistantSlot","lastSeq","_seqOf","textAvailable","_emitAssistantStream","seq","delta","_deleteAssistantSlot","_tombstoneAssistantStream","inFlight","task","streams","_assistantGeneration","_deleteAssistantPendingIfGeneration","_applyAssistantSnapshot","_drainAssistantPending","s","doneStatus","resumed","expiresAt","_assistantSlots","slots","graves","languages","updatedAt","fields","ttl","_expireAssistantStream","order","overrides","_assistantPhaseLabel","_emitAssistantRoomProgress","_assistantRoomProgressPayload","alive","streamCount","a","b","sort","stage","unit","suffix","getAssistantProgress","getSubscribedRooms","isSubscribed","MediaStreamManager","localStream","screenStream","videoEnabled","audioEnabled","_deviceChangeHandler","getUserMedia","constraints","video","audio","mediaDevices","stream","_getMediaErrorMessage","getDisplayMedia","getVideoTracks","onended","toggleVideo","videoTracks","track","enabled","toggleAudio","audioTracks","getAudioTracks","setVideoEnabled","setAudioEnabled","getLocalStream","getScreenStream","isVideoEnabled","isAudioEnabled","stopAll","getTracks","stop","replaceTrack","oldTrack","newTrack","removeTrack","addTrack","kind","getTrack","tracks","getDevices","devices","enumerateDevices","videoInputs","d","audioInputs","audioOutputs","switchDevice","exact","startDeviceChangeDetection","stopDeviceChangeDetection","getStreamInfo","hasStream","muted","applyVideoConstraints","videoTrack","applyConstraints","getVideoSettings","getSettings","getAudioSettings","audioTrack","PeerConnectionManager","peers","setLocalStream","_addTracksToConnection","connection","setIceServers","servers","createPeerConnection","peerId","polite","iceCandidatePoolSize","RTCPeerConnection","peerState","makingOffer","ignoreOffer","isSettingRemoteAnswerPending","skipAutoNegotiation","_setupConnectionHandlers","resumeAutoNegotiation","signalingState","onnegotiationneeded","onicecandidate","candidate","oniceconnectionstatechange","iceConnectionState","onconnectionstatechange","connectionState","setLocalDescription","localDescription","ontrack","ondatachannel","existingSenders","getSenders","find","sender","createOffer","offer","createAnswer","answer","handleRemoteDescription","offerCollision","setRemoteDescription","addIceCandidate","remoteDescription","getPeerConnection","getPeerIds","closePeerConnection","closeAllPeerConnections","promises","getStats","getConnectionSummary","summary","DIRECT_CONTROL_SIGNALS","WebRTCClient","mediaManager","peerManager","currentRoom","isGroupCall","participants","_callActive","_callGeneration","_deferredGroupPeers","_joinedGroupRoom","_groupJoin","_directCallPeer","_incomingCallPeer","_receivedInvitations","_hostedCallRooms","_pendingRoomCreations","_earlyRoomRejects","_groupRecoveryTimer","_groupRecoveryAttempts","_onReconnected","_recoverGroupParticipation","_iceServersFetched","_waitingForCallAccept","_pendingCallTarget","_pendingIceCandidates","_setupEventHandlers","initializeIceServers","getTurnCredentials","_setFallbackIceServers","_sendMediaState","_sendSignal","receiverId","sdp","createCallRoom","callRoomId","_rememberCallRoom","splice","_handleSignal","getCallRoom","joinCallRoomApi","_clearDeferredGroup","leaveCallRoomApi","enableIncomingCalls","_incomingCallsEnabled","_subscribeToDirectSignaling","disableIncomingCalls","directDestination","isIncomingCallsEnabled","startCall","isGroup","mediaConstraints","_pendingStart","_cancelGroupRecoveryRetry","_groupRecovery","start","cancelled","checkCancelled","deferredRoom","_subscribeToSignaling","_resumeGroupNegotiation","_unsubscribeFromSignaling","callUser","targetUserId","acceptCall","callerId","rejectCall","cancelCall","endCall","_endCall","notifyPeers","startScreenShare","localVideoTrack","currentVideoTrack","stopScreenShare","_groupRoomFor","mediaState","senderId","_holdsEarlyRoomReject","_acceptsSignal","_handleUserJoined","_handleUserLeft","_handleOffer","_handleAnswer","_handleIceCandidate","_handleMediaState","isInCall","title","hostUserId","maxParticipants","_handleCallAccepted","callEndScope","_handleCallEnded","groupRoom","peerRoom","joinedAt","defer","_processPendingIceCandidates","stillCurrent","recovery","participating","_queueIceCandidate","RTCIceCandidate","candidates","getCurrentRoom","getParticipants","getMediaState","streamInfo","PushErrorCode","freeze","UNSUPPORTED_BROWSER","FIREBASE_NOT_INSTALLED","SW_REGISTER_FAILED","PERMISSION_DENIED","TOKEN_FAILED","SERVER_REGISTER_FAILED","PushError","cause","DEFAULT_FIREBASE_CONFIG","authDomain","storageBucket","messagingSenderId","appId","PENDING_NAVIGATION_STORE_NAME","PENDING_NAVIGATION_LEGACY_KEY","DEVICE_STORE_NAME","DEVICE_ID_KEY","LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY","openPendingNavigationDb","indexedDB","onupgradeneeded","db","objectStoreNames","contains","createObjectStore","keyPath","onsuccess","readDeviceIdFromIndexedDb","tx","transaction","objectStore","record","oncomplete","writeDeviceIdToIndexedDb","generateDeviceId","substring","consumePendingRoomFromIndexedDB","primaryKey","PENDING_NAVIGATION_KEY_PREFIX","buildPendingNavigationKey","shouldTryLegacyFallback","store","pendingRoomId","primaryRequest","legacyRequest","legacyRecord","PushManager","firebaseConfig","vapidKey","serviceWorkerPath","_messaging","_currentToken","_enabled","_enablePromise","_foregroundMessageUnsubscribe","_swRegistration","_projectRegisteredToSW","_visibilityHandler","_deviceIdCache","consumePendingRoom","consumePendingMail","PENDING_MAIL_KEY_PREFIX","savedAt","consumePendingMailFromIndexedDB","getPermissionState","Notification","permission","enable","_enableInternal","firebaseApp","firebaseMessaging","app","import","getApp","initializeApp","registration","_registerServiceWorker","requestPermission","getMessaging","tokenOptions","serviceWorkerRegistration","getToken","_registerTokenToServer","onMessage","_onForegroundMessage","_registerProjectToSW","_installVisibilityReRegister","update","swVersion","_getSWVersion","serviceWorker","register","updateViaCache","_getDeviceId","deviceToken","deviceType","legacy","removeItem","fallbackId","reset","_uninstallVisibilityReRegister","_unregisterProjectFromSW","sw","ready","isEnabled","getMyDevices","setDeviceEnabled","setCurrentDeviceEnabled","readyRegistration","waiting","installing","MessageChannel","port1","port2","deduperOf","_mailNotificationDeduper","ttlMs","accept","at","seenKey","seenAt","createMailNotificationDeduper","projectIdOf","keyOf","senderOf","CONNECTION_EVENT_MAP","CHAT_EVENT_MAP","WEBRTC_EVENT_MAP","forwardMappedEvents","source","target","eventMap","sourceEvent","targetEvent","setupEventForwarding","_state","chat","deduper","preview","threadId","subject","receivedAt","emitNewMailFromSocket","webrtc","CHAT_DELEGATE_METHODS","WEBRTC_DELEGATE_METHODS","defineDelegates","targetKey","methods","TalkFlowClient","_validateOptions","sessionMode","autoSubscribeRoomList","_initialized","_userId","authSession","_chat","_webrtc","_pushManager","_pushEnablePromise","_initializeSubClients","_enableRefreshSession","hasToken","pushManager","_setupEventForwarding","initializeSubClients","_authUnsubscribe","_authErrorUnsubscribe","_authReady","setToken","loginUser","getMyProfile","_checkToken","updateMyProfile","profile","changeMyPassword","currentPassword","newPassword","setPreferredLanguage","preferredLanguage","detectBrowserLanguage","normalizeLanguageCode","trimmed","toUpperCase","lower","dash","primary","displayText","myLang","getCompanyDirectory","getUsers","searchUsers","keyword","getUserId","isInitialized","isReady","getStatus","initialized","jwt","enablePush","enablePushNotifications","PUSH_DEDUP_TTL_MS","ids","count","parseInt","unseen","notification","handleMailPush","activeRoomId","_recentPushMessageIds","alreadyEnabled","getPushPermissionState","isPushEnabled","getPushToken","resetPush","needReinitialize","newToken","applySessionMethods","applyDelegatedMethods","GENERAL","PEOPLE_ONLY","CALL_ONLY","TEXT","IMAGE","FILE","VIDEO","AUDIO","DOCUMENT","MIXED","GALLERY","SYSTEM","MIN_OPTIONS","MAX_OPTIONS","LABEL_MAX_LENGTH","QUESTION_MAX_LENGTH","QUIET","NORMAL","ACTIVE","LEGAL_ADVISOR","MARKETING","PRODUCT_PLANNING","HR","FINANCE","CUSTOMER_SUPPORT","SALES","ENGINEERING","DATA_ANALYST","PROJECT_MANAGEMENT","RESEARCH","TRANSLATION","DESIGN","PM","SUPER_ADMIN","PROJECT_ADMIN","ROOM_OWNER","PROJECT","ROOM","PERSONA_MULTI","PM_BACKSTAGE","currentUserId","_currentUserId","_rooms","_room","snapshotCount","snapshotVersion","pendingDeltas","applyMessageReceived","applySnapshot","v","getCount","removeRoom","USER","ASSISTANT","MINUTES","SHORT","TIMELINE","ACTIONS","OPTIONS","Infinity"],"mappings":"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,UAAW,CACnBoJ,YACA8rB,aAAcn2B,EAAMm2B,aACpB9P,UAAWmrB,EAAQnrB,UACnBorB,SAAUD,EAAQC,UAAY,KAC9B3Q,OAAQ4P,EAASc,EAAQ7vC,MACzBA,KAAM6vC,EAAQ7vC,MAAQ,KACtB+vC,QAASF,EAAQE,SAAW,KAC5BC,WAAYH,EAAQG,YAAc,KAClCZ,OAAQ,UAGpB,CCmBiDa,CAAsB78B,EAAQ/U,KAGvE+U,EAAO88B,QACPf,GAAoB/7B,EAAO88B,OAAQ98B,EAAQ87B,IAEnD,CCvHA,MAAMiB,GAAwB,CAC1B,WACA,UACA,cACA,oBACA,qBACA,kBACA,gBACA,YAEA,oBACA,aACA,kBACA,oBACA,aACA,YACA,cACA,mBACA,yBACA,mBACA,YACA,cACA,mBACA,cACA,wBACA,kBACA,4BACA,YACA,sBAEA,qBACA,aACA,kBACA,4BAGA,mBACA,yBACA,cACA,gBACA,aACA,aACA,eACA,iBACA,gBACA,kBACA,sBACA,gBACA,kBACA,gBACA,oBACA,sBACA,uBACA,gBACA,kBACA,mBACA,qBACA,eACA,cACA,aAGA,uBACA,yBACA,gBACA,gBACA,uBACA,yBACA,yBACA,mBACA,eACA,4BACA,mBACA,sBACA,+BACA,iBACA,aACA,kBACA,qBACA,uBACA,yBACA,0BACA,8BACA,sBAEA,aACA,WACA,UACA,aACA,aACA,YACA,aACA,mBAGEC,GAA0B,CAC5B,uBACA,qBACA,iBACA,cACA,kBACA,mBACA,sBACA,uBACA,yBACA,YACA,WACA,aACA,aACA,aACA,UACA,cACA,cACA,kBACA,kBACA,mBACA,kBACA,iBACA,aACA,eACA,6BACA,4BACA,wBACA,mBACA,mBACA,WACA,iBACA,kBACA,gBACA,uBACA,YAGJ,SAASC,GAAgBne,EAAWoe,EAAWC,GAC3CA,EAAQ/wC,QAAS4J,IACb8oB,EAAU9oB,GAAU,YAA4BrK,GAC5C,OAAOd,KAAKqyC,GAAWlnC,MAAWrK,EACtC,GAER,CC3HA,MAAMyxC,WAAuBzyC,EAqCzBC,WAAAA,CAAYsK,GACRgH,QAGArR,KAAK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"}
1
+ {"version":3,"file":"talkflow-sdk.umd.js","sources":["../src/utils/EventEmitter.js","../src/constants.js","../src/utils/Logger.js","../src/utils/ApiClient.js","../src/auth/AuthSession.js","../src/utils/jwtUtils.js","../src/core/ConnectionManager.js","../src/chat/ChatClient.js","../src/webrtc/MediaStreamManager.js","../src/webrtc/PeerConnectionManager.js","../src/webrtc/WebRTCClient.js","../src/push/PushManager.js","../src/talkflow/mailNotifications.js","../src/talkflow/eventForwarding.js","../src/talkflow/delegates.js","../src/TalkFlowClient.js","../src/talkflow/session.js","../src/chat/RoomUnreadReconciler.js"],"sourcesContent":["/**\r\n * EventEmitter 유틸리티\r\n * 이벤트 기반 통신을 위한 간단한 구현\r\n */\r\n\r\nclass EventEmitter {\r\n constructor() {\r\n this._events = new Map();\r\n }\r\n\r\n /**\r\n * 이벤트 리스너 등록\r\n * @param {string} event - 이벤트 이름\r\n * @param {Function} listener - 콜백 함수\r\n * @returns {Function} - 구독 해제 함수\r\n */\r\n on(event, listener) {\r\n if (!this._events.has(event)) {\r\n this._events.set(event, new Set());\r\n }\r\n this._events.get(event).add(listener);\r\n\r\n // 구독 해제 함수 반환\r\n return () => this.off(event, listener);\r\n }\r\n\r\n /**\r\n * 일회성 이벤트 리스너 등록\r\n * @param {string} event - 이벤트 이름\r\n * @param {Function} listener - 콜백 함수\r\n * @returns {Function} - 구독 해제 함수\r\n */\r\n once(event, listener) {\r\n const onceWrapper = (...args) => {\r\n this.off(event, onceWrapper);\r\n listener.apply(this, args);\r\n };\r\n onceWrapper._originalListener = listener;\r\n return this.on(event, onceWrapper);\r\n }\r\n\r\n /**\r\n * 이벤트 리스너 제거\r\n * @param {string} event - 이벤트 이름\r\n * @param {Function} listener - 콜백 함수\r\n */\r\n off(event, listener) {\r\n const listeners = this._events.get(event);\r\n if (listeners) {\r\n // once로 등록된 리스너도 제거 가능하도록\r\n for (const l of listeners) {\r\n if (l === listener || l._originalListener === listener) {\r\n listeners.delete(l);\r\n break;\r\n }\r\n }\r\n if (listeners.size === 0) {\r\n this._events.delete(event);\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * 이벤트 발생.\r\n * payload 가 없는 이벤트({@code loggedOut}, {@code pushEnabled} 등) 는 {@code data} 생략 가능.\r\n * @param {string} event - 이벤트 이름\r\n * @param {*} [data] - 이벤트 데이터 (선택)\r\n */\r\n emit(event, data) {\r\n const listeners = this._events.get(event);\r\n if (listeners) {\r\n listeners.forEach(listener => {\r\n try {\r\n listener(data);\r\n } catch (error) {\r\n console.error(`Error in event listener for '${event}':`, error);\r\n }\r\n });\r\n }\r\n }\r\n\r\n /**\r\n * 특정 이벤트의 모든 리스너 제거 (이벤트명 생략 시 전체 제거)\r\n * @param {string} [event] - 이벤트 이름 (선택)\r\n */\r\n removeAllListeners(event) {\r\n if (event) {\r\n this._events.delete(event);\r\n } else {\r\n this._events.clear();\r\n }\r\n }\r\n\r\n /**\r\n * 이벤트 리스너 수 반환\r\n * @param {string} event - 이벤트 이름\r\n * @returns {number}\r\n */\r\n listenerCount(event) {\r\n const listeners = this._events.get(event);\r\n return listeners ? listeners.size : 0;\r\n }\r\n\r\n /**\r\n * 등록된 이벤트 이름 목록\r\n * @returns {string[]}\r\n */\r\n eventNames() {\r\n return Array.from(this._events.keys());\r\n }\r\n}\r\n\r\nexport default EventEmitter;\r\n","/**\r\n * TalkFlow SDK Constants\r\n * 클라이언트 상태, 에러 타입, 시그널 타입 등 상수 정의\r\n */\r\n\r\n/**\r\n * 클라이언트 연결 상태\r\n */\r\nexport const ConnectionState = {\r\n DISCONNECTED: 'disconnected',\r\n CONNECTING: 'connecting',\r\n CONNECTED: 'connected',\r\n RECONNECTING: 'reconnecting',\r\n ERROR: 'error'\r\n};\r\n\r\n/**\r\n * 에러 타입\r\n */\r\nexport const ErrorTypes = {\r\n // Connection errors\r\n CONNECTION_FAILED: 'CONNECTION_FAILED',\r\n CONNECTION_LOST: 'CONNECTION_LOST',\r\n CONNECTION_TIMEOUT: 'CONNECTION_TIMEOUT',\r\n // 회사 구독 없음 — 서버가 NOT_SUBSCRIBED 로 거부했다(결제 전·해지). 재연결하지 않고 connect() 를 기다린다.\r\n SUBSCRIPTION_REQUIRED: 'SUBSCRIPTION_REQUIRED',\r\n\r\n // Authentication errors\r\n JWT_INVALID: 'JWT_INVALID',\r\n JWT_EXPIRED: 'JWT_EXPIRED',\r\n JWT_PARSE_FAILED: 'JWT_PARSE_FAILED',\r\n UNAUTHORIZED: 'UNAUTHORIZED',\r\n\r\n // API errors\r\n API_ERROR: 'API_ERROR',\r\n API_TIMEOUT: 'API_TIMEOUT',\r\n\r\n // Chat errors\r\n CHAT_ROOM_NOT_FOUND: 'CHAT_ROOM_NOT_FOUND',\r\n CHAT_MESSAGE_FAILED: 'CHAT_MESSAGE_FAILED',\r\n CHAT_SUBSCRIPTION_FAILED: 'CHAT_SUBSCRIPTION_FAILED',\r\n\r\n // WebRTC errors\r\n MEDIA_ACCESS_DENIED: 'MEDIA_ACCESS_DENIED',\r\n SCREEN_SHARE_DENIED: 'SCREEN_SHARE_DENIED',\r\n PEER_CONNECTION_FAILED: 'PEER_CONNECTION_FAILED',\r\n ICE_CONNECTION_FAILED: 'ICE_CONNECTION_FAILED',\r\n SIGNALING_FAILED: 'SIGNALING_FAILED',\r\n DEVICE_SWITCH_FAILED: 'DEVICE_SWITCH_FAILED',\r\n ENUMERATE_DEVICES_FAILED: 'ENUMERATE_DEVICES_FAILED',\r\n CALL_ROOM_NOT_FOUND: 'CALL_ROOM_NOT_FOUND',\r\n\r\n // General errors\r\n INVALID_STATE: 'INVALID_STATE',\r\n UNKNOWN_ERROR: 'UNKNOWN_ERROR'\r\n};\r\n\r\n/**\r\n * WebRTC 시그널 타입 (서버 WebRTCMessageType과 일치)\r\n */\r\nexport const SignalTypes = {\r\n // Offer/Answer (WebRTC SDP)\r\n CALL_OFFER: 'call_offer',\r\n CALL_ANSWER: 'call_answer',\r\n\r\n // ICE\r\n ICE_CANDIDATE: 'ice_candidate',\r\n\r\n // Room events\r\n JOIN_ROOM: 'join_room',\r\n LEAVE_ROOM: 'leave_room',\r\n PEER_JOINED: 'peer_joined',\r\n PEER_LEFT: 'peer_left',\r\n\r\n // Media state\r\n VIDEO_STATE_CHANGED: 'video_state_changed',\r\n AUDIO_STATE_CHANGED: 'audio_state_changed',\r\n SCREEN_SHARE_STARTED: 'screen_share_started',\r\n SCREEN_SHARE_ENDED: 'screen_share_ended',\r\n\r\n // Call events (UI 레벨)\r\n CALL_REQUEST: 'call_request',\r\n CALL_ACCEPT: 'call_accept',\r\n CALL_REJECT: 'call_reject',\r\n CALL_CANCEL: 'call_cancel',\r\n CALL_END: 'call_end',\r\n CALL_INVITATION: 'call_invitation',\r\n CALL_BUSY: 'call_busy'\r\n};\r\n\r\n/**\r\n * 채팅 메시지 타입\r\n */\r\nexport const ChatMessageType = {\r\n TEXT: 'TEXT',\r\n IMAGE: 'IMAGE',\r\n FILE: 'FILE',\r\n VIDEO: 'VIDEO',\r\n AUDIO: 'AUDIO',\r\n DOCUMENT: 'DOCUMENT',\r\n /**\r\n * 서로 다른 카테고리의 첨부가 섞인 메시지 — 예: 이미지 2 + PDF 1.\r\n * <p>전송 1회 = 메시지 1건 계약에서 새로 생긴 값. 클라이언트가 모르면 렌더가 깨진다.</p>\r\n */\r\n MIXED: 'MIXED',\r\n /**\r\n * @deprecated 복수 첨부(타입 무관)를 뜻하던 <b>과거</b> 값. 신규 저장에서는 생성되지 않지만\r\n * 기존 데이터에 남아 있다. 동영상·문서 복수도 전부 이 값이므로 <b>IMAGE 로 매핑하면 안 되고</b>,\r\n * {@code ChatClient#resolveMessageCategory} 로 fileMetaDataList 의 MIME 에서 복원해야 한다.\r\n */\r\n GALLERY: 'GALLERY',\r\n SYSTEM: 'SYSTEM'\r\n};\r\n\r\n/**\r\n * AI 의사결정 카드({@code message.decisionRequest}) 렌더 상한 — 서버가 강제하는 값의 사본.\r\n *\r\n * <p>🔴 <b>카드는 새 {@code chatMessageType} 이 아니다.</b> 카드 메시지의 타입은 그냥 {@code TEXT} 이고,\r\n * 선택 요청은 {@code decisionRequest} <b>필드</b>로 온다. 그래서 필드를 모르는 렌더러는 <b>평범한 텍스트\r\n * 메시지로 그리면 되고 그게 정상 동작</b>이다 — {@code content} 에 질문과 후보가 사람이 읽는 형태로\r\n * 들어 있다.</p>\r\n *\r\n * <p>🔴 반대로 카드를 그린다면 {@code content} 를 <b>다시 그리지 마라</b> — 버튼과 본문에 같은 질문·후보가\r\n * 두 번 보인다. 둘 중 하나만 그린다.</p>\r\n *\r\n * <p>클라가 이 값으로 <b>검증할 필요는 없다</b>(서버가 이미 강제한다). 레이아웃 계산용 참고값이다.</p>\r\n */\r\nexport const DecisionCardLimits = {\r\n /** 선택지 최소 개수 — 1개면 물을 이유가 없어 서버가 카드를 만들지 않는다. */\r\n MIN_OPTIONS: 2,\r\n /** 선택지 최대 개수 — 그 이상은 선택이 아니라 목록이다. */\r\n MAX_OPTIONS: 5,\r\n /** 라벨 표시 길이(초과분은 서버가 말줄임 처리해서 보낸다). */\r\n LABEL_MAX_LENGTH: 80,\r\n /** 질문 표시 길이. */\r\n QUESTION_MAX_LENGTH: 200\r\n};\r\n\r\n/**\r\n * 메시지 발신자 타입 — 서버 {@code SenderType} 과 일치.\r\n *\r\n * <ul>\r\n * <li>{@code USER} — 사람 사용자가 보낸 메시지 (기존 모든 메시지의 default)</li>\r\n * <li>{@code ASSISTANT} — AI 어시스턴트 페르소나가 생성한 메시지. {@code userId} 자리는 페르소나 ID</li>\r\n * </ul>\r\n *\r\n * <p>서버 응답에서 {@code senderType} 필드가 없으면 {@code USER} 로 간주 (기존 데이터 graceful).</p>\r\n */\r\nexport const SenderType = {\r\n USER: 'USER',\r\n ASSISTANT: 'ASSISTANT'\r\n};\r\n\r\n/**\r\n * 어시스턴트 페르소나 분류 — 서버 {@code PersonaRole} enum 과 일치 (14개).\r\n *\r\n * <p>{@code PM} 은 PM_BACKSTAGE 방의 단일 PM Front 전용 — 기존 14인 PERSONA_MULTI 라인업과 별개 구성이며\r\n * {@code PROJECT_MANAGEMENT}(PM 비서)와도 다르다.</p>\r\n */\r\nexport const PersonaRole = {\r\n LEGAL_ADVISOR: 'LEGAL_ADVISOR',\r\n MARKETING: 'MARKETING',\r\n PRODUCT_PLANNING: 'PRODUCT_PLANNING',\r\n HR: 'HR',\r\n FINANCE: 'FINANCE',\r\n CUSTOMER_SUPPORT: 'CUSTOMER_SUPPORT',\r\n SALES: 'SALES',\r\n ENGINEERING: 'ENGINEERING',\r\n DATA_ANALYST: 'DATA_ANALYST',\r\n PROJECT_MANAGEMENT: 'PROJECT_MANAGEMENT',\r\n RESEARCH: 'RESEARCH',\r\n TRANSLATION: 'TRANSLATION',\r\n DESIGN: 'DESIGN',\r\n // PM_BACKSTAGE 전용 — PM Front 단일 응답 (PERSONA_MULTI 14인과 별개)\r\n PM: 'PM'\r\n};\r\n\r\n/**\r\n * 채팅 요약 포맷 — 서버 {@code SummarizeFormat} enum 과 일치 (5개).\r\n *\r\n * <p>{@link ChatClient#summarizeWithAssistant} 의 {@code format} 인자.\r\n * 서버가 대소문자 무관하게 매핑하며, 미지정/알 수 없는 값이면 {@code SHORT} 로 처리.</p>\r\n *\r\n * <ul>\r\n * <li>{@code MINUTES} — 회의록 (참석/안건/핵심 논의/결정사항)</li>\r\n * <li>{@code SHORT} — 짧은 요약 (몇 문장 핵심). 기본값.</li>\r\n * <li>{@code TIMELINE} — 시간순 타임라인</li>\r\n * <li>{@code ACTIONS} — 액션 아이템 (task/owner/기한)</li>\r\n * <li>{@code OPTIONS} — 옵션 비교 (논의된 선택지별 장단점)</li>\r\n * </ul>\r\n */\r\nexport const SummarizeFormat = {\r\n MINUTES: 'MINUTES',\r\n SHORT: 'SHORT',\r\n TIMELINE: 'TIMELINE',\r\n ACTIONS: 'ACTIONS',\r\n OPTIONS: 'OPTIONS'\r\n};\r\n\r\n\r\n/**\r\n * 채팅방 타입 (서버 {@code ChatRoomType} enum 과 일치).\r\n *\r\n * <ul>\r\n * <li>{@code DIRECT} — 1:1 채팅방</li>\r\n * <li>{@code GROUP} — 공개 그룹 채팅방</li>\r\n * <li>{@code PRIVATE_GROUP} — 비밀 그룹 채팅방 (입장 시 비밀번호 필요)</li>\r\n * <li>{@code TEAM} — 팀 채팅방 (초대 전용 — 직접 입장 불가, 멤버는 입장 시점과 무관하게\r\n * 방 전체 히스토리 열람. 비참여자에게는 탐색/전체 목록에서도 숨김. 비밀번호 없음)</li>\r\n * </ul>\r\n */\r\nexport const ChatRoomType = {\r\n DIRECT: 'DIRECT',\r\n GROUP: 'GROUP',\r\n PRIVATE_GROUP: 'PRIVATE_GROUP',\r\n TEAM: 'TEAM'\r\n};\r\n\r\n/**\r\n * 어시스턴트 참여 모드 (서버 {@code AssistantMode} enum 과 일치).\r\n *\r\n * <ul>\r\n * <li>{@code GENERAL}: 자동 라우팅 + @mention 모두 활성</li>\r\n * <li>{@code PEOPLE_ONLY}: AI 응답 전면 차단</li>\r\n * <li>{@code CALL_ONLY}: @mention / 명시 호출 시만 응답</li>\r\n * </ul>\r\n */\r\nexport const AssistantMode = {\r\n GENERAL: 'GENERAL',\r\n PEOPLE_ONLY: 'PEOPLE_ONLY',\r\n CALL_ONLY: 'CALL_ONLY'\r\n};\r\n\r\n/**\r\n * 방의 AI 사용 방식 (상위 SSOT) — 서버 {@code RoomAiType} enum 과 일치.\r\n *\r\n * <p>{@link ChatClient#createGroupRoom} 의 {@code roomAiType} 으로 방 생성 시 의도를 명시한다.\r\n * 미지정 시 서버가 API 키 권한(capability) + 첨부 페르소나로 파생한다 (레거시 호환).\r\n * <b>단, PERSONA_MULTI 권한이 없는 PM 전용 키는 미지정 시 {@code NONE}(사람 방)으로 파생되므로</b>\r\n * {@link ChatClient#getRoomAiMeta} 로 확인 후 고른 타입을 명시 전송할 것.</p>\r\n *\r\n * <ul>\r\n * <li>{@code NONE}: AI 미사용 (순수 사람 채팅). {@code invitedAssistantPersonaIds}/{@code assistantMode} 동시 지정 불가.</li>\r\n * <li>{@code PERSONA_MULTI}: 다중 페르소나, LLM 이 판단해 호출. {@code assistantMode} 로 세부 제어. (현재 유일 활성)</li>\r\n * <li>{@code PM_BACKSTAGE}: PM + 백스테이지. PM 단일 응답 구현됨 — {@code engagementIntensity} 로 개입 강도 제어. 백스테이지 위임은 후속.</li>\r\n * </ul>\r\n */\r\nexport const RoomAiType = {\r\n NONE: 'NONE',\r\n PERSONA_MULTI: 'PERSONA_MULTI',\r\n PM_BACKSTAGE: 'PM_BACKSTAGE'\r\n};\r\n\r\n/**\r\n * PM_BACKSTAGE 방의 PM 개입 강도 (서버 {@code EngagementIntensity} enum 과 일치).\r\n *\r\n * <p>{@link ChatClient#createGroupRoom} / {@code updateGroupRoom} 의 {@code engagementIntensity}.\r\n * <b>PM_BACKSTAGE 방 전용</b> — 그 외 방 타입에 지정 시 서버가 거절한다. 14인 PERSONA_MULTI 는\r\n * 쿨다운 + Planner 자동 판단이라 무관하며, 이 값은 PM 단일 응답의 개입 빈도만 제어한다.</p>\r\n *\r\n * <ul>\r\n * <li>{@code QUIET}: 보수적 — 명확한 질문/도움 요청에만 PM 응답</li>\r\n * <li>{@code NORMAL}: 기본 — 업무 신호에 응답, 잡담엔 침묵</li>\r\n * <li>{@code ACTIVE}: 적극 — 가벼운 신호에도 응답</li>\r\n * </ul>\r\n */\r\nexport const EngagementIntensity = {\r\n QUIET: 'QUIET',\r\n NORMAL: 'NORMAL',\r\n ACTIVE: 'ACTIVE'\r\n};\r\n\r\n/**\r\n * PM 프롬프트 레이어 차원 — 서버 {@code PmPromptLayerScope} enum 과 일치.\r\n *\r\n * <p>전역 PM 프롬프트 위에 합성되는 추가 지시문 2종. 합성 순서: 전역 → PROJECT → ROOM.\r\n * SDK 가 다루는 것은 ROOM(방 레이어 — {@link ChatClient#getRoomPmPrompt} 등)이며,\r\n * PROJECT(회사 레이어)는 어드민 콘솔 영역이다.</p>\r\n */\r\nexport const PmPromptLayerScope = {\r\n /** 회사(프로젝트) 레이어 — 어드민 콘솔(PROJECT_ADMIN) 관리. */\r\n PROJECT: 'PROJECT',\r\n /** 방 레이어 — 방 주인(OWNER) 관리. PM_BACKSTAGE 방 전용. */\r\n ROOM: 'ROOM'\r\n};\r\n\r\n/**\r\n * PM 프롬프트 레이어 수정 주체 (감사 추적) — 서버 {@code PmPromptLayerEditorType} enum 과 일치.\r\n */\r\nexport const PmPromptLayerEditorType = {\r\n /** 플랫폼 운영자 (대리 수정). */\r\n SUPER_ADMIN: 'SUPER_ADMIN',\r\n /** 회사 관리자 — PROJECT 레이어. */\r\n PROJECT_ADMIN: 'PROJECT_ADMIN',\r\n /** 방 주인(OWNER) — ROOM 레이어. */\r\n ROOM_OWNER: 'ROOM_OWNER'\r\n};\r\n\r\n/**\r\n * 채팅방 리스트 실시간 업데이트 이벤트 타입 (카톡 스타일).\r\n *\r\n * 서버 RoomListEventType enum 과 일치해야 함.\r\n *\r\n * @example\r\n * client.on('roomListUpdate', (event) => {\r\n * switch (event.eventType) {\r\n * case RoomListEventType.MESSAGE_RECEIVED:\r\n * // 해당 방을 최상단으로, unread +1 (본인 메시지는 제외)\r\n * break;\r\n * case RoomListEventType.ROOM_LEFT:\r\n * // actorId === currentUserId 이면 리스트에서 제거\r\n * break;\r\n * }\r\n * });\r\n */\r\nexport const RoomListEventType = {\r\n /** 새 메시지 수신 → 리스트 최상단 이동, unread 카운트 +1 (본인 메시지 제외). */\r\n MESSAGE_RECEIVED: 'MESSAGE_RECEIVED',\r\n /** 현재 lastMessage 삭제 → 리스트 preview 갱신, unread 카운트 변경 없음. */\r\n MESSAGE_DELETED: 'MESSAGE_DELETED',\r\n /** 현재 lastMessage 편집 → 리스트 preview 새 content 로 갱신, unread 카운트 변경 없음. */\r\n MESSAGE_UPDATED: 'MESSAGE_UPDATED',\r\n /** 방 생성 (Direct/Group) → 참가자 리스트에 신규 item 추가. */\r\n ROOM_CREATED: 'ROOM_CREATED',\r\n /** 기존 방에 누군가 입장 → 참가자 수 갱신. */\r\n ROOM_JOINED: 'ROOM_JOINED',\r\n /** 방 퇴장 → 나간 본인 리스트에서는 제거, 남은 참가자는 참가자 수 감소. */\r\n ROOM_LEFT: 'ROOM_LEFT',\r\n /** 방 정보 변경 (이름, 설명 등). 미래 확장용. */\r\n ROOM_UPDATED: 'ROOM_UPDATED',\r\n /**\r\n * 방장에 의한 단순 추방 → 대상은 리스트에서 방 제거, 남은 참가자는 참가자 수 감소.\r\n * 재입장 가능 (ban 아님). actorId 는 추방한 방장, members 에 추방된 사용자 정보.\r\n */\r\n ROOM_KICKED: 'ROOM_KICKED',\r\n /** 방장에 의한 추방 + 영구 차단 → payload 는 ROOM_KICKED 와 동일, 재입장 거부됨. */\r\n ROOM_BANNED: 'ROOM_BANNED',\r\n /**\r\n * 방장 위임 → actorId 는 이전 방장, members[0] 이 새 방장. 방장 표시·관리 권한을 갱신한다.\r\n * «위임 후 나가기» 면 이어서 같은 이전 방장의 ROOM_LEFT 가 온다.\r\n * SDK 는 이를 roomListOwnerChanged 이벤트로 emit 한다.\r\n */\r\n ROOM_OWNER_CHANGED: 'ROOM_OWNER_CHANGED',\r\n /**\r\n * 방장이 방을 삭제 → 리스트에서 제거 + 열린 방 화면 닫기. actorId 는 삭제한 방장.\r\n * 이후 enterRoom·getRoomInfo 는 403(ACCESS_FORBIDDEN — 더는 참가자가 아님), joinGroupRoom 은 404.\r\n * SDK 는 이를 roomListDeleted 이벤트로 emit 한다.\r\n */\r\n ROOM_DELETED: 'ROOM_DELETED',\r\n /** 메시지 보관 기간 정리 → cutoffTime 이전 메시지를 UI에서 제거. */\r\n MESSAGE_RETENTION_CLEANUP: 'MESSAGE_RETENTION_CLEANUP',\r\n /**\r\n * 내 unread 가 변경됨 → 방 리스트 badge 를 roomUnreadCount 절대값으로 세팅(delta 아님).\r\n * 내가 어느 기기에서 읽든 같은 사용자의 다른 세션에 전달돼 크로스-디바이스로 badge 를 맞춘다.\r\n * unreadVersion 으로 순서 역전(읽음 0 이 신규 메시지보다 먼저 도착)을 방어한다.\r\n * SDK 는 이를 roomListUnreadUpdated 이벤트로 emit 한다.\r\n */\r\n ROOM_UNREAD_UPDATED: 'ROOM_UNREAD_UPDATED',\r\n /**\r\n * PM 을 직접(@PM) 불렀는데 회사 AI 가 막혀(크레딧·결제) 답하지 않았다 → 부른 사람에게만 온다. 방에는 아무것도 남지 않는다.\r\n * 잠깐 알리고 사라지게(토스트) 쓴다. 리스트·미독은 건드리지 않는다. 사유는 aiBlocked(REST 402 와 같은 코드).\r\n * 막힌 상태를 계속 보여 줄 띠는 getMyProfile() 의 aiBlocked 로 그린다.\r\n * SDK 는 이를 aiUnavailable 이벤트로 emit 한다.\r\n */\r\n AI_UNAVAILABLE: 'AI_UNAVAILABLE'\r\n};\r\n\r\n/**\r\n * WebSocket 경로\r\n */\r\nexport const WebSocketPaths = {\r\n // Endpoints\r\n SOCKJS_ENDPOINT: '/ws-chat',\r\n NATIVE_ENDPOINT: '/ws-chat-native',\r\n\r\n // Prefixes\r\n APP_PREFIX: '/app',\r\n TOPIC_PREFIX: '/topic',\r\n QUEUE_PREFIX: '/queue',\r\n USER_PREFIX: '/user',\r\n\r\n // Chat destinations\r\n getChatDestination: (roomId) => `/topic/chat/${roomId}`,\r\n getChatReadDestination: (roomId) => `/topic/chat/${roomId}/read`,\r\n getChatTypingDestination: (roomId) => `/topic/chat/${roomId}/typing`,\r\n // AI 진행 표시(생각중/검색중/작성중) + 토큰 스트리밍 — typing 과 별개 채널(가산 UX 레이어).\r\n getChatAssistantStreamDestination: (roomId) => `/topic/chat/${roomId}/assistant-stream`,\r\n // Rolling 누적 요약 종결 알림(READY/NO_CHANGE/NO_MESSAGES/FAILED) — refreshSummary 비동기 완료 채널.\r\n getChatSummaryDestination: (roomId) => `/topic/chat/${roomId}/summary`,\r\n\r\n // Room list (카톡 스타일 리스트 실시간 업데이트)\r\n // 서버가 convertAndSendToUser(userId, \"/queue/rooms\", event) 로 전송\r\n // 클라이언트는 \"/user/queue/rooms\" 구독 (Spring STOMP 가 세션별 자동 격리)\r\n ROOM_LIST_USER_DESTINATION: '/user/queue/rooms',\r\n\r\n // Gmail 메일함 변화 (서버 GMAIL_MAILBOX_FEATURES_DESIGN.md §6-6·§6-7)\r\n // 서버가 convertAndSendToUser(userId, \"/queue/mail\", MAIL_CHANGED) 로 전송 — 새 메일이면 messages[] 미리보기 포함\r\n MAIL_USER_DESTINATION: '/user/queue/mail',\r\n\r\n // WebRTC destinations\r\n getWebRTCDestination: (roomId) => `/topic/webrtc/${roomId}`,\r\n getWebRTCUserDestination: () => `/user/queue/webrtc`,\r\n\r\n // Send destinations\r\n CHAT_SEND: '/app/chat/send',\r\n CHAT_READ: '/app/chat/read',\r\n CHAT_TYPING: '/app/chat/typing',\r\n WEBRTC_SIGNAL: '/app/webrtc/signal'\r\n};\r\n\r\n/**\r\n * 환경 타입\r\n */\r\nexport const Environment = {\r\n DEVELOPMENT: 'development',\r\n STAGING: 'staging',\r\n PRODUCTION: 'production'\r\n};\r\n\r\n/**\r\n * 환경별 서버 설정\r\n */\r\nexport const Endpoints = {\r\n [Environment.DEVELOPMENT]: {\r\n serverUrl: 'https://dev-chat.apiorbit.net',\r\n wsEndpoint: '/ws-chat'\r\n },\r\n [Environment.STAGING]: {\r\n serverUrl: 'https://stg-api.talk-x.app',\r\n wsEndpoint: '/ws-chat'\r\n },\r\n [Environment.PRODUCTION]: {\r\n serverUrl: 'https://prod-api.talk-x.app',\r\n wsEndpoint: '/ws-chat'\r\n }\r\n};\r\n\r\n/**\r\n * 기본 설정값\r\n */\r\nexport const DefaultConfig = {\r\n // 기본 환경 (production)\r\n environment: Environment.PRODUCTION,\r\n\r\n // Connection\r\n reconnectDelay: 5000,\r\n // 지수 백오프 상한 — 서버 재시작 시 전 클라이언트가 고정 간격으로 몰리는 것을 완화.\r\n maxReconnectDelay: 30000,\r\n maxReconnectAttempts: 10,\r\n reconnectRecoveryDelay: 60000,\r\n heartbeatIncoming: 10000,\r\n heartbeatOutgoing: 10000,\r\n\r\n // 포그라운드 복귀 / 네트워크 복구 시 자동 재연결 (RECONNECT_SYNC_DESIGN §6.1). false = 킬 스위치.\r\n resumeOnVisibility: true,\r\n\r\n // 갭 복구 (RECONNECT_SYNC_DESIGN §6.3). false = 킬 스위치.\r\n syncOnReconnect: true,\r\n // 멀티 인스턴스 시계 스큐 방어 되감기 폭 — 첫 페이지에만, 서버가 clamp 후 1회 차감.\r\n rewindMillis: 3000,\r\n // 갭 페이지 상한. 초과 시 버퍼 폐기 + gapTruncated + 최신 페이지 재로드.\r\n maxSyncPages: 5,\r\n syncPageSize: 100,\r\n\r\n // API\r\n apiTimeout: 30000,\r\n\r\n // WebRTC\r\n iceServers: [\r\n { urls: 'stun:stun.l.google.com:19302' },\r\n { urls: 'stun:stun1.l.google.com:19302' }\r\n ],\r\n\r\n // Logging\r\n logLevel: 2 // WARN\r\n};\r\n\r\n/**\r\n * 환경에 따른 서버 URL 반환\r\n * @param {string} env - Environment 값\r\n * @returns {string} 서버 URL\r\n */\r\nexport function getServerUrl(env) {\r\n const endpoint = Endpoints[env] || Endpoints[Environment.PRODUCTION];\r\n return endpoint.serverUrl;\r\n}\r\n\r\n/**\r\n * 로그 레벨\r\n */\r\nexport const LogLevel = {\r\n DEBUG: 0,\r\n INFO: 1,\r\n WARN: 2,\r\n ERROR: 3,\r\n NONE: 4\r\n};\r\n","/**\r\n * Logger 유틸리티\r\n * 로그 레벨에 따른 콘솔 출력 관리\r\n */\r\n\r\nimport { LogLevel } from '../constants.js';\r\n\r\nclass Logger {\r\n /**\r\n * @param {number} [level] - 로그 레벨\r\n * @param {string} [prefix] - 로그 접두사\r\n */\r\n constructor(level = LogLevel.WARN, prefix = 'TalkFlow') {\r\n this.level = level;\r\n this.prefix = prefix;\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level - 로그 레벨\r\n */\r\n setLevel(level) {\r\n this.level = level;\r\n }\r\n\r\n /**\r\n * 로그 접두사 설정\r\n * @param {string} prefix - 로그 접두사\r\n */\r\n setPrefix(prefix) {\r\n this.prefix = prefix;\r\n }\r\n\r\n /**\r\n * 로그 포맷 생성\r\n * @private\r\n * @param {string} level - 로그 레벨 문자열\r\n * @param {...*} args - 로그 인자들\r\n * @returns {Array} 포맷된 로그 배열\r\n */\r\n _format(level, ...args) {\r\n const timestamp = new Date().toISOString();\r\n return [`[${timestamp}] [${level}] [${this.prefix}]`, ...args];\r\n }\r\n\r\n /**\r\n * 디버그 로그 출력\r\n * @param {...*} args - 로그 인자들\r\n */\r\n debug(...args) {\r\n if (this.level <= LogLevel.DEBUG) {\r\n console.debug(...this._format('DEBUG', ...args));\r\n }\r\n }\r\n\r\n /**\r\n * 정보 로그 출력\r\n * @param {...*} args - 로그 인자들\r\n */\r\n info(...args) {\r\n if (this.level <= LogLevel.INFO) {\r\n console.info(...this._format('INFO', ...args));\r\n }\r\n }\r\n\r\n /**\r\n * 경고 로그 출력\r\n * @param {...*} args - 로그 인자들\r\n */\r\n warn(...args) {\r\n if (this.level <= LogLevel.WARN) {\r\n console.warn(...this._format('WARN', ...args));\r\n }\r\n }\r\n\r\n /**\r\n * 에러 로그 출력\r\n * @param {...*} args - 로그 인자들\r\n */\r\n error(...args) {\r\n if (this.level <= LogLevel.ERROR) {\r\n console.error(...this._format('ERROR', ...args));\r\n }\r\n }\r\n}\r\n\r\nexport default Logger;\r\nexport { LogLevel };\r\n","/**\r\n * API Client\r\n * REST API 호출을 위한 HTTP 클라이언트\r\n */\r\n\r\nimport { ErrorTypes, DefaultConfig } from '../constants.js';\r\nimport Logger from './Logger.js';\r\n\r\nclass ApiClient {\r\n /**\r\n * @param {Object} options\r\n * @param {string} options.baseUrl - API 기본 URL\r\n * @param {string} options.apiKey - API 키\r\n * @param {string} options.projectId - 프로젝트 ID\r\n * @param {string} options.jwtToken - JWT 토큰\r\n * @param {number} [options.timeout] - 요청 타임아웃 (ms)\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options) {\r\n this.baseUrl = options.baseUrl.replace(/\\/$/, '');\r\n this.apiKey = options.apiKey;\r\n this.projectId = options.projectId;\r\n this.jwtToken = options.jwtToken;\r\n this.timeout = options.timeout || DefaultConfig.apiTimeout;\r\n\r\n this.logger = new Logger(options.logLevel, 'ApiClient');\r\n }\r\n\r\n /**\r\n * JWT 토큰 업데이트.\r\n * logout 경로에서 토큰 제거를 위해 {@code null} 도 전달 가능.\r\n * @param {string|null} token\r\n */\r\n setJwtToken(token) {\r\n this.jwtToken = token;\r\n }\r\n\r\n /**\r\n * 공통 헤더 생성\r\n * @returns {Object}\r\n */\r\n _getHeaders() {\r\n const headers = {\r\n 'Content-Type': 'application/json',\r\n 'X-API-KEY': this.apiKey,\r\n 'X-PROJECT-ID': this.projectId\r\n };\r\n\r\n if (this.jwtToken) {\r\n headers['Authorization'] = this.jwtToken.startsWith('Bearer ')\r\n ? this.jwtToken\r\n : `Bearer ${this.jwtToken}`;\r\n }\r\n\r\n return headers;\r\n }\r\n\r\n /**\r\n * HTTP 요청 실행\r\n * @param {string} method - HTTP 메서드\r\n * @param {string} path - API 경로\r\n * @param {Object} [options] - 옵션\r\n * @param {Object} [options.body] - 요청 본문\r\n * @param {Object} [options.params] - URL 파라미터\r\n * @param {Object} [options.headers] - 이 요청에만 얹는 추가 헤더. 기본 헤더(Authorization 등)와 병합된다.\r\n * 같은 이름이면 이 값이 이긴다 — 인증 헤더를 덮어쓰지 않도록 호출자가 주의한다.\r\n * @returns {Promise<Object>}\r\n */\r\n async request(method, path, options = {}) {\r\n if (!this.authProvider || options.skipAuth) return this._request(method, path, options);\r\n this.jwtToken = await this.authProvider.getAccessToken();\r\n const sentToken = this.jwtToken;\r\n const generation = this.authProvider.generation;\r\n try { return await this._request(method, path, options); }\r\n catch (error) {\r\n if (error.status === 401 && ['ACCESS_UNAUTHORIZED', 'TOKEN_INVALID'].includes(error.response?.code)) {\r\n if (generation !== this.authProvider.generation) throw error;\r\n this.jwtToken = await this.authProvider.getAccessToken({ forceRefresh: true, rejectedToken: sentToken });\r\n const retryToken = this.jwtToken;\r\n const retryGeneration = this.authProvider.generation;\r\n try { return await this._request(method, path, options); }\r\n catch (retryError) { await this.authProvider.authenticationFailed(retryError, { rejectedToken: retryToken, generation: retryGeneration }); throw retryError; }\r\n }\r\n await this.authProvider.authenticationFailed(error, { rejectedToken: sentToken, generation });\r\n throw error;\r\n }\r\n }\r\n\r\n async _request(method, path, options = {}) {\r\n const { body, params, headers } = options;\r\n\r\n // URL 빌드\r\n let url = `${this.baseUrl}${path}`;\r\n if (params) {\r\n const searchParams = new URLSearchParams();\r\n Object.entries(params).forEach(([key, value]) => {\r\n if (value !== undefined && value !== null) {\r\n searchParams.append(key, value);\r\n }\r\n });\r\n const queryString = searchParams.toString();\r\n if (queryString) {\r\n url += `?${queryString}`;\r\n }\r\n }\r\n\r\n // AbortController for timeout\r\n const controller = new AbortController();\r\n const timeoutId = setTimeout(() => controller.abort(), this.timeout);\r\n\r\n let response;\r\n try {\r\n this.logger.debug(`${method} ${url}`);\r\n\r\n response = await fetch(url, {\r\n method,\r\n headers: { ...this._getHeaders(), ...headers },\r\n credentials: this.authProvider ? 'omit' : 'same-origin',\r\n body: body ? JSON.stringify(body) : undefined,\r\n signal: controller.signal\r\n });\r\n } catch (error) {\r\n clearTimeout(timeoutId);\r\n\r\n if (error.name === 'AbortError') {\r\n const timeoutError = new Error('Request timeout');\r\n timeoutError.code = ErrorTypes.API_TIMEOUT;\r\n throw timeoutError;\r\n }\r\n\r\n this.logger.error(`API Error: ${method} ${url}`, error);\r\n throw error;\r\n }\r\n\r\n clearTimeout(timeoutId);\r\n\r\n // 응답 파싱\r\n const contentType = response.headers.get('content-type');\r\n let data;\r\n\r\n if (contentType && contentType.includes('application/json')) {\r\n data = await response.json();\r\n } else {\r\n data = await response.text();\r\n }\r\n\r\n // 에러 처리\r\n if (!response.ok) {\r\n const error = new Error(data?.message || `HTTP ${response.status}`);\r\n error.code = ErrorTypes.API_ERROR;\r\n error.status = response.status;\r\n error.response = data;\r\n this.logger.error(`API Error: ${method} ${url}`, error);\r\n throw error;\r\n }\r\n\r\n this.logger.debug(`Response ${response.status}`);\r\n return data;\r\n }\r\n\r\n // HTTP 메서드 헬퍼\r\n\r\n /**\r\n * GET 요청\r\n * @param {string} path - API 경로\r\n * @param {Object} [params] - URL 파라미터\r\n * @returns {Promise<Object>}\r\n */\r\n get(path, params) {\r\n return this.request('GET', path, { params });\r\n }\r\n\r\n /**\r\n * POST 요청\r\n * @param {string} path - API 경로\r\n * @param {Object} [body] - 요청 본문\r\n * @param {Object} [params] - URL 파라미터\r\n * @param {Object} [headers] - 이 요청에만 얹는 추가 헤더 (예: {@code X-Idempotency-Key}).\r\n * 기존 호출자는 인자를 넘기지 않으므로 동작이 바뀌지 않는다.\r\n * @returns {Promise<Object>}\r\n */\r\n post(path, body, params, headers) {\r\n return this.request('POST', path, { body, params, headers });\r\n }\r\n\r\n /**\r\n * PUT 요청\r\n * @param {string} path - API 경로\r\n * @param {Object} [body] - 요청 본문\r\n * @returns {Promise<Object>}\r\n */\r\n put(path, body) {\r\n return this.request('PUT', path, { body });\r\n }\r\n\r\n /**\r\n * PATCH 요청\r\n * @param {string} path - API 경로\r\n * @param {Object} [body] - 요청 본문\r\n * @returns {Promise<Object>}\r\n */\r\n patch(path, body) {\r\n return this.request('PATCH', path, { body });\r\n }\r\n\r\n /**\r\n * DELETE 요청\r\n * @param {string} path - API 경로\r\n * @param {Object} [params] - URL 파라미터\r\n * @returns {Promise<Object>}\r\n */\r\n delete(path, params) {\r\n return this.request('DELETE', path, { params });\r\n }\r\n\r\n /**\r\n * multipart/form-data 파일 업로드.\r\n *\r\n * <p>{@code fetch} 가 업로드 진행률을 지원하지 않아 {@code XMLHttpRequest} 사용.\r\n * Content-Type 은 브라우저가 boundary 를 포함해 자동 세팅하므로 명시하지 않는다.</p>\r\n *\r\n * @param {string} path - API 경로 (baseUrl 뒤에 붙음)\r\n * @param {File|Blob} file - 업로드할 파일\r\n * @param {Object} [options]\r\n * @param {string} [options.fieldName='file'] - multipart field 이름 (서버 @RequestParam 과 일치)\r\n * @param {Function} [options.onProgress] - {@code ({loaded, total, percent}) => void} — 업로드 진행률 콜백\r\n * @param {AbortSignal} [options.signal] - 업로드 취소용 AbortSignal\r\n * @param {number} [options.timeout=600000] - 타임아웃 (ms). 기본 10분.\r\n * @returns {Promise<Object>} 서버 응답 JSON (SuccessResponse 래퍼 포함)\r\n */\r\n async upload(path, file, options = {}) {\r\n if (!this.authProvider) return this._upload(path, file, options);\r\n this.jwtToken = await this.authProvider.getAccessToken();\r\n const sentToken = this.jwtToken;\r\n const generation = this.authProvider.generation;\r\n try { return await this._upload(path, file, options); }\r\n catch (error) {\r\n if (error.status === 401 && ['ACCESS_UNAUTHORIZED', 'TOKEN_INVALID'].includes(error.response?.code)) {\r\n if (generation !== this.authProvider.generation) throw error;\r\n this.jwtToken = await this.authProvider.getAccessToken({ forceRefresh: true, rejectedToken: sentToken });\r\n const retryToken = this.jwtToken;\r\n const retryGeneration = this.authProvider.generation;\r\n try { return await this._upload(path, file, options); }\r\n catch (retryError) { await this.authProvider.authenticationFailed(retryError, { rejectedToken: retryToken, generation: retryGeneration }); throw retryError; }\r\n }\r\n await this.authProvider.authenticationFailed(error, { rejectedToken: sentToken, generation });\r\n throw error;\r\n }\r\n }\r\n\r\n _upload(path, file, options = {}) {\r\n const {\r\n fieldName = 'file',\r\n onProgress,\r\n signal,\r\n timeout = 600_000\r\n } = options;\r\n\r\n return new Promise((resolve, reject) => {\r\n const url = `${this.baseUrl}${path}`;\r\n const xhr = new XMLHttpRequest();\r\n const form = new FormData();\r\n form.append(fieldName, file);\r\n\r\n xhr.open('POST', url);\r\n xhr.timeout = timeout;\r\n\r\n // Content-Type 은 XHR 이 multipart boundary 포함해 자동 세팅 — 명시 금지\r\n xhr.setRequestHeader('X-API-KEY', this.apiKey);\r\n xhr.setRequestHeader('X-PROJECT-ID', this.projectId);\r\n if (this.jwtToken) {\r\n xhr.setRequestHeader(\r\n 'Authorization',\r\n this.jwtToken.startsWith('Bearer ') ? this.jwtToken : `Bearer ${this.jwtToken}`\r\n );\r\n }\r\n\r\n if (typeof onProgress === 'function') {\r\n xhr.upload.onprogress = (e) => {\r\n if (e.lengthComputable) {\r\n onProgress({\r\n loaded: e.loaded,\r\n total: e.total,\r\n percent: Math.round((e.loaded / e.total) * 100)\r\n });\r\n }\r\n };\r\n }\r\n\r\n // 외부 AbortSignal 과 연동 (취소 지원)\r\n const onAbort = () => xhr.abort();\r\n if (signal) {\r\n if (signal.aborted) {\r\n reject(new Error('Upload cancelled'));\r\n return;\r\n }\r\n signal.addEventListener('abort', onAbort);\r\n }\r\n const cleanupSignal = () => {\r\n if (signal) signal.removeEventListener('abort', onAbort);\r\n };\r\n\r\n xhr.onload = () => {\r\n cleanupSignal();\r\n let parsed = xhr.responseText;\r\n const contentType = xhr.getResponseHeader('content-type') || '';\r\n if (contentType.includes('application/json')) {\r\n try { parsed = JSON.parse(xhr.responseText); } catch { /* keep text */ }\r\n }\r\n if (xhr.status >= 200 && xhr.status < 300) {\r\n this.logger.debug(`Upload ${xhr.status}:`, parsed);\r\n resolve(parsed);\r\n } else {\r\n const error = new Error(parsed?.message || `HTTP ${xhr.status}`);\r\n error.code = ErrorTypes.API_ERROR;\r\n error.status = xhr.status;\r\n error.response = parsed;\r\n this.logger.error(`Upload Error: POST ${url}`, error);\r\n reject(error);\r\n }\r\n };\r\n\r\n xhr.onerror = () => {\r\n cleanupSignal();\r\n const err = new Error('Network error during upload');\r\n err.code = ErrorTypes.API_ERROR;\r\n this.logger.error(`Upload Network Error: POST ${url}`, err);\r\n reject(err);\r\n };\r\n\r\n xhr.ontimeout = () => {\r\n cleanupSignal();\r\n const err = new Error('Upload timeout');\r\n err.code = ErrorTypes.API_TIMEOUT;\r\n this.logger.error(`Upload Timeout: POST ${url}`, err);\r\n reject(err);\r\n };\r\n\r\n xhr.onabort = () => {\r\n cleanupSignal();\r\n reject(new Error('Upload cancelled'));\r\n };\r\n\r\n this.logger.debug(`POST ${url} (multipart, ${file.size} bytes)`);\r\n xhr.send(form);\r\n });\r\n }\r\n}\r\n\r\nexport default ApiClient;\r\n","import EventEmitter from '../utils/EventEmitter.js';\r\n\r\nconst sessions = new Map();\r\nconst TERMINAL_CODES = new Set(['REFRESH_INVALID', 'REFRESH_REUSED', 'SESSION_REVOKED', 'ACCOUNT_BLOCKED', 'LOGIN_FAILED']);\r\nexport const isTerminalAuthError = error => TERMINAL_CODES.has(error?.response?.code || error?.code);\r\n\r\n/**\r\n * 로그인 회사 선택 결과 — 토큰이 없다. 목록이 비었거나 회사 id 가 없는 응답은 계약 위반이라 인증 불가로 끊는다(빈 선택 화면을 띄우지 않게).\r\n */\r\nfunction workspaceSelection(data) {\r\n const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];\r\n if (workspaces.length === 0 || workspaces.some(workspace => !workspace?.projectId)) {\r\n const error = new Error('Invalid authentication response'); error.code = 'AUTH_UNAVAILABLE'; throw error;\r\n }\r\n return {\r\n workspaceSelectionRequired: true,\r\n workspaces: workspaces.map(({ projectId, companyName, companyRole }) =>\r\n ({ projectId, companyName: companyName ?? null, companyRole }))\r\n };\r\n}\r\n\r\nexport function tokenClaims(token) {\r\n try {\r\n const value = token.replace(/^Bearer\\s+/i, '').split('.')[1].replace(/-/g, '+').replace(/_/g, '/');\r\n return JSON.parse(atob(value));\r\n } catch { return {}; }\r\n}\r\n\r\n/** 서버별 인증 상태. Access는 메모리, Refresh와 로그아웃 의도는 하나의 저장 레코드로 관리한다. */\r\nexport default class AuthSession extends EventEmitter {\r\n static forServer(serverUrl) {\r\n const url = serverUrl.replace(/\\/$/, '');\r\n if (!sessions.has(url)) sessions.set(url, new AuthSession(url));\r\n return sessions.get(url);\r\n }\r\n\r\n constructor(serverUrl) {\r\n super();\r\n this.serverUrl = serverUrl;\r\n this.state = 'checking';\r\n this.data = null;\r\n this.clients = new Set();\r\n this.generation = 0;\r\n this.refreshPromise = null;\r\n this.timer = null;\r\n this.peerWaiters = new Map();\r\n this.markerKey = `talkflow:auth:${serverUrl}`;\r\n this.marker = this._readMarker();\r\n this.serverClock = null;\r\n this.channel = null;\r\n this.storageListener = event => {\r\n if (event.key !== this.markerKey) return;\r\n const stored = this._readMarker();\r\n if (stored.revision !== this.marker.revision || stored.logout) {\r\n this.marker = stored;\r\n this.generation++;\r\n this._clear(false).catch(error => this.emit('authError', error));\r\n }\r\n };\r\n globalThis.addEventListener?.('storage', this.storageListener);\r\n if (typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined') {\r\n this.channel = new BroadcastChannel(`talkflow:auth:${serverUrl}`);\r\n this.channel.onmessage = event => this._receive(event.data);\r\n this.channel.unref?.();\r\n }\r\n }\r\n\r\n _readMarker() {\r\n try {\r\n const value = globalThis.localStorage?.getItem(this.markerKey);\r\n return value ? JSON.parse(value) : { revision: 'initial', logout: false };\r\n } catch { throw this._storageError(); }\r\n }\r\n _storageError() { const error = new Error('Authentication storage is unavailable'); error.code = 'AUTH_STORAGE_UNAVAILABLE'; return error; }\r\n _writeMarker(marker) {\r\n try {\r\n if (!globalThis.localStorage) throw this._storageError();\r\n globalThis.localStorage.setItem(this.markerKey, JSON.stringify(marker));\r\n this.marker = marker;\r\n } catch { throw this._storageError(); }\r\n }\r\n _newMarker(logout, pending = logout) {\r\n const previous = this._readMarker();\r\n const marker = { revision: globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(), logout,\r\n deviceId: previous.deviceId, pending,\r\n pendingRefreshToken: logout && pending ? previous.refreshToken || previous.pendingRefreshToken : undefined };\r\n this._writeMarker(marker);\r\n return marker;\r\n }\r\n setDeviceId(deviceId) { this._writeMarker({ ...this._readMarker(), deviceId }); }\r\n _monotonic() { return globalThis.performance?.now?.() ?? Date.now(); }\r\n _serverNow() {\r\n if (!this.serverClock) return Date.now();\r\n return this.serverClock.server + Math.max(this._monotonic() - this.serverClock.mono, Date.now() - this.serverClock.wall);\r\n }\r\n _shareData() { return this.data ? { ...this.data, refreshToken: undefined, serverTime: this._serverNow() } : null; }\r\n _publish(type, data, marker = this.marker) {\r\n const { revision, logout, pending } = marker;\r\n this.channel?.postMessage({ type, marker: { revision, logout, pending }, data });\r\n }\r\n _setState(state, error, force = false) {\r\n const previous = this.state;\r\n this.state = state;\r\n if (previous !== state || error || force) this.emit('authStateChanged', { state, previous, error });\r\n }\r\n getAuthState() { return this.state; }\r\n isLoggedIn() { return this.state === 'authenticated'; }\r\n _fresh(token = this.data?.accessToken) { return Number(tokenClaims(token || '').exp || 0) * 1000 > this._serverNow() + (this.refreshMargin ?? 60000); }\r\n\r\n attach(client) { this.clients.add(client); }\r\n detach(client) { this.clients.delete(client); this.schedule(); }\r\n\r\n _enqueue(work) {\r\n const operation = (this.transition || Promise.resolve()).then(work);\r\n this.transition = operation.catch(() => {});\r\n return operation;\r\n }\r\n\r\n _accept(data, publish = true) {\r\n const generation = this.generation;\r\n const marker = this.marker;\r\n return this._enqueue(async () => {\r\n if (generation !== this.generation) throw this._cancelled();\r\n if (!data?.accessToken || !data?.projectId || !data?.clientKey || !data?.user?.userId) {\r\n const error = new Error('Invalid authentication response'); error.code = 'AUTH_UNAVAILABLE'; throw error;\r\n }\r\n const identityChanged = this.data && (this.data.user.userId !== data.user.userId || this.data.projectId !== data.projectId);\r\n if (publish && data.refreshToken) {\r\n const stored = this._readMarker();\r\n if (stored.revision !== marker.revision || stored.logout) throw this._cancelled();\r\n this._writeMarker({ ...stored, refreshToken: data.refreshToken, refreshRequestId: undefined, pending: false, pendingRefreshToken: undefined });\r\n }\r\n if (Number.isFinite(data.serverTime)) this.serverClock = {\r\n server: data.serverTime + (data._roundTripMillis || 0), mono: this._monotonic(), wall: Date.now()\r\n };\r\n this.refreshMargin = Math.min(60000, Math.max(0, Number(tokenClaims(data.accessToken).exp) * 1000 - this._serverNow()) / 10);\r\n // Access 응답만 메모리에 남긴다. Refresh는 매 갱신 시 저장소에서 다시 읽는다.\r\n this.data = { ...data, refreshToken: undefined };\r\n await Promise.all([...this.clients].map(client => client._acceptAuthSession(data)));\r\n if (generation !== this.generation) throw this._cancelled();\r\n this._setState('authenticated', undefined, identityChanged);\r\n if (publish) this._publish('tokens', this._shareData(), this.marker);\r\n this._reconnectClients();\r\n this.schedule();\r\n return data;\r\n });\r\n }\r\n\r\n _reconnectClients() {\r\n for (const client of this.clients) {\r\n if (client._authConnectionRequested && client.isConnected?.()) {\r\n const manager = client.connectionManager;\r\n if (manager?._authenticatedToken !== this.data?.accessToken) {\r\n manager.forceReconnect('access-token-renewed').catch(error => this.emit('authError', error));\r\n }\r\n }\r\n }\r\n }\r\n\r\n _clear(publish = true) {\r\n const marker = this.marker;\r\n return this._enqueue(async () => {\r\n this.data = null;\r\n clearTimeout(this.timer);\r\n this.timer = null;\r\n this._setState('unauthenticated');\r\n await Promise.all([...this.clients].map(client => client._clearAuthSession()));\r\n if (publish) this._publish('logout', undefined, marker);\r\n });\r\n }\r\n\r\n async _receive(message) {\r\n if (!message?.marker) return;\r\n try {\r\n const stored = this._readMarker();\r\n if (message.marker.revision !== stored.revision && stored.revision !== this.marker.revision) return;\r\n if (message.type === 'logout' || message.type === 'login-start') {\r\n if (message.marker.revision !== stored.revision) return;\r\n this.marker = message.marker;\r\n this.generation++;\r\n await this._clear(false);\r\n if (message.type === 'login-start') this._setState('checking');\r\n return;\r\n }\r\n if (message.marker.revision !== this._readMarker().revision || message.marker.logout) return;\r\n if (message.type === 'request-token' && this._fresh()) {\r\n this._publish('peer-token', { requestId: message.data.requestId, session: this._shareData() });\r\n } else if (message.type === 'peer-token') {\r\n this.peerWaiters.get(message.data.requestId)?.(message.data.session);\r\n } else if (message.type === 'tokens') {\r\n if (this.marker.revision !== message.marker.revision) this.generation++;\r\n this.marker = message.marker;\r\n // 지연된 방송이 더 최근 토큰을 덮어쓰지 않도록 한다.\r\n const incoming = tokenClaims(message.data?.accessToken || '');\r\n const current = tokenClaims(this.data?.accessToken || '');\r\n if (incoming.sid === current.sid && incoming.exp < current.exp) return;\r\n await this._accept(message.data, false);\r\n }\r\n } catch (error) { this.emit('authError', error); }\r\n }\r\n\r\n async _peerToken(rejectedToken) {\r\n if (!this.channel) return null;\r\n const requestId = `${Date.now()}-${Math.random()}`;\r\n return new Promise(resolve => {\r\n const done = data => {\r\n if (data && (data.accessToken === rejectedToken || Number(tokenClaims(data.accessToken).exp) * 1000 <= Number(data.serverTime) + 60000)) return;\r\n clearTimeout(timer);\r\n this.peerWaiters.delete(requestId);\r\n resolve(data);\r\n };\r\n const timer = setTimeout(() => done(null), 100);\r\n this.peerWaiters.set(requestId, done);\r\n this._publish('request-token', { requestId });\r\n });\r\n }\r\n\r\n _locked(work) {\r\n const locks = typeof navigator !== 'undefined' ? navigator.locks : null;\r\n const operation = (this.httpQueue || Promise.resolve()).then(() =>\r\n locks?.request ? locks.request(`talkflow:auth:${this.serverUrl}`, work) : work());\r\n this.httpQueue = operation.catch(() => {});\r\n return operation;\r\n }\r\n\r\n _newRefreshRequestId() {\r\n const bytes = new Uint8Array(32);\r\n if (!globalThis.crypto?.getRandomValues) {\r\n const error = new Error('Secure randomness is unavailable'); error.code = 'AUTH_RANDOM_UNAVAILABLE'; throw error;\r\n }\r\n globalThis.crypto.getRandomValues(bytes);\r\n return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');\r\n }\r\n\r\n async _request(path, body, headers = {}) {\r\n const started = this._monotonic();\r\n const controller = new AbortController();\r\n const timeout = setTimeout(() => controller.abort(), 15000);\r\n try {\r\n const response = await fetch(`${this.serverUrl}${path}`, {\r\n method: 'POST', credentials: 'omit', signal: controller.signal,\r\n headers: { 'Content-Type': 'application/json', 'X-TalkFlow-Session': 'refresh', ...headers },\r\n body: body === undefined ? undefined : JSON.stringify(body)\r\n });\r\n const text = response.headers.get('content-type')?.includes('application/json')\r\n ? await response.json() : await response.text();\r\n if (!response.ok) {\r\n const error = new Error(text?.message || `HTTP ${response.status}`);\r\n error.status = response.status; error.code = text?.code; error.response = text;\r\n throw error;\r\n }\r\n const data = text?.data !== undefined ? text.data : text;\r\n if (data && typeof data === 'object') return Object.defineProperty({ ...data }, '_roundTripMillis', { value: this._monotonic() - started });\r\n return data;\r\n } finally { clearTimeout(timeout); }\r\n }\r\n\r\n /**\r\n * 같은 이메일로 여러 회사 계정이 있고 비밀번호가 둘 이상 맞으면 서버는 토큰 없이 회사 목록만 준다. 그 응답은\r\n * {@link workspaceSelection} 결과로 돌려준다. 저장소·인증 상태·이전 세션은 건드리지 않는다 — 서버도 이 단계에서는\r\n * 이전 세션을 끊지 않는다. 호출자는 고른 {@code projectId} 를 실어 같은 자격으로 다시 부른다(서버가 비밀번호를 다시 검증).\r\n */\r\n async login({ email, password, projectId }) {\r\n const generation = ++this.generation;\r\n return this._locked(async () => {\r\n const previous = this._readMarker();\r\n this._writeMarker(previous);\r\n if (previous.pendingRefreshToken) await this._request('/v1/auth/signout', { refreshToken: previous.pendingRefreshToken, deviceId: previous.deviceId });\r\n const data = await this._request('/api/v1/auth/login',\r\n { email, password, previousRefreshToken: previous.refreshToken, ...(projectId ? { projectId } : {}) });\r\n if (data?.workspaceSelectionRequired === true) {\r\n if (generation !== this.generation) throw this._cancelled();\r\n return workspaceSelection(data);\r\n }\r\n if (!data?.refreshToken) {\r\n const error = new Error('Server does not support the refresh session contract');\r\n error.code = 'AUTH_PROTOCOL_UNSUPPORTED';\r\n throw error;\r\n }\r\n if (generation !== this.generation || this._readMarker().revision !== previous.revision) {\r\n await this._request('/v1/auth/signout', { refreshToken: data.refreshToken });\r\n throw this._cancelled();\r\n }\r\n this._newMarker(false);\r\n return this._accept(data);\r\n });\r\n }\r\n\r\n async restoreSession() {\r\n if (this._readMarker().logout) {\r\n if (this._readMarker().pending) await this.logout();\r\n else await this._clear(false);\r\n return null;\r\n }\r\n if (this._fresh()) return this.data;\r\n if (!this._readMarker().refreshToken) { await this._clear(false); return null; }\r\n if (!this.data) this._setState('checking');\r\n try { await this.getAccessToken(); return this.data; }\r\n catch (error) { if (isTerminalAuthError(error)) return null; throw error; }\r\n }\r\n\r\n async getAccessToken({ forceRefresh = false, rejectedToken } = {}) {\r\n if (this._readMarker().logout) throw this._loggedOut();\r\n if (this._fresh() && (!forceRefresh || (rejectedToken && rejectedToken !== this.data.accessToken)))\r\n return this.data.accessToken;\r\n if (this.refreshPromise) return this.refreshPromise;\r\n const generation = this.generation;\r\n const revision = this._readMarker().revision;\r\n const operation = this._locked(async () => {\r\n if (generation !== this.generation || revision !== this._readMarker().revision) throw this._cancelled();\r\n if (this._fresh() && (!forceRefresh || (rejectedToken && rejectedToken !== this.data.accessToken)))\r\n return this.data.accessToken;\r\n this.marker = this._readMarker();\r\n if (!this.marker.refreshToken) throw this._loggedOut();\r\n // 회전 전에 저장 가능 여부를 확인한다. 잠금 대기 전 읽은 토큰은 사용하지 않는다.\r\n this._writeMarker(this.marker);\r\n const peer = await this._peerToken(rejectedToken);\r\n let data = peer;\r\n if (!data) {\r\n // 요청 전에 자격과 식별자를 함께 저장한다. 실패·재시작 뒤에도 같은 요청으로 복구한다.\r\n const stored = this._readMarker();\r\n if (generation !== this.generation || revision !== stored.revision || stored.logout) throw this._cancelled();\r\n const requestId = stored.refreshRequestId || this._newRefreshRequestId();\r\n this._writeMarker({ ...stored, refreshRequestId: requestId });\r\n data = await this._request('/api/v1/auth/refresh', { refreshToken: stored.refreshToken, requestId });\r\n }\r\n if (generation !== this.generation || revision !== this._readMarker().revision) throw this._cancelled();\r\n await this._accept(data, !peer);\r\n return data.accessToken;\r\n }).catch(async error => {\r\n if (generation === this.generation && revision === this._readMarker().revision) {\r\n if (isTerminalAuthError(error)) await this.authenticationFailed(error);\r\n else if (!this.data) this._setState('unavailable', error);\r\n else this.emit('authError', error);\r\n }\r\n throw error;\r\n }).finally(() => { if (this.refreshPromise === operation) this.refreshPromise = null; });\r\n this.refreshPromise = operation;\r\n return operation;\r\n }\r\n\r\n async logout() {\r\n const token = this.data?.accessToken;\r\n this.generation++;\r\n const marker = this._newMarker(true);\r\n // 로컬 정리보다 먼저 HTTP 순서를 예약해 뒤의 로그인이 로그아웃을 추월하지 못하게 한다.\r\n const cleanup = this._clear();\r\n const request = this._locked(async () => {\r\n const result = await this._request('/v1/auth/signout', { refreshToken: marker.pendingRefreshToken, deviceId: marker.deviceId },\r\n token ? { Authorization: 'Bearer ' + token.replace(/^Bearer\\s+/i, '') } : {});\r\n if (this._readMarker().revision === marker.revision) this._writeMarker({ ...marker, pending: false, pendingRefreshToken: undefined });\r\n return result;\r\n });\r\n await Promise.all([cleanup, request]);\r\n if (this._readMarker().revision === marker.revision) {\r\n this._writeMarker({ ...marker, pending: false, pendingRefreshToken: undefined });\r\n }\r\n // 명시적 로그인 전에는 재시작해도 로그아웃 의도를 유지한다.\r\n }\r\n\r\n async changePassword(request) {\r\n await this.getAccessToken();\r\n const generation = ++this.generation;\r\n return this._locked(async () => {\r\n const response = await request();\r\n if (generation !== this.generation) throw this._cancelled();\r\n const data = response?.data ?? response;\r\n if (data?.reauthenticationRequired) {\r\n this._newMarker(true, false);\r\n await this._clear();\r\n return response;\r\n }\r\n this._newMarker(false);\r\n await this._accept(data);\r\n return response;\r\n }).catch(async error => {\r\n if (generation === this.generation) await this.authenticationFailed(error);\r\n throw error;\r\n });\r\n }\r\n\r\n _loggedOut() { const error = new Error('Sign in is required'); error.code = 'SESSION_REVOKED'; return error; }\r\n async authenticationFailed(error, { rejectedToken, generation } = {}) {\r\n if (generation !== undefined && generation !== this.generation) return;\r\n if (rejectedToken && this.data && rejectedToken !== this.data.accessToken) return;\r\n if (isTerminalAuthError(error)) {\r\n this.generation++;\r\n this._newMarker(true, false);\r\n await this._clear();\r\n }\r\n }\r\n replaceAccessToken(accessToken) {\r\n if (!this.data || this.data.accessToken === accessToken) return;\r\n const claims = tokenClaims(accessToken);\r\n if (claims.sid !== tokenClaims(this.data.accessToken).sid || claims.sub !== this.data.user.userId) return;\r\n this.data = { ...this.data, accessToken, expiresIn: claims.exp };\r\n this._publish('tokens', this._shareData());\r\n this._reconnectClients();\r\n this.schedule();\r\n }\r\n _cancelled() { const error = new Error('Authentication operation was superseded'); error.code = 'AUTH_OPERATION_CANCELLED'; return error; }\r\n\r\n schedule(delay) {\r\n clearTimeout(this.timer);\r\n this.timer = null;\r\n if (!this.data || ![...this.clients].some(client => client._authConnectionRequested)) return;\r\n const wait = delay ?? Math.max(1000, Number(tokenClaims(this.data.accessToken).exp || 0) * 1000 - this._serverNow() - (this.refreshMargin ?? 60000));\r\n this.timer = setTimeout(async () => {\r\n try {\r\n const before = this.data?.accessToken;\r\n await this.getAccessToken({ forceRefresh: true, rejectedToken: before });\r\n if (this.data) {\r\n await Promise.all([...this.clients].filter(client => client._authConnectionRequested\r\n && !client.isConnected())\r\n .map(client => client.connectionManager.forceReconnect('access-token-renewed')));\r\n }\r\n this.schedule();\r\n } catch (error) {\r\n if (!isTerminalAuthError(error)) this.schedule(30000);\r\n }\r\n }, wait);\r\n this.timer.unref?.();\r\n }\r\n\r\n destroy() {\r\n this.generation++;\r\n clearTimeout(this.timer);\r\n this.channel?.close();\r\n globalThis.removeEventListener?.('storage', this.storageListener);\r\n this.clients.clear();\r\n this.removeAllListeners();\r\n if (sessions.get(this.serverUrl) === this) sessions.delete(this.serverUrl);\r\n }\r\n}\r\n","/**\r\n * JWT 유틸리티 함수\r\n * JWT 토큰 파싱 및 검증\r\n */\r\n\r\nimport { ErrorTypes } from '../constants.js';\r\n\r\n/**\r\n * Bearer 접두사 제거\r\n * @param {string} token - JWT 토큰\r\n * @returns {string}\r\n */\r\nfunction stripBearer(token) {\r\n if (!token || typeof token !== 'string') {\r\n return '';\r\n }\r\n return token.replace(/^Bearer\\s+/i, '');\r\n}\r\n\r\n/**\r\n * Base64URL 디코딩 (UTF-8 안전)\r\n * @param {string} str - Base64URL 인코딩된 문자열\r\n * @returns {string}\r\n */\r\nfunction base64UrlDecode(str) {\r\n let base64 = str.replace(/-/g, '+').replace(/_/g, '/');\r\n const padding = base64.length % 4;\r\n if (padding) {\r\n base64 += '='.repeat(4 - padding);\r\n }\r\n // atob()은 Latin-1 기반이라 멀티바이트 UTF-8(한국어 등)이 깨짐\r\n // → Uint8Array 변환 후 TextDecoder로 UTF-8 디코딩\r\n const binaryString = atob(base64);\r\n const bytes = Uint8Array.from(binaryString, c => c.charCodeAt(0));\r\n return new TextDecoder().decode(bytes);\r\n}\r\n\r\n/**\r\n * JWT 토큰 검증 및 파싱\r\n * @param {string} jwtToken - JWT 토큰 (Bearer 접두사 포함 가능)\r\n * @param {Object} [options] - 옵션\r\n * @param {number} [options.bufferSeconds=30] - 만료 버퍼 시간 (초)\r\n * @param {boolean} [options.validateExpiry=true] - 만료 시간 검증 여부\r\n * @returns {{userId: string, payload: Object}}\r\n * @throws {Error}\r\n */\r\nexport function validateAndParseJWT(jwtToken, options = {}) {\r\n const { bufferSeconds = 30, validateExpiry = true, validateIssuedAt = false } = options;\r\n\r\n if (!jwtToken || typeof jwtToken !== 'string') {\r\n const error = new Error('JWT token is required');\r\n error.code = ErrorTypes.JWT_INVALID;\r\n throw error;\r\n }\r\n\r\n const tokenWithoutBearer = stripBearer(jwtToken);\r\n const parts = tokenWithoutBearer.split('.');\r\n\r\n if (parts.length !== 3) {\r\n const error = new Error('Invalid JWT format: expected 3 parts separated by dots');\r\n error.code = ErrorTypes.JWT_INVALID;\r\n throw error;\r\n }\r\n\r\n let payload;\r\n try {\r\n payload = JSON.parse(base64UrlDecode(parts[1]));\r\n } catch (e) {\r\n const error = new Error('Invalid JWT: failed to decode payload');\r\n error.code = ErrorTypes.JWT_PARSE_FAILED;\r\n error.originalError = e;\r\n throw error;\r\n }\r\n\r\n if (validateExpiry && payload.exp) {\r\n const expiryTime = payload.exp * 1000;\r\n const bufferTime = bufferSeconds * 1000;\r\n const now = Date.now();\r\n\r\n if (expiryTime < now + bufferTime) {\r\n const error = new Error(\r\n expiryTime < now\r\n ? 'JWT token expired'\r\n : `JWT token expires within ${bufferSeconds} seconds`\r\n );\r\n error.code = ErrorTypes.JWT_EXPIRED;\r\n error.expiredAt = new Date(expiryTime).toISOString();\r\n throw error;\r\n }\r\n }\r\n\r\n if (validateIssuedAt && payload.iat) {\r\n const issuedAt = payload.iat * 1000;\r\n const clockSkew = 60 * 1000;\r\n\r\n if (issuedAt > Date.now() + clockSkew) {\r\n const error = new Error('JWT token issued in the future');\r\n error.code = ErrorTypes.JWT_INVALID;\r\n throw error;\r\n }\r\n }\r\n\r\n const userId = payload.userId || payload.sub || payload.user_id;\r\n\r\n if (!userId) {\r\n const error = new Error('Cannot extract userId from JWT: missing userId, sub, or user_id claim');\r\n error.code = ErrorTypes.JWT_INVALID;\r\n throw error;\r\n }\r\n\r\n return { userId, payload };\r\n}\r\n\r\n/**\r\n * JWT에서 userId만 추출\r\n * @param {string} jwtToken - JWT 토큰\r\n * @returns {string|null}\r\n */\r\nexport function extractUserIdFromJWT(jwtToken) {\r\n try {\r\n const tokenWithoutBearer = stripBearer(jwtToken);\r\n const parts = tokenWithoutBearer.split('.');\r\n\r\n if (parts.length !== 3) {\r\n return null;\r\n }\r\n\r\n const payload = JSON.parse(base64UrlDecode(parts[1]));\r\n return payload.userId || payload.sub || payload.user_id || null;\r\n } catch (error) {\r\n console.error('Failed to extract userId from JWT:', error);\r\n return null;\r\n }\r\n}\r\n\r\n/**\r\n * JWT 만료 여부 확인\r\n * @param {string} jwtToken - JWT 토큰\r\n * @param {number} [bufferSeconds=0] - 버퍼 시간 (초)\r\n * @returns {boolean}\r\n */\r\nexport function isJWTExpired(jwtToken, bufferSeconds = 0) {\r\n try {\r\n const tokenWithoutBearer = stripBearer(jwtToken);\r\n const parts = tokenWithoutBearer.split('.');\r\n\r\n if (parts.length !== 3) {\r\n return true;\r\n }\r\n\r\n const payload = JSON.parse(base64UrlDecode(parts[1]));\r\n\r\n if (!payload.exp) {\r\n return false;\r\n }\r\n\r\n const expiryTime = payload.exp * 1000;\r\n const bufferTime = bufferSeconds * 1000;\r\n\r\n return expiryTime < Date.now() + bufferTime;\r\n } catch (error) {\r\n console.error('Failed to check JWT expiration:', error);\r\n return true;\r\n }\r\n}\r\n\r\n/**\r\n * JWT 남은 유효 시간 반환\r\n * @param {string} jwtToken - JWT 토큰\r\n * @returns {number} - 밀리초, 만료 시 음수\r\n */\r\nexport function getJWTRemainingTime(jwtToken) {\r\n try {\r\n const tokenWithoutBearer = stripBearer(jwtToken);\r\n const parts = tokenWithoutBearer.split('.');\r\n\r\n if (parts.length !== 3) {\r\n return -1;\r\n }\r\n\r\n const payload = JSON.parse(base64UrlDecode(parts[1]));\r\n\r\n if (!payload.exp) {\r\n return Infinity;\r\n }\r\n\r\n return (payload.exp * 1000) - Date.now();\r\n } catch (error) {\r\n console.error('Failed to get JWT remaining time:', error);\r\n return -1;\r\n }\r\n}\r\n\r\n/**\r\n * JWT payload 디코딩 (검증 없이)\r\n * @param {string} jwtToken - JWT 토큰\r\n * @returns {Object|null}\r\n */\r\nexport function decodeJWTPayload(jwtToken) {\r\n try {\r\n const tokenWithoutBearer = stripBearer(jwtToken);\r\n const parts = tokenWithoutBearer.split('.');\r\n\r\n if (parts.length !== 3) {\r\n return null;\r\n }\r\n\r\n return JSON.parse(base64UrlDecode(parts[1]));\r\n } catch (error) {\r\n console.error('Failed to decode JWT payload:', error);\r\n return null;\r\n }\r\n}\r\n","/**\r\n * ConnectionManager\r\n * WebSocket/STOMP 연결 관리 (Chat, WebRTC 공유)\r\n */\r\n\r\nimport { isTerminalAuthError } from '../auth/AuthSession.js';\r\nimport EventEmitter from '../utils/EventEmitter.js';\r\nimport Logger from '../utils/Logger.js';\r\nimport { ConnectionState, ErrorTypes, WebSocketPaths, DefaultConfig, LogLevel } from '../constants.js';\r\nimport { Client as StompClient, ReconnectionTimeMode, StompSocketState } from '@stomp/stompjs';\r\nimport SockJS from 'sockjs-client';\r\n\r\n/** 서버 구독 관문의 거부 코드(STOMP ERROR 본문 code · HTTP 402) — 결제 전·해지 회사. */\r\nconst SUBSCRIPTION_REQUIRED_CODE = 'NOT_SUBSCRIBED';\r\n/** 서버가 구독 상태를 잠시 확인하지 못했다(503) — 대기가 아니라 일시 실패다. */\r\nconst SUBSCRIPTION_CHECK_UNAVAILABLE_CODE = 'SUBSCRIPTION_CHECK_UNAVAILABLE';\r\n\r\n// STOMP Client 가져오기 (CDN 사용 시 전역 객체 우선)\r\nconst getStompClient = () => {\r\n // noinspection JSUnresolvedReference\r\n if (typeof window !== 'undefined' && window.StompJs && window.StompJs.Client) {\r\n return window.StompJs.Client;\r\n }\r\n return StompClient;\r\n};\r\n\r\n// SockJS 가져오기 (CDN 사용 시 전역 객체 우선)\r\nconst getSockJS = () => {\r\n // noinspection JSUnresolvedReference\r\n if (typeof window !== 'undefined' && window.SockJS) {\r\n return window.SockJS;\r\n }\r\n return SockJS;\r\n};\r\n\r\nclass ConnectionManager extends EventEmitter {\r\n /**\r\n * @param {Object} options\r\n * @param {string} options.serverUrl - 서버 URL\r\n * @param {string} options.jwtToken - JWT 토큰\r\n * @param {string} options.apiKey - API 키\r\n * @param {string} options.projectId - 프로젝트 ID\r\n * @param {boolean} [options.useSockJS=true] - SockJS 사용 여부\r\n * @param {number} [options.reconnectDelay] - 재연결 지연 시간\r\n * @param {number} [options.maxReconnectAttempts] - 한 묶음의 최대 재연결 시도 횟수\r\n * @param {number} [options.reconnectRecoveryDelay=60000] - 기존 연결의 재시도 소진 후 대기(ms), 0이면 비활성화\r\n * @param {number} [options.heartbeatIncoming] - 수신 하트비트 간격\r\n * @param {number} [options.heartbeatOutgoing] - 발신 하트비트 간격\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options) {\r\n super();\r\n\r\n this.serverUrl = options.serverUrl.replace(/\\/$/, '');\r\n this.jwtToken = options.jwtToken;\r\n this.apiKey = options.apiKey;\r\n this.projectId = options.projectId;\r\n this.useSockJS = options.useSockJS !== false;\r\n\r\n this.reconnectDelay = options.reconnectDelay || DefaultConfig.reconnectDelay;\r\n this.maxReconnectDelay = options.maxReconnectDelay || DefaultConfig.maxReconnectDelay;\r\n this.maxReconnectAttempts = options.maxReconnectAttempts || DefaultConfig.maxReconnectAttempts;\r\n this.reconnectRecoveryDelay = options.reconnectRecoveryDelay ?? DefaultConfig.reconnectRecoveryDelay;\r\n this.heartbeatIncoming = options.heartbeatIncoming || DefaultConfig.heartbeatIncoming;\r\n this.heartbeatOutgoing = options.heartbeatOutgoing || DefaultConfig.heartbeatOutgoing;\r\n this.resumeOnVisibility = options.resumeOnVisibility !== false;\r\n\r\n this.logger = new Logger(options.logLevel || LogLevel.WARN, 'ConnectionManager');\r\n\r\n // 상태\r\n this.state = ConnectionState.DISCONNECTED;\r\n this.stompClient = null;\r\n this.reconnectAttempts = 0;\r\n this.subscriptions = new Map();\r\n this.pendingSubscriptions = [];\r\n this.userId = null;\r\n\r\n // 사용자가 명시적으로 끊은 상태인지 — 포그라운드 복귀가 이걸 되살리면 안 된다.\r\n this._intentionalDisconnect = false;\r\n // forceReconnect 의 teardown 중 발생하는 close 이벤트를 재연결 시도로 오해하지 않도록.\r\n this._tearingDown = false;\r\n // connect() 가 최소 1회 호출됐는지 — 복귀 자동 재연결의 전제 조건.\r\n this._connectRequested = false;\r\n // forceReconnect 단일 실행 보장 (복귀 이벤트 3종이 거의 동시에 들어온다).\r\n this._forceReconnectPromise = null;\r\n this._lifecycleHandlers = null;\r\n this._recoveryTimer = null;\r\n this._terminalAuthError = false;\r\n this._recoveryEligible = false;\r\n // 회사 구독 대기 — 서버가 CONNECT·SUBSCRIBE·SEND 를 NOT_SUBSCRIBED 로 거부했다(결제 전·해지 회사).\r\n // 인증 거부와 다르다: 토큰은 그대로 유효하고, 결제가 끝나면 앱이 같은 토큰으로 connect() 를 다시 부른다.\r\n this._subscriptionRequired = false;\r\n\r\n this._installLifecycleListeners();\r\n }\r\n\r\n /**\r\n * 포그라운드 복귀 / 네트워크 복구 감지 (RECONNECT_SYNC_DESIGN §6.1).\r\n *\r\n * <p>이게 없으면 백그라운드에서 재시도 횟수를 소진해 ERROR 로 죽은 연결이 복귀해도 되살아나지 않아\r\n * <b>새로고침만이 유일한 복구 수단</b>이 된다. 모바일은 OS 가 타이머까지 얼려서 재시도 루프 자체가\r\n * 돌지 못하므로 복귀 시점의 능동적 깨우기가 필수다.</p>\r\n *\r\n * <p>SSR/Node 환경에서는 no-op.</p>\r\n *\r\n * @private\r\n */\r\n _installLifecycleListeners() {\r\n if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') {\r\n return;\r\n }\r\n\r\n const onVisibilityChange = () => {\r\n if (typeof document !== 'undefined' && document.visibilityState === 'visible') {\r\n this._onResume('visibilitychange');\r\n }\r\n };\r\n // bfcache 복원 — visibilitychange 가 안 오는 경로가 있어 별도로 받는다.\r\n const onPageShow = () => this._onResume('pageshow');\r\n const onOnline = () => this._onResume('online');\r\n\r\n if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {\r\n document.addEventListener('visibilitychange', onVisibilityChange);\r\n }\r\n window.addEventListener('pageshow', onPageShow);\r\n window.addEventListener('online', onOnline);\r\n\r\n this._lifecycleHandlers = { onVisibilityChange, onPageShow, onOnline };\r\n }\r\n\r\n /** @private */\r\n _removeLifecycleListeners() {\r\n if (!this._lifecycleHandlers) return;\r\n\r\n const { onVisibilityChange, onPageShow, onOnline } = this._lifecycleHandlers;\r\n if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {\r\n document.removeEventListener('visibilitychange', onVisibilityChange);\r\n }\r\n if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {\r\n window.removeEventListener('pageshow', onPageShow);\r\n window.removeEventListener('online', onOnline);\r\n }\r\n this._lifecycleHandlers = null;\r\n }\r\n\r\n /**\r\n * 복귀 시점 판정.\r\n *\r\n * <p><b>이벤트 계약</b> — {@code resumed} 와 {@code reconnected} 는 <b>상호 배타적</b>이며 둘을 합쳐야\r\n * \"재동기화가 필요한 시점\" 전체를 덮는다:</p>\r\n * <ul>\r\n * <li>{@code resumed} — 복귀했는데 연결이 <b>살아 있어 재연결이 필요 없었다</b>. 그래도 백그라운드\r\n * 동안 프레임을 놓쳤을 수 있으므로 갭 복구는 돌아야 한다.</li>\r\n * <li>{@code reconnected} — 재연결에 <b>성공</b>했다. 복귀가 유발한 경우든(좀비 소켓/ERROR 복구)\r\n * 탭이 열린 채 일시 끊겼다 붙은 경우든 이 이벤트로 수렴한다.</li>\r\n * </ul>\r\n *\r\n * <p>재연결이 일어나는 경로에서는 {@code resumed} 를 발행하지 않는다 — 하나의 복귀에 두 이벤트가\r\n * 겹쳐 나가면 소비자가 갭 복구를 두 번 돌린다. 소비자는 <b>둘 다</b> 구독해야 한다.</p>\r\n *\r\n * @private\r\n */\r\n _onResume(reason) {\r\n if (!this.resumeOnVisibility || this._intentionalDisconnect || this._terminalAuthError || this._subscriptionRequired) {\r\n return;\r\n }\r\n\r\n // connect() 를 아직 한 번도 호출하지 않았으면 복귀가 연결을 만들어내면 안 된다.\r\n // 이 경로는 TalkFlowClient.connect() 를 우회하므로 roomList 자동 구독 / WebRTC enable 등\r\n // 상위 초기화가 통째로 빠진 \"반쪽 연결\" 이 된다. 최초 연결은 반드시 사용자 호출로 시작한다.\r\n // (1회 연결된 뒤의 재연결은 _restoreSubscriptions 가 STOMP 구독을 되살리므로 안전하다.)\r\n if (!this._connectRequested) {\r\n this.logger.debug(`Resume (${reason}) ignored — connect() has not been called yet`);\r\n return;\r\n }\r\n\r\n // 연결 시도 중이거나 강제 재연결이 진행 중이면 그대로 둔다 — 중복 activate 방지.\r\n if (this.state === ConnectionState.CONNECTING || this._forceReconnectPromise) {\r\n return;\r\n }\r\n\r\n if (this.state === ConnectionState.CONNECTED) {\r\n if (this._isSocketAlive()) {\r\n this.logger.debug(`Resume (${reason}): connection alive`);\r\n this.emit('resumed', { reason });\r\n return;\r\n }\r\n // 좀비 소켓 — STOMP 가 아직 close 를 인지하지 못한 상태.\r\n this.logger.warn(`Resume (${reason}): zombie socket detected, forcing reconnect`);\r\n this.forceReconnect(`zombie-socket:${reason}`);\r\n return;\r\n }\r\n\r\n // DISCONNECTED(비의도) / RECONNECTING / ERROR — 재시도 횟수를 리셋하고 즉시 되살린다.\r\n this.logger.info(`Resume (${reason}): reviving connection from state=${this.state}`);\r\n this.forceReconnect(reason);\r\n }\r\n\r\n /**\r\n * 소켓이 실제로 열려 있는지.\r\n *\r\n * <p>{@code state === CONNECTED} 만으로는 부족하다 — OS 가 백그라운드에서 소켓을 끊어도\r\n * STOMP 가 close 를 인지하기 전까지 상태는 CONNECTED 로 남는다. 반대로 readyState 가 OPEN 인데\r\n * 실제로는 죽은 경우는 STOMP 자체의 incoming heartbeat 감시가 닫아주고, 그 사이 유실분은\r\n * 갭 복구가 메운다.</p>\r\n *\r\n * @private\r\n */\r\n _isSocketAlive() {\r\n const socket = this.stompClient && this.stompClient.webSocket;\r\n if (!socket) return false;\r\n return socket.readyState === StompSocketState.OPEN;\r\n }\r\n\r\n /**\r\n * 강제 재연결 — 기존 클라이언트를 완전히 정리한 뒤 새로 활성화.\r\n *\r\n * <p>ERROR 상태({@link #_handleReconnect} 가 {@code deactivate} 로 STOMP 루프를 끈 상태)에서도\r\n * 되살아나야 하므로 {@code activate} 재호출이 필요하다.</p>\r\n *\r\n * @param {string} reason\r\n * @returns {Promise<void>}\r\n */\r\n async forceReconnect(reason) {\r\n if (this._intentionalDisconnect || this._terminalAuthError || this.state === ConnectionState.CONNECTING) {\r\n return;\r\n }\r\n\r\n // 단일 실행 — 복귀 시 visibilitychange / pageshow / online 이 거의 동시에 들어오는데,\r\n // deactivate() 를 await 하는 동안 state 는 아직 CONNECTED 라 재진입 가드가 없으면\r\n // 두 번째 resume 이 그대로 통과해 클라이언트를 이중으로 만든다.\r\n if (!this._forceReconnectPromise) {\r\n this._forceReconnectPromise = this._doForceReconnect(reason)\r\n .finally(() => { this._forceReconnectPromise = null; });\r\n }\r\n await this._forceReconnectPromise;\r\n // 외부 호출의 완료 의미는 유지하되, 다음 복구 묶음을 막는 실행 잠금은 이미 풀었다.\r\n try { await this._connectPromise; }\r\n catch { /* connect/error 이벤트가 종료 사유를 전달한다. */ }\r\n }\r\n\r\n /** @private */\r\n async _doForceReconnect(reason) {\r\n this._cancelReconnectRecovery();\r\n this.logger.info(`Force reconnect: ${reason}`);\r\n\r\n if (this.stompClient) {\r\n this._tearingDown = true;\r\n try {\r\n await this.stompClient.deactivate();\r\n } catch (error) {\r\n this.logger.warn('Error deactivating stomp client on force reconnect:', error);\r\n } finally {\r\n this._tearingDown = false;\r\n }\r\n this.stompClient = null;\r\n }\r\n\r\n // 구독 대기면 다시 붙지 않는다 — refresh 세션의 토큰 갱신(AuthSession 'access-token-renewed')·저빈도 복구·탭 복귀가 모두\r\n // 이리로 오고, 정리를 기다리는 동안 옛 클라이언트가 NOT_SUBSCRIBED 로 대기에 들어간 경우도 여기서 걸린다. 대기는 앱이 connect() 를\r\n // 다시 불러야 풀린다.\r\n if (this._intentionalDisconnect || this._terminalAuthError || this._subscriptionRequired) return;\r\n\r\n // connect() 가 CONNECTED/CONNECTING 이면 조기 반환하므로 상태를 먼저 내려둔다.\r\n this._setState(ConnectionState.DISCONNECTED);\r\n this.reconnectAttempts = 0;\r\n\r\n try {\r\n // 활성화까지만 직렬화한다. connect 대기는 장애 전체에 걸쳐 유지되므로\r\n // 여기서 await 하면 다음 저빈도 복구가 이 잠금에 막힌다.\r\n this.connect().catch(error => this.logger.warn(`Force reconnect failed (${reason}):`, error));\r\n } catch (error) {\r\n this.logger.warn(`Force reconnect failed (${reason}):`, error);\r\n }\r\n }\r\n\r\n /**\r\n * 연결 상태 변경\r\n * @private\r\n */\r\n _setState(state) {\r\n const prevState = this.state;\r\n this.state = state;\r\n this.emit('stateChange', { state, prevState });\r\n this.logger.info(`State changed: ${prevState} -> ${state}`);\r\n }\r\n\r\n /**\r\n * WebSocket URL 생성.\r\n *\r\n * <p>JWT 는 STOMP connectHeaders 의 Authorization 헤더로만 전달합니다.\r\n * URL query parameter 에 토큰을 넣지 않습니다 — 서버 로그, 프록시 로그,\r\n * 브라우저 히스토리에 JWT 가 평문 노출되는 것을 방지.</p>\r\n *\r\n * @private\r\n */\r\n _getWebSocketUrl() {\r\n const endpoint = this.useSockJS\r\n ? WebSocketPaths.SOCKJS_ENDPOINT\r\n : WebSocketPaths.NATIVE_ENDPOINT;\r\n\r\n return `${this.serverUrl}${endpoint}`;\r\n }\r\n\r\n /**\r\n * STOMP 클라이언트 생성\r\n * @private\r\n */\r\n _createStompClient() {\r\n const wsUrl = this._getWebSocketUrl();\r\n // 교체된 클라이언트(정지 뒤 새 토큰으로 다시 연결 등)의 늦은 콜백은 현재 연결 상태를 바꾸지 않는다.\r\n let stomp = null;\r\n const current = () => !this.stompClient || this.stompClient === stomp;\r\n\r\n const config = {\r\n beforeConnect: async (client) => {\r\n if (!this.getAccessToken) {\r\n this._connectionToken = this.jwtToken;\r\n return;\r\n }\r\n let attempt = 0;\r\n while (!this._intentionalDisconnect && (!client || client.active)) {\r\n try {\r\n const token = await this.getAccessToken({ forceRefresh: this._authRejected === true });\r\n if (this._intentionalDisconnect || (client && !client.active)) return;\r\n if (this._authRejected && (!token || token.replace(/^Bearer\\s+/i, '') === this._rejectedToken)) {\r\n this._stopForAuthError(new Error('Authentication rejected without a new access token'), client);\r\n return;\r\n }\r\n this.updateToken(token);\r\n this._connectionToken = token;\r\n this._authRejected = false;\r\n return;\r\n } catch (error) {\r\n this.emit('error', { type: ErrorTypes.CONNECTION_FAILED, message: error.message, error });\r\n // 거부된 access token과 갱신 요청의 일시 실패는 별개다.\r\n // 새 토큰/동일 토큰 판정은 위에서, 확정된 인증 종료만 여기서 처리한다.\r\n if (isTerminalAuthError(error)) {\r\n this._stopForAuthError(error, client);\r\n return;\r\n }\r\n // beforeConnect의 예외에는 STOMP 소켓 종료 백오프가 적용되지 않는다.\r\n const wait = Math.min(30000, 1000 * 2 ** Math.min(attempt++, 5)) * (0.8 + Math.random() * 0.4);\r\n await new Promise(resolve => {\r\n this._cancelAuthRetry = () => { clearTimeout(this._authRetryTimer); resolve(); };\r\n this._authRetryTimer = setTimeout(resolve, wait);\r\n this._authRetryTimer.unref?.();\r\n });\r\n this._cancelAuthRetry = null;\r\n }\r\n }\r\n },\r\n connectHeaders: {\r\n 'Authorization': this.jwtToken.startsWith('Bearer ')\r\n ? this.jwtToken\r\n : `Bearer ${this.jwtToken}`,\r\n 'X-API-KEY': this.apiKey,\r\n 'X-PROJECT-ID': this.projectId\r\n },\r\n heartbeatIncoming: this.heartbeatIncoming,\r\n heartbeatOutgoing: this.heartbeatOutgoing,\r\n // stompjs는 연결 성공 시 다음 지연을 저장한다. close 콜백보다 먼저 지터를 적용한다.\r\n reconnectDelay: this.reconnectDelay * (0.8 + Math.random() * 0.4),\r\n // 지수 백오프 — 고정 간격이면 서버 재시작 시 전 클라이언트가 같은 주기로 몰린다.\r\n // STOMP 7 내장 기능이라 직접 타이머를 굴리지 않는다.\r\n reconnectTimeMode: ReconnectionTimeMode.EXPONENTIAL,\r\n maxReconnectDelay: this.maxReconnectDelay,\r\n debug: (str) => {\r\n if (this.logger.level <= LogLevel.DEBUG) {\r\n this.logger.debug('STOMP:', str);\r\n }\r\n },\r\n onConnect: (frame) => { if (current()) this._onConnect(frame); },\r\n onDisconnect: (frame) => { if (current()) this._onDisconnect(frame); },\r\n onStompError: (frame) => { if (current()) this._onStompError(frame); },\r\n onWebSocketClose: (event) => { if (current()) this._onWebSocketClose(event); },\r\n onWebSocketError: (event) => { if (current()) this._onWebSocketError(event); }\r\n };\r\n\r\n if (this.useSockJS) {\r\n const SockJS = getSockJS();\r\n config.webSocketFactory = () => new SockJS(wsUrl);\r\n } else {\r\n // Native WebSocket — 토큰은 connectHeaders 로만 전달 (URL 노출 방지)\r\n const wsProtocol = this.serverUrl.startsWith('https') ? 'wss' : 'ws';\r\n const wsHost = this.serverUrl.replace(/^https?:\\/\\//, '');\r\n config.brokerURL = `${wsProtocol}://${wsHost}${WebSocketPaths.NATIVE_ENDPOINT}`;\r\n }\r\n\r\n const Client = getStompClient();\r\n stomp = new Client(config);\r\n return stomp;\r\n }\r\n\r\n /**\r\n * 연결\r\n * @returns {Promise<void>}\r\n */\r\n async connect() {\r\n if (this.state === ConnectionState.CONNECTED) {\r\n this.logger.warn('Already connected');\r\n return;\r\n }\r\n\r\n if (!this._connectPromise) {\r\n this._connectPromise = new Promise((resolve, reject) => {\r\n const clear = () => {\r\n this._connectPromise = null;\r\n this._connectResolve = this._connectReject = null;\r\n };\r\n this._connectResolve = () => { clear(); resolve(); };\r\n this._connectReject = error => { clear(); reject(error); };\r\n });\r\n }\r\n const connected = this._connectPromise;\r\n if (this.stompClient && (this.state === ConnectionState.CONNECTING || this.state === ConnectionState.RECONNECTING)) {\r\n return connected;\r\n }\r\n this._cancelReconnectRecovery();\r\n this._terminalAuthError = false;\r\n // 구독 대기는 앱이 connect() 를 다시 불러야 풀린다(결제 확인 뒤). 아직 구독이 없으면 서버가 다시 거부해 대기로 돌아온다.\r\n this._subscriptionRequired = false;\r\n this._setState(ConnectionState.CONNECTING);\r\n this.reconnectAttempts = 0;\r\n this._intentionalDisconnect = false;\r\n this._connectRequested = true;\r\n try {\r\n this.stompClient = this._createStompClient();\r\n this.stompClient.activate();\r\n } catch (error) {\r\n this._setState(ConnectionState.ERROR);\r\n this._drainPendingSubscriptions('Connection activation failed');\r\n this._connectReject?.(error);\r\n }\r\n return connected;\r\n }\r\n\r\n /**\r\n * 연결 성공 콜백\r\n * @private\r\n */\r\n _onConnect(frame) {\r\n this._authenticatedToken = this.jwtToken;\r\n // 재연결 여부 판정은 카운터 리셋 전에 — 갭 복구(S4)가 이 신호로 동기화를 건다.\r\n const attempts = this.reconnectAttempts;\r\n const isReconnect = attempts > 0 || this._hasConnectedBefore === true;\r\n\r\n this._setState(ConnectionState.CONNECTED);\r\n this.reconnectAttempts = 0;\r\n this._hasConnectedBefore = true;\r\n this._recoveryEligible = true;\r\n this._authRejected = false;\r\n this._rejectedToken = null;\r\n this._notifiedRejectedToken = null;\r\n this.logger.info('Connected to server', frame);\r\n\r\n // 기존 구독 재등록 (재연결 시)\r\n this._restoreSubscriptions();\r\n\r\n // 대기 중인 구독 처리\r\n this._processPendingSubscriptions();\r\n\r\n this.emit('connected', { frame });\r\n\r\n // 재연결 '성공' 신호 — 기존에는 성공 이벤트가 없어 connected 재발행으로만 유추해야 했다.\r\n if (isReconnect) {\r\n this.emit('reconnected', { attempts });\r\n }\r\n\r\n if (this._connectResolve) {\r\n this._connectResolve();\r\n this._connectResolve = null;\r\n this._connectReject = null;\r\n }\r\n }\r\n\r\n /**\r\n * 연결 해제 콜백\r\n * @private\r\n */\r\n _onDisconnect(frame) {\r\n this.logger.info('Disconnected from server', frame);\r\n this._setState(ConnectionState.DISCONNECTED);\r\n this.emit('disconnected', { frame });\r\n }\r\n\r\n /**\r\n * STOMP 에러 콜백\r\n * @private\r\n */\r\n _onStompError(frame) {\r\n let code = null;\r\n let serverMessage = null;\r\n try {\r\n const payload = JSON.parse(frame.body || frame.headers?.message || '{}');\r\n code = payload.code ?? null;\r\n serverMessage = typeof payload.message === 'string' ? payload.message : null;\r\n this._authRejected = ['ACCESS_UNAUTHORIZED', 'SESSION_REVOKED', 'TOKEN_INVALID'].includes(code);\r\n } catch { /* Non-authentication STOMP errors keep the normal reconnect policy. */ }\r\n // 회사 구독 없음 — 재연결해도 같은 거부다. 인증 거부로 기록하지 않고 «구독 대기» 로 멈춘다.\r\n // 구독 확인 일시 실패(SUBSCRIPTION_CHECK_UNAVAILABLE)는 아래 기본 경로 — 평소처럼 다시 붙는다.\r\n if (code === SUBSCRIPTION_REQUIRED_CODE) {\r\n this._stopForSubscription(serverMessage);\r\n return;\r\n }\r\n this.logger.error('STOMP error:', frame);\r\n this._setState(ConnectionState.ERROR);\r\n\r\n const error = {\r\n type: ErrorTypes.CONNECTION_FAILED,\r\n message: frame.headers?.message || 'STOMP error',\r\n frame\r\n };\r\n\r\n if (this._authRejected) this._recordAuthRejection();\r\n // 구독 확인 일시 실패는 첫 연결이라도 대기 중인 connect() 를 끝내지 않는다 — STOMP 가 곧 다시 붙는다. 여기서 reject 하면\r\n // 앱은 실패로 알고 방 목록 구독 등 후속 초기화를 건너뛰는데 연결은 붙어 «반쪽 연결» 이 된다.\r\n const terminal = code !== SUBSCRIPTION_CHECK_UNAVAILABLE_CODE && !this._canRecoverConnection();\r\n if (terminal) this._drainPendingSubscriptions(`STOMP error: ${error.message}`);\r\n\r\n this.emit('error', error);\r\n\r\n if (terminal && this._connectReject) {\r\n this._connectReject(new Error(error.message));\r\n this._connectResolve = null;\r\n this._connectReject = null;\r\n }\r\n }\r\n\r\n /**\r\n * WebSocket 종료 콜백\r\n * @private\r\n */\r\n _onWebSocketClose(event) {\r\n if (event?.code === 4001) this._recordAuthRejection();\r\n this.logger.warn('WebSocket closed:', event);\r\n\r\n // forceReconnect 의 의도적 teardown 이면 재연결 시도로 집계하지 않는다 —\r\n // 카운터가 오염되면 복귀 직후 다시 max 를 소진해버린다.\r\n if (this._tearingDown || this._intentionalDisconnect || this._terminalAuthError || this._subscriptionRequired) {\r\n this.logger.debug('WebSocket closed during intentional teardown — not counting as reconnect');\r\n return;\r\n }\r\n\r\n if (this.state !== ConnectionState.DISCONNECTED) {\r\n this._handleReconnect();\r\n }\r\n }\r\n\r\n /**\r\n * WebSocket 에러 콜백\r\n * @private\r\n */\r\n _onWebSocketError(event) {\r\n this.logger.error('WebSocket error:', event);\r\n\r\n // 이전에 연결된 클라이언트의 복구 중에는 원래 호출자의 대기를 유지한다.\r\n if (this._connectReject && !this._canRecoverConnection()) {\r\n this._drainPendingSubscriptions('WebSocket connection failed');\r\n this._connectReject(new Error('WebSocket connection failed'));\r\n this._connectResolve = null;\r\n this._connectReject = null;\r\n }\r\n }\r\n\r\n /**\r\n * 재연결 처리.\r\n *\r\n * <p>max 초과 시 STOMP 내부 재연결 루프(reconnectDelay) 를 차단하기 위해\r\n * stompClient 를 반드시 deactivate 하고 참조를 해제한다. state 변경만으로는\r\n * STOMP 자체가 소유한 타이머를 멈출 수 없음.</p>\r\n *\r\n * @private\r\n */\r\n _handleReconnect() {\r\n this.reconnectAttempts++;\r\n\r\n if (this.reconnectAttempts > this.maxReconnectAttempts) {\r\n const exhaustedClient = this.stompClient;\r\n this.stompClient = null;\r\n // 같은 STOMP 인스턴스의 타이머를 완전히 끈 뒤 저빈도 복구를 예약한다.\r\n // Promise 안에서 호출을 시작하면 stompjs가 close 이후 재시도를 먼저 예약할 수 있다.\r\n let deactivated;\r\n try {\r\n deactivated = exhaustedClient?.deactivate();\r\n } catch (error) {\r\n this.logger.warn('Error deactivating stomp client on max reconnect:', error);\r\n }\r\n Promise.resolve(deactivated).catch(error => {\r\n this.logger.warn('Error deactivating stomp client on max reconnect:', error);\r\n }).then(() => this._scheduleReconnectRecovery());\r\n\r\n // 복구 묶음 사이에서도 같은 connect/subscribe 요청을 유지한다.\r\n if (!this._canRecoverConnection()) {\r\n this._connectReject?.(new Error('Max reconnection attempts exceeded'));\r\n this._connectResolve = this._connectReject = null;\r\n this._drainPendingSubscriptions('Max reconnection attempts exceeded');\r\n }\r\n\r\n this._setState(ConnectionState.ERROR);\r\n this.emit('error', {\r\n type: ErrorTypes.CONNECTION_LOST,\r\n message: 'Max reconnection attempts exceeded'\r\n });\r\n return;\r\n }\r\n\r\n this._setState(ConnectionState.RECONNECTING);\r\n this.emit('reconnecting', { attempt: this.reconnectAttempts });\r\n this.logger.info(`Reconnecting... attempt ${this.reconnectAttempts}`);\r\n }\r\n\r\n /** 실제 거부된 자격 증명을 기록한다. 공급 함수가 있어도 정적 토큰일 수 있다. */\r\n _recordAuthRejection() {\r\n this._authRejected = true;\r\n this._rejectedToken = (this._connectionToken || this.jwtToken).replace(/^Bearer\\s+/i, '');\r\n if (!this.getAccessToken) this._stopForAuthError(new Error('STOMP error: authentication rejected'));\r\n }\r\n\r\n _stopForAuthError(error, client = this.stompClient) {\r\n // 같은 거부 토큰은 한 번만 알린다. 오류 처리기가 connect() 를 다시 불러도 즉시 정지 → 알림이 반복되지 않는다.\r\n const notify = !this._terminalAuthError && this._usesStaticToken() && this._rejectedToken !== this._notifiedRejectedToken;\r\n this._terminalAuthError = true;\r\n this._recoveryEligible = false;\r\n this._cancelReconnectRecovery();\r\n this._setState(ConnectionState.ERROR);\r\n this._connectReject?.(error);\r\n this._drainPendingSubscriptions(error.message);\r\n // beforeConnect 안에서도 호출된다. 여기서 deactivate 완료를 기다리지 않는다.\r\n Promise.resolve(client?.deactivate()).catch(failure => this.logger.warn('Auth teardown failed:', failure));\r\n // 정적 토큰 모드는 정지를 오류 이벤트(FE connectionError)로 알린다. 새 토큰은 updateToken() 으로 온다.\r\n // 정지 전이(이 함수와 호출한 _onStompError 의 나머지)를 마친 뒤 알린다. 처리기의 updateToken()·connect() 가 정리 중인 연결과 섞이지 않는다.\r\n if (notify) {\r\n this._notifiedRejectedToken = this._rejectedToken;\r\n Promise.resolve().then(() => this.emit('error', { type: ErrorTypes.CONNECTION_LOST, message: error.message, error }));\r\n }\r\n }\r\n\r\n /** 갱신 세션이 없는 정적 토큰(레거시) 모드. canRefreshToken 은 TalkFlowClient 가 설치한다. */\r\n _usesStaticToken() {\r\n return !this.getAccessToken || this.canRefreshToken?.() === false;\r\n }\r\n\r\n _canRecoverConnection() {\r\n return this._recoveryEligible && !this._intentionalDisconnect && !this._terminalAuthError\r\n && !this._subscriptionRequired && this.reconnectRecoveryDelay > 0;\r\n }\r\n\r\n /** 회사 구독 대기 중인가 — 서버가 NOT_SUBSCRIBED 로 거부해 연결을 멈췄다. 앱이 connect() 를 다시 부르면 풀린다. */\r\n isSubscriptionRequired() {\r\n return this._subscriptionRequired;\r\n }\r\n\r\n /**\r\n * «구독 대기» 로 멈춘다 (서버 구독 관문 설계 v3 §7).\r\n *\r\n * <p>인증 정지({@link #_stopForAuthError})와 같은 방식으로 STOMP 재연결 루프·저빈도 복구·탭 복귀 재연결을 끄되, 토큰은\r\n * 거부된 토큰으로 기록하지 않는다 — 토큰 갱신·로그인은 그대로 둔다. 대기 중인 connect()·구독은 reject 하고, 한 번 들어올 때\r\n * {@code subscriptionRequired} 를 알린다(일반 {@code error} 는 내지 않는다 — 연결 오류 안내가 결제 안내를 가리지 않게).\r\n * 연결 중에 해지되면 다음 SUBSCRIBE·SEND 의 ERROR 로 같은 경로에 온다.</p>\r\n *\r\n * @private\r\n */\r\n _stopForSubscription(serverMessage) {\r\n const entering = !this._subscriptionRequired;\r\n this._subscriptionRequired = true;\r\n this._cancelReconnectRecovery();\r\n this._setState(ConnectionState.ERROR);\r\n // 본문의 안내 문구 — message 헤더는 오류 JSON 전체가 이스케이프된 채 오므로 쓰지 않는다.\r\n const message = serverMessage || 'Company subscription required';\r\n const error = Object.assign(new Error('Company subscription required'),\r\n { code: SUBSCRIPTION_REQUIRED_CODE, type: ErrorTypes.SUBSCRIPTION_REQUIRED });\r\n this._connectReject?.(error);\r\n this._connectResolve = this._connectReject = null;\r\n this._drainPendingSubscriptions('Company subscription required');\r\n const client = this.stompClient;\r\n Promise.resolve(client?.deactivate()).catch(failure => this.logger.warn('Subscription teardown failed:', failure));\r\n this.logger.warn('Company subscription required — connection stopped until connect() is called again');\r\n if (entering) {\r\n // 정지 전이를 마친 뒤 알린다 — 처리기가 곧바로 connect() 를 불러도 정리 중인 연결과 섞이지 않는다.\r\n Promise.resolve().then(() => this.emit('subscriptionRequired', { code: SUBSCRIPTION_REQUIRED_CODE, message }));\r\n }\r\n }\r\n\r\n /** 기존에 연결됐던 클라이언트만 긴 장애 후 탭 이벤트 없이 다시 시도한다. */\r\n _scheduleReconnectRecovery() {\r\n if (!this._canRecoverConnection() || this.state !== ConnectionState.ERROR || this.stompClient) return;\r\n this._cancelReconnectRecovery();\r\n const delay = this.reconnectRecoveryDelay * (0.8 + Math.random() * 0.4);\r\n this._recoveryTimer = setTimeout(() => {\r\n this._recoveryTimer = null;\r\n if (!this._intentionalDisconnect && !this._terminalAuthError && this.state === ConnectionState.ERROR) {\r\n this.forceReconnect('retry-cooldown');\r\n }\r\n }, delay);\r\n this._recoveryTimer.unref?.();\r\n }\r\n\r\n _cancelReconnectRecovery() {\r\n clearTimeout(this._recoveryTimer);\r\n this._recoveryTimer = null;\r\n }\r\n\r\n /**\r\n * 기존 구독 재등록 (재연결 시)\r\n * @private\r\n */\r\n _restoreSubscriptions() {\r\n if (this.subscriptions.size === 0) return;\r\n\r\n this.logger.info(`Restoring ${this.subscriptions.size} subscriptions after reconnect`);\r\n\r\n const previousSubscriptions = new Map(this.subscriptions);\r\n this.subscriptions.clear();\r\n\r\n previousSubscriptions.forEach(({ callback, headers }, destination) => {\r\n this._subscribeInternal(destination, callback, headers);\r\n this.logger.debug(`Restored subscription: ${destination}`);\r\n });\r\n }\r\n\r\n /**\r\n * 대기 중인 구독 처리\r\n * @private\r\n */\r\n _processPendingSubscriptions() {\r\n while (this.pendingSubscriptions.length > 0) {\r\n const { destination, callback, headers, resolve } = this.pendingSubscriptions.shift();\r\n const subscription = this._subscribeInternal(destination, callback, headers);\r\n if (resolve) {\r\n resolve(subscription);\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * 내부 구독 처리\r\n * @private\r\n */\r\n _subscribeInternal(destination, callback, headers = {}) {\r\n const subscription = this.stompClient.subscribe(destination, (message) => {\r\n try {\r\n const body = JSON.parse(message.body);\r\n callback(body, message);\r\n } catch (error) {\r\n this.logger.error('Failed to parse message:', error);\r\n callback(message.body, message);\r\n }\r\n }, headers);\r\n\r\n // 재연결 시 복구를 위해 callback, headers도 함께 저장\r\n this.subscriptions.set(destination, { subscription, callback, headers });\r\n this.logger.debug(`Subscribed to: ${destination}`);\r\n\r\n return subscription;\r\n }\r\n\r\n /**\r\n * 채널 구독\r\n * @param {string} destination - 구독 경로\r\n * @param {Function} callback - 메시지 콜백\r\n * @param {Object} [headers] - 추가 헤더\r\n * @returns {Promise<Object>} - 구독 객체\r\n */\r\n subscribe(destination, callback, headers = {}) {\r\n // 이미 구독 중이면 기존 구독 해제 후 재구독\r\n if (this.subscriptions.has(destination)) {\r\n this.unsubscribe(destination);\r\n }\r\n\r\n if (this.state !== ConnectionState.CONNECTED) {\r\n return new Promise((resolve, reject) => {\r\n this.pendingSubscriptions.push({ destination, callback, headers, resolve, reject });\r\n this.logger.debug(`Queued subscription for: ${destination}`);\r\n });\r\n }\r\n\r\n return Promise.resolve(this._subscribeInternal(destination, callback, headers));\r\n }\r\n\r\n /**\r\n * 구독 해제\r\n * @param {string} destination - 구독 경로\r\n */\r\n unsubscribe(destination) {\r\n const entry = this.subscriptions.get(destination);\r\n if (entry) {\r\n entry.subscription.unsubscribe();\r\n this.subscriptions.delete(destination);\r\n this.logger.debug(`Unsubscribed from: ${destination}`);\r\n }\r\n }\r\n\r\n /**\r\n * 모든 구독 해제\r\n */\r\n unsubscribeAll() {\r\n this.subscriptions.forEach(({ subscription }, destination) => {\r\n subscription.unsubscribe();\r\n this.logger.debug(`Unsubscribed from: ${destination}`);\r\n });\r\n this.subscriptions.clear();\r\n }\r\n\r\n /**\r\n * 메시지 전송\r\n * @param {string} destination - 전송 경로\r\n * @param {Object} body - 메시지 본문\r\n * @param {Object} [headers] - 추가 헤더\r\n */\r\n send(destination, body, headers = {}) {\r\n if (this.state !== ConnectionState.CONNECTED) {\r\n this.logger.warn('Cannot send message: not connected');\r\n return false;\r\n }\r\n\r\n this.stompClient.publish({\r\n destination,\r\n body: JSON.stringify(body),\r\n headers\r\n });\r\n\r\n this.logger.debug(`Sent to ${destination}:`, body);\r\n return true;\r\n }\r\n\r\n /**\r\n * JWT 토큰 업데이트\r\n * @param {string} token\r\n */\r\n updateToken(token) {\r\n this.jwtToken = token;\r\n\r\n if (this.stompClient) {\r\n this.stompClient.connectHeaders = {\r\n ...this.stompClient.connectHeaders,\r\n 'Authorization': token.startsWith('Bearer ') ? token : `Bearer ${token}`\r\n };\r\n }\r\n\r\n // 정적 토큰 모드에서 거부된 토큰으로 정지했다면 새 토큰으로 다시 연결한다. 같은 토큰이면 정지를 유지한다.\r\n // connect() 는 진행 중인 연결을 공유한다. connect(jwt) 가 이어서 connect() 를 불러도 클라이언트는 하나다.\r\n if (this._terminalAuthError && !this._intentionalDisconnect && this._usesStaticToken()\r\n && token.replace(/^Bearer\\s+/i, '') !== this._rejectedToken) {\r\n this.connect().catch(error => this.logger.warn('Reconnect with updated token failed:', error));\r\n }\r\n }\r\n\r\n /**\r\n * 연결 해제.\r\n *\r\n * <p>대기 중 구독이 남아 있으면 각 Promise 를 reject 하고 큐를 비운다.\r\n * clear 만 하면 호출자 {@code await} 가 영원히 hang 된다.</p>\r\n */\r\n async disconnect() {\r\n // 명시적 해제 — 포그라운드 복귀가 이걸 되살리면 안 된다.\r\n this._intentionalDisconnect = true;\r\n this._recoveryEligible = false;\r\n this._subscriptionRequired = false;\r\n this._authRejected = false;\r\n this._rejectedToken = null;\r\n this._cancelReconnectRecovery();\r\n this._connectRequested = false;\r\n this._cancelAuthRetry?.();\r\n this._connectReject?.(Object.assign(new Error('Connection cancelled'), { code: 'AUTH_OPERATION_CANCELLED' }));\r\n this._connectResolve = this._connectReject = null;\r\n this._drainPendingSubscriptions('Disconnected before subscription completed');\r\n\r\n if (this.stompClient) {\r\n this.unsubscribeAll();\r\n await this.stompClient.deactivate();\r\n this.stompClient = null;\r\n }\r\n\r\n this._setState(ConnectionState.DISCONNECTED);\r\n this.logger.info('Disconnected');\r\n }\r\n\r\n /**\r\n * 대기 중 구독 Promise 일괄 reject + 큐 비움.\r\n * @private\r\n */\r\n _drainPendingSubscriptions(reason) {\r\n if (this.pendingSubscriptions.length === 0) return;\r\n\r\n const pending = this.pendingSubscriptions;\r\n this.pendingSubscriptions = [];\r\n\r\n pending.forEach(({ destination, reject }) => {\r\n if (reject) {\r\n reject(new Error(`${reason}: ${destination}`));\r\n }\r\n });\r\n\r\n this.logger.debug(`Drained ${pending.length} pending subscriptions: ${reason}`);\r\n }\r\n\r\n /**\r\n * 연결 상태 확인\r\n * @returns {boolean}\r\n */\r\n isConnected() {\r\n return this.state === ConnectionState.CONNECTED;\r\n }\r\n\r\n /**\r\n * 현재 상태 반환\r\n * @returns {string}\r\n */\r\n getState() {\r\n return this.state;\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n }\r\n\r\n /**\r\n * 리소스 정리.\r\n *\r\n * <p>{@link #disconnect} 가 async 이므로 await 필수. 최상위 {@link TalkFlowClient#destroy}\r\n * 는 별도 {@code await this.disconnect()} 선행으로 이미 완충되지만,\r\n * ConnectionManager 를 직접 소유한 사용자가 destroy 만 호출하는 경로도 안전해야 함.</p>\r\n */\r\n async destroy() {\r\n this._removeLifecycleListeners();\r\n await this.disconnect();\r\n this.removeAllListeners();\r\n this.logger.info('ConnectionManager destroyed');\r\n }\r\n}\r\n\r\nexport default ConnectionManager;\r\n","// noinspection JSValidateJSDoc\r\n\r\n/**\r\n * ChatClient\r\n * 채팅 기능 클라이언트\r\n */\r\n\r\nimport EventEmitter from '../utils/EventEmitter.js';\r\nimport Logger from '../utils/Logger.js';\r\nimport { WebSocketPaths, LogLevel, RoomListEventType, ChatRoomType, DefaultConfig, ConnectionState } from '../constants.js';\r\n\r\nconst MAX_FILES_PER_MESSAGE = 20;\r\n\r\n/**\r\n * 방 PM 프롬프트 레이어 본문 한도.\r\n * 서버 도메인 상수(PmPromptLayer.ROOM_CONTENT_MAX)의 미러 — 서버가 바뀌면 여기도 함께 올려야 한다.\r\n * 값이 뒤처지면 서버는 받아주는데 SDK 가 먼저 막아 \"저장이 안 된다\"로 보인다.\r\n */\r\nconst MAX_ROOM_PM_PROMPT_LENGTH = 20000;\r\n\r\n/**\r\n * 매직 시그니처가 없는 평문 문서 MIME (txt / md).\r\n * 서버 도메인 상수(PlainTextDocumentType)의 미러 — 서버가 평문 타입을 늘리면 여기도 함께 늘려야 한다.\r\n * 빠지면 이 MIME 들이 DOCUMENT 가 아닌 FILE 로 분류돼 그룹 수 예측이 서버와 어긋나고,\r\n * 결과적으로 다중 파일 전송의 messageId prediction 이 틀어진다.\r\n */\r\nconst PLAIN_TEXT_DOCUMENT_MIME_TYPES = new Set(['text/plain', 'text/markdown', 'text/x-markdown']);\r\n\r\nclass ChatClient extends EventEmitter {\r\n /**\r\n * @param {Object} options\r\n * @param {Object} options.connectionManager - ConnectionManager 인스턴스\r\n * @param {Object} options.apiClient - ApiClient 인스턴스\r\n * @param {string} options.userId - 사용자 ID\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options) {\r\n super();\r\n\r\n this.connectionManager = options.connectionManager;\r\n this.apiClient = options.apiClient;\r\n this.userId = options.userId;\r\n\r\n this.logger = new Logger(options.logLevel || LogLevel.WARN, 'ChatClient');\r\n\r\n // 구독 중인 채팅방\r\n this.subscribedRooms = new Map();\r\n\r\n // 채팅방 리스트 구독 상태 (카톡 스타일 리스트 실시간 업데이트)\r\n this.roomListSubscribed = false;\r\n\r\n // Gmail 메일함 변화 구독 상태 — 자동 구독하지 않는다(Gmail 을 연결한 화면에서 subscribeMail()).\r\n this.mailSubscribed = false;\r\n\r\n // 현재 보고 있는 채팅방 ID (이 방에서만 자동 읽음 처리)\r\n this._activeRoomId = null;\r\n\r\n // 타이핑 표시 debounce 타이머 (roomId → setTimeout ID)\r\n // 이 Map 은 outgoing 전용 — 사용자가 startTyping() 호출 시 N초 후 자동 stopTyping() 발신용.\r\n this._typingTimers = new Map();\r\n\r\n // 어시스턴트 응답 준비 중 typing=true 수신 후 client-side timeout (roomId → setTimeout ID).\r\n // 서버는 typing=false 를 발행하지 않으며 (멘션 N personas / 락 실패 / LLM 실패 / skip 등 종료 추적\r\n // 부담 회피), SDK 가 (1) assistant 메시지 도착 시 즉시 해제 (2) 본 timeout 만료 시 자동 해제 책임.\r\n // outgoing 타이머 (_typingTimers) 와 의미가 달라 별도 Map 유지.\r\n this._assistantTypingTimers = new Map();\r\n // 어시 typing 자동 해제 시한 (ms) — LLM 호출 최대 시간 (Sonnet 5-10초) + 부수 작업 + 여유.\r\n // 운영 환경 LLM 응답 지연 분포에 따라 조정 가능.\r\n this._assistantTypingTimeoutMs = 30000;\r\n // 어시 typing 의 generic sender 식별자 — 서버 ChatMessageSentAssistantListener 의\r\n // ASSISTANT_TYPING_USER_ID 와 정확히 일치해야 함. UI 가 roomId+userId 로 typing state 추적 시\r\n // typing=true / typing=false 가 같은 키로 매칭되도록 일관 유지 필수.\r\n this._assistantTypingUserId = '__assistant__';\r\n this._assistantTypingUserName = 'AI';\r\n\r\n // AI 진행 표시(생각중/검색중/작성중) — assistant-stream 채널의 PHASE/DELTA/DONE 수신 상태.\r\n //\r\n // roomId → Map(streamId → { personaId, phase, text, lastSeq, textAvailable, updatedAt, timer })\r\n //\r\n // ⚠️ v5.0.0 에서 <b>방 단위 1슬롯 → stream 단위 다중 슬롯</b>으로 바꿨다(설계 §7.1). 한 방에 동시\r\n // 스트림이 <b>실재</b>하기 때문이다 — 어시 리스너가 @Async 이고 방 단위 직렬화 락이 없어서, PM 방에\r\n // 트리거가 겹치면 각각 streamId 를 발급받아 동시에 산다. 1슬롯이면 나중 것이 앞 것을 덮어 한쪽이\r\n // 통째로 사라진다(그게 v4.x 의 \"stale 무시\" 가드가 하던 일이다).\r\n //\r\n // 자동 해제는 **2경로**다: (1) DONE 수신 (2) TTL(expiresAt) 만료.\r\n // ⚠️ 어시 메시지 도착은 해제 경로가 <b>아니다</b>(v4.2.0, 설계 §7.3) — 서버가 MESSAGE_CREATED 를\r\n // DONE 보다 먼저 보내 그 시점엔 messageId → streamId 를 알 수 없다.\r\n //\r\n // lastSeq/textAvailable = seq 가드용 커서(설계 §5.5.0). 커서 초기값은 -1 이다 — 첫 청크가 seq=0.\r\n this._assistantProgress = new Map();\r\n\r\n // 종료된 streamId 의 묘비 — roomId → Map(streamId → expiresAt).\r\n //\r\n // <b>다중 슬롯 전환의 대가다.</b> 1슬롯 시절엔 \"streamId 가 다르면 버린다\"가 늦은 이벤트를 막는\r\n // race 차단 장치였는데, 슬롯이 여러 개가 되면 그 가드가 사라진다. 그러면 DONE 으로 닫힌 스트림의\r\n // 늦은 DELTA 가 <b>새 슬롯을 만들어 유령 버블로 되살아난다</b>. 서버 gate 가 terminal 직후 제거를\r\n // 금지하고 TTL 로만 회수하는 것과 같은 이유이고 같은 해법이다(설계 §7.2).\r\n //\r\n // ⚠️ 등록 트리거는 <b>DONE 하나뿐</b>이다. 클라 TTL 만료는 \"표시가 낡았다\"이지 서버의 확정 종료가\r\n // 아니라서, 그걸로 묘비를 세우면 뒤늦게 오는 정상 DELTA 가 영영 막힌다.\r\n //\r\n // 회수는 <b>lazy</b> 다 — 만료분은 같은 방에 다음 DONE 이 오거나 그 streamId 이벤트가 올 때 지워진다.\r\n // 별도 타이머를 두지 않는 이유: 항목이 문자열+숫자뿐이고 방 구독을 끊으면 방 단위로 통째 삭제되므로\r\n // 상한이 자연히 걸린다. 타이머 하나를 더 관리하는 비용이 이득보다 크다.\r\n this._assistantStreamTombstones = new Map();\r\n this._assistantTombstoneTtlMs = 120000;\r\n // ⚠️ <b>상한이 아니라 경보 임계</b>다. 넘어도 유효한 묘비를 지우지 않는다(지우면 유령이 되살아난다).\r\n this._assistantTombstoneWarnThreshold = 32;\r\n /** 임계 초과 경보를 방마다 1회만 내기 위한 표식 — 넘은 동안 DONE 마다 짖지 않게. @private */\r\n this._assistantTombstoneWarned = new Set();\r\n\r\n // 슬롯 갱신 순번 — \"가장 최근에 갱신된 stream\" 판정용.\r\n // ⚠️ 벽시계(Date.now)로는 안 된다. 밀리초 해상도라 연속 갱신이 <b>동률</b>이 되고, 동률이면\r\n // 순회 순서가 승자를 정해 <b>다른 stream 의 본문이 방 버블에 뜬다</b>. 단조 카운터가 유일하게 안전하다.\r\n this._assistantSlotSeq = 0;\r\n\r\n // 방별 세대 카운터 — 구독/해제마다 증가. 스냅샷 응답이 \"아직 같은 세대인가\"를 판정하는 축이다\r\n // (늦게 도착한 응답이 이미 떠난 방의 슬롯을 되살리는 것을 막는다, 설계 §6.1-a).\r\n this._assistantRoomGeneration = new Map();\r\n\r\n // ── 스냅샷 재개 (설계 §6.1, R-4) ──\r\n //\r\n // 스트림은 Pub/Sub 라 <b>구독 시점 이후만</b> 온다. 재입장·중간입장 사용자가 앞부분을 받는 유일한\r\n // 경로가 서버 스냅샷이고, 그걸 라이브와 이어붙이는 순서가 이 세 자료구조에 걸려 있다.\r\n //\r\n // roomId → { generation, events: [] } : 스냅샷 도착 전 라이브 이벤트 보류함.\r\n //\r\n // ⚠️ <b>구독보다 먼저</b> 켜야 한다. 구독 호출과 보류 플래그 사이에 DELTA 가 오면 그건 보류되지\r\n // 않고 그대로 렌더돼, 스냅샷 병합 전에 화면이 잘린 본문으로 먼저 그려진다.\r\n //\r\n // ⚠️ 큐는 <b>방 단위 단일 배열</b>이다. stream 별로 쪼개면 A0→B0→A1 로 도착한 것이 A0→A1→B0 로\r\n // 풀려 \"가장 최근 stream\" 판정이 뒤집히고 방 집계 버블이 엉뚱한 본문을 보여준다 — R-3 이 단조\r\n // 카운터로 세운 정합성을 drain 이 다시 깨는 자리다.\r\n //\r\n // ⚠️ 세대를 함께 들고 있어야 한다. 안 그러면 이전 세대의 늦은 응답이 <b>현재 세대의 보류분을\r\n // 통째로 삭제</b>한다.\r\n this._assistantSnapshotPending = new Map();\r\n // roomId → { generation, task } : 진행 중인 스냅샷 요청. <b>같은 세대일 때만</b> 재사용한다 —\r\n // 다른 세대 요청을 재사용하면 그 응답이 세대 불일치로 버려지면서 이번 요청까지 함께 사라진다.\r\n this._assistantSnapshotInFlight = new Map();\r\n // 스냅샷 재개 사용 여부 — 서버가 구버전이면 404/빈 배열이라 자동으로 무해하지만, 끄고 싶을 때를 위해.\r\n this.assistantStreamReplay = options.assistantStreamReplay !== false;\r\n // phase → 기본 표시 라벨(설계 §2 — 서버는 의미만, 라벨 텍스트는 SDK 매핑). 소비자는 payload.phase 로 커스텀 가능.\r\n this._assistantPhaseLabels = {\r\n THINKING: '생각 중…',\r\n SEARCHING: '검색 중…',\r\n PLANNING: '기획 중…',\r\n DOCUMENT_ANALYSIS: '문서 분석 중…',\r\n VIDEO_ANALYSIS: '영상 분석 중…',\r\n WEB_READING: '페이지 읽는 중…',\r\n WRITING: '작성 중…'\r\n };\r\n // PHASE 의 expiresAt 누락/이상 시 fallback TTL (ms) — 서버 ttl(최소 30s) 보다 약간 길게.\r\n this._assistantProgressFallbackTtlMs = 35000;\r\n\r\n // 메시지 dedup 보호 — 같은 messageId 의 중복 수신 차단.\r\n // 서버측 멱등 race 복구 / 네트워크 재전송 / WebSocket 재연결 직후 등 다양한 중복 시나리오 커버.\r\n //\r\n // chat WebSocket (MESSAGE_CREATED) 와 roomList (MESSAGE_RECEIVED) 는 같은 messageId 의 별개 이벤트라\r\n // 각각 다른 키 공간 사용. 정상 흐름에서 두 이벤트가 거의 동시에 도착하므로 키를 공유하면 둘 중\r\n // 하나가 차단되는 부작용 발생.\r\n //\r\n // 구조: roomId → insertion-order Map<messageId, true> (LRU). size 초과 시 가장 오래된 항목 제거.\r\n this._seenChatMessageIdsByRoom = new Map();\r\n this._seenRoomListMessageIdsByRoom = new Map();\r\n\r\n // ── 갭 복구 (RECONNECT_SYNC_DESIGN §6.2 / §6.3) ──\r\n this.syncOnReconnect = options.syncOnReconnect !== false;\r\n this.rewindMillis = options.rewindMillis !== undefined\r\n ? options.rewindMillis\r\n : DefaultConfig.rewindMillis;\r\n this.maxSyncPages = options.maxSyncPages || DefaultConfig.maxSyncPages;\r\n this.syncPageSize = options.syncPageSize || DefaultConfig.syncPageSize;\r\n\r\n // 방별 서버 커서 워터마크 — Mongo _id 단독(anchor). sentAt 은 비교에 쓰지 않는다:\r\n // 같은 메시지라도 WS(서버 systemDefault epoch)와 REST(offset 없는 ISO → 브라우저 로컬 해석)의\r\n // sentAt 이 서로 다른 값이라, 두 출처를 섞어 비교하면 단조 규칙이 잘못된 방향으로 작동한다.\r\n this._anchor = new Map();\r\n // anchor 세대 — sync 진행 중 라이브 메시지가 앞서 갔는지 판정한다(generation guard).\r\n this._anchorGen = new Map();\r\n // 방별 sync 중복 실행 방지 (빠른 visible/hidden 토글).\r\n this._syncInFlight = new Set();\r\n // replay 중에는 개별 메시지로 anchor 를 전진시키지 않는다 — 완료 후 lastId 로 1회 커밋.\r\n this._replaying = false;\r\n\r\n // 방 하나당 dedup 대상 messageId 수. sync 상한보다 커야 되감기 구간의 중복이 새어나가지 않는다.\r\n // 상수로 두면 maxSyncPages 튜닝 시 조용히 어긋나므로 config 에서 파생시킨다 (설계 §7).\r\n this._maxSeenPerRoom = Math.max(200, this.maxSyncPages * this.syncPageSize + 100);\r\n\r\n this._reconnectSyncGeneration = 0;\r\n this._delayedReconnectSyncTimer = null;\r\n this._installSyncTriggers();\r\n }\r\n\r\n /**\r\n * 갭 복구 트리거 배선 — {@code reconnected} / {@code resumed} 양쪽 (설계 §6.1 이벤트 계약).\r\n *\r\n * <p>두 이벤트는 상호 배타적이다: 복귀가 재연결을 유발하면 {@code reconnected} 만, 연결이 살아 있으면\r\n * {@code resumed} 만 발행된다. 그래서 둘 다 들어도 한 번의 복귀에 sync 가 두 번 돌지 않는다.\r\n * 반대로 하나만 들으면 절반의 시나리오를 놓친다.</p>\r\n *\r\n * @private\r\n */\r\n _installSyncTriggers() {\r\n if (!this.connectionManager || typeof this.connectionManager.on !== 'function') return;\r\n\r\n // ⚠️ 어시 스트림 재개는 <b>메시지 갭 복구와 별도 경로</b>다(설계 §7.4). syncAfterReconnect 는\r\n // syncOnReconnect 옵션 + anchor 있는 방이라는 두 겹 필터가 걸려 있어서, 거기 얹으면 옵션이\r\n // 꺼져 있거나 그 방에서 메시지를 받은 적이 없을 때 replay 가 조용히 빠진다.\r\n this._onReconnectedForSync = ({ attempts } = {}) => {\r\n this._cancelDelayedReconnectSync();\r\n const generation = this._reconnectSyncGeneration;\r\n this.syncAfterReconnect(`reconnected:${attempts ?? 0}`)\r\n .catch((error) => this.logger.warn('Gap sync after reconnect failed:', error))\r\n .then((cursors) => {\r\n if (!this.syncOnReconnect || !cursors || generation !== this._reconnectSyncGeneration) return;\r\n // SUBSCRIBE 전송과 SimpleBroker 등록은 다른 시점이다. 첫 snapshot 뒤의 공백을 다시 읽는다.\r\n this._delayedReconnectSyncTimer = setTimeout(() => {\r\n this._delayedReconnectSyncTimer = null;\r\n this._supplementAfterReconnect(cursors, generation)\r\n .catch(error => this.logger.warn('Delayed gap sync failed:', error));\r\n }, 1000);\r\n this._delayedReconnectSyncTimer.unref?.();\r\n });\r\n this.resumeAssistantStreams(`reconnected:${attempts ?? 0}`)\r\n .catch((error) => this.logger.warn('Assistant stream replay after reconnect failed:', error));\r\n };\r\n this._onResumedForSync = ({ reason } = {}) => {\r\n this.syncAfterReconnect(`resumed:${reason ?? 'unknown'}`)\r\n .catch((error) => this.logger.warn('Gap sync after resume failed:', error));\r\n this.resumeAssistantStreams(`resumed:${reason ?? 'unknown'}`)\r\n .catch((error) => this.logger.warn('Assistant stream replay after resume failed:', error));\r\n };\r\n\r\n this._onStateChangeForSync = ({ state }) => {\r\n if (state !== ConnectionState.CONNECTED) this._cancelDelayedReconnectSync();\r\n };\r\n this.connectionManager.on('stateChange', this._onStateChangeForSync);\r\n this.connectionManager.on('reconnected', this._onReconnectedForSync);\r\n this.connectionManager.on('resumed', this._onResumedForSync);\r\n }\r\n\r\n /**\r\n * 방별 LRU dedup 체크 — 이미 본 messageId 면 {@code true} 반환 (중복).\r\n * 처음 보는 messageId 면 기록 후 {@code false} 반환.\r\n *\r\n * <p>Map 의 insertion-order 보존 특성을 이용한 가벼운 LRU. 외부 lib 없음.</p>\r\n *\r\n * @private\r\n * @param {Map<string, Map<string, true>>} bucket - 키 공간 (chat / roomList 별도)\r\n * @param {string} roomId\r\n * @param {string} messageId\r\n * @returns {boolean} 이미 본 메시지면 true (호출자가 무시)\r\n */\r\n _shouldDedupMessage(bucket, roomId, messageId) {\r\n if (!roomId || !messageId) return false;\r\n let seen = bucket.get(roomId);\r\n if (!seen) {\r\n seen = new Map();\r\n bucket.set(roomId, seen);\r\n }\r\n if (seen.has(messageId)) {\r\n return true;\r\n }\r\n seen.set(messageId, true);\r\n if (seen.size > this._maxSeenPerRoom) {\r\n // 가장 오래된 키 제거 (Map insertion-order 의 첫 항목).\r\n const oldestKey = seen.keys().next().value;\r\n seen.delete(oldestKey);\r\n }\r\n return false;\r\n }\r\n\r\n // ==================== REST API ====================\r\n\r\n /**\r\n * 내 채팅방 목록 조회\r\n * @param {Object} [params]\r\n * @param {number} [params.size=50] - 페이지 크기 (1-100)\r\n * @param {string} [params.lastId] - 마지막 채팅방 ID (커서)\r\n * @param {number} [params.lastSortValue] - 마지막 정렬값 (epoch millis)\r\n * @param {string} [params.type] - 채팅방 타입 필터 ('DIRECT' | 'GROUP'). 생략 시 전체.\r\n * 'GROUP' 은 \"그룹 탭\" 시맨틱 — PRIVATE_GROUP/TEAM 을 포함한 그룹 전체를 반환\r\n * (PRIVATE_GROUP/TEAM 값을 보내도 동일하게 그룹 전체 — 타입별 정확 필터 아님).\r\n * 특정 타입만 필요하면 응답의 {@code roomType} 필드로 클라이언트에서 거른다.\r\n * @returns {Promise<Object>}\r\n */\r\n async getRooms(params = {}) {\r\n const { size = 50, lastId, lastSortValue, type } = params;\r\n return this.apiClient.get('/api/v1/rooms/my', { size, lastId, lastSortValue, type });\r\n }\r\n\r\n /**\r\n * 채팅방 입장 (상세 조회).\r\n *\r\n * <p>서버 {@code markAllAsRead} 를 트리거하므로 방 안의 누적 unread 가 전부 읽음 처리된다.\r\n * 실시간 읽음 이벤트 보정 수신이 필요하면 {@link #enterRoom} 사용을 권장 — subscribe 를\r\n * {@link #getRoom} 보다 먼저 수행해서 \"응답 후, 구독 전\" 구간의 read 이벤트 유실을 막는다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async getRoom(roomId) {\r\n const genAtStart = this._anchorGen.get(roomId) || 0;\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}`);\r\n\r\n // 입장 응답의 초기 메시지(최신순)로 갭 복구 anchor 를 심는다 — 이 방에서 라이브 메시지를\r\n // 한 건도 받기 전에 백그라운드로 가는 경로를 덮기 위함(§6.2).\r\n // (타임스탬프 정규화는 기존 동작을 그대로 둔다 — 여기서 바꾸면 기존 소비자 계약이 흔들린다.)\r\n const detail = this._unwrapSuccessResponse(response);\r\n if (detail && Array.isArray(detail.messages)) {\r\n this._seedAnchorFromLatest(roomId, detail.messages, genAtStart);\r\n }\r\n return response;\r\n }\r\n\r\n /**\r\n * 채팅방 진입 — <b>권장 진입 경로</b>.\r\n *\r\n * <p>순서를 고정해서 race-free 진입을 보장:</p>\r\n * <ol>\r\n * <li><b>subscribeRoom</b> — WebSocket 구독 먼저. 서버가 발행하는 read/message/typing 이벤트의\r\n * 도달을 이 시점부터 보장.</li>\r\n * <li><b>setActiveRoom</b> — 이후 수신되는 신규 메시지 자동 읽음 처리 활성화.</li>\r\n * <li><b>getRoom</b> — 서버 {@code markAllAsRead} + 초기 메시지 50개 fetch. 이 시점에 서버가\r\n * 발행하는 read 이벤트는 (1) 의 구독으로 모두 수신됨.</li>\r\n * </ol>\r\n *\r\n * <p>구독이 {@code getRoom} 보다 먼저 완료돼야 \"서버에서 발행된 read 이벤트가 아직 구독 안 된\r\n * 클라에 유실\" 되는 케이스가 제거된다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>} {@link #getRoom} 의 응답 (방 상세 + 초기 메시지)\r\n *\r\n * @example\r\n * // 방 클릭 시\r\n * const detail = await client.chat.enterRoom('room-id');\r\n * renderRoom(detail);\r\n */\r\n async enterRoom(roomId) {\r\n // \"이 호출이 새로 만든 구독인지\" 기억 — getRoom 실패 시 rollback 시에 이전 구독은 유지.\r\n const wasAlreadySubscribed = this.subscribedRooms.has(roomId);\r\n\r\n // 1) WebSocket 구독을 먼저 — 이후 서버 read 이벤트 유실 방지\r\n if (!wasAlreadySubscribed) {\r\n await this.subscribeRoom(roomId);\r\n } else if (this.assistantStreamReplay) {\r\n // ⭐ <b>이미 구독 중인 방으로 재입장</b> — 구독을 유지한 채 방 화면만 나갔다 들어오는 흐름이다\r\n // (목록 화면에서 unread 를 받으려면 구독을 끊지 않는 것이 정상 구현이다). 이때 subscribeRoom 이\r\n // 통째로 건너뛰어져 <b>스냅샷 트리거가 하나도 돌지 않는다</b> — 재개 경로는 여기와 재연결\r\n // (`resumeAssistantStreams`) 둘뿐이기 때문이다.\r\n //\r\n // 진행 표시는 transient 이벤트라 화면이 언마운트되면 프론트에서 사라지는데, SDK 슬롯에만 남은\r\n // 그 상태를 다시 그려줄 신호가 없다 → 다음 PHASE 가 올 때까지(워커 heartbeat 간격 15초) 배지가\r\n // 안 뜬다. 스냅샷을 다시 받아 그 공백을 없앤다.\r\n //\r\n // await 하지 않는다 — 구독은 살아 있으므로 그 사이 도착분은 보류함에 쌓였다가 병합 후 순서대로\r\n // 풀린다(§6.1). 실패해도 보류분은 반드시 풀리므로 라이브만으로 계속 동작한다.\r\n const generation = this._armAssistantSnapshot(roomId);\r\n this._loadAssistantSnapshot(roomId, generation)\r\n .catch((error) => this.logger.warn(`Assistant snapshot on re-enter failed: room=${roomId}`, error));\r\n }\r\n // 2) 자동 읽음 처리 활성 방 지정\r\n this.setActiveRoom(roomId);\r\n\r\n try {\r\n // 3) 서버 입장 API — markAllAsRead + 초기 메시지 조회\r\n return await this.getRoom(roomId);\r\n } catch (e) {\r\n // getRoom 실패 시 half-open 상태 방지 — 이 호출이 켠 리소스만 되돌림.\r\n if (this.getActiveRoom() === roomId) {\r\n this.clearActiveRoom();\r\n }\r\n if (!wasAlreadySubscribed) {\r\n this.unsubscribeRoom(roomId);\r\n }\r\n throw e;\r\n }\r\n }\r\n\r\n /**\r\n * 채팅방 정보 조회 (입장하지 않고 정보만)\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async getRoomInfo(roomId) {\r\n return this.apiClient.get(`/api/v1/rooms/${roomId}/info`);\r\n }\r\n\r\n /**\r\n * 내 방 언어 설정 (다국어). 이 방에서 내가 볼 언어를 지정한다.\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string|null} language - BCP-47 코드(예 'ko','en','ja') / 'OFF'(원문 보기) /\r\n * null·''(오버라이드 해제 → 사용자 기본 언어로 복귀)\r\n * @returns {Promise<Object>} 갱신된 방 상세\r\n */\r\n async setMyRoomLanguage(roomId, language) {\r\n return this.apiClient.put(`/api/v1/rooms/${roomId}/my-language`, { language });\r\n }\r\n\r\n /**\r\n * 1:1 채팅방 생성/조회\r\n * @param {string} friendId - 상대방 사용자 ID (서버 내부 {@code users._id} — 참가자 목록의 userId)\r\n * @returns {Promise<Object>}\r\n */\r\n async createOneToOneRoom(friendId) {\r\n return this.apiClient.post(`/api/v1/rooms/direct/${friendId}`);\r\n }\r\n\r\n /**\r\n * 그룹 채팅방 생성.\r\n *\r\n * <p>방 타입 {@code GROUP} (공개) / {@code PRIVATE_GROUP} (비밀) / {@code TEAM} (팀) 지정 가능.\r\n * 비밀방인 경우 {@code password} 필수 (서버가 bcrypt 해시로 저장). 팀방은 비밀번호 금지 —\r\n * 초대 전용이라 비밀번호 개념이 없고, 멤버는 입장 시점과 무관하게 방 전체 히스토리를 본다.</p>\r\n *\r\n * <p>{@code invitedAssistantPersonaIds} 로 AI 어시스턴트 페르소나도 함께 추가 가능.\r\n * 페르소나 ID 는 {@link #getAssistants} 로 사전 조회. 서버가 해당 프로젝트에서 사용 가능한\r\n * 페르소나인지 검증 (글로벌 또는 자체/오버라이드). 활성 페르소나만 허용.</p>\r\n *\r\n * @param {Object} data\r\n * @param {string} data.roomName - 채팅방 이름 (필수, max 100자)\r\n * @param {string} [data.description] - 채팅방 설명 (max 500자)\r\n * @param {string[]} [data.invitedUserIds] - 초대할 사용자 ID 배열 (max 50명)\r\n * @param {string[]} [data.invitedAssistantPersonaIds] - 추가할 어시스턴트 ID 배열 (max 10개).\r\n * {@link #getAssistants} 로 조회한 어시스턴트의 {@code id} 를 전달.\r\n * @param {string} [data.roomType='GROUP'] - 방 타입 ('GROUP' | 'PRIVATE_GROUP' | 'TEAM'). 기본값 'GROUP'.\r\n * 'TEAM' = 초대 전용(직접 입장 불가) + 멤버 전체 히스토리 열람 + 비참여자에게 목록 비노출.\r\n * @param {string} [data.password] - 비밀번호 (roomType='PRIVATE_GROUP' 일 때 필수, 4~50자.\r\n * 'GROUP'/'TEAM' 에는 금지)\r\n * @param {number} [data.messageRetentionHours] - 메시지 보관 기간 (시간, 1~8760). 미설정 시 무제한.\r\n * 설정 시 메시지를 삭제하지 않고 조회 시 기간 필터링만 적용. 변경 시 즉시 반영.\r\n * @param {string} [data.assistantMode='GENERAL'] - 어시스턴트 참여 모드\r\n * ('GENERAL' | 'PEOPLE_ONLY' | 'CALL_ONLY'). 생략 시 서버 기본값 GENERAL.\r\n * @param {string} [data.roomAiType] - 방 AI 사용 방식 intent ('NONE' | 'PERSONA_MULTI' | 'PM_BACKSTAGE').\r\n * 생략 시 서버가 API 키 권한 + 첨부 페르소나로 파생 (레거시 호환). {@code 'NONE'} 이면\r\n * {@code invitedAssistantPersonaIds}/{@code assistantMode} 동시 지정 불가 (서버 400).\r\n * {@code 'PM_BACKSTAGE'} 는 PM 자동부착 — persona/assistantMode 대신 {@code engagementIntensity} 로 제어.\r\n * 권한 없는 값 지정 시 403.\r\n * <b>주의(함정):</b> PERSONA_MULTI 권한이 없는 PM 전용 키는 {@code roomAiType} 을 생략하면 서버 파생이\r\n * {@code 'NONE'}(사람 방)으로 떨어진다 (PM_BACKSTAGE 로 자동 승격되지 않음). {@link #getRoomAiMeta} 의\r\n * {@code availableRoomAiTypes} 로 확인한 타입을 반드시 명시 전송할 것.\r\n * @param {string} [data.engagementIntensity] - PM 개입 강도 ('QUIET' | 'NORMAL' | 'ACTIVE').\r\n * {@code 'PM_BACKSTAGE'} 방 전용 — 그 외 타입에 지정 시 서버 거절. 생략 시 서버 기본값 'NORMAL'.\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * // 공개 그룹방\r\n * await chat.createGroupRoom({\r\n * roomName: '개발팀 잡담방',\r\n * invitedUserIds: ['user-1', 'user-2']\r\n * });\r\n *\r\n * @example\r\n * // 어시스턴트 포함 그룹방\r\n * const assistants = await chat.getAssistants();\r\n * // UI 에 표시하고 사용자가 선택한 id 들을 그대로 전달\r\n * await chat.createGroupRoom({\r\n * roomName: '계약 검토방',\r\n * invitedUserIds: ['user-1', 'user-2'],\r\n * invitedAssistantPersonaIds: [/* 사용자가 선택한 id 배열 *\\/],\r\n * assistantMode: 'GENERAL'\r\n * });\r\n *\r\n * @example\r\n * // 비밀 그룹방\r\n * await chat.createGroupRoom({\r\n * roomName: '비공개 회의실',\r\n * invitedUserIds: ['user-1'],\r\n * roomType: ChatRoomType.PRIVATE_GROUP,\r\n * password: 'secret1234'\r\n * });\r\n *\r\n * @example\r\n * // 팀 채팅방 — 초대 전용 + 전체 히스토리 (중간에 들어와도 이전 대화 열람)\r\n * await chat.createGroupRoom({\r\n * roomName: '백엔드팀',\r\n * invitedUserIds: ['user-1', 'user-2'],\r\n * roomType: ChatRoomType.TEAM\r\n * });\r\n */\r\n async createGroupRoom(data) {\r\n const roomType = data.roomType || ChatRoomType.GROUP;\r\n\r\n // 클라이언트 측 사전 검증 (서버에서도 재검증됨)\r\n if (roomType === ChatRoomType.PRIVATE_GROUP) {\r\n if (!data.password || data.password.length < 4) {\r\n throw new Error('PRIVATE_GROUP requires a password of at least 4 characters');\r\n }\r\n } else if (roomType === ChatRoomType.GROUP) {\r\n if (data.password) {\r\n throw new Error('Public GROUP rooms cannot have a password');\r\n }\r\n } else if (roomType === ChatRoomType.TEAM) {\r\n if (data.password) {\r\n throw new Error('TEAM rooms cannot have a password (invite-only)');\r\n }\r\n }\r\n\r\n return this.apiClient.post('/api/v1/rooms/group', {\r\n roomName: data.roomName,\r\n description: data.description,\r\n invitedUserIds: data.invitedUserIds,\r\n roomType,\r\n password: data.password,\r\n messageRetentionHours: data.messageRetentionHours,\r\n invitedAssistantPersonaIds: data.invitedAssistantPersonaIds,\r\n assistantMode: data.assistantMode,\r\n roomAiType: data.roomAiType,\r\n engagementIntensity: data.engagementIntensity\r\n });\r\n }\r\n\r\n /**\r\n * 사용 가능한 어시스턴트 리스트 조회.\r\n *\r\n * <p>받은 리스트를 UI 에 표시하고, 사용자가 선택한 어시스턴트의 {@code id} 를\r\n * {@link #createGroupRoom} 의 {@code invitedAssistantPersonaIds} 로 전달.</p>\r\n *\r\n * @returns {Promise<Array<{id: string, displayName: string, mentionKey: string, role: string}>>}\r\n *\r\n * @example\r\n * const assistants = await chat.getAssistants();\r\n * // [{ id, displayName, mentionKey, role }, ...]\r\n */\r\n async getAssistants() {\r\n const response = await this.apiClient.get('/api/v1/assistants');\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 현재 API 키로 생성 가능한 방 AI 타입 조회 (discovery).\r\n *\r\n * <p>방 생성 UI 에서 {@link #createGroupRoom} 의 {@code roomAiType} 으로 만들 수 있는 타입만 노출하는 데 사용한다.\r\n * 키 권한(capability)으로 결정 — {@code 'NONE'}(사람 채팅)은 항상, {@code 'PERSONA_MULTI'}/{@code 'PM_BACKSTAGE'}\r\n * 는 권한 보유 시 포함.</p>\r\n *\r\n * <p><b>중요:</b> 여기서 고른 타입을 {@link #createGroupRoom} 의 {@code roomAiType} 으로 <b>반드시 명시 전송</b>해야 한다.\r\n * 생략하면 서버가 키 권한 + persona 로 파생하는데, PERSONA_MULTI 권한이 없는 PM 전용 키는 {@code 'NONE'}(사람 방)으로\r\n * 떨어진다 (PM_BACKSTAGE 자동 승격 없음).</p>\r\n *\r\n * @returns {Promise<{availableRoomAiTypes: string[]}>}\r\n *\r\n * @example\r\n * const meta = await chat.getRoomAiMeta();\r\n * // meta.availableRoomAiTypes 예: ['NONE', 'PM_BACKSTAGE']\r\n * // → UI 에서 이 타입들만 방 생성 옵션으로 노출, 고른 타입을 createGroupRoom 의 roomAiType 으로 명시 전송\r\n */\r\n async getRoomAiMeta() {\r\n const response = await this.apiClient.get('/api/v1/rooms/ai-meta');\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 어시스턴트 메시지 평점 등록 (1~5점).\r\n *\r\n * <p>AI 어시스턴트가 생성한 메시지({@code senderType === 'ASSISTANT'})에 대해서만 의미가 있다.\r\n * UI 는 어시 메시지이면서 삭제되지 않은 메시지에만 평점 버튼을 노출하는 것을 권장한다\r\n * (서버도 사람 메시지 / 삭제 메시지는 거부).</p>\r\n *\r\n * <p>같은 메시지를 다시 평가하면 갱신된다 (단, 서버에서 해당 평점이 아직 검토 전일 때만).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 평가할 어시스턴트 메시지의 {@code messageId}\r\n * @param {number} rating - 평점 (1~5 정수)\r\n * @param {string} [comment] - 자유 코멘트 (max 2000자)\r\n * @returns {Promise<void>}\r\n *\r\n * @example\r\n * await chat.rateAssistantMessage(roomId, msg.messageId, 5, '정확한 요약이었어요');\r\n */\r\n async rateAssistantMessage(roomId, messageId, rating, comment) {\r\n // 클라이언트 측 사전 검증 (서버에서도 @Min(1)@Max(5) 재검증됨)\r\n if (!Number.isInteger(rating) || rating < 1 || rating > 5) {\r\n throw new Error('rating must be an integer between 1 and 5');\r\n }\r\n\r\n await this.apiClient.post(\r\n `/api/v1/chat-messages/${roomId}/messages/${messageId}/rating`,\r\n { rating, comment }\r\n );\r\n }\r\n\r\n /**\r\n * 어시스턴트로 대화 요약 (버튼 진입점, 동기).\r\n *\r\n * <p>지정한 페르소나가 최근 메시지 범위를 요약해 방에 어시스턴트 메시지로 broadcast 한다.\r\n * HTTP 응답은 LLM 처리(수 초) 후 결과를 반환하며, 생성된 메시지는 일반 {@code chatMessage}\r\n * 이벤트로도 수신된다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} options\r\n * @param {string} options.personaId - 요약을 수행할 어시스턴트 ID ({@link #getAssistants} 의 {@code id}). 필수.\r\n * @param {string} [options.format] - 요약 포맷 ({@link SummarizeFormat}). 미지정 시 서버 기본값 {@code SHORT}.\r\n * @param {number} [options.messageCount] - 요약 대상 최근 메시지 수 (1~50). 미지정 시 서버 기본값.\r\n * @returns {Promise<{outcome: string, messageId: (string|null), detail: (string|null)}>}\r\n * {@code outcome}: {@code 'EMITTED'} (생성됨, {@code messageId} 존재) |\r\n * {@code 'SKIPPED'} / {@code 'FAILED'} ({@code detail} 에 사유).\r\n *\r\n * @example\r\n * const result = await chat.summarizeWithAssistant(roomId, {\r\n * personaId,\r\n * format: SummarizeFormat.MINUTES,\r\n * messageCount: 30\r\n * });\r\n * if (result.outcome !== 'EMITTED') console.warn(result.detail);\r\n */\r\n async summarizeWithAssistant(roomId, options = {}) {\r\n const { personaId, format, messageCount } = options;\r\n if (!personaId) {\r\n throw new Error('summarizeWithAssistant requires options.personaId');\r\n }\r\n\r\n const response = await this.apiClient.post(\r\n `/api/v1/rooms/${roomId}/assistant/tools/summarize`,\r\n { personaId, format, messageCount }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /** 영상 분석용 비공개 업로드. 반환한 assetId를 analyzeVideo에 전달한다. */\r\n async uploadVideoAsset(file, options = {}) {\r\n if (!file) throw new Error('file is required');\r\n return this._unwrapSuccessResponse(await this.apiClient.upload('/api/v1/video-assets', file, {\r\n onProgress: options.onProgress, signal: options.signal\r\n }));\r\n }\r\n\r\n /** 비공개 분석 접수. 네트워크 재시도에는 같은 idempotencyKey를 사용한다. */\r\n async analyzeVideo(analysis, options = {}) {\r\n if (typeof options.idempotencyKey !== 'string' || !options.idempotencyKey.trim()) {\r\n throw new Error('idempotencyKey is required for video analysis');\r\n }\r\n return this._unwrapSuccessResponse(await this.apiClient.post('/api/v1/video-analyses', analysis,\r\n undefined, { 'Idempotency-Key': options.idempotencyKey }));\r\n }\r\n\r\n /** 같은 분석 코어를 호출하고 결과를 지정한 방에 게시한다. */\r\n async analyzeVideoWithAssistant(roomId, request, options = {}) {\r\n if (!roomId || !request?.personaId || !request?.triggerMessageId) {\r\n throw new Error('roomId, personaId and triggerMessageId are required');\r\n }\r\n if (typeof options.idempotencyKey !== 'string' || !options.idempotencyKey.trim()) {\r\n throw new Error('idempotencyKey is required for video analysis');\r\n }\r\n return this._unwrapSuccessResponse(await this.apiClient.post(\r\n `/api/v1/rooms/${encodeURIComponent(roomId)}/assistant/tools/analyze-video`, request,\r\n undefined, { 'Idempotency-Key': options.idempotencyKey }));\r\n }\r\n\r\n async getVideoAnalysis(jobId) {\r\n if (!jobId) throw new Error('jobId is required');\r\n return this._unwrapSuccessResponse(await this.apiClient.get(`/api/v1/video-analyses/${encodeURIComponent(jobId)}`));\r\n }\r\n\r\n async cancelVideoAnalysis(jobId) {\r\n if (!jobId) throw new Error('jobId is required');\r\n return this._unwrapSuccessResponse(await this.apiClient.post(`/api/v1/video-analyses/${encodeURIComponent(jobId)}/cancel`));\r\n }\r\n\r\n async getVideoAnalysisCapabilities() {\r\n return this._unwrapSuccessResponse(await this.apiClient.get('/api/v1/video-analyses/capabilities'));\r\n }\r\n\r\n /**\r\n * 특정 메시지 온디맨드 번역 (버튼 진입점, 동기).\r\n *\r\n * <p>{@code sourceMessageId} 메시지(텍스트/이미지/문서)를 {@code targetLang} 으로 번역해 요청자에게만\r\n * 반환한다. 이미지는 이미지 속 텍스트를, 문서(PDF/DOCX/XLSX/PPTX 등)는 파일 안의 텍스트를 서버가 추출해\r\n * 번역한다. 방에 메시지를 저장하거나 {@code chatMessage} 이벤트를 broadcast 하지 않는다. 읽을 텍스트가\r\n * 없거나 문서가 너무 크면 에러를 반환한다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} options\r\n * @param {string} options.sourceMessageId - 번역 대상 메시지의 {@code messageId} (텍스트/이미지/문서). 필수.\r\n * @param {string} options.targetLang - 목표 언어 (예: {@code 'en'}, {@code '日本語'}). 필수.\r\n * @returns {Promise<{translatedText: string, sourceMessageId: string, targetLang: string}>}\r\n *\r\n * @example\r\n * const result = await chat.translateWithAssistant(roomId, {\r\n * sourceMessageId: msg.messageId,\r\n * targetLang: 'en'\r\n * });\r\n */\r\n async translateWithAssistant(roomId, options = {}) {\r\n const { targetLang, sourceMessageId } = options;\r\n if (!sourceMessageId) {\r\n throw new Error('translateWithAssistant requires options.sourceMessageId');\r\n }\r\n if (!targetLang) {\r\n throw new Error('translateWithAssistant requires options.targetLang');\r\n }\r\n\r\n const response = await this.apiClient.post(\r\n `/api/v1/rooms/${roomId}/assistant/tools/translate`,\r\n { targetLang, sourceMessageId }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * Rolling 누적 요약 갱신 트리거 (비동기).\r\n *\r\n * <p>방의 <b>누적 대화 요약</b>을 갱신한다 — {@link #summarizeWithAssistant}(페르소나가 최근 N개를 방에\r\n * 어시스턴트 메시지로 발행)와 달리, <b>포맷×언어별</b> 통합 요약본(rollup)을 마지막 요약 지점 이후 <b>새 대화만\r\n * 증분</b> 반영한다(같은 방을 여러 포맷·언어로 보관). 실제 요약은 서버 워커가 비동기로 수행하며, 완료/실패는\r\n * {@code summaryUpdate} 이벤트로 통지된다. 확정 내용은 {@link #getSummary} 로 조회한다(알림 payload 에는 내용이 없다).</p>\r\n *\r\n * <p><b>언어</b>: {@code lang} 미지정 시 서버가 요청자 언어(방 설정 ?? 사용자 선호)로 해소한다 — 자동번역으로\r\n * 원문이 섞여도 그 언어로만 통합된다. {@code 'OFF'} 는 원문 유지. 응답에 해소된 {@code format}/{@code language} 가\r\n * 담긴다.</p>\r\n *\r\n * <p>전체 히스토리를 보는 방(예: TEAM) 대상 기능이며, AI capability {@code TOOL_SUMMARIZE} 가 필요하다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} [options]\r\n * @param {string} [options.format] - 요약 포맷 ({@link SummarizeFormat}). 미지정/알 수 없는 값이면 서버가 {@code SHORT} 적용.\r\n * @param {string} [options.lang] - 출력 언어(BCP-47 예 {@code 'ko'}/{@code 'en'}, 또는 {@code 'OFF'}=원문). 미지정 시 요청자 언어로 해소.\r\n * @returns {Promise<{status: string, format?: string, language?: string}>} {@code status}: {@code 'QUEUED'} —\r\n * 방 단위 durable work item 에 저장·병합됐다. local worker 포화여도 요청은 버려지지 않으며\r\n * 최종 {@code READY|NO_CHANGE|NO_MESSAGES|FAILED} {@code summaryUpdate} 를 기다린다.\r\n * {@code format}/{@code language}=이번 호출이 work item 에 병합한 키.\r\n * ⚠️ 닫힌 목록이 아니다 — 분기에는 default 를 두고, 모르는 값에 스피너를 띄우지 마라.\r\n * @throws {Error} HTTP 503 {@code SUMMARY_REFRESH_UNAVAILABLE} — durable 저장 자체가 실패했다.\r\n * 간격을 두고 재시도하고 사용자에게는 일시 오류로 알린다.\r\n * {@code err.status}=503, {@code err.response.code}={@code 'SUMMARY_REFRESH_UNAVAILABLE'}.\r\n *\r\n * @fires summaryUpdate - { roomId, status: 'READY'|'NO_CHANGE'|'NO_MESSAGES'|'FAILED'|'INVALIDATED'|string, format, languages, updatedAt }.\r\n * fan-out 으로 여러 언어를 한 번에 생성하므로 {@code languages} 는 배열(§S1). {@code INVALIDATED} 는 refreshSummary 가\r\n * 아니라 방 원문 삭제/편집으로 발생(요약이 통째로 폐기됨).\r\n *\r\n * @example\r\n * const { status } = await chat.refreshSummary(roomId, { format: SummarizeFormat.MINUTES, lang: 'ko' });\r\n * if (status === 'QUEUED') showQueuedProgress(); // durable 접수 — summaryUpdate 대기\r\n * else showUnknownSummaryState(status); // 미래 서버 값은 추측하지 않는다\r\n * // 503(SUMMARY_REFRESH_UNAVAILABLE)은 durable 접수 실패 — try/catch로 받고 간격을 두고 재시도.\r\n */\r\n async refreshSummary(roomId, options = {}) {\r\n const { format, lang } = options;\r\n const response = await this.apiClient.post(\r\n `/api/v1/rooms/${roomId}/summary/refresh`,\r\n { format, lang }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * Rolling 누적 요약 조회 — 통합본(rollup) + 구간 부분요약(segments) 페이지. 저장된 요약의 단일 진실 소스.\r\n *\r\n * <p>terminal {@code summaryUpdate} 수신 후, 화면 진입/재접속 시, 그리고 {@code QUEUED} 뒤 앱이 정한 완료\r\n * timeout에 <b>한 번</b> 호출한다 — 알림은 transient(저장 안 됨)라 유실될 수 있으므로 GET 으로 동기화한다.\r\n * 주기적 polling은 하지 않는다. 아직 한 번도 요약된 적 없으면 {@code rollup} 이 {@code null} 이다.\r\n * segment 는 {@code afterSeq}/{@code size} 로 페이지네이션한다({@code hasNext}/{@code nextSeq}).</p>\r\n *\r\n * <p>같은 방을 여러 포맷×언어로 보관하므로 {@code options.format}/{@code options.lang} 으로 골라 조회한다.\r\n * {@code lang} 미지정 시 서버가 요청자 언어로 해소하고, 응답 {@code language} 로 <b>어느 언어 기준</b>인지\r\n * 알려준다. {@code availableFormats} 는 <b>그 언어에 존재하는</b> 포맷만 담는다(타 언어로 fallback 하지 않음).\r\n * 요청 포맷이 그 언어에 아직 없으면 {@code rollup=null}(refreshSummary 로 생성). {@code rollup.stale} 이\r\n * {@code true} 면 그 키가 최신 대화를 아직 반영 안 함(refresh 로 최신화).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} [options]\r\n * @param {string} [options.format] - 조회할 포맷({@link SummarizeFormat}). 미지정 시 그 언어의 기본(SHORT 우선). 미보유면 rollup=null.\r\n * @param {string} [options.lang] - 조회할 언어(BCP-47 예 {@code 'ko'}, 또는 {@code 'OFF'}=원문). 미지정 시 요청자 언어로 해소(응답 {@code language}).\r\n * @param {number} [options.afterSeq] - segment 페이지 커서(exclusive). 미지정 시 처음부터.\r\n * @param {number} [options.size=50] - segment 페이지 크기(서버 상한 50).\r\n * @returns {Promise<RoomSummaryView>} { language, roomStatus, rollup | null, availableFormats, segments, hasNext, nextSeq }\r\n *\r\n * @example\r\n * const view = await chat.getSummary(roomId, { format: 'timeline', lang: 'ko' });\r\n * console.log(view.language); // 'ko' (미지정 조회였다면 서버가 해소한 언어)\r\n * if (!view.rollup) return renderEmpty(); // 그 포맷×언어 아직 없음 (availableFormats 로 탭 렌더)\r\n * renderRollup(view.rollup, view.rollup.stale); // stale 이면 \"최신화\" 유도\r\n * renderTabs(view.availableFormats); // 이 언어에 보유한 포맷 탭\r\n */\r\n async getSummary(roomId, options = {}) {\r\n const { format, lang, afterSeq, size } = options;\r\n const response = await this.apiClient.get(\r\n `/api/v1/rooms/${roomId}/summary`,\r\n { format, lang, afterSeq, size }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 조회 — PM_BACKSTAGE 방 전용.\r\n *\r\n * <p>방 레이어는 전역 PM 프롬프트(+회사 레이어)에 <b>합성</b>되는 방 단위 추가 지시문이다 (교체 아님).\r\n * 등록된 적이 없으면 404 ({@code PM_PROMPT_LAYER_NOT_FOUND}) — \"아직 없음\" 상태로 처리할 것.</p>\r\n *\r\n * <p>권한: 방 참여자 전체 조회 가능. PM_BACKSTAGE 가 아닌 방이면 400\r\n * ({@code PM_PROMPT_ROOM_TYPE_UNSUPPORTED}).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>} PmPromptLayer — {@code {id, scope:'ROOM', projectId, roomId, content, active, activeVersion, editorType, updatedAt}}\r\n *\r\n * @example\r\n * try {\r\n * const layer = await chat.getRoomPmPrompt(roomId);\r\n * console.log(layer.active, layer.content);\r\n * } catch (e) {\r\n * if (e.status === 404) {\r\n * // 레이어 미등록 — 입력 UI 노출\r\n * }\r\n * }\r\n */\r\n async getRoomPmPrompt(roomId) {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/pm-prompt`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 등록/수정 (upsert) — <b>방 주인(OWNER) 전용</b>.\r\n *\r\n * <p>저장 시 새 버전이 자동 기록된다 ({@link #getRoomPmPromptVersions}). 본문 한도 20,000자.\r\n * 비주인 호출은 403 ({@code NOT_ROOM_ADMIN}).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} content - 레이어 본문 (1~20,000자). 방의 목적/맥락/금지사항 등 PM 에게 줄 추가 지시문.\r\n * @returns {Promise<Object>} 저장된 PmPromptLayer\r\n *\r\n * @example\r\n * await chat.upsertRoomPmPrompt(roomId, '이 방은 모바일앱 리뉴얼 TF. 결정사항은 표로 정리해줘.');\r\n */\r\n async upsertRoomPmPrompt(roomId, content) {\r\n if (typeof content !== 'string' || !content.trim()) {\r\n throw new Error('upsertRoomPmPrompt requires non-blank content');\r\n }\r\n if (content.length > MAX_ROOM_PM_PROMPT_LENGTH) {\r\n throw new Error(`upsertRoomPmPrompt content exceeds ${MAX_ROOM_PM_PROMPT_LENGTH} characters`);\r\n }\r\n\r\n const response = await this.apiClient.put(`/api/v1/rooms/${roomId}/pm-prompt`, { content });\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 합성 복귀 (활성화) — 방 주인(OWNER) 전용.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>} 변경된 PmPromptLayer ({@code active: true})\r\n */\r\n async activateRoomPmPrompt(roomId) {\r\n const response = await this.apiClient.patch(`/api/v1/rooms/${roomId}/pm-prompt/activate`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 합성 제외 (비활성화, soft delete) — 방 주인(OWNER) 전용.\r\n *\r\n * <p>본문/이력은 보존되고 PM 응답 합성에서만 빠진다. {@link #activateRoomPmPrompt} 로 복귀.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>} 변경된 PmPromptLayer ({@code active: false})\r\n */\r\n async deactivateRoomPmPrompt(roomId) {\r\n const response = await this.apiClient.patch(`/api/v1/rooms/${roomId}/pm-prompt/deactivate`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 버전 이력 조회 — 방 참여자 전체.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Array<Object>>} 버전 목록 — 각 항목 {@code {version, content, active, editorType, createdAt}} ({@code active}=현재 활성 버전)\r\n */\r\n async getRoomPmPromptVersions(roomId) {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/pm-prompt/versions`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방 PM 프롬프트 레이어 버전 활성화 — 방 주인(OWNER) 전용.\r\n *\r\n * <p>active 포인터 이동 방식 — 지정 버전을 활성으로 전환하고 라이브 본문을 그 버전으로 투영한다.\r\n * <b>새 버전을 만들지 않는다</b>(버전 수 불변, 글로벌/회사/방 PM 통일 모델). 존재하지 않는\r\n * 버전이면 404 ({@code PM_PROMPT_LAYER_VERSION_NOT_FOUND}).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {number} version - 활성화할 버전 번호 ({@link #getRoomPmPromptVersions} 의 {@code version})\r\n * @returns {Promise<Object>} 활성 버전이 반영된 PmPromptLayer\r\n */\r\n async activateRoomPmPromptVersion(roomId, version) {\r\n if (!Number.isInteger(version) || version < 1) {\r\n throw new Error('activateRoomPmPromptVersion requires a positive integer version');\r\n }\r\n\r\n const response = await this.apiClient.post(\r\n `/api/v1/rooms/${roomId}/pm-prompt/versions/${version}/activate`\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * PM 프롬프트 합성 미리보기 — 방 참여자 전체.\r\n *\r\n * <p>전역(+회사)(+방) 레이어가 합성된 최종 SYSTEM 본문을 반환한다. 컨텍스트 정책 등\r\n * 런타임 부착분은 미포함 — 레이어 합성 결과까지만.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<{composedPrompt: string}>}\r\n *\r\n * @example\r\n * const { composedPrompt } = await chat.previewRoomPmPrompt(roomId);\r\n */\r\n async previewRoomPmPrompt(roomId) {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/pm-prompt/preview`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 그룹 채팅방 입장.\r\n *\r\n * <p>비밀방 ({@code PRIVATE_GROUP}) 은 최초 입장 시 또는 나갔다 재입장 시 비밀번호 필요.\r\n * 이미 active 참가자라면 비밀번호 없이 재접근 가능 (서버의 fast path).</p>\r\n *\r\n * <p>팀방 ({@code TEAM}) 은 초대 전용 — 직접 입장 시도는 {@code TEAM_ROOM_INVITE_ONLY}(403).\r\n * {@link #inviteToGroupRoom} 으로 초대받아야 입장된다 (이미 멤버면 fast path 멱등 통과).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} [password] - 비밀방 입장 비밀번호 (비밀방 최초/재입장 시 필수)\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * // 공개방 입장\r\n * await chat.joinGroupRoom('room-id');\r\n *\r\n * @example\r\n * // 비밀방 입장\r\n * await chat.joinGroupRoom('room-id', 'secret1234');\r\n */\r\n async joinGroupRoom(roomId, password) {\r\n const body = password ? { password } : undefined;\r\n return this.apiClient.post(`/api/v1/rooms/group/${roomId}/join`, body);\r\n }\r\n\r\n /**\r\n * 채팅방 나가기 (구독도 자동 해제됨).\r\n *\r\n * <p>일반 참가자는 {@code leaveRoom(roomId)} 그대로. <b>방장</b>은 그냥 나갈 수 없다 — 나가면 방에 관리자가\r\n * 0명이 되기 때문이다. 방장은 둘 중 하나를 고른다:</p>\r\n * <ul>\r\n * <li>위임 후 나가기 — {@code leaveRoom(roomId, { newOwnerId })}</li>\r\n * <li>방 삭제 — {@link #deleteRoom}</li>\r\n * </ul>\r\n *\r\n * <p>서버 응답:</p>\r\n * <ul>\r\n * <li>409 {@code ROOM_OWNER_CANNOT_LEAVE} — 방장이 newOwnerId 없이 나가려 함 (선택 화면을 띄울 신호)</li>\r\n * <li>403 {@code NOT_ROOM_ADMIN} — 방장이 아닌데 newOwnerId 를 보냄</li>\r\n * <li>400 {@code CANNOT_TRANSFER_OWNERSHIP_TO_SELF} / {@code INVALID_OWNERSHIP_TARGET}, 404 {@code USER_NOT_IN_ROOM}\r\n * — 위임 대상 오류 ({@link #transferOwnership} 와 같다). 이때는 나가지도 않는다.</li>\r\n * </ul>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} [options]\r\n * @param {string} [options.newOwnerId] - 방장이 나갈 때 방장을 넘겨받을 사람 (방의 활성 사람 참가자 · 활성 계정)\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * try {\r\n * await chat.leaveRoom(roomId);\r\n * } catch (e) {\r\n * // 서버 코드는 e.response.code — 방장이면 «위임 후 나가기 / 방 삭제» 선택 화면\r\n * if (e.response?.code === 'ROOM_OWNER_CANNOT_LEAVE') openOwnerExitDialog(roomId);\r\n * }\r\n * // 위임 후 나가기\r\n * await chat.leaveRoom(roomId, { newOwnerId: 'user-2' });\r\n */\r\n async leaveRoom(roomId, options = {}) {\r\n const body = options.newOwnerId ? { newOwnerId: options.newOwnerId } : undefined;\r\n const result = await this.apiClient.post(`/api/v1/rooms/${roomId}/leave`, body);\r\n this.unsubscribeRoom(roomId);\r\n return result;\r\n }\r\n\r\n /**\r\n * 방장 위임 — 방장은 방에 남고 MEMBER 가 된다. 방장(OWNER) 전용.\r\n *\r\n * <p>나가면서 넘기려면 {@code leaveRoom(roomId, { newOwnerId })}. 방 참가자 전원에게\r\n * {@code roomListOwnerChanged} 가 간다(actorId = 이전 방장, members[0] = 새 방장).</p>\r\n *\r\n * <p>서버 응답:</p>\r\n * <ul>\r\n * <li>403 {@code NOT_ROOM_ADMIN} — 방장 아님 (DIRECT 방은 방장이 없다)</li>\r\n * <li>400 {@code CANNOT_TRANSFER_OWNERSHIP_TO_SELF} — 자기 자신에게 위임</li>\r\n * <li>404 {@code USER_NOT_IN_ROOM} — 대상이 현재 참가자가 아님</li>\r\n * <li>400 {@code INVALID_OWNERSHIP_TARGET} — 대상이 어시스턴트이거나 퇴사·차단 계정</li>\r\n * </ul>\r\n *\r\n * @param {string} roomId\r\n * @param {string} newOwnerId - 새 방장 userId\r\n * @returns {Promise<Object>} 갱신된 방 상세 (participants[].role 반영 — 봉투를 벗긴 data)\r\n */\r\n async transferOwnership(roomId, newOwnerId) {\r\n const response = await this.apiClient.put(`/api/v1/rooms/${roomId}/owner`, { newOwnerId });\r\n return response && typeof response === 'object' && 'data' in response\r\n ? response.data\r\n : response;\r\n }\r\n\r\n /**\r\n * 방 삭제 — 방장(OWNER) 전용. 참가자 전원이 나가게 되고 방은 목록에서 사라진다.\r\n * 이후 enterRoom·getRoomInfo 는 403(ACCESS_FORBIDDEN — 더는 참가자가 아님), joinGroupRoom 은 404(CHAT_ROOM_NOT_FOUND).\r\n *\r\n * <p>참가자 전원(방장 포함)에게 {@code roomListDeleted} 가 간다. 본인 구독은 자동 해제된다.\r\n * 메시지·첨부는 서버에 보존된다(소프트 삭제) — 되살리는 API 는 없다.</p>\r\n *\r\n * <p>서버 응답: 403 {@code NOT_ROOM_ADMIN} — 방장 아님.</p>\r\n *\r\n * @param {string} roomId\r\n * @returns {Promise<Object>}\r\n */\r\n async deleteRoom(roomId) {\r\n const result = await this.apiClient.delete(`/api/v1/rooms/${roomId}`);\r\n this.unsubscribeRoom(roomId);\r\n return result;\r\n }\r\n\r\n /**\r\n * 그룹 채팅방 설정 수정 (방장만).\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} data - 수정할 필드 (null/undefined 이면 변경 안 함)\r\n * @param {string} [data.roomName] - 방 이름\r\n * @param {string} [data.description] - 설명\r\n * @param {string} [data.roomType] - 공개/비공개 전환 ('GROUP' | 'PRIVATE_GROUP').\r\n * 'PRIVATE_GROUP' 전환 시 password 필수, 'GROUP' 전환 시 기존 비번 제거. 생략 시 변경 안 함.\r\n * @param {string} [data.password] - 비밀번호 (PRIVATE_GROUP). 공백만으로는 설정 불가, 4~50자.\r\n * @param {number} [data.messageRetentionHours] - 보관 기간 (시간)\r\n * @param {boolean} [data.anyoneCanInvite] - 누구나 초대 가능 여부\r\n * @param {string} [data.assistantMode] - 어시스턴트 참여 모드\r\n * ('GENERAL' | 'PEOPLE_ONLY' | 'CALL_ONLY'). PERSONA_MULTI 방 전용.\r\n * @param {string} [data.engagementIntensity] - PM 개입 강도 ('QUIET' | 'NORMAL' | 'ACTIVE').\r\n * PM_BACKSTAGE 방 전용 — PM 참여 레벨 변경. 키 PM_BACKSTAGE 권한 필요(없으면 403).\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * // PERSONA_MULTI 방 — 어시스턴트 모드 변경\r\n * await chat.updateGroupRoom('room-id', { assistantMode: 'CALL_ONLY' });\r\n *\r\n * @example\r\n * // PM_BACKSTAGE 방 — PM 개입 강도 변경\r\n * await chat.updateGroupRoom('room-id', { engagementIntensity: 'QUIET' });\r\n *\r\n * @example\r\n * // 공개 → 비공개 전환 (비밀번호 필수)\r\n * await chat.updateGroupRoom('room-id', { roomType: 'PRIVATE_GROUP', password: 'secret12' });\r\n *\r\n * @example\r\n * // 비공개 → 공개 전환 (기존 비밀번호 제거됨)\r\n * await chat.updateGroupRoom('room-id', { roomType: 'GROUP' });\r\n */\r\n async updateGroupRoom(roomId, data) {\r\n return this.apiClient.put(`/api/v1/rooms/group/${roomId}`, data);\r\n }\r\n\r\n /**\r\n * 그룹 채팅방에 회원 / 어시스턴트 추가 초대.\r\n *\r\n * <p>두 가지 호출 형식 지원 (backward-compat):</p>\r\n * <ul>\r\n * <li>{@code inviteToGroupRoom(roomId, ['user-1', 'user-2'])} — 회원만 초대 (기존)</li>\r\n * <li>{@code inviteToGroupRoom(roomId, { userIds, assistantPersonaIds })} — 회원 + 어시 (신규)</li>\r\n * </ul>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string[]|{userIds?: string[], assistantPersonaIds?: string[]}} userIdsOrData\r\n * 배열이면 회원 ID 목록, 객체면 {@code userIds} / {@code assistantPersonaIds}.\r\n * 둘 중 하나 이상 필수.\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * // 회원만\r\n * await chat.inviteToGroupRoom(roomId, ['user-1', 'user-2']);\r\n *\r\n * @example\r\n * // 회원 + 어시스턴트\r\n * await chat.inviteToGroupRoom(roomId, {\r\n * userIds: ['user-3'],\r\n * assistantPersonaIds: [/* 어시스턴트 id 배열 *\\/]\r\n * });\r\n */\r\n async inviteToGroupRoom(roomId, userIdsOrData) {\r\n const body = Array.isArray(userIdsOrData)\r\n ? { userIds: userIdsOrData }\r\n : {\r\n userIds: userIdsOrData?.userIds,\r\n assistantPersonaIds: userIdsOrData?.assistantPersonaIds\r\n };\r\n return this.apiClient.post(`/api/v1/rooms/group/${roomId}/invite`, body);\r\n }\r\n\r\n /**\r\n * 그룹 채팅방에서 참가자 단순 추방 — 방 관리자(OWNER/ADMIN) 만 호출 가능.\r\n *\r\n * <p>대상은 방에서 제거되지만 재입장 / 재초대 가능. 영구 차단이 필요하면\r\n * {@link #banMember} 사용.</p>\r\n *\r\n * <p>서버 응답:</p>\r\n * <ul>\r\n * <li>400 {@code CANNOT_KICK_SELF} — 자기 자신 추방 시도</li>\r\n * <li>403 {@code NOT_ROOM_ADMIN} — 방장 아님</li>\r\n * <li>404 {@code USER_NOT_IN_ROOM} — 대상이 현재 참가자 아님</li>\r\n * </ul>\r\n *\r\n * @param {string} roomId\r\n * @param {string} userId - 추방할 사용자 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async kickMember(roomId, userId) {\r\n return this.apiClient.delete(`/api/v1/rooms/${roomId}/members/${userId}`);\r\n }\r\n\r\n /**\r\n * 그룹 채팅방에서 참가자 추방 + 영구 차단 — 방 관리자(OWNER/ADMIN) 만 호출 가능.\r\n *\r\n * <p>{@link #kickMember} 와 동일하게 방에서 제거하되 {@code bannedUserIds} 에 추가되어\r\n * {@link #unbanMember} 호출 전까지 재입장 / 재초대가 거부된다. MVP 정책상 현재 또는\r\n * 과거 참가자만 차단 가능 (선제적 차단 불가 — 404 {@code USER_NOT_IN_ROOM}).</p>\r\n *\r\n * @param {string} roomId\r\n * @param {string} userId - 차단할 사용자 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async banMember(roomId, userId) {\r\n return this.apiClient.post(`/api/v1/rooms/${roomId}/banned-members`, { userId });\r\n }\r\n\r\n /**\r\n * 영구 차단 해제 — 방 관리자(OWNER/ADMIN) 만 호출 가능.\r\n *\r\n * <p>{@code bannedUserIds} 에서 제거만 수행. 실제 재입장은 별도 초대 / join 호출 필요.</p>\r\n *\r\n * @param {string} roomId\r\n * @param {string} userId - 해제할 사용자 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async unbanMember(roomId, userId) {\r\n return this.apiClient.delete(`/api/v1/rooms/${roomId}/banned-members/${userId}`);\r\n }\r\n\r\n /**\r\n * 방의 영구 차단 사용자 목록 조회 — 방 관리자(OWNER/ADMIN) 만 호출 가능.\r\n *\r\n * <p>응답 각 item: {@code { userId, nickname, profileUrl }}.\r\n * 일반 참가자가 호출 시 403 {@code NOT_ROOM_ADMIN}.</p>\r\n *\r\n * @param {string} roomId\r\n * @returns {Promise<Array<{userId: string, nickname: string, profileUrl: string}>>}\r\n */\r\n async getBannedMembers(roomId) {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/banned-members`);\r\n return response && typeof response === 'object' && 'data' in response\r\n ? response.data\r\n : response;\r\n }\r\n\r\n /**\r\n * 메시지 고정 (방장만).\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 고정할 메시지 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async pinMessage(roomId, messageId) {\r\n return this.apiClient.post(`/api/v1/rooms/group/${roomId}/pin/${messageId}`);\r\n }\r\n\r\n /**\r\n * 메시지 고정 해제 (방장만).\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async unpinMessage(roomId) {\r\n return this.apiClient.post(`/api/v1/rooms/group/${roomId}/unpin`);\r\n }\r\n\r\n // ── 투표 ────────────────────────────────────────────────────────────────\r\n //\r\n // 투표 서버는 클러스터 내부에만 있어 클라이언트가 직접 부를 수 없다. 모든 호출은 채팅 서버를 거치고,\r\n // 신원(userId/projectId)은 채팅 서버가 인증 결과로 채워 넣는다.\r\n //\r\n // 🔴 실패는 서버가 투표 서버의 code 를 <b>그대로</b> 내려준다 — {@code error.response.code} 로 분기한다\r\n // (VOTE_ALREADY_CLOSED / VOTE_NOT_EDITABLE / VOTE_MODIFY_FORBIDDEN / CONFLICT ...).\r\n // 409 는 세 종류이고 대응이 다르다: ALREADY_CLOSED·NOT_EDITABLE 은 재시도해도 같고,\r\n // CONFLICT 만 <b>재조회 후</b> 재전송하면 성공한다.\r\n //\r\n // 🔴 결과 마스킹은 <b>호출자마다</b> 계산된다. 목록에는 집계가 없고, 단건 조회/접수 응답에만 있다.\r\n // 같은 투표라도 다른 사용자가 보면 가려지므로 응답을 공유 캐시에 넣으면 안 된다.\r\n\r\n /**\r\n * 투표 생성.\r\n *\r\n * <p>🔴 <b>{@code idempotencyKey} 는 필수다.</b> SDK 가 대신 만들지 않는다 — 만들면 그 값이 호출자에게\r\n * 남지 않아, 응답만 유실된 타임아웃에서 <b>복구가 불가능</b>하다(다음 호출이 새 키를 만들어 투표가\r\n * 두 개 생긴다). 호출자가 키를 소유하고, 같은 시도의 재시도에는 <b>같은 키</b>를 넘긴다 —\r\n * 그러면 서버가 기존 투표를 돌려준다(응답도 201, {@code voteId} 도 같다).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} vote - 투표 폼\r\n * @param {string} vote.question - 질문 (최대 500자)\r\n * @param {'SINGLE'|'MULTIPLE'} vote.type\r\n * @param {number} vote.maxSelections - 양수. {@code SINGLE} 이면 1\r\n * @param {'TEXT'|'TEXT_WITH_IMAGE'} vote.optionType - {@code 'IMAGE'} 라는 값은 없다\r\n * @param {number} vote.closeAt - 마감 시각 (epoch millis). 과거·현재 불가 · 최대 30일 뒤\r\n * @param {Array<{id: string, content: string, order: number, imageUrl?: string}>} vote.options - 2~10개.\r\n * {@code imageUrl} 은 {@link #uploadVoteImage} 가 돌려준 값을 <b>가공 없이</b> 넣는다\r\n * @param {Object} options\r\n * @param {string} options.idempotencyKey - <b>필수.</b> 재시도에는 반드시 같은 값을 넘긴다\r\n * @returns {Promise<Object>} 생성된 투표 (집계 포함)\r\n * @throws {Error} {@code idempotencyKey} 가 없으면 서버를 호출하지 않고 즉시 실패한다\r\n * @example\r\n * const key = crypto.randomUUID();\r\n * const vote = await chat.createVote(roomId, {\r\n * question: '점심 뭐 먹지', type: 'SINGLE', optionType: 'TEXT', maxSelections: 1,\r\n * closeAt: Date.now() + 86_400_000,\r\n * options: [{ id: 'opt-1', content: '삼겹살', order: 0 }, { id: 'opt-2', content: '국밥', order: 1 }]\r\n * }, { idempotencyKey: key });\r\n */\r\n async createVote(roomId, vote, options = {}) {\r\n if (!roomId) {\r\n throw new Error('roomId is required');\r\n }\r\n const { idempotencyKey } = options;\r\n if (!idempotencyKey) {\r\n // SDK 가 만들어 주면 호출자는 그 값을 볼 수 없다 — 타임아웃으로 응답만 유실됐을 때\r\n // 같은 키로 재시도할 방법이 사라지고, 다음 호출이 투표를 하나 더 만든다.\r\n throw new Error(\r\n 'createVote: options.idempotencyKey is required — '\r\n + 'generate it once per vote and reuse the same value on retry '\r\n + '(재시도에 같은 키를 넘겨야 투표가 중복 생성되지 않는다).'\r\n );\r\n }\r\n const response = await this.apiClient.post(\r\n `/api/v1/rooms/${roomId}/votes`,\r\n vote,\r\n undefined,\r\n { 'X-Idempotency-Key': idempotencyKey }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 방의 투표 목록 (최신순).\r\n *\r\n * <p>목록 항목에는 선택지별 집계도 내 선택도 없다 — 참여자 수({@code responseCount})만 있다.\r\n * 결과를 보려면 {@link #getVote} 를 쓴다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} [options]\r\n * @param {string} [options.cursor] - 이전 응답의 {@code nextCursor} 를 <b>그대로</b>. 불투명 문자열이라 파싱하지 않는다\r\n * @param {number} [options.limit=20] - 최대 100. 초과분은 서버가 조용히 100 으로 보정한다(에러 아님)\r\n * @returns {Promise<{items: Array<Object>, nextCursor: ?string}>} {@code items} 가 비면 끝\r\n */\r\n async getVotes(roomId, options = {}) {\r\n const { cursor, limit } = options;\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/votes`, { cursor, limit });\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 투표 단건 + 집계.\r\n *\r\n * <p>{@code revealed: false} 일 때의 {@code count}/{@code ratio} 0 은 「참여자가 없다」가 아니라\r\n * <b>서버가 가린 값</b>이다. 결과 공개 조건은 「내가 응답했다」 또는 「마감됐다」. 비공개 상태에서도\r\n * {@code myOptionIds} 는 채워진다(재투표 화면용).</p>\r\n *\r\n * <p>{@code ratio} 의 분모는 응답자 수이지 총 선택 수가 아니다 — 복수 선택에서 <b>합이 100을 넘는 것이\r\n * 정상</b>이고 화면이 「복수 응답」임을 표기해야 한다.</p>\r\n *\r\n * <p>마감 여부는 {@code status} 만으로 정해지지 않는다. 서버 자동 마감이 미구현이라\r\n * {@code status: 'OPEN'} 인데 {@code closeAt} 이 지난 투표가 있고 그건 마감된 것으로 동작한다 —\r\n * <b>{@code status} 와 {@code closeAt} 을 함께</b> 봐야 한다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} voteId - 투표 ID\r\n * @returns {Promise<Object>}\r\n */\r\n async getVote(roomId, voteId) {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/votes/${voteId}`);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 투표 수정 — <b>부분 수정이 아니다.</b> 등록 폼 전체를 보낸다({@link #createVote} 와 같은 타입).\r\n *\r\n * <p>{@code maxSelections}↔{@code type}, {@code optionType}↔{@code imageUrl} 이 서로 묶여 있어\r\n * 한쪽만 바꾸면 판독 불가능한 투표가 만들어지기 때문이다.</p>\r\n *\r\n * <p>조건 둘: <b>개설자만</b> · <b>참여자가 0명일 때만</b>. 표가 들어오면\r\n * {@code VOTE_NOT_EDITABLE}(409), 개설자가 아니면 {@code VOTE_MODIFY_FORBIDDEN}(403).\r\n * 선택지 {@code id} 를 이전 값과 맞출 필요는 없다 — 참여자가 0명이라 어긋날 응답이 없다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} voteId - 투표 ID\r\n * @param {Object} vote - 투표 폼 (생성과 동일)\r\n * @returns {Promise<Object>}\r\n */\r\n async updateVote(roomId, voteId, vote) {\r\n const response = await this.apiClient.patch(`/api/v1/rooms/${roomId}/votes/${voteId}`, vote);\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 투표 삭제 — 개설자면 된다. 마감 여부도 참여자 유무도 보지 않는다.\r\n *\r\n * <p>채팅에 이미 게시된 투표 카드는 <b>그대로 남는다</b>(서버가 발행된 메시지를 고칠 수 없다).\r\n * 원본 카드를 누르면 404 가 오고, 이 404 를 「취소된 투표」로 표시하는 것은 화면 몫이다.\r\n * 삭제 사실은 별도의 취소 카드 메시지로도 방에 발행된다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} voteId - 투표 ID\r\n * @returns {Promise<void>} 본문 없음 (204)\r\n */\r\n async deleteVote(roomId, voteId) {\r\n await this.apiClient.delete(`/api/v1/rooms/${roomId}/votes/${voteId}`);\r\n }\r\n\r\n /**\r\n * 수동 마감 (개설자만).\r\n *\r\n * <p>본문이 없으므로 마감 후 결과를 보려면 {@link #getVote} 를 다시 호출한다.\r\n * 이미 마감이면 {@code VOTE_ALREADY_CLOSED}(409), 개설자가 아니면 {@code VOTE_CLOSE_FORBIDDEN}(403)\r\n * — 수정의 403 과 code 가 다르다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} voteId - 투표 ID\r\n * @returns {Promise<void>} 본문 없음 (204)\r\n */\r\n async closeVote(roomId, voteId) {\r\n await this.apiClient.patch(`/api/v1/rooms/${roomId}/votes/${voteId}/close`);\r\n }\r\n\r\n /**\r\n * 투표 접수 · 재투표 — <b>같은 메서드</b>다.\r\n *\r\n * <p>재투표는 새 자원을 만들지 않고 기존 응답을 덮어쓴다. 클라이언트가 최초/재투표를 구분해 보낼 필요가\r\n * 없고, 같은 값을 여러 번 보내도 결과가 같다. 응답에 <b>갱신된 집계가 함께</b> 오므로 별도 폴링이 필요 없다.</p>\r\n *\r\n * <p>빈 배열은 「투표 취소」로 해석되지 않는다 — 투표를 취소하는 경로는 없다(400).</p>\r\n *\r\n * <p>여러 규칙을 동시에 위반해도 서버는 <b>먼저 걸린 하나만</b> 응답한다. 하나를 고쳐 재시도하면\r\n * 다음 위반이 드러날 수 있어, 모든 오류를 한 번에 수집하는 화면은 성립하지 않는다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} voteId - 투표 ID\r\n * @param {string[]} optionIds - 고른 선택지. 빈 배열 불가\r\n * @returns {Promise<Object>} 갱신된 투표 (집계 포함)\r\n */\r\n async submitVote(roomId, voteId, optionIds) {\r\n const response = await this.apiClient.put(\r\n `/api/v1/rooms/${roomId}/votes/${voteId}/responses`,\r\n { optionIds }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 선택지 이미지 업로드.\r\n *\r\n * <p>돌려받은 {@code imageUrl} 을 <b>가공 없이 그대로</b> 생성·수정 본문의 {@code options[].imageUrl} 에\r\n * 넣는다 — 서버가 문자열로 비교하고, 이 API 가 발급한 URL 만 통과한다. <b>채팅 첨부 URL 은 쓸 수 없다</b>\r\n * (같은 버킷이라도 거절된다).</p>\r\n *\r\n * <p>형식은 매직넘버로 판정한다(PNG · JPEG · GIF · WEBP) — 신고된 Content-Type 과 확장자는 쓰이지 않으므로\r\n * {@code .png} 로 이름 붙인 텍스트 파일은 거절된다. 10MB 를 넘으면 {@code VOTE_IMAGE_TOO_LARGE}.</p>\r\n *\r\n * <p>{@code optionType: 'TEXT_WITH_IMAGE'} 는 「이미지를 쓸 수 있다」는 상한이지 「모두 이미지다」가 아니다 —\r\n * 이미지가 있는 선택지와 없는 선택지를 섞어도 되고 하나도 없어도 된다. 다만 {@code imageUrl} 이\r\n * <b>빈 문자열</b>이면 거절된다(없으면 키를 아예 빼거나 {@code null}).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {File|Blob} file - 이미지 파일\r\n * @param {Object} [options]\r\n * @param {Function} [options.onProgress] - {@code ({loaded, total, percent}) => void}\r\n * @param {AbortSignal} [options.signal] - 취소용\r\n * @returns {Promise<{imageUrl: string}>}\r\n */\r\n async uploadVoteImage(roomId, file, options = {}) {\r\n if (!roomId) {\r\n throw new Error('roomId is required');\r\n }\r\n if (!file) {\r\n throw new Error('file is required');\r\n }\r\n const response = await this.apiClient.upload(\r\n `/api/v1/rooms/${roomId}/votes/images`,\r\n file,\r\n { onProgress: options.onProgress, signal: options.signal }\r\n );\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 이모지 리액션 토글.\r\n * <p>한 사용자는 메시지당 하나의 이모지만 보유.\r\n * 같은 이모지 재전송 시 해제, 다른 이모지 전송 시 교체.</p>\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 메시지 ID\r\n * @param {string} emoji - 이모지 (예: \"👍\", \"😂\")\r\n * @returns {Promise<Object>}\r\n */\r\n async toggleReaction(roomId, messageId, emoji) {\r\n return this.apiClient.post(`/api/v1/chat-messages/${roomId}/messages/${messageId}/reaction`, { emoji });\r\n }\r\n\r\n /**\r\n * 참가 가능한 그룹 채팅방 목록 조회\r\n * @param {Object} [params]\r\n * @param {number} [params.size=50] - 페이지 크기\r\n * @param {string} [params.lastId] - 마지막 채팅방 ID\r\n * @param {number} [params.lastSortValue] - 마지막 정렬값\r\n * @returns {Promise<Object>}\r\n */\r\n async getAvailableGroupRooms(params = {}) {\r\n const { size = 50, lastId, lastSortValue } = params;\r\n return this.apiClient.get('/api/v1/rooms/groups/available', { size, lastId, lastSortValue });\r\n }\r\n\r\n /**\r\n * 모든 그룹 채팅방 목록 조회\r\n * @param {Object} [params]\r\n * @param {number} [params.size=50] - 페이지 크기\r\n * @param {string} [params.lastId] - 마지막 채팅방 ID\r\n * @param {number} [params.lastSortValue] - 마지막 정렬값\r\n * @returns {Promise<Object>}\r\n */\r\n async getAllGroupRooms(params = {}) {\r\n const { size = 50, lastId, lastSortValue } = params;\r\n return this.apiClient.get('/api/v1/rooms/groups/all', { size, lastId, lastSortValue });\r\n }\r\n\r\n /**\r\n * 파일함 — 내가 지금 참여 중인 방들에 올라온 파일(미디어 포함) 목록, 최신순 커서 페이지.\r\n *\r\n * <p>한 줄이 파일 하나다(메시지 하나에 파일이 여러 개면 여러 줄). 보이는 범위는 채팅 화면과 같다 — 참여 중인 방만,\r\n * 재입장 이전·보관 기간 밖·삭제된 메시지의 파일은 오지 않는다. PM 이 만든 파일도 온다.</p>\r\n *\r\n * <p>다른 목록 조회({@link ChatClient#getMessages})처럼 서버 응답 래퍼째 돌려준다 — 페이지는 {@code result.data}.</p>\r\n *\r\n * @param {Object} [params]\r\n * @param {string} [params.roomId] - 방 하나로 거르기. 생략하면 참여 중인 방 전부. 참여하지 않은 방이면 403\r\n * @param {number} [params.size=50] - 페이지 크기 (1-100, 파일 수)\r\n * @param {string} [params.lastId] - 직전 응답의 lastId 를 그대로 — 불투명 값이라 만들어 쓰지 않는다\r\n * @param {number} [params.lastSortValue] - 직전 응답의 lastSortValue 를 그대로\r\n * @returns {Promise<Object>}\r\n */\r\n async getFiles(params = {}) {\r\n const { roomId, size = 50, lastId, lastSortValue } = params;\r\n return this.apiClient.get('/api/v1/files', { roomId, size, lastId, lastSortValue });\r\n }\r\n\r\n /**\r\n * 메시지 목록 조회\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} [params]\r\n * @param {number} [params.size=50] - 페이지 크기 (1-100)\r\n * @param {string} [params.lastId] - 마지막 메시지 ID (커서)\r\n * @param {number} [params.lastSortValue] - 마지막 정렬값 (epoch millis)\r\n * @returns {Promise<Object>}\r\n */\r\n async getMessages(roomId, params = {}) {\r\n const { size = 50, lastId, lastSortValue } = params;\r\n // 커서가 없으면 \"최신 페이지\" — 이때만 anchor 를 심는다 (과거 페이지네이션은 워터마크를 뒤로 민다).\r\n // null 과 undefined 를 함께 잡는다(느슨한 비교) — 호출자가 명시적으로 null 을 넘겨도 서버에는\r\n // 최신 페이지로 나가므로, 엄격 비교로 두면 그 경로만 조용히 seed 를 건너뛴다.\r\n const isLatestPage = lastId == null && lastSortValue == null;\r\n const genAtStart = this._anchorGen.get(roomId) || 0;\r\n\r\n const response = await this.apiClient.get(\r\n `/api/v1/chat-messages/${roomId}/list`, { size, lastId, lastSortValue }\r\n );\r\n // REST 와 WebSocket 의 타임스탬프 직렬화가 다르다 (REST=ISO 문자열, WS=epoch number).\r\n // 선언된 타입(number)으로 통일 — 기존 응답 구조(envelope)는 그대로 둔다.\r\n const page = this._unwrapSuccessResponse(response);\r\n if (page && Array.isArray(page.content)) {\r\n page.content.forEach((m) => this._normalizeMessageTimestamps(m));\r\n if (isLatestPage) {\r\n this._seedAnchorFromLatest(roomId, page.content, genAtStart);\r\n }\r\n }\r\n return response;\r\n }\r\n\r\n /**\r\n * 초기 REST 로드로 anchor 를 심는다 (RECONNECT_SYNC_DESIGN §6.2).\r\n *\r\n * <p><b>없으면 가장 흔한 복귀 경로가 통째로 skip 된다</b> — 방에 들어와 초기 50건을 REST 로 본 뒤\r\n * 라이브 메시지를 한 건도 받기 전에 백그라운드로 가면 anchor 가 없어 갭 복구가 돌지 않는다.\r\n * 그게 바로 이 기능이 없애려는 증상이다.</p>\r\n *\r\n * <p><b>최신 페이지에서만</b> 호출해야 한다. 과거 방향 페이지네이션(무한 스크롤) 결과로 심으면\r\n * 워터마크가 뒤로 밀린다. 목록은 최신순(DESC)이라 {@code content[0]} 이 가장 최신이다.</p>\r\n *\r\n * <p>진행 중 라이브 메시지가 도착했으면(세대 변화) 그쪽이 더 최신이므로 덮어쓰지 않는다 —\r\n * {@code id} 는 문자열 비교로 시간 순서를 보장하지 못해 \"더 최신인지\" 를 직접 판별할 수 없기 때문에\r\n * sync 커밋과 동일한 generation guard 로 처리한다.</p>\r\n *\r\n * @private\r\n */\r\n _seedAnchorFromLatest(roomId, descendingMessages, genAtStart) {\r\n if (!Array.isArray(descendingMessages) || descendingMessages.length === 0) return false;\r\n const newest = descendingMessages[0];\r\n if (!newest || !newest.id) return false;\r\n return this._commitAnchorFromSync(roomId, newest.id, genAtStart);\r\n }\r\n\r\n /**\r\n * 갭 복구용 forward 조회 — 앵커 <b>이후</b> 메시지를 오름차순으로 가져온다.\r\n *\r\n * <p>커서는 메시지 {@code id}(Mongo {@code _id}) 하나뿐이다. 시간값은 보내지 않는다 —\r\n * 서버가 앵커를 조회해 {@code sentAt} 을 직접 읽으므로 timezone/정밀도 불일치가 생길 수 없다.</p>\r\n *\r\n * <p>읽음 부작용이 없다({@code /list} 와 동일). 백그라운드 방의 unread 를 건드리지 않는다.</p>\r\n *\r\n * @param {string} roomId\r\n * @param {Object} [params]\r\n * @param {string} [params.afterId] - 앵커 메시지의 Mongo _id. 없으면 방의 가장 오래된 것부터.\r\n * @param {number} [params.rewindMillis] - 시계 스큐 방어 되감기. <b>첫 페이지에만</b> 보낸다.\r\n * @param {number} [params.size]\r\n * @returns {Promise<Object>}\r\n */\r\n async syncMessages(roomId, params = {}) {\r\n const { afterId, rewindMillis, size = this.syncPageSize } = params;\r\n const response = await this.apiClient.get(\r\n `/api/v1/chat-messages/${roomId}/sync`, { afterId, rewindMillis, size }\r\n );\r\n const page = this._unwrapSuccessResponse(response);\r\n if (page && Array.isArray(page.content)) {\r\n page.content.forEach((m) => this._normalizeMessageTimestamps(m));\r\n }\r\n return response;\r\n }\r\n\r\n /**\r\n * 라이브 수신으로 anchor 전진 (+세대 증가).\r\n *\r\n * <p>{@code MESSAGE_CREATED} 에서만 호출한다. UPDATED/DELETED/REACTION 은 과거 메시지의 재발행이라\r\n * anchor 를 건드리면 워터마크가 뒤로 밀린다.</p>\r\n *\r\n * @private\r\n */\r\n _advanceAnchorFromLive(roomId, messageId) {\r\n if (!roomId || !messageId) return;\r\n this._anchor.set(roomId, messageId);\r\n this._anchorGen.set(roomId, (this._anchorGen.get(roomId) || 0) + 1);\r\n }\r\n\r\n /**\r\n * sync 완료 커밋 — 시작 시점 세대가 유지된 경우에만 anchor 를 덮어쓴다.\r\n *\r\n * <p>sync 진행 중 라이브 메시지가 들어와 anchor 가 더 앞서 갔을 수 있는데, 그걸 sync 결과로 덮으면\r\n * <b>워터마크가 후퇴</b>해 다음 sync 가 같은 구간을 다시 긁는다.</p>\r\n *\r\n * @private\r\n * @returns {boolean} 커밋 여부\r\n */\r\n _commitAnchorFromSync(roomId, lastId, genAtStart) {\r\n if (!lastId) return false;\r\n if ((this._anchorGen.get(roomId) || 0) !== genAtStart) {\r\n this.logger.debug(`Anchor commit skipped (live advanced): room=${roomId}`);\r\n return false;\r\n }\r\n this._anchor.set(roomId, lastId);\r\n return true;\r\n }\r\n\r\n /**\r\n * 복구한 메시지를 라이브와 <b>동일한 경로</b>로 재생한다.\r\n *\r\n * <p>반드시 {@link #_handleChatMessage} 하나로만 흘려야 한다 — dedup({@code _shouldDedupMessage})은\r\n * check 와 record 가 한 몸이라, 호출자가 미리 dedup 을 돌린 뒤 넘기면 {@code _handleChatMessage} 가\r\n * 같은 messageId 를 중복으로 판정해 <b>복구 메시지를 전부 삼킨다</b>.</p>\r\n *\r\n * <p>REST 응답에는 {@code eventType} 이 없으므로 {@code MESSAGE_CREATED} 를 합성한다. 삭제\r\n * placeholder 도 CREATED 로 흘린다 — dedup 대상이 CREATED 뿐이라, DELETED 로 보내면 재동기화 때마다\r\n * 중복 emit 된다. 삭제 표시는 payload 의 {@code deletedStatus} 가 담당한다.</p>\r\n *\r\n * @private\r\n */\r\n _replayMessages(roomId, messages) {\r\n if (!Array.isArray(messages) || messages.length === 0) return 0;\r\n\r\n let emitted = 0;\r\n this._replaying = true;\r\n try {\r\n messages.forEach((message) => {\r\n // 되감기 구간에는 이미 받은 메시지가 정상적으로 섞여 들어온다. dedup 에 걸려 화면에\r\n // 나가지 않은 건 복구 건수에서 빼야 syncCompleted 지표가 \"실제로 새로 들어온 수\" 가 된다.\r\n const handled = this._handleChatMessage(roomId, {\r\n ...message,\r\n eventType: message.eventType || 'MESSAGE_CREATED'\r\n });\r\n if (handled) emitted += 1;\r\n });\r\n } finally {\r\n this._replaying = false;\r\n }\r\n return emitted;\r\n }\r\n\r\n /**\r\n * 갭이 상한을 넘었을 때의 폴백 — 최신 페이지만 다시 싣는다.\r\n *\r\n * <p>{@code getRoom}/{@code enterRoom} 은 서버 {@code markAllAsRead} 를 트리거하므로 절대 쓰지 않는다.\r\n * 백그라운드 방의 누적 unread 가 통째로 지워진다. 읽음 부작용이 없는 {@code /list} 만 사용한다.</p>\r\n *\r\n * @private\r\n */\r\n async _reloadLatestPage(roomId, genAtStart) {\r\n const response = await this.getMessages(roomId, { size: this.syncPageSize });\r\n const page = this._unwrapSuccessResponse(response) || {};\r\n const content = Array.isArray(page.content) ? page.content : [];\r\n\r\n // /list 는 최신순(DESC) — 도착 순서대로 replay 하려면 뒤집는다.\r\n const ascending = content.slice().reverse();\r\n // 폴백 페이지에도 이미 본 메시지가 섞이므로, 지표는 조회 건수가 아니라 실제 emit 수여야 한다.\r\n const emitted = this._replayMessages(roomId, ascending);\r\n\r\n const newest = ascending.length ? ascending[ascending.length - 1].id : null;\r\n this._commitAnchorFromSync(roomId, newest, genAtStart);\r\n return emitted;\r\n }\r\n\r\n /** @private */\r\n _isCursorExpired(error) {\r\n return error?.status === 410 || error?.response?.code === 'SYNC_CURSOR_EXPIRED';\r\n }\r\n\r\n /**\r\n * 방 하나의 갭 복구 (RECONNECT_SYNC_DESIGN §6.3).\r\n *\r\n * <p><b>fetch 와 replay 를 분리</b>한다. 페이지마다 즉시 emit 하면 상한 초과를 알게 된 시점에 이미\r\n * 일부를 내보낸 뒤라 \"부분 replay 없음\" 이 성립하지 않는다.</p>\r\n *\r\n * @private\r\n * @returns {Promise<Object|null>} 복구 결과. 대상이 아니면 null.\r\n */\r\n async _syncRoom(roomId, anchor = this._anchor.get(roomId), quiet = false) {\r\n // 이 세션에서 아직 받은 메시지가 없는 방 — enterRoom 의 초기 로드가 담당한다.\r\n if (!anchor) return null;\r\n if (this._syncInFlight.has(roomId)) return null;\r\n\r\n this._syncInFlight.add(roomId);\r\n const genAtStart = this._anchorGen.get(roomId) || 0;\r\n const startedAt = Date.now();\r\n\r\n try {\r\n // ── Phase 1: fetch only (emit 없음) ──\r\n const buffer = [];\r\n let cursor = anchor;\r\n // 되감기는 워터마크 기준 첫 페이지에서 1회만 의미가 있다. 매 페이지 되감으면 같은 구간을 반복 조회한다.\r\n let rewindMillis = this.rewindMillis;\r\n let hasMore = true;\r\n let pages = 0;\r\n\r\n while (hasMore && pages < this.maxSyncPages) {\r\n const response = await this.syncMessages(roomId, { afterId: cursor, rewindMillis });\r\n const page = this._unwrapSuccessResponse(response) || {};\r\n const content = Array.isArray(page.content) ? page.content : [];\r\n\r\n buffer.push(...content);\r\n rewindMillis = 0;\r\n hasMore = !!page.hasNext;\r\n if (page.lastId) cursor = page.lastId;\r\n pages += 1;\r\n }\r\n\r\n // ── Phase 2: 판정 후 replay ──\r\n if (hasMore) {\r\n // 상한 초과 — 버퍼를 통째로 버리고 최신 페이지로 전환한다(부분 replay 금지).\r\n this.logger.info(`Gap too large, falling back to latest page: room=${roomId}`);\r\n if (!quiet) this.emit('gapTruncated', { roomId, fetched: buffer.length });\r\n const reloaded = await this._reloadLatestPage(roomId, genAtStart);\r\n return {\r\n roomId, cursor: this._anchor.get(roomId), recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt\r\n };\r\n }\r\n\r\n const recovered = this._replayMessages(roomId, buffer);\r\n this._commitAnchorFromSync(roomId, cursor, genAtStart);\r\n return { roomId, cursor, recovered, truncated: false, durationMs: Date.now() - startedAt };\r\n\r\n } catch (error) {\r\n if (this._isCursorExpired(error)) {\r\n // retention 정리 등으로 앵커가 사라짐 — 최신 페이지로 복구한다.\r\n this.logger.warn(`Sync cursor expired, reloading latest: room=${roomId}`);\r\n if (!quiet) this.emit('gapTruncated', { roomId, fetched: 0, reason: 'cursorExpired' });\r\n const reloaded = await this._reloadLatestPage(roomId, genAtStart);\r\n return {\r\n roomId, cursor: this._anchor.get(roomId), recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt\r\n };\r\n }\r\n throw error;\r\n } finally {\r\n this._syncInFlight.delete(roomId);\r\n }\r\n }\r\n\r\n /**\r\n * 구독 중인 모든 방의 갭 복구 + 방 목록 갱신.\r\n *\r\n * <p>재연결 성공({@code reconnected}) 과 복귀 감지({@code resumed}) 양쪽에서 호출된다. 두 이벤트는\r\n * 상호 배타적이라 중복 실행되지 않는다.</p>\r\n *\r\n * @param {string} [reason]\r\n * @returns {Promise<Map<string, string>|undefined>} 1차 조회 cursor — 내부 보충 조회용.\r\n */\r\n async syncAfterReconnect(reason = 'manual') {\r\n if (!this.syncOnReconnect) return;\r\n\r\n const roomIds = Array.from(this.subscribedRooms.keys()).filter((id) => this._anchor.has(id));\r\n if (roomIds.length === 0) {\r\n await this._refreshRoomList(reason);\r\n return;\r\n }\r\n\r\n this.emit('syncStarted', { roomIds, reason });\r\n const startedAt = Date.now();\r\n const rooms = [];\r\n const cursors = new Map();\r\n\r\n for (const roomId of roomIds) {\r\n try {\r\n const result = await this._syncRoom(roomId);\r\n if (result) {\r\n rooms.push(result);\r\n cursors.set(roomId, result.cursor);\r\n }\r\n } catch (error) {\r\n // 한 방의 실패가 나머지 방 복구를 막지 않게 한다.\r\n this.logger.warn(`Gap sync failed: room=${roomId}`, error);\r\n }\r\n }\r\n\r\n await this._refreshRoomList(reason);\r\n\r\n this.emit('syncCompleted', {\r\n rooms,\r\n totalRecovered: rooms.reduce((sum, r) => sum + (r.recovered || 0), 0),\r\n durationMs: Date.now() - startedAt,\r\n reason\r\n });\r\n return cursors;\r\n }\r\n\r\n /** 첫 조회가 끝난 cursor부터 조용히 보충한다. 목록·동기화 이벤트는 첫 조회의 책임이다. */\r\n async _supplementAfterReconnect(cursors, generation) {\r\n for (const [roomId, cursor] of cursors) {\r\n if (generation !== this._reconnectSyncGeneration) return;\r\n if (!this.subscribedRooms.has(roomId)) continue;\r\n try { await this._syncRoom(roomId, cursor, true); }\r\n catch (error) { this.logger.warn(`Supplemental gap sync failed: room=${roomId}`, error); }\r\n }\r\n }\r\n\r\n /**\r\n * 재연결 시 방 목록/unread 1회 갱신.\r\n *\r\n * <p>기존 {@code roomListUpdate} 로 흘리지 않는다 — 그건 단건 delta 계약이라 전체 스냅샷을 실으면\r\n * 기존 구독자가 형태가 다른 payload 를 delta 로 오해한다. 별도 {@code roomListSynced} 로 분리.</p>\r\n *\r\n * @private\r\n */\r\n async _refreshRoomList(reason) {\r\n if (!this.roomListSubscribed) return;\r\n try {\r\n const response = await this.getRooms({ size: 50 });\r\n const page = this._unwrapSuccessResponse(response) || {};\r\n this.emit('roomListSynced', { rooms: page.content || [], reason });\r\n } catch (error) {\r\n this.logger.warn('Room list refresh failed (non-fatal):', error);\r\n }\r\n }\r\n\r\n /**\r\n * 링크 미리보기 조회.\r\n *\r\n * <p>서버가 OG / Twitter Card / HTML fallback 을 파싱해 미리보기 카드를 반환합니다.\r\n * 외부 사이트 차단 등으로 HTML 을 못 가져오면 공개 DNS 로 확인되는 URL 에 한해\r\n * 도메인 + favicon 기반 최소 카드를 반환할 수 있습니다.\r\n * 이 경우 {@code previewSource} 는 {@code FAVICON_FALLBACK} 입니다.\r\n * 미리보기를 만들 수 없는 URL 이면 {@code null} 을 반환합니다.</p>\r\n *\r\n * <p>민감한 URL 이 query string 으로 남지 않도록 POST body 로 호출합니다.</p>\r\n *\r\n * @param {string} url - 미리보기를 조회할 URL\r\n * @returns {Promise<Object|null>} LinkPreview 또는 null\r\n */\r\n async fetchLinkPreview(url) {\r\n const normalizedUrl = typeof url === 'string' ? url.trim() : '';\r\n if (!normalizedUrl) {\r\n throw new Error('url is required');\r\n }\r\n\r\n const response = await this.apiClient.post('/api/v1/link-preview', {\r\n url: normalizedUrl\r\n });\r\n\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 메시지 전송 (REST API).\r\n *\r\n * <p>멱등 POST — SDK 가 내부에서 UUID 를 자동 생성하며, 호출자는 {@code messageId} 를\r\n * 지정할 수 없다. 같은 요청의 네트워크 재시도(SDK 내부 구현 시) 에도 같은 UUID 가 쓰이도록\r\n * 설계된다. 서버 Unique 제약 + pre-check 로 중복 저장이 차단되며, 재시도 히트 시\r\n * {@code duplicate: true} 로 기존 결과가 그대로 반환된다.</p>\r\n *\r\n * <p><b>전송 1회 = 메시지 1건</b>: 첨부가 몇 개든 한 메시지로 저장되며 응답 {@code messages} 는 길이 1이다.\r\n * {@code chatMessageType} 은 렌더러 힌트로, 첨부가 동질이면 그 카테고리, 혼재면 {@code MIXED} 다.</p>\r\n *\r\n * <p>답글: {@code data.replyToMessageId} 에 원본 메시지의 서버 {@code messageId} 를 지정.\r\n * 서버가 원본을 조회해서 snapshot 을 생성 후 저장하며, 응답/수신 메시지의 {@code replyTo}\r\n * 필드에 원본 요약이 포함됨. 원본이 존재하지 않으면 서버가 400 (REPLY_TARGET_NOT_FOUND) 반환.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} data\r\n * @param {string} [data.message] - 메시지 내용 (message 또는 fileInfos 중 하나 필수)\r\n * @param {Object[]} [data.fileInfos] - 파일 메타데이터 배열. 각 원소는 <b>UUID {@code fileId} 필수</b>\r\n * ({@link #uploadFile} 반환값은 이미 포함). 외부 스토리지 직접 업로드 연동은 직접 채워야 한다.\r\n * @param {string} [data.replyToMessageId] - 답글 대상 원본 메시지 ID (옵션)\r\n * @param {Object} [data.decisionResponse] - 의사결정 카드의 선택 — {@code { optionId }} (옵션).\r\n * 카드 버튼을 누르면 <b>평범한 답글</b>로 보낸다: {@code replyToMessageId} = 카드의 messageId,\r\n * {@code message} = 고른 선택지의 {@code label}, 여기에 {@code optionId}. 전용 endpoint 는 없다.\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n * - {@code duplicate}: 멱등 재시도로 기존 결과를 반환한 경우 {@code true}\r\n * - {@code messages}: 저장된 메시지 배열. {@code messageId} 는 서버가 부여한 ID.\r\n *\r\n * @example\r\n * // 일반 메시지\r\n * const { messages } = await chat.sendMessage('room-id', { message: '안녕하세요' });\r\n * console.log(messages[0].messageId); // 서버가 생성한 ID — 답글/삭제/리액션에 사용\r\n *\r\n * @example\r\n * // 답글\r\n * await chat.sendMessage('room-id', {\r\n * message: '네 동감이에요',\r\n * replyToMessageId: 'original-msg-id'\r\n * });\r\n *\r\n * @example\r\n * // 의사결정 카드의 선택 — 답글 하나로 나간다 (새 API 없음)\r\n * const option = card.decisionRequest.options[0];\r\n * await chat.sendMessage('room-id', {\r\n * message: option.label, // 폴백 가독성 — 카드를 못 그리는 화면에서도 대화가 읽힌다\r\n * replyToMessageId: card.messageId,\r\n * decisionResponse: { optionId: option.id }\r\n * });\r\n *\r\n * @example\r\n * // 첨부 3장 + 캡션 — 메시지 1건\r\n * const { messages } = await chat.sendMessage('room-id', {\r\n * message: '사진 보냅니다',\r\n * fileInfos: [f0, f1, f2] // uploadFile 결과 (fileId 포함)\r\n * });\r\n * // messages.length === 1 · messages[0].chatMessageType === 'IMAGE'\r\n * // messages[0].fileMetaDataList.length === 3\r\n */\r\n async sendMessage(roomId, data) {\r\n const messageId = this._generateMessageId();\r\n return this._sendMessageWithId(roomId, messageId, data);\r\n }\r\n\r\n /**\r\n * 메시지 전송 — Optimistic UI 버전.\r\n *\r\n * <p>{@link #sendMessage} 와 동일하게 서버에 메시지를 전송하지만, 호출 즉시\r\n * SDK 가 생성한 {@code baseMessageId} 와 응답 {@code messages} 배열과 1-1 매칭되는\r\n * 예측 {@code messageIds} 배열을 동기 반환한다. 호출자는 promise 가 resolve 되기 전에\r\n * 미리 placeholder 메시지를 UI 에 그려둘 수 있고, resolve 후 같은 ID 로 안전하게 교체할 수 있다.</p>\r\n *\r\n * <p>전송 1회 = 메시지 1건이므로 {@code messageIds} 는 <b>항상 {@code [baseMessageId]} 하나</b>다.\r\n * 서버 분류 규칙을 mirror 해 여러 ID 를 예측하던 로직은 제거됐다.</p>\r\n *\r\n * <p><b>전환 기간 주의</b>: 서버가 아직 병합 저장을 켜지 않았다면 한 요청이 여러 row 로 저장되어\r\n * {@code optimistic.predictionMatched} 가 {@code false} 가 된다. 오류가 아니라 전환 기간의 정상\r\n * 상태이며, 이때는 응답의 {@code actualMessageIds} 로 재구성해야 한다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} data - {@link #sendMessage} 와 동일한 페이로드\r\n * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}\r\n *\r\n * @example\r\n * const { messageIds, promise } = chat.sendMessageOptimistic('room-id', {\r\n * message: '안녕하세요'\r\n * });\r\n * messageIds.forEach(id => appendMessage({ messageId: id, status: 'sending' }));\r\n * try {\r\n * const { messages } = await promise;\r\n * messages.forEach(m => replaceMessage(m.messageId, m));\r\n * } catch (e) {\r\n * messageIds.forEach(id => markMessageFailed(id, e));\r\n * }\r\n */\r\n sendMessageOptimistic(roomId, data) {\r\n const baseMessageId = this._generateMessageId();\r\n // 전송 1회 = 메시지 1건 — 예측할 것이 없다. 서버 분류 규칙을 mirror 하던 예측기는 제거됐다.\r\n const messageIds = [baseMessageId];\r\n\r\n const promise = this._sendMessageWithId(roomId, baseMessageId, data)\r\n .then((result) => this._attachOptimisticMetadata(messageIds, result));\r\n\r\n return { baseMessageId, messageIds, promise };\r\n }\r\n\r\n /**\r\n * messageId 를 외부에서 주입받아 실제 send API 를 호출하는 내부 메서드.\r\n * <p>{@link #sendMessage} 와 {@link #sendMessageOptimistic} 가 공유하는 단일 코어 — base messageId 를\r\n * 양쪽이 같은 값으로 사용해야 optimistic 핸들의 예측 ID 가 서버 저장 ID 와 일치한다.</p>\r\n *\r\n * @private\r\n * @param {string} roomId\r\n * @param {string} messageId - SDK 가 생성한 base messageId (UUID)\r\n * @param {Object} data\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n */\r\n async _sendMessageWithId(roomId, messageId, data) {\r\n // 메시지 전송 시 타이핑 표시 자동 중단\r\n this.stopTyping(roomId);\r\n\r\n // separateFiles 는 더 이상 보내지 않는다 — 전송 1회 = 메시지 1건이 계약이다.\r\n // 서버는 이 필드를 병합 저장 플래그가 OFF 인 동안에만(legacy 재현용) 읽으며, 값이 없으면\r\n // 기본 true 로 동작한다. 즉 OFF 서버에서는 여전히 N건이 저장되고, 그 불일치는\r\n // optimistic 의 predictionMatched:false 로 드러난다(설계 §5).\r\n const response = await this.apiClient.post(`/api/v1/chat-messages/${roomId}/send`, {\r\n messageId,\r\n message: data.message,\r\n fileInfos: data.fileInfos,\r\n replyToMessageId: data.replyToMessageId,\r\n // 의사결정 카드의 선택. 🔴 이 줄이 없으면 호출자가 넘긴 값이 <b>조용히 유실</b>된다 —\r\n // 이 payload 는 화이트리스트라 data 에 있어도 전달되지 않는다.\r\n decisionResponse: data.decisionResponse\r\n });\r\n\r\n const result = this._unwrapSuccessResponse(response);\r\n if (result && Array.isArray(result.messages)) {\r\n result.messages.forEach((m) => this._normalizeMessageTimestamps(m));\r\n }\r\n return result;\r\n }\r\n\r\n /**\r\n * 텍스트 메시지 전송 (간편 버전).\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} message - 메시지 내용\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n *\r\n * @example\r\n * const { messages } = await chat.sendTextMessage('room-id', '안녕하세요');\r\n * console.log(messages[0].messageId);\r\n */\r\n async sendTextMessage(roomId, message) {\r\n return this.sendMessage(roomId, { message });\r\n }\r\n\r\n /**\r\n * 텍스트 메시지 전송 — Optimistic UI 버전.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} message - 메시지 내용\r\n * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}\r\n *\r\n * @example\r\n * const { baseMessageId, promise } = chat.sendTextMessageOptimistic('room-id', '안녕');\r\n * appendMessage({ messageId: baseMessageId, content: '안녕', status: 'sending' });\r\n * const { messages } = await promise;\r\n * replaceMessage(baseMessageId, messages[0]);\r\n */\r\n sendTextMessageOptimistic(roomId, message) {\r\n return this.sendMessageOptimistic(roomId, { message });\r\n }\r\n\r\n /**\r\n * 채팅 첨부 파일 업로드 (Low-level).\r\n *\r\n * <p>파일 바이트를 서버로 업로드하고 서버가 생성한 {@code FileMetaData} 를 반환.\r\n * 반환값은 {@link #sendMessage} 의 {@code fileInfos} 에 그대로 넣어 전송.</p>\r\n *\r\n * <p>업로드와 메시지 전송을 분리 호출해야 하는 케이스 (업로드 후 프리뷰 표시 →\r\n * 사용자가 전송 버튼 클릭 시 메시지 전송 등) 에 사용. 한 번에 처리하려면\r\n * {@link #sendFileMessage} 가 편리.</p>\r\n *\r\n * @param {string} roomId - 업로드 대상 채팅방 ID (권한 검증 + S3 namespace)\r\n * @param {File|Blob} file - 업로드할 파일\r\n * @param {Object} [options]\r\n * @param {Function} [options.onProgress] - {@code ({loaded, total, percent}) => void} — 진행률 콜백\r\n * @param {AbortSignal} [options.signal] - 업로드 취소용 AbortSignal\r\n * @returns {Promise<Object>} FileMetaData (fileId/fileUrl/fileName/fileType/fileSize/imageInfo/videoInfo/audioInfo/documentInfo)\r\n *\r\n * @example\r\n * const meta = await chat.uploadFile('room-id', fileInput.files[0], {\r\n * onProgress: ({ percent }) => console.log(`${percent}%`)\r\n * });\r\n * await chat.sendMessage('room-id', {\r\n * fileInfos: [meta],\r\n * message: '첨부합니다'\r\n * });\r\n */\r\n async uploadFile(roomId, file, options = {}) {\r\n if (!roomId) {\r\n throw new Error('roomId is required');\r\n }\r\n if (!file) {\r\n throw new Error('file is required');\r\n }\r\n\r\n const response = await this.apiClient.upload(\r\n `/api/v1/files/${roomId}/upload`,\r\n file,\r\n {\r\n onProgress: options.onProgress,\r\n signal: options.signal\r\n }\r\n );\r\n\r\n // 서버 SuccessResponse 래퍼 안의 data 가 FileMetaData\r\n return this._unwrapSuccessResponse(response);\r\n }\r\n\r\n /**\r\n * 파일 업로드 + 메시지 전송 통합 (High-level).\r\n *\r\n * <p>내부적으로:</p>\r\n * <ol>\r\n * <li>각 파일을 {@link #uploadFile} 로 업로드 (배열이면 병렬)</li>\r\n * <li>수신된 {@code FileMetaData} 들을 모아 {@link #sendMessage} 로 전송</li>\r\n * </ol>\r\n *\r\n * <p>업로드 중 하나라도 실패하면 전체 rejected — 이미 업로드된 S3 객체는\r\n * 고아로 남으며 라이프사이클 정책으로 정리됨 (설계 결정).</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {File|Blob|Array<File|Blob>} files - 단일 파일 또는 파일 배열\r\n * @param {Object} [options]\r\n * @param {string} [options.message] - 함께 보낼 텍스트 본문\r\n * @param {string} [options.replyToMessageId] - 답글 대상 원본 메시지의 서버 {@code messageId}\r\n * @param {Function} [options.onUploadProgress] - {@code ({loaded,total,percent,fileIndex}) => void} — 파일별 진행률\r\n * @param {AbortSignal} [options.signal] - 취소 시그널 — 업로드 중에만 유효\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n *\r\n * @example\r\n * // 단일 파일\r\n * await chat.sendFileMessage('room-id', file, {\r\n * message: '첨부',\r\n * onUploadProgress: ({ percent }) => console.log(`${percent}%`)\r\n * });\r\n *\r\n * @example\r\n * // 다중 파일 (병렬 업로드)\r\n * await chat.sendFileMessage('room-id', [file1, file2, file3], {\r\n * message: '여러 파일',\r\n * onUploadProgress: ({ fileIndex, percent }) =>\r\n * console.log(`파일 ${fileIndex}: ${percent}%`)\r\n * });\r\n */\r\n async sendFileMessage(roomId, files, options = {}) {\r\n const fileArray = this._normalizeFileArray(files);\r\n const metas = await this._uploadFiles(roomId, fileArray, options);\r\n const fileInfos = this._applyFileMetadata(metas, options.metadata);\r\n\r\n return this.sendMessage(roomId, {\r\n message: options.message,\r\n fileInfos,\r\n replyToMessageId: options.replyToMessageId\r\n });\r\n }\r\n\r\n /**\r\n * 파일 업로드 + 메시지 전송 — Optimistic UI 버전.\r\n *\r\n * <p>호출 즉시 {@code baseMessageId} 와 응답 {@code messages} 와 1-1 매칭되는 예측\r\n * {@code messageIds} 를 동기 반환한다. 호출자는 업로드 시작 전에 placeholder 메시지를\r\n * UI 에 그릴 수 있다.</p>\r\n *\r\n * <p><b>입력 검증은 동기 throw</b>: 빈 배열 / max 초과 등 {@link #_normalizeFileArray} 의\r\n * 검증 실패는 promise 가 만들어지기 전에 즉시 throw 되므로, 잘못된 입력에 대해 placeholder 가\r\n * 먼저 그려지는 일은 없다.</p>\r\n *\r\n * <p><b>업로드 실패 처리</b>: 업로드 단계에서 실패하면 promise 가 reject 된다. 호출자는 UI 의\r\n * placeholder 들을 모두 실패 상태로 표시하고, S3 에 부분 업로드된 객체는 라이프사이클 정책으로 정리된다.</p>\r\n *\r\n * @param {string} roomId\r\n * @param {File|Blob|Array<File|Blob>} files\r\n * @param {Object} [options] - {@link #sendFileMessage} 와 동일\r\n * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}\r\n *\r\n * @example\r\n * const { messageIds, promise } = chat.sendFileMessageOptimistic('room-id', files, {\r\n * message: '사진 보냅니다'\r\n * });\r\n * messageIds.forEach((id, i) => appendMessage({\r\n * messageId: id,\r\n * localFile: files[i] || null,\r\n * status: 'uploading'\r\n * }));\r\n * try {\r\n * const { messages } = await promise;\r\n * messages.forEach(m => replaceMessage(m.messageId, m));\r\n * } catch (e) {\r\n * messageIds.forEach(id => markMessageFailed(id, e));\r\n * }\r\n */\r\n sendFileMessageOptimistic(roomId, files, options = {}) {\r\n // validation 은 동기 — placeholder 가 그려지기 전에 throw 되어야 함\r\n const fileArray = this._normalizeFileArray(files);\r\n\r\n const baseMessageId = this._generateMessageId();\r\n const messageIds = [baseMessageId]; // 전송 1회 = 메시지 1건\r\n\r\n const promise = (async () => {\r\n const metas = await this._uploadFiles(roomId, fileArray, options);\r\n const fileInfos = this._applyFileMetadata(metas, options.metadata);\r\n const result = await this._sendMessageWithId(roomId, baseMessageId, {\r\n message: options.message,\r\n fileInfos,\r\n replyToMessageId: options.replyToMessageId\r\n });\r\n return this._attachOptimisticMetadata(messageIds, result);\r\n })();\r\n\r\n return { baseMessageId, messageIds, promise };\r\n }\r\n\r\n /**\r\n * 파일 입력 정규화 — 단일 파일 / 배열 양쪽 허용 + 개수 검증.\r\n * <p>{@link #sendFileMessage} 와 {@link #sendFileMessageOptimistic} 가 공유.</p>\r\n *\r\n * @private\r\n * @param {File|Blob|Array<File|Blob>} files\r\n * @returns {Array<File|Blob>}\r\n * @throws {Error} 빈 배열 또는 MAX_FILES_PER_MESSAGE 초과\r\n */\r\n _normalizeFileArray(files) {\r\n const fileArray = Array.isArray(files) ? files : [files];\r\n if (fileArray.length === 0) {\r\n throw new Error('At least one file is required');\r\n }\r\n if (fileArray.length > MAX_FILES_PER_MESSAGE) {\r\n throw new Error(`A maximum of ${MAX_FILES_PER_MESSAGE} files can be sent in one message`);\r\n }\r\n return fileArray;\r\n }\r\n\r\n /**\r\n * 파일 배열 병렬 업로드 — 각 파일의 진행률은 fileIndex 로 구분.\r\n * <p>{@link #sendFileMessage} 와 {@link #sendFileMessageOptimistic} 가 공유.</p>\r\n *\r\n * @private\r\n * @param {string} roomId\r\n * @param {Array<File|Blob>} fileArray\r\n * @param {Object} options - signal, onUploadProgress 사용\r\n * @returns {Promise<Object[]>} FileMetaData 배열\r\n */\r\n async _uploadFiles(roomId, fileArray, options = {}) {\r\n return Promise.all(\r\n fileArray.map((file, index) =>\r\n this.uploadFile(roomId, file, {\r\n signal: options.signal,\r\n onProgress: options.onUploadProgress\r\n ? (p) => options.onUploadProgress({ ...p, fileIndex: index })\r\n : undefined\r\n })\r\n )\r\n );\r\n }\r\n\r\n /**\r\n * 업로드된 FileMetaData 배열에 고객사 pass-through metadata 를 병합.\r\n *\r\n * <p>{@link #sendFileMessage} / {@link #sendFileMessageOptimistic} 가 공유.\r\n * 호출자가 {@code options.metadata} 를 안 주면 metas 가 그대로 반환되어 기존 동작과 100% 호환.</p>\r\n *\r\n * @private\r\n * @param {Object[]} metas - uploadFile 결과의 FileMetaData 배열\r\n * @param {Object|Array<Object|null|undefined>|null|undefined} metadata - 단일 객체(broadcast) 또는 index 별 배열\r\n * @returns {Object[]} metadata 가 병합된 새 배열 (기존 meta 객체는 수정하지 않음)\r\n */\r\n _applyFileMetadata(metas, metadata) {\r\n if (metadata === undefined || metadata === null) {\r\n return metas;\r\n }\r\n\r\n return metas.map((meta, index) => {\r\n const itemMetadata = Array.isArray(metadata)\r\n ? metadata[index]\r\n : metadata;\r\n\r\n if (itemMetadata === undefined || itemMetadata === null) {\r\n return meta;\r\n }\r\n\r\n return {\r\n ...meta,\r\n metadata: itemMetadata\r\n };\r\n });\r\n }\r\n\r\n /**\r\n * 답글 전송 (간편 버전) — 텍스트 답글 전용.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} message - 답글 내용\r\n * @param {string} replyToMessageId - 원본 메시지의 서버 {@code messageId}\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n *\r\n * @example\r\n * // \"안녕하세요\" 메시지에 답장\r\n * await chat.sendReply('room-id', '네 반가워요', 'original-msg-id');\r\n */\r\n async sendReply(roomId, message, replyToMessageId) {\r\n return this.sendMessage(roomId, { message, replyToMessageId });\r\n }\r\n\r\n /**\r\n * 답글 전송 — Optimistic UI 버전.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} message - 답글 내용\r\n * @param {string} replyToMessageId - 원본 메시지의 서버 {@code messageId}\r\n * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}\r\n */\r\n sendReplyOptimistic(roomId, message, replyToMessageId) {\r\n return this.sendMessageOptimistic(roomId, { message, replyToMessageId });\r\n }\r\n\r\n /**\r\n * 의사결정 카드의 선택 전송 (간편 버전).\r\n *\r\n * <p>세 가지를 <b>한 번에 맞춰</b> 보낸다 — 셋 중 하나만 빠져도 계약이 깨지므로 직접 조립하기보다\r\n * 이 메서드를 쓰는 편이 안전하다:</p>\r\n * <ul>\r\n * <li>{@code replyToMessageId} = 카드 messageId — 어느 질문에 답한 것인지</li>\r\n * <li>{@code message} = 고른 선택지의 {@code label} — <b>가독 폴백</b>. 빈 본문이면 대화에\r\n * 빈 칸이 남아 나중에 읽을 때 무엇을 골랐는지 알 수 없다</li>\r\n * <li>{@code decisionResponse.optionId} — 서버가 보는 <b>권위</b> 값</li>\r\n * </ul>\r\n *\r\n * <p>전용 endpoint 가 아니라 <b>평범한 답글</b>이다. 그래서 선택이 대화에 메시지로 남고, 카드를\r\n * 못 그리는 화면에서도 흐름이 읽힌다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} card - 카드 메시지 ({@code decisionRequest} 를 가진 그 메시지)\r\n * @param {Object} option - 사용자가 고른 {@code card.decisionRequest.options[i]}\r\n * @returns {Promise<{duplicate: boolean, messages: Object[]}>}\r\n *\r\n * @example\r\n * const card = messages.find(m => m.decisionRequest);\r\n * await chat.sendDecisionChoice('room-id', card, card.decisionRequest.options[1]);\r\n */\r\n async sendDecisionChoice(roomId, card, option) {\r\n if (!card || !card.messageId) {\r\n throw new Error('sendDecisionChoice: card with messageId is required');\r\n }\r\n if (!option || !option.id) {\r\n throw new Error('sendDecisionChoice: option with id is required');\r\n }\r\n return this.sendMessage(roomId, {\r\n message: option.label,\r\n replyToMessageId: card.messageId,\r\n decisionResponse: { optionId: option.id }\r\n });\r\n }\r\n\r\n /**\r\n * 메시지 삭제.\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 메시지 ID\r\n * @param {string} [deleteType='ALL'] - 삭제 유형.\r\n * {@code 'ALL'} — 모두에게 삭제 (양쪽 \"삭제된 메시지입니다\" 표시).\r\n * {@code 'SELF'} — 나만 삭제 (내 화면에서만 숨김, 상대방은 원본 유지).\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * // 모두에게 삭제\r\n * await chat.deleteMessage('room-id', 'msg-id');\r\n *\r\n * @example\r\n * // 나만 삭제\r\n * await chat.deleteMessage('room-id', 'msg-id', 'SELF');\r\n */\r\n async deleteMessage(roomId, messageId, deleteType = 'ALL') {\r\n return this.apiClient.post(`/api/v1/chat-messages/${roomId}/messages/${messageId}/delete`, {\r\n deleteType\r\n });\r\n }\r\n\r\n /**\r\n * 메시지 수정.\r\n *\r\n * <p>본인 메시지만 수정 가능. 수정 후 '수정됨' 라벨이 표시됩니다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 메시지 ID\r\n * @param {string} message - 수정할 내용 (max 20000자)\r\n * @returns {Promise<Object>}\r\n *\r\n * @example\r\n * await chat.editMessage('room-id', 'msg-id', '수정된 내용입니다');\r\n */\r\n async editMessage(roomId, messageId, message) {\r\n return this.apiClient.put(`/api/v1/chat-messages/${roomId}/messages/${messageId}`, {\r\n message\r\n });\r\n }\r\n\r\n /**\r\n * 서버의 {@code SendMessageService.hasTextMessage} mirror — null/공백 문자열을 모두 \"텍스트 없음\" 으로 취급.\r\n * <p>서버 기준 ({@code message != null && !message.isBlank()}) 과 동일해야 messageIds prediction 이 일치한다.</p>\r\n *\r\n * @private\r\n * @param {string|null|undefined} message\r\n * @returns {boolean}\r\n */\r\n _hasTextMessage(message) {\r\n return typeof message === 'string' && message.trim().length > 0;\r\n }\r\n\r\n /**\r\n * Content-Type 의 base MIME — {@code ;} 이후 파라미터(charset 등) 제거 + trim + 소문자.\r\n * <p>서버 {@code ChatFileCategory.baseMimeType} mirror. 'text/plain; charset=utf-8' → 'text/plain'.\r\n * 'text/plain-invalid' 같은 변형은 그대로 남아 정확 비교에서 걸러진다.</p>\r\n *\r\n * @private\r\n * @param {string} mimeType\r\n * @returns {string}\r\n */\r\n _baseMimeType(mimeType) {\r\n const raw = mimeType || '';\r\n const sep = raw.indexOf(';');\r\n return (sep >= 0 ? raw.slice(0, sep) : raw).trim().toLowerCase();\r\n }\r\n\r\n /**\r\n * MIME 타입 → {@code ChatMessageType} 카테고리 (서버 {@code AttachmentTypeClassifier} mirror).\r\n *\r\n * <p><b>공개 API</b> — legacy {@code GALLERY} 메시지의 실제 카테고리를 복원하는 데 쓴다\r\n * ({@link #resolveMessageCategory}). 서버 규칙이 바뀌면 본 메서드도 동기화해야 한다.</p>\r\n *\r\n * @param {string} mimeType\r\n * @returns {'IMAGE'|'VIDEO'|'AUDIO'|'DOCUMENT'|'FILE'}\r\n */\r\n classifyFileType(mimeType) {\r\n const mime = this._baseMimeType(mimeType);\r\n if (mime.startsWith('image/')) return 'IMAGE';\r\n if (mime.startsWith('video/')) return 'VIDEO';\r\n if (mime.startsWith('audio/')) return 'AUDIO';\r\n // 평문(txt/md)도 DOCUMENT — 서버가 온디맨드 번역·PM 문서 인식 대상으로 삼는 기준과 동일해야 한다.\r\n // pre-2007 Office(.doc/.xls/.ppt)도 서버가 OLE 매직까지 검증해 DOCUMENT 로 받는다.\r\n if (mime.includes('pdf') || mime.includes('document')\r\n || mime.includes('msword') || mime.includes('ms-excel') || mime.includes('ms-powerpoint')\r\n || PLAIN_TEXT_DOCUMENT_MIME_TYPES.has(mime)) {\r\n return 'DOCUMENT';\r\n }\r\n return 'FILE';\r\n }\r\n\r\n /**\r\n * 하위호환 별칭 — {@link #classifyFileType} 를 쓸 것.\r\n * @private\r\n * @deprecated\r\n */\r\n _classifyFileType(mimeType) {\r\n return this.classifyFileType(mimeType);\r\n }\r\n\r\n /**\r\n * 메시지의 <b>렌더 카테고리</b>를 해석한다 — legacy {@code GALLERY} 복원용 공개 헬퍼.\r\n *\r\n * <p>서버는 이제 첨부가 동질이면 그 카테고리를, 혼재면 {@code MIXED} 를 보낸다. 그러나 이 변경\r\n * <b>이전에 저장된 메시지는 복수 첨부면 타입 무관 전부 {@code GALLERY}</b> 다 — 동영상 2개도, PDF 2개도\r\n * {@code GALLERY} 다. 따라서 <b>{@code GALLERY} 를 {@code IMAGE} 로 매핑하면 안 된다.</b>\r\n * 이 메서드가 {@code fileMetaDataList} 의 MIME 으로 실제 카테고리를 복원한다.</p>\r\n *\r\n * @param {Object} message - 서버 메시지 ({@code chatMessageType} + {@code fileMetaDataList})\r\n * @returns {string} 'TEXT'|'IMAGE'|'VIDEO'|'AUDIO'|'DOCUMENT'|'FILE'|'MIXED'|'SYSTEM'\r\n *\r\n * @example\r\n * // 렌더러 선택 — GALLERY 분기를 따로 두지 않아도 된다\r\n * switch (client.resolveMessageCategory(message)) {\r\n * case 'IMAGE': return <ImageGrid files={message.fileMetaDataList} />;\r\n * case 'VIDEO': return <VideoList files={message.fileMetaDataList} />;\r\n * case 'MIXED': return <MixedAttachmentList files={message.fileMetaDataList} />;\r\n * default: return <FileList files={message.fileMetaDataList} />;\r\n * }\r\n */\r\n resolveMessageCategory(message) {\r\n const type = message && message.chatMessageType;\r\n const files = (message && message.fileMetaDataList) || [];\r\n // GALLERY 만 복원 대상 — 나머지는 서버가 이미 정확한 카테고리를 보냈다.\r\n if (type !== 'GALLERY') {\r\n return type || 'TEXT';\r\n }\r\n if (files.length === 0) {\r\n return 'FILE';\r\n }\r\n const categories = new Set(files.map(f => this.classifyFileType((f && f.fileType) || '')));\r\n return categories.size === 1 ? categories.values().next().value : 'MIXED';\r\n }\r\n\r\n /**\r\n * 예측 messageIds 와 서버 응답을 비교하여 result 에 optimistic metadata 첨부.\r\n * <p>서버 분류 규칙 drift 를 런타임에 감지하기 위한 방어 로직. mismatch 발생 시\r\n * warn 로그 + 응답에 {@code optimistic.predictionMatched: false} 를 실어 호출자가\r\n * 프로그래밍적으로 fallback reconcile 을 결정할 수 있게 한다.</p>\r\n *\r\n * <p>길이 mismatch 와 같은 길이의 값/순서 mismatch 모두 동일하게 {@code predictionMatched: false}\r\n * 로 표시된다. 호출자는 README \"Drift 방어\" 섹션의 fallback 패턴을 사용하면 된다.</p>\r\n *\r\n * @private\r\n * @param {string[]} predictedMessageIds\r\n * @param {{duplicate: boolean, messages: Array<{messageId: string}>}} result\r\n * @returns {{duplicate: boolean, messages: Array, optimistic: {predictedMessageIds: string[], actualMessageIds: string[], predictionMatched: boolean}}}\r\n */\r\n _attachOptimisticMetadata(predictedMessageIds, result) {\r\n const actualMessageIds = (result && Array.isArray(result.messages))\r\n ? result.messages.map(m => (m && m.messageId) || null)\r\n : [];\r\n\r\n const predictionMatched =\r\n actualMessageIds.length === predictedMessageIds.length &&\r\n predictedMessageIds.every((id, i) => id === actualMessageIds[i]);\r\n\r\n if (!predictionMatched) {\r\n this.logger.warn('Optimistic messageId prediction mismatch — server may have changed grouping rules. Use result.optimistic.actualMessageIds to reconcile.', {\r\n predicted: predictedMessageIds,\r\n actual: actualMessageIds\r\n });\r\n }\r\n\r\n return {\r\n ...result,\r\n optimistic: {\r\n predictedMessageIds,\r\n actualMessageIds,\r\n predictionMatched\r\n }\r\n };\r\n }\r\n\r\n /**\r\n * 메시지 ID 생성 (UUID v4 형식)\r\n * @private\r\n * @returns {string}\r\n */\r\n _generateMessageId() {\r\n // crypto.randomUUID() 우선 사용 (높은 엔트로피), 미지원 환경 fallback\r\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\r\n return crypto.randomUUID();\r\n }\r\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\r\n const r = Math.random() * 16 | 0;\r\n const v = c === 'x' ? r : (r & 0x3 | 0x8);\r\n return v.toString(16);\r\n });\r\n }\r\n\r\n /**\r\n * 서버 SuccessResponse 래퍼가 있으면 data 만 꺼내고, 아니면 원본 응답을 그대로 반환합니다.\r\n * data 가 null 이어도 그대로 null 을 반환해야 하므로 nullish coalescing 을 쓰지 않습니다.\r\n *\r\n * @private\r\n * @param {*} response\r\n * @returns {*}\r\n */\r\n _unwrapSuccessResponse(response) {\r\n return response\r\n && typeof response === 'object'\r\n && Object.prototype.hasOwnProperty.call(response, 'data')\r\n ? response.data\r\n : response;\r\n }\r\n\r\n /**\r\n * REST 메시지 응답의 타임스탬프를 epoch millis(number)로 정규화합니다 (in-place).\r\n *\r\n * <p>서버의 두 전달 경로가 서로 다르게 직렬화한다 — WebSocket 이벤트는 epoch number,\r\n * REST(목록/전송 응답)는 ISO 문자열(타임존 없는 LocalDateTime). 선언된 타입\r\n * ({@code ChatMessage.sentAt: number})으로 통일해 소비자 분기를 제거한다.</p>\r\n *\r\n * <p>⚠️ 한계: 서버 ISO 문자열에 타임존이 없어 브라우저 로컬 타임존으로 해석된다 —\r\n * 서버(KST)와 사용자 타임존이 다르면 오프셋만큼 어긋날 수 있다. 근본 해소는\r\n * 백엔드 직렬화 통일(후속 결정) 영역.</p>\r\n *\r\n * @private\r\n * @param {Object} message - REST 응답의 메시지 객체 (수정됨)\r\n * @returns {Object} 같은 객체\r\n */\r\n _normalizeMessageTimestamps(message) {\r\n if (!message || typeof message !== 'object') {\r\n return message;\r\n }\r\n message.sentAt = this._toEpochMillis(message.sentAt);\r\n message.editedAt = this._toEpochMillis(message.editedAt);\r\n if (message.replyTo && typeof message.replyTo === 'object') {\r\n message.replyTo.sentAt = this._toEpochMillis(message.replyTo.sentAt);\r\n }\r\n return message;\r\n }\r\n\r\n /**\r\n * ISO 문자열이면 epoch millis 로 변환, number/null/undefined 는 그대로 통과.\r\n * 파싱 불가 문자열은 원본 유지 (조용한 데이터 손실 방지).\r\n *\r\n * @private\r\n * @param {*} value\r\n * @returns {*}\r\n */\r\n _toEpochMillis(value) {\r\n if (typeof value !== 'string') {\r\n return value;\r\n }\r\n const parsed = Date.parse(value);\r\n return Number.isNaN(parsed) ? value : parsed;\r\n }\r\n\r\n // ==================== WebSocket 구독 ====================\r\n\r\n /**\r\n * 채팅방 구독.\r\n *\r\n * <p>구독 시 메시지, 읽음, 타이핑, 멤버 변경 이벤트를 자동으로 수신.\r\n * 멤버 변경은 roomList 구독 이벤트를 내부 변환하여\r\n * {@code memberJoined} / {@code memberLeft} 로 emit.</p>\r\n *\r\n * <p>자동 읽음 처리는 {@link #setActiveRoom} 으로 현재 보고 있는 방을 설정해야 동작합니다.\r\n * 구독만으로는 읽음 처리되지 않으며, 다른 방을 보고 있으면 안읽음 카운트가 유지됩니다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Promise<void>}\r\n *\r\n * @fires memberJoined - { roomId, members: [{ userId, nickname, profileUrl, companyRole? }], participantCount, timestamp }\r\n * @fires memberLeft - { roomId, members: [{ userId, nickname, profileUrl, companyRole? }], participantCount, timestamp }\r\n */\r\n async subscribeRoom(roomId) {\r\n if (this.subscribedRooms.has(roomId)) {\r\n this.logger.warn(`Already subscribed to room: ${roomId}`);\r\n return;\r\n }\r\n\r\n // 메시지 구독\r\n const chatDestination = WebSocketPaths.getChatDestination(roomId);\r\n await this.connectionManager.subscribe(chatDestination, (message) => {\r\n this._handleChatMessage(roomId, message);\r\n });\r\n\r\n // 읽음 이벤트 구독\r\n const readDestination = WebSocketPaths.getChatReadDestination(roomId);\r\n await this.connectionManager.subscribe(readDestination, (message) => {\r\n this._handleReadEvent(roomId, message);\r\n });\r\n\r\n // 타이핑 이벤트 구독\r\n const typingDestination = WebSocketPaths.getChatTypingDestination(roomId);\r\n await this.connectionManager.subscribe(typingDestination, (event) => {\r\n this._handleTypingEvent(roomId, event);\r\n });\r\n\r\n // AI 진행 표시(생각중/검색중/작성중) 구독 — typing 과 별개 채널(가산 UX). 구버전 SDK 는 미구독 → 기존 typing 만.\r\n //\r\n // ⚠️ 보류함을 <b>구독보다 먼저</b> 연다(설계 §6.1 ①). 구독 호출과 보류 플래그 사이에 DELTA 가\r\n // 도착하면 그건 보류되지 않고 그대로 렌더돼, 스냅샷 병합 전에 잘린 본문이 화면에 먼저 그려진다.\r\n const snapshotGeneration = this.assistantStreamReplay ? this._armAssistantSnapshot(roomId) : null;\r\n const assistantStreamDestination = WebSocketPaths.getChatAssistantStreamDestination(roomId);\r\n await this.connectionManager.subscribe(assistantStreamDestination, (event) => {\r\n this._handleAssistantStreamEvent(roomId, event);\r\n });\r\n if (snapshotGeneration !== null) {\r\n // 구독 직후 스냅샷 — 응답을 기다리지 않는다. 구독은 이미 걸렸으므로 그 사이 이벤트는 보류함에 쌓이고,\r\n // 병합이 끝나면 순서대로 풀린다. 실패해도 보류분은 풀리므로 라이브만으로 계속 동작한다.\r\n this._loadAssistantSnapshot(roomId, snapshotGeneration)\r\n .catch((error) => this.logger.warn(`Assistant snapshot merge failed: room=${roomId}`, error));\r\n }\r\n\r\n // Rolling 누적 요약 완료 알림 구독 — refreshSummary 완료/실패를 summaryUpdate 이벤트로 통지(transient).\r\n const summaryDestination = WebSocketPaths.getChatSummaryDestination(roomId);\r\n await this.connectionManager.subscribe(summaryDestination, (event) => {\r\n this._handleSummaryEvent(roomId, event);\r\n });\r\n\r\n // 멤버 변경 이벤트 — roomList 이벤트를 방 수준으로 변환하여 자동 emit\r\n // 서버가 members 배열(입장/퇴장 사용자들) + participantCount(정확한 인원수) 포함\r\n const memberJoinedHandler = (event) => {\r\n if (event.roomId === roomId) {\r\n this.emit('memberJoined', {\r\n roomId,\r\n members: event.members || [],\r\n participantCount: event.activeParticipantCount,\r\n timestamp: event.timestamp\r\n });\r\n }\r\n };\r\n const memberLeftHandler = (event) => {\r\n if (event.roomId === roomId) {\r\n this.emit('memberLeft', {\r\n roomId,\r\n members: event.members || [],\r\n participantCount: event.activeParticipantCount,\r\n timestamp: event.timestamp\r\n });\r\n }\r\n };\r\n this.on('roomListJoined', memberJoinedHandler);\r\n this.on('roomListLeft', memberLeftHandler);\r\n\r\n this.subscribedRooms.set(roomId, {\r\n chatDestination,\r\n readDestination,\r\n typingDestination,\r\n assistantStreamDestination,\r\n summaryDestination,\r\n memberJoinedHandler,\r\n memberLeftHandler,\r\n subscribedAt: new Date()\r\n });\r\n\r\n this.logger.info(`Subscribed to room: ${roomId}`);\r\n this.emit('roomSubscribed', { roomId });\r\n }\r\n\r\n /**\r\n * 채팅방 구독 해제\r\n * @param {string} roomId - 채팅방 ID\r\n */\r\n unsubscribeRoom(roomId) {\r\n const subscription = this.subscribedRooms.get(roomId);\r\n if (!subscription) {\r\n this.logger.warn(`Not subscribed to room: ${roomId}`);\r\n return;\r\n }\r\n\r\n this.connectionManager.unsubscribe(subscription.chatDestination);\r\n this.connectionManager.unsubscribe(subscription.readDestination);\r\n if (subscription.typingDestination) {\r\n this.connectionManager.unsubscribe(subscription.typingDestination);\r\n }\r\n if (subscription.assistantStreamDestination) {\r\n this.connectionManager.unsubscribe(subscription.assistantStreamDestination);\r\n }\r\n if (subscription.summaryDestination) {\r\n this.connectionManager.unsubscribe(subscription.summaryDestination);\r\n }\r\n\r\n // 멤버 변경 리스너 해제\r\n if (subscription.memberJoinedHandler) {\r\n this.off('roomListJoined', subscription.memberJoinedHandler);\r\n }\r\n if (subscription.memberLeftHandler) {\r\n this.off('roomListLeft', subscription.memberLeftHandler);\r\n }\r\n\r\n // 타이핑 타이머 정리 (outgoing + incoming assistant 둘 다)\r\n this._clearTypingTimer(roomId);\r\n this._clearAssistantTypingTimer(roomId);\r\n // AI 진행 표시 상태/타이머/묘비 정리 + 세대 증가 (emit 없이 — 구독 해제 중)\r\n this._clearAssistantProgress(roomId);\r\n\r\n // 현재 보고 있는 방이면 activeRoom 해제\r\n if (this._activeRoomId === roomId) {\r\n this._activeRoomId = null;\r\n }\r\n\r\n this.subscribedRooms.delete(roomId);\r\n\r\n // 방별 dedup bucket 정리 — 장시간 세션에서 많은 방을 오갈 때 메모리 누수 차단.\r\n // 각 bucket 은 방당 최대 _maxSeenPerRoom(200) entries 라 개별은 작지만 방 수가 늘면 누적.\r\n this._seenChatMessageIdsByRoom.delete(roomId);\r\n this._seenRoomListMessageIdsByRoom.delete(roomId);\r\n\r\n this.logger.info(`Unsubscribed from room: ${roomId}`);\r\n this.emit('roomUnsubscribed', { roomId });\r\n }\r\n\r\n /**\r\n * 모든 채팅방 구독 해제\r\n */\r\n unsubscribeAllRooms() {\r\n this.subscribedRooms.forEach((_, roomId) => {\r\n this.unsubscribeRoom(roomId);\r\n });\r\n }\r\n\r\n /**\r\n * 현재 보고 있는 채팅방 설정.\r\n * 이 방에서 수신되는 다른 사용자의 메시지는 자동으로 읽음 처리됩니다.\r\n * 다른 방이나 리스트 화면으로 이동 시 {@link #clearActiveRoom} 호출.\r\n *\r\n * @param {string} roomId - 현재 보고 있는 채팅방 ID\r\n */\r\n setActiveRoom(roomId) {\r\n this._activeRoomId = roomId;\r\n this.logger.debug(`Active room set: ${roomId}`);\r\n }\r\n\r\n /**\r\n * 현재 보고 있는 채팅방 해제 (리스트 화면 등으로 이동 시).\r\n * 자동 읽음 처리가 중단됩니다.\r\n */\r\n clearActiveRoom() {\r\n this.logger.debug(`Active room cleared (was: ${this._activeRoomId})`);\r\n this._activeRoomId = null;\r\n }\r\n\r\n /**\r\n * 현재 보고 있는 채팅방 ID 반환.\r\n * @returns {string|null}\r\n */\r\n getActiveRoom() {\r\n return this._activeRoomId;\r\n }\r\n\r\n /**\r\n * 채팅방 리스트 실시간 업데이트 구독 (카톡 스타일).\r\n *\r\n * 리스트 화면에서 호출. 구독 후 다음 이벤트를 받을 수 있음:\r\n * - roomListUpdate — 모든 이벤트 (통합, eventType 으로 분기)\r\n * - roomListMessage — 새 메시지 수신 (MESSAGE_RECEIVED)\r\n * - roomListMessageDeleted — 현재 lastMessage 삭제 (MESSAGE_DELETED)\r\n * - roomListMessageUpdated — 현재 lastMessage 편집 (MESSAGE_UPDATED)\r\n * - roomListCreated — 새 방 생성 (ROOM_CREATED)\r\n * - roomListJoined — 방 입장 (ROOM_JOINED)\r\n * - roomListLeft — 방 퇴장 (ROOM_LEFT)\r\n * - roomListSelfLeft — 본인이 나간 경우 (리스트에서 제거 신호)\r\n * - roomListOwnerChanged — 방장 위임 (ROOM_OWNER_CHANGED, members[0] = 새 방장)\r\n * - roomListDeleted — 방장이 방을 삭제 (ROOM_DELETED, 리스트 제거 + 방 화면 닫기)\r\n *\r\n * @returns {Promise<void>}\r\n *\r\n * @example\r\n * await client.chat.subscribeRoomList();\r\n *\r\n * client.on('roomListMessage', (event) => {\r\n * // 해당 방을 리스트 최상단으로 이동\r\n * // event.actorId !== currentUserId 일 때만 unread +1\r\n * moveRoomToTop(event.roomId);\r\n * updateLastMessage(event.roomId, event.lastMessage, event.lastMessageAt);\r\n * if (event.actorId !== currentUserId) {\r\n * incrementUnread(event.roomId, event.unreadCountDelta);\r\n * }\r\n * });\r\n *\r\n * client.on('roomListSelfLeft', (event) => {\r\n * // 본인이 나간 방 → 리스트에서 제거\r\n * removeRoomFromList(event.roomId);\r\n * });\r\n */\r\n async subscribeRoomList() {\r\n if (this.roomListSubscribed) {\r\n this.logger.warn('Already subscribed to room list');\r\n return;\r\n }\r\n\r\n const destination = WebSocketPaths.ROOM_LIST_USER_DESTINATION;\r\n await this.connectionManager.subscribe(destination, (event) => {\r\n // noinspection JSCheckFunctionSignatures\r\n this._handleRoomListEvent(event);\r\n });\r\n\r\n this.roomListSubscribed = true;\r\n this.logger.info('Subscribed to room list');\r\n this.emit('roomListSubscribed', {});\r\n }\r\n\r\n /**\r\n * 채팅방 리스트 구독 해제.\r\n */\r\n unsubscribeRoomList() {\r\n if (!this.roomListSubscribed) {\r\n this.logger.warn('Not subscribed to room list');\r\n return;\r\n }\r\n\r\n this.connectionManager.unsubscribe(WebSocketPaths.ROOM_LIST_USER_DESTINATION);\r\n this.roomListSubscribed = false;\r\n\r\n this.logger.info('Unsubscribed from room list');\r\n this.emit('roomListUnsubscribed', {});\r\n }\r\n\r\n /**\r\n * 채팅방 리스트 구독 여부.\r\n * @returns {boolean}\r\n */\r\n isRoomListSubscribed() {\r\n return this.roomListSubscribed;\r\n }\r\n\r\n /**\r\n * Gmail 메일함 변화 구독 — {@code /user/queue/mail}. 받은 이벤트를 {@code mailChanged} 로 그대로 낸다.\r\n *\r\n * <p>새 메일 알림은 최상위 클라이언트의 {@code newMail} 을 쓴다 — 웹소켓 이벤트의 미리보기와 포그라운드 FCM 메일 푸시를\r\n * 합쳐 메일마다 한 번만 낸다. 재연결 때는 연결 관리자가 구독을 되살린다(이벤트는 최대 한 번 전달이라 재연결·창 포커스 때\r\n * 안읽은 수와 목록을 다시 읽는다).</p>\r\n */\r\n async subscribeMail() {\r\n if (this.mailSubscribed) {\r\n this.logger.debug('Already subscribed to mail');\r\n return;\r\n }\r\n\r\n await this.connectionManager.subscribe(WebSocketPaths.MAIL_USER_DESTINATION, (event) => {\r\n this._handleMailEvent(event);\r\n });\r\n\r\n this.mailSubscribed = true;\r\n this.logger.info('Subscribed to mail');\r\n this.emit('mailSubscribed', {});\r\n }\r\n\r\n /**\r\n * Gmail 메일함 변화 구독 해제.\r\n */\r\n unsubscribeMail() {\r\n if (!this.mailSubscribed) {\r\n return;\r\n }\r\n\r\n this.connectionManager.unsubscribe(WebSocketPaths.MAIL_USER_DESTINATION);\r\n this.mailSubscribed = false;\r\n\r\n this.logger.info('Unsubscribed from mail');\r\n this.emit('mailUnsubscribed', {});\r\n }\r\n\r\n /**\r\n * Gmail 메일함 변화 구독 여부.\r\n * @returns {boolean}\r\n */\r\n isMailSubscribed() {\r\n return this.mailSubscribed;\r\n }\r\n\r\n /** 메일 이벤트 — 보낸 사람·제목이 실리므로 내용은 로그에 남기지 않는다(연결 ID·건수만). */\r\n _handleMailEvent(event) {\r\n if (!event || event.eventType !== 'MAIL_CHANGED') {\r\n return;\r\n }\r\n this.logger.debug('Mail changed:', event.connectionId,\r\n Array.isArray(event.messages) ? event.messages.length : 0);\r\n this.emit('mailChanged', event);\r\n }\r\n\r\n /**\r\n * 메시지 읽음 처리\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {string} messageId - 메시지 ID\r\n */\r\n markAsRead(roomId, messageId) {\r\n if (!this.connectionManager.isConnected()) {\r\n this.logger.warn('Cannot mark as read: not connected');\r\n return false;\r\n }\r\n\r\n return this.connectionManager.send(WebSocketPaths.CHAT_READ, {\r\n roomId,\r\n messageId\r\n });\r\n }\r\n\r\n // ==================== 이벤트 핸들러 ====================\r\n\r\n /**\r\n * 채팅 메시지 수신 처리.\r\n *\r\n * <p>서버는 동일 채널(/topic/chat/{roomId})로 신규/수정/삭제/리액션/OG 첨부\r\n * 이벤트를 모두 발행하고, 페이로드의 {@code eventType} 으로 구분한다.\r\n * 클라는 eventType 에 따라 별도 이벤트로 re-emit.</p>\r\n *\r\n * <ul>\r\n * <li>{@code MESSAGE_CREATED} → {@code message} + {@code newMessage} (상대 메시지면) + 자동 읽음</li>\r\n * <li>{@code MESSAGE_UPDATED} → {@code message} + {@code messageUpdated}</li>\r\n * <li>{@code MESSAGE_DELETED} → {@code message} + {@code messageDeleted}</li>\r\n * <li>{@code REACTION_CHANGED} → {@code message} + {@code reactionChanged}</li>\r\n * <li>{@code LINK_PREVIEW_ATTACHED} → {@code message} + {@code linkPreviewAttached}</li>\r\n * <li>{@code MESSAGE_TRANSLATED} → {@code message} + {@code messageTranslated} (translations 머지)</li>\r\n * </ul>\r\n *\r\n * <p>{@code message} 이벤트는 모든 케이스에서 발행되어 하위 호환을 유지한다 —\r\n * 기존 구독 코드는 messageId 로 merge 하면 계속 작동.</p>\r\n *\r\n * @private\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} message - 수신된 메시지 (eventType 필드 포함)\r\n */\r\n _handleChatMessage(roomId, message) {\r\n this.logger.debug(`Message received in room ${roomId} (${message.eventType}):`, message);\r\n\r\n // MESSAGE_CREATED 만 dedup 대상 — 다른 eventType (UPDATED/DELETED/REACTION/LINK_PREVIEW) 는\r\n // 같은 messageId 의 의도된 재발행이라 dedup 하면 안 됨.\r\n if (message.eventType === 'MESSAGE_CREATED'\r\n && this._shouldDedupMessage(this._seenChatMessageIdsByRoom, roomId, message.messageId)) {\r\n this.logger.debug(`Duplicate MESSAGE_CREATED suppressed: room=${roomId}, messageId=${message.messageId}`);\r\n return false;\r\n }\r\n\r\n // 갭 복구 워터마크 전진 — 라이브 수신분만. replay 중에는 개별 전진을 막고\r\n // sync 완료 시 응답 lastId 로 1회 커밋한다(중간 실패 시 어중간한 위치에 남는 것 방지).\r\n if (message.eventType === 'MESSAGE_CREATED' && !this._replaying) {\r\n this._advanceAnchorFromLive(roomId, message.id);\r\n }\r\n\r\n // 편집(MESSAGE_UPDATED) 시 서버가 번역을 무효화한다. payload 에 번역 필드가 없으면 generic emit 前에\r\n // 명시적 null 처리 — generic 'message' / 상위 'chatMessage' 소비자도 stale 번역을 즉시 지우도록 (다국어 §9).\r\n if (message.eventType === 'MESSAGE_UPDATED') {\r\n if (message.translations === undefined) message.translations = null;\r\n if (message.sourceLang === undefined) message.sourceLang = null;\r\n if (message.translationsOf === undefined) message.translationsOf = null;\r\n }\r\n\r\n // 모든 이벤트는 통합 이벤트로 pass-through (하위 호환)\r\n this.emit('message', { roomId, message });\r\n\r\n switch (message.eventType) {\r\n case 'MESSAGE_CREATED':\r\n // 어시 메시지 도착 = \"AI 응답 준비중\" 종료 신호. typing timer 즉시 해제 + typing=false emit.\r\n // 서버는 typing=false 를 발행하지 않고 client-side 가 책임 (assistant typing 설계).\r\n // userId / userName 은 서버 typing=true 발행과 정확히 일치하는 generic marker 사용 —\r\n // UI 가 roomId+userId 로 typing state 추적 시 true/false 가 같은 키로 매칭되어야 안 꺼지는\r\n // 케이스 방지. message.userId (페르소나 ID) 를 그대로 쓰면 키 불일치로 영구 표시 위험.\r\n if (message.senderType === 'ASSISTANT') {\r\n this._clearAssistantTypingTimer(roomId);\r\n this.emit('typing', {\r\n roomId,\r\n userId: this._assistantTypingUserId,\r\n userName: this._assistantTypingUserName,\r\n typing: false,\r\n senderType: 'ASSISTANT'\r\n });\r\n // ⚠️ AI 진행 표시(assistantProgress)는 <b>여기서 끄지 않는다</b>\r\n // (ASSISTANT_STREAM_REPLAY_DESIGN §7.3).\r\n //\r\n // 서버 발행 순서가 메시지 → DONE 이라(ChatMessagePublicationWorkflow 가 MESSAGE_CREATED 를\r\n // 먼저 내고, InterveneInChatService 가 그 뒤에 DONE(streamId, messageId) 을 낸다) 이 시점의\r\n // 클라는 <b>messageId → streamId 관계를 아직 모른다</b>. 그래서 \"이 메시지에 해당하는 stream 만\r\n // 끈다\" 가 불가능하고, 방 단위로 끄면 같은 방의 <b>다른 살아 있는 stream</b> 까지 사라진다.\r\n //\r\n // 종료 SSOT 는 DONE 이고, DONE 유실 대비 안전망은 TTL(_startAssistantProgressTimer) 이다.\r\n // DONE 은 메시지 post 직후에 나오므로 표시가 늦게 꺼지는 체감도 사실상 없다.\r\n }\r\n\r\n // 상대방 메시지일 때만 newMessage + 자동 읽음 처리\r\n if (message.userId !== this.userId) {\r\n this.emit('newMessage', { roomId, message });\r\n\r\n // 현재 보고 있는 방에서만 자동 읽음 처리\r\n if (this._activeRoomId === roomId && message.messageId) {\r\n this.markAsRead(roomId, message.messageId);\r\n }\r\n }\r\n break;\r\n\r\n case 'MESSAGE_UPDATED':\r\n // 번역 stale 클리어(번역 필드 null 보정)는 generic emit 前에 이미 수행됨 (_handleChatMessage 상단).\r\n this.emit('messageUpdated', { roomId, message });\r\n break;\r\n\r\n case 'MESSAGE_DELETED':\r\n this.emit('messageDeleted', { roomId, message });\r\n break;\r\n\r\n case 'REACTION_CHANGED':\r\n this.emit('reactionChanged', { roomId, message });\r\n break;\r\n\r\n case 'LINK_PREVIEW_ATTACHED':\r\n this.emit('linkPreviewAttached', { roomId, message });\r\n break;\r\n\r\n case 'MESSAGE_TRANSLATED':\r\n // 서버가 비동기 번역 완료 후 패치 — message.translations/sourceLang 채워져 옴.\r\n // 소비자는 messageId 로 기존 메시지에 머지 (linkPreviewAttached 와 동일 패턴).\r\n this.emit('messageTranslated', { roomId, message });\r\n break;\r\n\r\n default:\r\n // 미지의 eventType — 구버전 서버(eventType 없음)와 호환 유지.\r\n // 안전장치로 기존 동작(신규 메시지 알림) 수행.\r\n this.logger.warn(`Unknown eventType \"${message.eventType}\" — falling back to legacy behavior`);\r\n if (message.userId !== this.userId) {\r\n this.emit('newMessage', { roomId, message });\r\n if (this._activeRoomId === roomId && message.messageId) {\r\n this.markAsRead(roomId, message.messageId);\r\n }\r\n }\r\n }\r\n\r\n return true;\r\n }\r\n\r\n /**\r\n * 채팅방 리스트 업데이트 이벤트 수신 처리.\r\n * @private\r\n * @param {Object} event - 서버 RoomListEvent\r\n * @param {string} event.eventType - RoomListEventType\r\n * @param {string} event.roomId\r\n * @param {string} event.actorId - 이벤트 발생 사용자\r\n * @param {number} [event.unreadCountDelta]\r\n * @param {string} [event.lastMessage]\r\n * @param {string} [event.lastMessageType]\r\n * @param {string} [event.lastMessageAt]\r\n * @param {string} event.timestamp\r\n */\r\n _handleRoomListEvent(event) {\r\n this.logger.debug('Room list event:', event);\r\n\r\n // MESSAGE_RECEIVED 의 messageId 기반 dedup — chat WebSocket 측과 별도 키 공간.\r\n // 서버가 messageId 를 안 내려준 구버전 페이로드면 dedup 통과 (backward compatible).\r\n if (event.eventType === RoomListEventType.MESSAGE_RECEIVED\r\n && event.messageId\r\n && this._shouldDedupMessage(this._seenRoomListMessageIdsByRoom, event.roomId, event.messageId)) {\r\n this.logger.debug(`Duplicate roomList MESSAGE_RECEIVED suppressed: room=${event.roomId}, messageId=${event.messageId}`);\r\n return;\r\n }\r\n\r\n // 1. 통합 이벤트 (모든 타입)\r\n this.emit('roomListUpdate', event);\r\n\r\n // 2. 타입별 이벤트\r\n switch (event.eventType) {\r\n case RoomListEventType.MESSAGE_RECEIVED:\r\n this.emit('roomListMessage', event);\r\n break;\r\n case RoomListEventType.MESSAGE_DELETED:\r\n this.emit('roomListMessageDeleted', event);\r\n break;\r\n case RoomListEventType.MESSAGE_UPDATED:\r\n this.emit('roomListMessageUpdated', event);\r\n break;\r\n case RoomListEventType.ROOM_CREATED:\r\n this.emit('roomListCreated', event);\r\n break;\r\n case RoomListEventType.ROOM_JOINED:\r\n this.emit('roomListJoined', event);\r\n break;\r\n case RoomListEventType.ROOM_LEFT:\r\n this.emit('roomListLeft', event);\r\n // 본인이 나간 경우 편의 이벤트 (리스트에서 제거해야 함)\r\n if (event.actorId === this.userId) {\r\n this.emit('roomListSelfLeft', event);\r\n }\r\n break;\r\n case RoomListEventType.ROOM_KICKED:\r\n this.emit('roomListKicked', event);\r\n // 본인이 추방당한 경우 편의 이벤트 — UI 에서 \"추방되었습니다\" 표시 + 리스트 제거\r\n if (this._isSelfInMembers(event)) {\r\n this.emit('roomListSelfKicked', event);\r\n }\r\n break;\r\n case RoomListEventType.ROOM_OWNER_CHANGED:\r\n // 방장 위임 — actorId=이전 방장, members[0]=새 방장. 방장 표시·관리 메뉴를 갱신한다.\r\n this.emit('roomListOwnerChanged', event);\r\n break;\r\n case RoomListEventType.ROOM_DELETED:\r\n // 방장이 방을 삭제 — 모든 참가자가 리스트에서 제거하고 열린 방 화면을 닫는다.\r\n // 🔴 그 방 구독은 SDK 가 «먼저» 푼다. 남겨 두면 아무것도 못 받으면서 재연결 때마다 되살아나 서버에\r\n // 거부된다 — 그 거부로 연결을 끊던 서버 버전에서는 «재연결 → 거부 → 끊김» 이 반복돼 다른 방 실시간까지 멈췄다.\r\n if (this.subscribedRooms.has(event.roomId)) {\r\n this.unsubscribeRoom(event.roomId);\r\n }\r\n this.emit('roomListDeleted', event);\r\n break;\r\n case RoomListEventType.ROOM_BANNED:\r\n this.emit('roomListBanned', event);\r\n // 본인이 영구 차단당한 경우 편의 이벤트 — \"영구 차단되었습니다\" 표시 + 리스트 제거\r\n if (this._isSelfInMembers(event)) {\r\n this.emit('roomListSelfBanned', event);\r\n }\r\n break;\r\n case RoomListEventType.ROOM_UPDATED:\r\n this.emit('roomListRoomUpdated', event);\r\n break;\r\n case RoomListEventType.MESSAGE_RETENTION_CLEANUP:\r\n // noinspection JSUnresolvedReference\r\n this.emit('retentionCleanup', {\r\n roomId: event.roomId,\r\n cutoffTime: event.cutoffTime\r\n });\r\n break;\r\n case RoomListEventType.ROOM_UNREAD_UPDATED:\r\n // 다른(또는 이) 기기에서 내가 읽어 방 미독이 바뀜 → badge 를 절대값으로 세팅.\r\n // roomUnreadCount 는 delta 가 아니라 절대값, unreadVersion 은 순서 역전 판별용.\r\n this.emit('roomListUnreadUpdated', {\r\n roomId: event.roomId,\r\n roomUnreadCount: event.roomUnreadCount,\r\n unreadVersion: event.unreadVersion\r\n });\r\n break;\r\n case RoomListEventType.AI_UNAVAILABLE:\r\n // PM 을 직접 불렀는데 회사 AI 가 막혀 답하지 않았다 — 부른 사람에게만 온다. 잠깐 알리고 사라지게(토스트).\r\n // 리스트·미독은 건드리지 않는다. 막힌 상태를 계속 보여 줄 띠는 getMyProfile().aiBlocked 로 그린다.\r\n this.emit('aiUnavailable', {\r\n roomId: event.roomId,\r\n messageId: event.messageId,\r\n aiBlocked: event.aiBlocked,\r\n timestamp: event.timestamp\r\n });\r\n break;\r\n default:\r\n this.logger.warn('Unknown room list event type:', event.eventType);\r\n }\r\n }\r\n\r\n /**\r\n * 읽음 이벤트 수신 처리\r\n * @private\r\n * @param {string} roomId - 채팅방 ID\r\n * @param {Object} event - 읽음 이벤트 (단일 또는 배치)\r\n */\r\n _handleReadEvent(roomId, event) {\r\n this.logger.debug(`Read event in room ${roomId}:`, event);\r\n\r\n const resolvedRoomId = event.roomId || roomId;\r\n\r\n // 배치 이벤트인 경우 (방 입장 시 일괄 읽음 처리)\r\n if (event.events && Array.isArray(event.events)) {\r\n event.events.forEach(e => {\r\n this.emit('messageRead', {\r\n roomId: resolvedRoomId,\r\n messageId: e.messageId,\r\n userId: event.userId,\r\n remainingUnreadCount: e.remainingUnreadCount\r\n });\r\n });\r\n }\r\n // 단일 이벤트인 경우 (실시간 개별 읽음 처리)\r\n else {\r\n this.emit('messageRead', {\r\n roomId: resolvedRoomId,\r\n messageId: event.messageId,\r\n userId: event.userId,\r\n remainingUnreadCount: event.remainingUnreadCount\r\n });\r\n }\r\n }\r\n\r\n // ==================== 타이핑 표시 ====================\r\n\r\n /**\r\n * 타이핑 시작 알림.\r\n *\r\n * <p>호출할 때마다 내부 타이머가 리셋됩니다 (debounce).\r\n * 3초간 추가 호출이 없으면 자동으로 {@link #stopTyping} 이 호출됩니다.\r\n * 메시지 전송 ({@link #sendMessage}) 시에도 자동 중단됩니다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n *\r\n * @example\r\n * // input 이벤트에 연결\r\n * inputField.addEventListener('input', () => {\r\n * client.chat.startTyping('room-id');\r\n * });\r\n */\r\n startTyping(roomId) {\r\n if (!this.connectionManager.isConnected()) return;\r\n\r\n const existingTimer = this._typingTimers.get(roomId);\r\n\r\n // throttle: 이미 typing 상태면 stop 타이머만 리셋 (STOMP 재전송 안 함)\r\n if (existingTimer) {\r\n clearTimeout(existingTimer);\r\n } else {\r\n // 최초 또는 stop 이후 첫 호출에만 STOMP 전송\r\n this.connectionManager.send(WebSocketPaths.CHAT_TYPING, {\r\n roomId,\r\n typing: true\r\n });\r\n }\r\n\r\n // 3초 후 자동 stopTyping\r\n const timer = setTimeout(() => {\r\n this.stopTyping(roomId);\r\n }, 3000);\r\n\r\n this._typingTimers.set(roomId, timer);\r\n }\r\n\r\n /**\r\n * 타이핑 중단 알림.\r\n *\r\n * <p>직접 호출하거나, {@link #startTyping} 의 3초 타이머 만료 시,\r\n * 또는 메시지 전송 시 자동 호출됩니다.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n */\r\n stopTyping(roomId) {\r\n this._clearTypingTimer(roomId);\r\n\r\n if (!this.connectionManager.isConnected()) return;\r\n\r\n this.connectionManager.send(WebSocketPaths.CHAT_TYPING, {\r\n roomId,\r\n typing: false\r\n });\r\n }\r\n\r\n /**\r\n * 이벤트 members 배열에 본인이 포함돼 있는지 확인.\r\n * <p>ROOM_KICKED / ROOM_BANNED 이벤트에서 \"내가 추방당했는가\" 판별용. actorId (추방한 방장) 가 아니라\r\n * members 배열(추방된 사용자) 을 보는 이유는, 같은 이벤트를 방장 본인도 수신하기 때문.</p>\r\n * @private\r\n */\r\n _isSelfInMembers(event) {\r\n return Array.isArray(event.members)\r\n && event.members.some(m => m && m.userId === this.userId);\r\n }\r\n\r\n /**\r\n * 타이핑 이벤트 수신 처리.\r\n * <p>본인 이벤트는 필터링하여 emit 하지 않음.</p>\r\n * @private\r\n */\r\n _handleTypingEvent(roomId, event) {\r\n // 본인 이벤트 필터 — USER 타이핑만 적용 (어시는 본인 userId 매칭 불가, generic marker 사용)\r\n if (event.senderType !== 'ASSISTANT' && event.userId === this.userId) return;\r\n\r\n // 어시 typing=true 수신 시 client-side timeout 시작 — 서버 측 typing=false 발행이 없으므로\r\n // (1) 어시 메시지 도착 (_handleChatMessage) 또는 (2) 본 timeout 만료 중 먼저 도달 시 typing=false emit.\r\n if (event.senderType === 'ASSISTANT' && event.typing === true) {\r\n this._startAssistantTypingTimer(roomId);\r\n }\r\n\r\n this.emit('typing', {\r\n roomId,\r\n userId: event.userId,\r\n userName: event.userName,\r\n typing: event.typing,\r\n senderType: event.senderType || 'USER' // 구버전 서버 호환 — senderType 없으면 USER\r\n });\r\n }\r\n\r\n /**\r\n * 어시스턴트 typing 자동 해제 타이머 시작/재시작.\r\n * 같은 방에 이미 timer 있으면 reset (새로운 어시 응답 라운드).\r\n * @private\r\n */\r\n _startAssistantTypingTimer(roomId) {\r\n this._clearAssistantTypingTimer(roomId);\r\n const timer = setTimeout(() => {\r\n this._assistantTypingTimers.delete(roomId);\r\n // timeout 만료 = LLM 지연 또는 서버 실패 — UI 가 영구 \"답변 준비중\" 에 갇히지 않도록 해제 emit.\r\n // userId / userName 은 generic marker 일관 (typing=true 발행과 매칭 — P1 fix).\r\n this.emit('typing', {\r\n roomId,\r\n userId: this._assistantTypingUserId,\r\n userName: this._assistantTypingUserName,\r\n typing: false,\r\n senderType: 'ASSISTANT'\r\n });\r\n this.logger.debug(`Assistant typing auto-cleared (timeout): room=${roomId}`);\r\n }, this._assistantTypingTimeoutMs);\r\n this._assistantTypingTimers.set(roomId, timer);\r\n }\r\n\r\n /**\r\n * 어시스턴트 typing 자동 해제 타이머 취소.\r\n * 어시 메시지 도착 시 즉시 호출 (timeout 도달 전 해제).\r\n * @private\r\n */\r\n _clearAssistantTypingTimer(roomId) {\r\n const timer = this._assistantTypingTimers.get(roomId);\r\n if (timer) {\r\n clearTimeout(timer);\r\n this._assistantTypingTimers.delete(roomId);\r\n }\r\n }\r\n\r\n /**\r\n * 타이핑 타이머 정리.\r\n * @private\r\n */\r\n _clearTypingTimer(roomId) {\r\n const timer = this._typingTimers.get(roomId);\r\n if (timer) {\r\n clearTimeout(timer);\r\n this._typingTimers.delete(roomId);\r\n }\r\n }\r\n\r\n /**\r\n * AI 진행 표시(assistant-stream) 이벤트 수신 처리 — PHASE(단계)/DELTA(토큰 청크)/DONE(종료) 분기.\r\n *\r\n * <p>typing 과 별개의 가산 레이어 — UI 는 {@code active:true} 면 {@code text}(토큰 스트림 누적) 우선,\r\n * 없으면 {@code label}(단계) 표시, {@code active:false} 면 해제. PHASE→DELTA 흐름으로 \"작성 중\" 라벨이\r\n * 점진 렌더 텍스트로 자연 전환된다. 서버가 typing 도 이중발행하므로 구버전 SDK 는 본 채널 미구독 → 기존\r\n * typing 만으로 정상 동작(graceful).</p>\r\n *\r\n * <p><b>seq 가드</b>(ASSISTANT_STREAM_REPLAY_DESIGN §5.5.0/§7.1-a): 스트림은 Pub/Sub 라 <b>구독 시점\r\n * 이후만</b> 수신된다. 방을 나갔다 재입장하거나 WS 가 순단되면 앞부분을 받은 적이 없는데, 예전 구현은\r\n * 그 상태에서 도착한 청크를 그대로 누적본으로 렌더해 <b>잘린 본문</b>을 보여줬다. 이제 {@code seq} 로\r\n * \"앞이 비었다\"를 스스로 판정해 본문 대신 {@code label} 로 물러선다({@code textAvailable=false}).\r\n * 최종 메시지가 도착하면 어차피 본문이 확정되므로, 잘린 본문을 보여주는 것보다 라벨이 낫다.</p>\r\n * @private\r\n */\r\n _handleAssistantStreamEvent(roomId, event) {\r\n if (!event || !event.type) return;\r\n\r\n const streamId = event.streamId || null;\r\n if (!streamId) {\r\n // streamId 없는 이벤트는 슬롯을 키잉할 수 없다. 서버 계약상 always non-null 이라 도달하지 않는다.\r\n this.logger.debug(`Assistant stream event without streamId ignored: room=${roomId}, type=${event.type}`);\r\n return;\r\n }\r\n // 종료된 stream 의 늦은 이벤트 — 되살리지 않는다(§7.2). DONE 자체도 중복 도착하면 여기서 걸린다.\r\n if (this._isAssistantStreamClosed(roomId, streamId)) {\r\n this.logger.debug(`Assistant event for closed stream ignored: room=${roomId}, stream=${streamId}, type=${event.type}`);\r\n return;\r\n }\r\n // 스냅샷 대기 중이면 보류 — 병합 뒤에 seq 규칙으로 이어붙인다(§6.1). 렌더는 아직 하지 않는다.\r\n //\r\n // ⚠️ <b>DONE 만은 즉시 처리한다</b>(§6.2). 큐에 넣으면 스냅샷의 ACTIVE 가 먼저 적용돼\r\n // {@code active:true} 버블이 한 번 떴다가 곧바로 꺼진다. DONE 은 \"서버가 끝났다고 명시한\" 신호라\r\n // 지금 tombstone 을 세워야 스냅샷이 그 stream 을 되살리지 못한다. 같은 stream 의 이미 보류된\r\n // PHASE/DELTA 는 drain 때 그 tombstone 에 걸려 자연히 무시된다.\r\n const pending = this._assistantSnapshotPending.get(roomId);\r\n if (pending && event.type !== 'DONE') {\r\n pending.events.push(event);\r\n return;\r\n }\r\n\r\n if (event.type === 'PHASE') {\r\n // PHASE 에는 seq 가 없다 — <b>시각이 유일한 순서 축</b>이다. 스냅샷의 phaseUpdatedAt 보다\r\n // 오래된 pending PHASE 를 적용하면 라벨이 WRITING → SEARCHING 으로 역행한다(§6.2).\r\n const prevSlot = this._assistantSlot(roomId, streamId);\r\n const createdAt = typeof event.createdAt === 'number' ? event.createdAt : null;\r\n // ⚠️ 등호도 stale 이다. 계약이 \"더 최신일 때만 적용\"이라 같은 시각이면 적용하면 안 된다 —\r\n // 같은 밀리초에 발행된 pending PHASE 가 스냅샷 phase 를 덮어 라벨이 역행한다.\r\n if (prevSlot && createdAt !== null && createdAt <= (prevSlot.phaseUpdatedAt || 0)) {\r\n this.logger.debug(`Stale assistant PHASE ignored: room=${roomId}, stream=${streamId}, createdAt=${createdAt}, applied=${prevSlot.phaseUpdatedAt}`);\r\n return;\r\n }\r\n const phase = event.phase || null;\r\n const samePhase = Boolean(prevSlot && prevSlot.phase === phase);\r\n // 같은 phase의 일반 heartbeat에는 progress가 없다. 그 틱이 직전 실측 숫자를 지우지 않게\r\n // 보존하고, phase가 바뀔 때만 이전 작업의 숫자를 폐기한다.\r\n const progress = event.progress\r\n ? this._normalizeAssistantProgress(event.progress)\r\n : (samePhase && prevSlot ? prevSlot.progress : null);\r\n // 같은 stream 의 PHASE 재발행(B-3 synthesis heartbeat 등)이면 누적 상태를 보존한다.\r\n // ⚠️ 새 stream 의 커서는 <b>-1</b> 로 연다. DeltaCoalescer 의 첫 청크가 seq=0 이라 0 으로 열면\r\n // 첫 글자가 \"중복\"으로 버려진다 (설계 §5.5.0).\r\n const prev = prevSlot;\r\n const slot = this._setAssistantSlot(roomId, streamId, event, {\r\n phase,\r\n progress,\r\n phaseUpdatedAt: createdAt !== null ? createdAt : (prev ? prev.phaseUpdatedAt : 0),\r\n text: prev ? (prev.text || '') : '',\r\n lastSeq: prev ? this._seqOf(prev.lastSeq) : -1,\r\n textAvailable: prev ? prev.textAvailable !== false : true\r\n });\r\n this._emitAssistantStream(roomId, streamId, slot, {\r\n active: true,\r\n text: slot.textAvailable ? (slot.text || null) : null\r\n });\r\n return;\r\n }\r\n\r\n if (event.type === 'DELTA') {\r\n // 토큰 청크(B) — 누적 텍스트에 이어붙여 점진 렌더용으로 emit. WRITING 단계에서 흐른다.\r\n const prev = this._assistantSlot(roomId, streamId);\r\n const phase = (prev && prev.phase) || 'WRITING';\r\n const seq = typeof event.seq === 'number' ? event.seq : null;\r\n\r\n let lastSeq = prev ? this._seqOf(prev.lastSeq) : -1;\r\n let textAvailable = prev ? prev.textAvailable !== false : true;\r\n let text = prev ? (prev.text || '') : '';\r\n\r\n // ── seq 취급 규칙 (설계 §5.5.0 SSOT — 소비자 열. 서버 스냅샷 병합도 같은 표를 쓴다) ──\r\n // seq <= lastSeq : 중복·지연 도착. <b>장애가 아니다</b> — 조용히 무시.\r\n // seq == lastSeq + 1 : 정상 append.\r\n // seq > lastSeq + 1 : 실제 gap. 앞이 비었으니 본문을 포기하고 라벨로 물러선다.\r\n // 슬롯이 없는 상태의 late join(재입장 직후 seq>0)도 lastSeq=-1 기준으로 마지막 분기에 걸린다.\r\n //\r\n // ⚠️ <b>커서 판정은 degrade 여부와 무관하다.</b> textAvailable=false 가 멈추는 것은 <b>본문 누적\r\n // 뿐</b>이다. 커서까지 멈추면 (1) 이후 중복이 걸러지지 않아 같은 청크가 다시 emit 되고,\r\n // (2) lastSeq 가 옛 값에 갇혀 R-4 스냅샷 병합이 잘못된 지점에서 절단한다.\r\n if (seq === null) {\r\n // 서버가 seq 를 안 실은 경우(구버전 서버) — 판정 불가라 기존 동작(그대로 append)을 유지한다.\r\n if (textAvailable) text += event.delta || '';\r\n } else if (seq <= lastSeq) {\r\n this.logger.debug(`Duplicate assistant DELTA ignored: room=${roomId}, stream=${streamId}, seq=${seq}, lastSeq=${lastSeq}`);\r\n return;\r\n } else if (seq === lastSeq + 1) {\r\n // 포기한 슬롯이면 커서만 전진시키고 본문은 비운 채 둔다 — 베이스가 없는데 이어붙이면 오염이다.\r\n if (textAvailable) text += event.delta || '';\r\n lastSeq = seq;\r\n } else {\r\n this.logger.debug(`Assistant DELTA gap — text dropped: room=${roomId}, stream=${streamId}, seq=${seq}, lastSeq=${lastSeq}`);\r\n textAvailable = false;\r\n text = '';\r\n lastSeq = seq;\r\n }\r\n\r\n const slot = this._setAssistantSlot(roomId, streamId, event, {\r\n phase, progress: prev ? (prev.progress || null) : null, text, lastSeq, textAvailable\r\n });\r\n this._emitAssistantStream(roomId, streamId, slot, {\r\n active: true,\r\n text: slot.textAvailable ? slot.text : null, // 누적 전체(버블 렌더용)\r\n delta: slot.textAvailable ? (event.delta || null) : null, // 이번 청크(append 렌더 선호 시)\r\n seq\r\n });\r\n return;\r\n }\r\n\r\n if (event.type === 'DONE') {\r\n // 종료(성공·실패 공통) — <b>이 stream 만</b> 닫는다. 같은 방의 다른 stream 은 계속 산다.\r\n const slot = this._assistantSlot(roomId, streamId);\r\n if (slot && slot.timer) clearTimeout(slot.timer);\r\n this._deleteAssistantSlot(roomId, streamId);\r\n // 서버가 \"끝났다\"고 명시한 유일한 신호 — 묘비는 여기서만 세운다(§7.2).\r\n this._tombstoneAssistantStream(roomId, streamId);\r\n this._emitAssistantStream(roomId, streamId, slot, {\r\n active: false,\r\n personaId: event.personaId || (slot && slot.personaId) || null,\r\n phase: null,\r\n progress: null,\r\n label: null,\r\n status: event.status || null,\r\n messageId: event.messageId || null\r\n });\r\n }\r\n // 그 외 미지 type 은 무시(forward-compat).\r\n }\r\n\r\n /**\r\n * 진행 중 스트림 스냅샷을 받아 라이브와 병합한다 (설계 §6.1, R-4).\r\n *\r\n * <pre>\r\n * 0. gen 발급 + pending ON ← 구독보다 <b>먼저</b> (호출자가 한다)\r\n * 1. SUBSCRIBE ← 호출자가 한다\r\n * 2. GET /assistant/streams\r\n * 3. 세대 검증 — 다르면 응답 통째 폐기\r\n * 4. 병합: 스냅샷을 베이스로, 보류분을 §5.5.0 규칙으로 이어붙임\r\n * 5. pending OFF + 렌더\r\n * </pre>\r\n *\r\n * <p><b>왜 구독이 먼저인가</b>: 반대로 하면 스냅샷 생성과 구독 사이에 발행된 청크가 <b>유실</b>된다.\r\n * 구독을 먼저 걸면 최악이 중복인데, 중복은 {@code seq} 로 정확히 잘라낼 수 있다.</p>\r\n *\r\n * <p><b>실패해도 보류분은 반드시 푼다</b> — 안 그러면 라이브 이벤트가 큐에 갇혀 진행 표시가 통째로\r\n * 멈춘다. 스냅샷 실패는 \"복구 포기\"일 뿐 채팅을 막지 않는다(§6.2).</p>\r\n * @private\r\n */\r\n async _loadAssistantSnapshot(roomId, generation) {\r\n // 같은 <b>세대</b>의 요청이 이미 떠 있으면 재사용한다. 다른 세대 것은 재사용하면 안 된다 —\r\n // 그 응답은 세대 불일치로 폐기되는데, 재사용했다면 이번 세대의 요청까지 함께 사라진다(그리고\r\n // 이번 세대가 쌓아둔 보류분이 통째로 버려진다).\r\n const inFlight = this._assistantSnapshotInFlight.get(roomId);\r\n if (inFlight && inFlight.generation === generation) return inFlight.task;\r\n\r\n const task = (async () => {\r\n let streams = null;\r\n try {\r\n const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/assistant/streams`);\r\n const data = this._unwrapSuccessResponse(response);\r\n streams = (data && Array.isArray(data.streams)) ? data.streams : [];\r\n } catch (error) {\r\n // 구버전 서버(404)·네트워크·5xx 모두 여기로 온다. 사용자에게 노출하지 않는다.\r\n this.logger.warn(`Assistant stream snapshot failed (live-only fallback): room=${roomId}`, error);\r\n }\r\n\r\n if (this._assistantGeneration(roomId) !== generation) {\r\n // 응답 전에 방을 떠났거나 재구독했다 — 이 스냅샷은 이미 다른 세대의 것이다.\r\n // ⚠️ <b>내 세대의 보류함만</b> 정리한다. 무조건 지우면 그 사이 열린 현재 세대의 보류분이\r\n // 통째로 사라져 라이브 이벤트가 증발한다.\r\n this.logger.debug(`Assistant snapshot discarded (stale generation): room=${roomId}`);\r\n this._deleteAssistantPendingIfGeneration(roomId, generation);\r\n return;\r\n }\r\n if (streams) this._applyAssistantSnapshot(roomId, streams);\r\n this._drainAssistantPending(roomId, generation);\r\n })();\r\n\r\n this._assistantSnapshotInFlight.set(roomId, { generation, task });\r\n try {\r\n await task;\r\n } finally {\r\n // 내가 등록한 항목일 때만 지운다 — 그 사이 새 세대가 자기 요청을 올려놨을 수 있다.\r\n const current = this._assistantSnapshotInFlight.get(roomId);\r\n if (current && current.generation === generation) {\r\n this._assistantSnapshotInFlight.delete(roomId);\r\n }\r\n }\r\n }\r\n\r\n /** @private */\r\n _deleteAssistantPendingIfGeneration(roomId, generation) {\r\n const pending = this._assistantSnapshotPending.get(roomId);\r\n if (pending && pending.generation === generation) {\r\n this._assistantSnapshotPending.delete(roomId);\r\n }\r\n }\r\n\r\n /** 스냅샷을 슬롯에 심는다 — 보류분을 풀기 <b>전에</b> 끝나야 베이스가 선다. @private */\r\n _applyAssistantSnapshot(roomId, streams) {\r\n streams.forEach((s) => {\r\n const streamId = s && s.streamId;\r\n if (!streamId) return;\r\n // 이미 DONE 을 라이브로 받아 닫은 stream — 스냅샷이 그걸 되살리면 안 된다(§6.2).\r\n if (this._isAssistantStreamClosed(roomId, streamId)) return;\r\n\r\n if (s.status === 'DONE') {\r\n // 유예 구간에 걸린 종료분. 진행 표시를 켜지 않고 최종 메시지로 바로 확정시킨다.\r\n this._tombstoneAssistantStream(roomId, streamId);\r\n this._emitAssistantStream(roomId, streamId, null, {\r\n active: false, phase: null, progress: null, label: null, personaId: s.personaId || null,\r\n status: s.doneStatus || null, messageId: s.messageId || null, resumed: true\r\n });\r\n return;\r\n }\r\n\r\n const textAvailable = s.textAvailable !== false;\r\n const slot = this._setAssistantSlot(roomId, streamId, {\r\n personaId: s.personaId, expiresAt: undefined\r\n }, {\r\n phase: s.phase || null,\r\n progress: this._normalizeAssistantProgress(s.progress),\r\n phaseUpdatedAt: typeof s.phaseUpdatedAt === 'number' ? s.phaseUpdatedAt : 0,\r\n text: textAvailable ? (s.text || '') : '',\r\n lastSeq: this._seqOf(s.lastSeq),\r\n textAvailable,\r\n resumed: true\r\n });\r\n this._emitAssistantStream(roomId, streamId, slot, {\r\n active: true,\r\n text: slot.textAvailable ? (slot.text || null) : null,\r\n resumed: true\r\n });\r\n });\r\n }\r\n\r\n /**\r\n * 보류분을 <b>도착 순서 그대로</b> 흘려보낸다. 이 시점부터 §5.5.0 규칙이 스냅샷 베이스 위에서 돈다.\r\n *\r\n * <p>순서가 중요하다 — stream 별로 몰아서 풀면 방 집계의 \"가장 최근 stream\" 판정이 실제 도착 순서와\r\n * 달라져 엉뚱한 본문이 버블에 뜬다.</p>\r\n * @private\r\n */\r\n _drainAssistantPending(roomId, generation) {\r\n const pending = this._assistantSnapshotPending.get(roomId);\r\n if (!pending) return;\r\n if (generation !== undefined && pending.generation !== generation) return;\r\n this._assistantSnapshotPending.delete(roomId); // 먼저 지워야 재귀로 다시 큐에 들어가지 않는다\r\n pending.events.forEach((event) => this._handleAssistantStreamEvent(roomId, event));\r\n }\r\n\r\n /** @private */\r\n _assistantGeneration(roomId) {\r\n return this._assistantRoomGeneration.get(roomId) || 0;\r\n }\r\n\r\n /** 세대를 넘기고 보류함을 연다 — <b>구독 직전</b>에 부른다(§6.1 ①). @private */\r\n _armAssistantSnapshot(roomId) {\r\n const generation = this._assistantGeneration(roomId) + 1;\r\n this._assistantRoomGeneration.set(roomId, generation);\r\n const previous = this._assistantSnapshotPending.get(roomId);\r\n // ⚠️ <b>보류분은 세대를 넘겨 이어받는다.</b> 세대는 <b>스냅샷 응답</b>의 유효성을 가르는 축이지\r\n // 큐 내용의 소유권이 아니다. 큐에 있는 건 아직 렌더되지 않은 <b>정상 라이브 이벤트</b>라, 새 세대를\r\n // 빈 배열로 열면 그대로 유실된다(GET 대기 중 재개가 한 번 더 불리면 그 사이 도착한 DELTA 가 통째로\r\n // 사라진다). 방을 떠날 때 버리는 건 별개 경로다({@link #_clearAssistantProgress}).\r\n this._assistantSnapshotPending.set(roomId, {\r\n generation,\r\n events: previous ? previous.events : []\r\n });\r\n return generation;\r\n }\r\n\r\n /**\r\n * 재연결·복귀 시 <b>구독 중인 모든 방</b>의 스냅샷을 다시 받는다 (설계 §7.4).\r\n *\r\n * <p>⚠️ {@link #syncAfterReconnect} 에 얹으면 안 된다. 그쪽은 {@code syncOnReconnect} 옵션과\r\n * <b>anchor 가 있는 방</b>이라는 두 겹 필터가 걸려 있어서, 옵션이 꺼져 있거나 그 방에서 메시지를 받은\r\n * 적이 없으면 replay 가 조용히 빠진다. 어시 재개는 메시지 갭 복구와 발동 조건이 다르다.</p>\r\n */\r\n async resumeAssistantStreams(reason = 'manual') {\r\n if (!this.assistantStreamReplay) return;\r\n const rooms = Array.from(this.subscribedRooms.keys());\r\n if (rooms.length === 0) return;\r\n this.logger.debug(`Assistant stream replay: rooms=${rooms.length}, reason=${reason}`);\r\n await Promise.all(rooms.map((roomId) => {\r\n const generation = this._armAssistantSnapshot(roomId);\r\n return this._loadAssistantSnapshot(roomId, generation)\r\n .catch((error) => this.logger.warn(`Assistant stream replay failed: room=${roomId}`, error));\r\n }));\r\n }\r\n\r\n /** 방의 stream 슬롯 맵 — 없으면 생성. @private */\r\n _assistantSlots(roomId) {\r\n let slots = this._assistantProgress.get(roomId);\r\n if (!slots) {\r\n slots = new Map();\r\n this._assistantProgress.set(roomId, slots);\r\n }\r\n return slots;\r\n }\r\n\r\n /** @private */\r\n _assistantSlot(roomId, streamId) {\r\n const slots = this._assistantProgress.get(roomId);\r\n return slots ? slots.get(streamId) : undefined;\r\n }\r\n\r\n /** @private */\r\n _deleteAssistantSlot(roomId, streamId) {\r\n const slots = this._assistantProgress.get(roomId);\r\n if (!slots) return;\r\n slots.delete(streamId);\r\n if (slots.size === 0) this._assistantProgress.delete(roomId);\r\n }\r\n\r\n /** 종료된 stream 인가 — 늦은 이벤트가 슬롯을 되살리는 것을 막는다(§7.2). @private */\r\n _isAssistantStreamClosed(roomId, streamId) {\r\n const graves = this._assistantStreamTombstones.get(roomId);\r\n if (!graves) return false;\r\n const expiresAt = graves.get(streamId);\r\n if (expiresAt === undefined) return false;\r\n if (expiresAt <= Date.now()) {\r\n graves.delete(streamId); // 만료된 묘비는 회수 — 그 뒤엔 새 stream 취급이 맞다.\r\n return false;\r\n }\r\n return true;\r\n }\r\n\r\n /**\r\n * 묘비 등록 — <b>회수는 시간으로만</b> 한다.\r\n *\r\n * <p>⚠️ 임계를 넘어도 <b>아직 유효한 묘비는 지우지 않는다.</b> 지우는 순간 그 stream 의 늦은 DELTA 가\r\n * 슬롯을 되살려 유령 버블이 되고, 그건 \"DONE 이후 절대 되살리지 않는다\"는 이 장치의 존재 이유 자체를\r\n * 깬다. 서버 gate 가 상한 초과 시에도 pruning 대신 경보만 내는 것과 <b>같은 규칙</b>이다 —\r\n * 계약 파손이 메모리보다 훨씬 비싸다(항목당 문자열+숫자다).</p>\r\n * @private\r\n */\r\n _tombstoneAssistantStream(roomId, streamId) {\r\n let graves = this._assistantStreamTombstones.get(roomId);\r\n if (!graves) {\r\n graves = new Map();\r\n this._assistantStreamTombstones.set(roomId, graves);\r\n }\r\n const now = Date.now();\r\n graves.forEach((expiresAt, id) => {\r\n if (expiresAt <= now) graves.delete(id); // 만료분만 회수(lazy)\r\n });\r\n graves.set(streamId, now + this._assistantTombstoneTtlMs);\r\n\r\n // 경보는 임계를 <b>넘는 순간 1회</b>만 — 넘은 동안 DONE 마다 짖으면 로그가 신호를 덮는다.\r\n if (graves.size > this._assistantTombstoneWarnThreshold) {\r\n if (!this._assistantTombstoneWarned.has(roomId)) {\r\n this._assistantTombstoneWarned.add(roomId);\r\n this.logger.warn(`Assistant tombstones above threshold — NOT pruning (TTL only): room=${roomId}, size=${graves.size}, threshold=${this._assistantTombstoneWarnThreshold}`);\r\n }\r\n } else {\r\n this._assistantTombstoneWarned.delete(roomId);\r\n }\r\n }\r\n\r\n /**\r\n * Rolling 누적 요약 완료 알림(summary 채널) 수신 처리 — status 를 그대로 {@code summaryUpdate} 로 emit.\r\n *\r\n * <p>서버 {@code SummaryStreamEvent}: {@code { status: 'READY'|'NO_CHANGE'|'NO_MESSAGES'|'FAILED'|'INVALIDATED'|string, roomId, format?, languages?, updatedAt? }}.\r\n * assistant-stream 과 달리 단계/토큰 누적이 없어 상태 관리 없이 그대로 통지한다. transient(저장 안 됨)라 UI 는\r\n * 이벤트를 신호로만 쓰고 확정 내용은 {@link #getSummary} 로 읽는다. {@code READY}=요약 갱신됨(→ getSummary),\r\n * {@code FAILED}=실패(재시도 가능), {@code NO_CHANGE}=durable 작업이 변경 없이 완료,\r\n * {@code NO_MESSAGES}=해당 키에 요약할 대화 없음,\r\n * {@code INVALIDATED}=방 원문 삭제/편집으로 요약이 통째로 버려짐(→ 요약 화면을 즉시 비우고, 필요 시 refreshSummary 로 재생성).</p>\r\n *\r\n * <p><b>키 식별(format+languages)</b>: 한 refresh 가 방 활성 언어들을 fan-out 으로 한 번에 생성하므로(§S1), 완료\r\n * 이벤트의 {@code languages} 에 내 언어가 있으면 {@code getSummary({ format, lang })} 로 읽는다.\r\n * {@code READY}·{@code NO_CHANGE}·{@code NO_MESSAGES}·{@code FAILED} 모두 언어 목록을 싣는다\r\n * ({@code INVALIDATED} 만 없음). 알 수 없는 미래 status도 그대로 전달하므로 소비자는 default 분기와\r\n * one-shot {@code getSummary} 재동기화를 둔다.</p>\r\n * @private\r\n */\r\n _handleSummaryEvent(roomId, event) {\r\n if (!event || !event.status) return;\r\n this.emit('summaryUpdate', {\r\n roomId,\r\n status: event.status, // known status + future server values (pass-through)\r\n format: event.format || null, // READY/NO_CHANGE/NO_MESSAGES/FAILED 포함\r\n languages: event.languages || null, // READY/NO_CHANGE/NO_MESSAGES/FAILED 처리 언어 목록\r\n updatedAt: event.updatedAt || null\r\n });\r\n }\r\n\r\n /**\r\n * seq 커서 정규화 — 값이 없으면 {@code -1}(DELTA 0건). 첫 청크가 {@code seq=0} 이라 기본값이 0 이면\r\n * 첫 글자를 중복으로 버린다(설계 §5.5.0).\r\n * @private\r\n */\r\n _seqOf(value) {\r\n return typeof value === 'number' ? value : -1;\r\n }\r\n\r\n /**\r\n * stream 슬롯 갱신 + TTL 타이머 재무장 — {@code expiresAt}(epochMillis) 기반, 누락/이상 시 fallback.\r\n *\r\n * <p>만료 = DONE 유실로 진행이 끊긴 것 → UI 가 영구 표시에 갇히지 않게 {@code active:false} 를 자동\r\n * emit 한다. ⚠️ 만료는 <b>묘비를 세우지 않는다</b> — \"표시가 낡았다\"는 것이지 서버의 확정 종료가\r\n * 아니라서, 뒤늦게 정상 DELTA 가 오면 다시 살아나야 맞다(§7.2).</p>\r\n * @private\r\n */\r\n _setAssistantSlot(roomId, streamId, event, fields) {\r\n const prev = this._assistantSlot(roomId, streamId);\r\n if (prev && prev.timer) clearTimeout(prev.timer);\r\n\r\n let ttl = this._assistantProgressFallbackTtlMs;\r\n if (typeof event.expiresAt === 'number') {\r\n // 비정상값 방어 — [5s, 120s] 로 clamp(과거 timestamp / 과대 TTL 모두 차단).\r\n ttl = Math.min(120000, Math.max(5000, event.expiresAt - Date.now()));\r\n }\r\n const timer = setTimeout(() => {\r\n this._expireAssistantStream(roomId, streamId);\r\n }, ttl);\r\n\r\n const slot = {\r\n personaId: event.personaId || (prev && prev.personaId) || null,\r\n phase: fields.phase,\r\n progress: fields.progress || null,\r\n // 마지막으로 <b>적용된</b> PHASE 의 발행 시각 — pending PHASE 역행 판정 커서(§6.2).\r\n phaseUpdatedAt: fields.phaseUpdatedAt !== undefined\r\n ? fields.phaseUpdatedAt\r\n : (prev ? (prev.phaseUpdatedAt || 0) : 0),\r\n text: fields.text || '',\r\n lastSeq: this._seqOf(fields.lastSeq),\r\n textAvailable: fields.textAvailable !== false,\r\n resumed: fields.resumed === true || (prev ? prev.resumed === true : false),\r\n updatedAt: Date.now(),\r\n order: ++this._assistantSlotSeq, // 최신 판정 축 — updatedAt 은 진단용일 뿐이다\r\n timer\r\n };\r\n this._assistantSlots(roomId).set(streamId, slot);\r\n return slot;\r\n }\r\n\r\n /** TTL 만료 — 이 stream 만 끈다. 묘비는 세우지 않는다. @private */\r\n _expireAssistantStream(roomId, streamId) {\r\n const slot = this._assistantSlot(roomId, streamId);\r\n if (!slot) return;\r\n if (slot.timer) clearTimeout(slot.timer);\r\n this._deleteAssistantSlot(roomId, streamId);\r\n this.logger.debug(`Assistant progress auto-cleared (timeout): room=${roomId}, stream=${streamId}`);\r\n this._emitAssistantStream(roomId, streamId, slot, {\r\n active: false, phase: null, progress: null, label: null, reason: 'timeout'\r\n });\r\n }\r\n\r\n /**\r\n * {@code assistantProgress}(stream 단위) + {@code assistantRoomProgress}(방 단위 집계)를 함께 발행한다.\r\n *\r\n * <p><b>왜 둘인가</b>: 진짜 상태는 stream 단위지만, 기존 프론트는 \"방마다 임시 버블 하나\"를 전제로\r\n * 짜여 있다. 그 코드에 stream 단위 {@code active:false} 가 들어가면 <b>같은 방의 다른 살아 있는\r\n * 버블까지 숨긴다</b>. 그래서 이행용 다리로 방 단위 집계를 별도 이벤트로 함께 낸다(설계 §7.5 (b)).\r\n * 프론트는 리스너 이름만 바꿔 기존 렌더를 유지하다가, 준비되면 stream 단위로 넘어오면 된다.</p>\r\n * @private\r\n */\r\n _emitAssistantStream(roomId, streamId, slot, overrides) {\r\n const phase = 'phase' in overrides ? overrides.phase : (slot ? slot.phase : null);\r\n const progress = 'progress' in overrides ? overrides.progress : (slot ? slot.progress : null);\r\n this.emit('assistantProgress', {\r\n roomId,\r\n streamId,\r\n personaId: slot ? slot.personaId : null,\r\n phase,\r\n progress,\r\n label: this._assistantPhaseLabel(phase, progress),\r\n textAvailable: slot ? slot.textAvailable : true,\r\n lastSeq: slot ? slot.lastSeq : -1,\r\n ...overrides\r\n });\r\n this._emitAssistantRoomProgress(roomId);\r\n }\r\n\r\n /**\r\n * 방 단위 집계 — 살아 있는 stream 이 하나라도 있으면 {@code active:true}.\r\n * 표시 내용은 <b>가장 최근에 갱신된</b> stream 을 따른다(단일 버블 UI 가 자연스럽게 최신을 보여준다).\r\n * @private\r\n */\r\n _emitAssistantRoomProgress(roomId) {\r\n // 이벤트 계약은 종전 그대로 둔다 — stream 목록은 getter 전용(이벤트로 매 틱 배열을 실어 보내면\r\n // DELTA 마다 방 전체 스트림을 다시 만들어 흘리는 셈이다).\r\n const { streams, ...event } = this._assistantRoomProgressPayload(roomId);\r\n this.emit('assistantRoomProgress', event);\r\n }\r\n\r\n /**\r\n * 방 단위 집계 payload 생성 — {@link #_emitAssistantRoomProgress} 와 {@link #getAssistantProgress} 의\r\n * <b>공통 SSOT</b>. 두 곳에서 각자 조립하면 \"이벤트로 본 상태\"와 \"물어봐서 본 상태\"가 갈린다.\r\n * @private\r\n */\r\n _assistantRoomProgressPayload(roomId) {\r\n const slots = this._assistantProgress.get(roomId);\r\n const alive = slots ? Array.from(slots.entries()) : [];\r\n if (alive.length === 0) {\r\n return {\r\n roomId, active: false, streamCount: 0,\r\n streamId: null, personaId: null, phase: null, label: null, text: null, textAvailable: true,\r\n progress: null,\r\n streams: []\r\n };\r\n }\r\n const [streamId, slot] = alive.reduce((a, b) => (b[1].order > a[1].order ? b : a));\r\n return {\r\n roomId,\r\n active: true,\r\n streamCount: alive.length,\r\n streamId,\r\n personaId: slot.personaId,\r\n phase: slot.phase,\r\n progress: slot.progress || null,\r\n label: this._assistantPhaseLabel(slot.phase, slot.progress),\r\n text: slot.textAvailable ? (slot.text || null) : null,\r\n textAvailable: slot.textAvailable,\r\n // stream 단위 UI(권장 (B))가 마운트 즉시 버블을 복원할 수 있도록 전부 싣는다.\r\n // 도착 순서(order)대로 — 소비자가 \"가장 최근\"을 다시 계산하지 않아도 되게 한다.\r\n streams: alive\r\n .sort((a, b) => a[1].order - b[1].order)\r\n .map(([id, s]) => ({\r\n streamId: id,\r\n personaId: s.personaId,\r\n phase: s.phase,\r\n progress: s.progress || null,\r\n label: this._assistantPhaseLabel(s.phase, s.progress),\r\n text: s.textAvailable ? (s.text || null) : null,\r\n textAvailable: s.textAvailable,\r\n resumed: s.resumed === true\r\n }))\r\n };\r\n }\r\n\r\n /** @private */\r\n _normalizeAssistantProgress(progress) {\r\n if (!progress || !['ANALYZING', 'SUMMARIZING'].includes(progress.stage)) return null;\r\n if (progress.stage === 'SUMMARIZING') {\r\n return { stage: 'SUMMARIZING', current: null, total: null, unit: null };\r\n }\r\n const current = Number.isInteger(progress.current) ? progress.current : null;\r\n const total = Number.isInteger(progress.total) ? progress.total : null;\r\n const unit = ['DOCUMENT', 'PAGE', 'TEXT_CHUNK'].includes(progress.unit) ? progress.unit : null;\r\n if (current === null || total === null || !unit || current < 0 || total < 1 || current > total) {\r\n return null;\r\n }\r\n return { stage: 'ANALYZING', current, total, unit };\r\n }\r\n\r\n /** @private */\r\n _assistantPhaseLabel(phase, progress) {\r\n if (!phase) return null;\r\n if (phase !== 'DOCUMENT_ANALYSIS') return this._assistantPhaseLabels[phase] || null;\r\n if (progress && progress.stage === 'SUMMARIZING') return '분석 결과 종합 중…';\r\n if (!progress || progress.stage !== 'ANALYZING') return this._assistantPhaseLabels[phase];\r\n const suffix = progress.unit === 'PAGE' ? '페이지'\r\n : progress.unit === 'TEXT_CHUNK' ? '구간' : '개';\r\n return `문서 분석 중… (${progress.current}/${progress.total}${suffix})`;\r\n }\r\n\r\n /**\r\n * 방의 <b>현재</b> AI 진행 상태를 즉시 반환 (네트워크 호출 없음).\r\n *\r\n * <p>{@code assistantProgress} / {@code assistantRoomProgress} 는 transient 이벤트라, 방 화면을 나갔다\r\n * 들어오면 프론트 상태가 비어 있는데 <b>이미 지나간 이벤트는 다시 오지 않는다</b>. 마운트 시점에 이걸\r\n * 한 번 읽으면 다음 이벤트를 기다리지 않고 바로 그릴 수 있다 — 재입장 스냅샷(1 RTT)보다도 빠르다.</p>\r\n *\r\n * <p>반환 형태는 {@code assistantRoomProgress} 이벤트와 동일하고, stream 단위 UI 를 위해 {@code streams}\r\n * 배열이 추가된다. 진행 중인 것이 없으면 {@code active:false} + 빈 배열.</p>\r\n *\r\n * @param {string} roomId - 채팅방 ID\r\n * @returns {Object} { roomId, active, streamCount, streamId, personaId, phase, label, text, textAvailable, streams[] }\r\n *\r\n * @example\r\n * // 방 화면 마운트 시 — 이벤트를 기다리지 않고 즉시 복원\r\n * const p = client.chat.getAssistantProgress(roomId);\r\n * if (p.active) renderBubble(p);\r\n */\r\n getAssistantProgress(roomId) {\r\n return this._assistantRoomProgressPayload(roomId);\r\n }\r\n\r\n /**\r\n * 방의 모든 stream 상태/타이머 정리 — 구독 해제·destroy 용. 조용히 지운다(emit 없음).\r\n *\r\n * <p>묘비도 함께 지운다. 방을 떠나면 되살아날 유령 자체가 없고, 재입장은 새 세대이기 때문이다.</p>\r\n * @private\r\n */\r\n _clearAssistantProgress(roomId) {\r\n const slots = this._assistantProgress.get(roomId);\r\n if (slots) {\r\n slots.forEach((slot) => slot.timer && clearTimeout(slot.timer));\r\n this._assistantProgress.delete(roomId);\r\n }\r\n this._assistantStreamTombstones.delete(roomId);\r\n // 경고 표식도 묘비와 <b>같은 수명</b>이어야 한다. 남겨두면 재구독 후 임계를 다시 넘어도\r\n // \"이미 알렸다\"로 판정해 경고가 영영 안 나오고, Set 에 죽은 roomId 가 쌓인다.\r\n this._assistantTombstoneWarned.delete(roomId);\r\n // 보류함도 비운다 — 방을 떠났는데 큐가 남아 있으면 재구독 때 옛 이벤트가 섞여 들어간다.\r\n // (진행 중 스냅샷 응답은 아래 세대 증가로 무효화되므로 별도 취소가 필요 없다.)\r\n this._assistantSnapshotPending.delete(roomId);\r\n // 진행 중이던 스냅샷 응답이 뒤늦게 도착해도 적용되지 않도록 세대를 넘긴다(§6.1-a, R-4 가 소비).\r\n this._assistantRoomGeneration.set(roomId, (this._assistantRoomGeneration.get(roomId) || 0) + 1);\r\n }\r\n\r\n // ==================== 유틸리티 ====================\r\n\r\n /**\r\n * 구독 중인 채팅방 목록\r\n * @returns {string[]}\r\n */\r\n getSubscribedRooms() {\r\n return Array.from(this.subscribedRooms.keys());\r\n }\r\n\r\n /**\r\n * 채팅방 구독 여부 확인\r\n * @param {string} roomId\r\n * @returns {boolean}\r\n */\r\n isSubscribed(roomId) {\r\n return this.subscribedRooms.has(roomId);\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n }\r\n\r\n _cancelDelayedReconnectSync() {\r\n this._reconnectSyncGeneration++;\r\n clearTimeout(this._delayedReconnectSyncTimer);\r\n this._delayedReconnectSyncTimer = null;\r\n }\r\n\r\n /** 구독, 예약된 복구, 방별 진행 표시를 정리한다. */\r\n destroy() {\r\n this._cancelDelayedReconnectSync();\r\n this.connectionManager.off?.('stateChange', this._onStateChangeForSync);\r\n this.unsubscribeAllRooms();\r\n if (this.roomListSubscribed) {\r\n this.unsubscribeRoomList();\r\n }\r\n\r\n this._typingTimers.forEach((timer) => clearTimeout(timer));\r\n this._typingTimers.clear();\r\n\r\n // 어시 typing 수신 타이머도 전체 cleanup — unsubscribeRoom 이 방별로 정리하지만 구독 목록에\r\n // 없는 roomId 의 타이머가 남아 있을 수 있어 명시 정리 (메모리 누수 차단).\r\n this._assistantTypingTimers.forEach((timer) => clearTimeout(timer));\r\n this._assistantTypingTimers.clear();\r\n\r\n // AI 진행 표시 상태/타이머도 전체 cleanup (구독 외 경로로 남은 roomId 방어).\r\n // 슬롯이 방 → stream 2단이라 안쪽까지 순회해야 타이머가 남지 않는다(v5.0.0).\r\n this._assistantProgress.forEach((slots) => slots.forEach((slot) => slot.timer && clearTimeout(slot.timer)));\r\n this._assistantProgress.clear();\r\n this._assistantStreamTombstones.clear();\r\n this._assistantTombstoneWarned.clear();\r\n this._assistantRoomGeneration.clear();\r\n this._assistantSnapshotPending.clear();\r\n this._assistantSnapshotInFlight.clear();\r\n\r\n // dedup bucket 전체 clear — unsubscribeRoom 이 방별로 정리하지만 구독 외 경로로 누적된 bucket\r\n // (예: 구독 안 한 방의 push/listener) 이 있을 수 있어 안전하게 명시 정리.\r\n this._seenChatMessageIdsByRoom.clear();\r\n this._seenRoomListMessageIdsByRoom.clear();\r\n\r\n // 갭 복구 트리거 해제 — connectionManager 는 ChatClient 보다 오래 살 수 있어(사용자 전환 등)\r\n // 리스너를 남기면 죽은 ChatClient 가 계속 깨어난다.\r\n if (this.connectionManager && typeof this.connectionManager.off === 'function') {\r\n if (this._onReconnectedForSync) {\r\n this.connectionManager.off('reconnected', this._onReconnectedForSync);\r\n }\r\n if (this._onResumedForSync) {\r\n this.connectionManager.off('resumed', this._onResumedForSync);\r\n }\r\n }\r\n this._anchor.clear();\r\n this._anchorGen.clear();\r\n this._syncInFlight.clear();\r\n\r\n this.removeAllListeners();\r\n this.logger.info('ChatClient destroyed');\r\n }\r\n}\r\n\r\nexport default ChatClient;\r\n","/**\r\n * MediaStreamManager\r\n * 로컬 미디어 스트림 관리 (카메라/마이크/화면공유)\r\n */\r\n\r\nimport EventEmitter from '../utils/EventEmitter.js';\r\nimport Logger from '../utils/Logger.js';\r\nimport { ErrorTypes, LogLevel } from '../constants.js';\r\n\r\nclass MediaStreamManager extends EventEmitter {\r\n /**\r\n * @param {Object} options\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options = {}) {\r\n super();\r\n\r\n this.localStream = null;\r\n this.screenStream = null;\r\n this.videoEnabled = true;\r\n this.audioEnabled = true;\r\n\r\n this.logger = new Logger(options.logLevel || LogLevel.WARN, 'MediaStreamManager');\r\n this._deviceChangeHandler = null;\r\n }\r\n\r\n /**\r\n * 사용자 미디어 가져오기\r\n * @param {Object} constraints - 미디어 제약조건\r\n * @returns {Promise<MediaStream>}\r\n */\r\n async getUserMedia(constraints = { video: true, audio: true }) {\r\n try {\r\n this.localStream = await navigator.mediaDevices.getUserMedia(constraints);\r\n this.videoEnabled = constraints.video !== false;\r\n this.audioEnabled = constraints.audio !== false;\r\n\r\n this.emit('streamStarted', { stream: this.localStream });\r\n this.logger.info('User media stream started');\r\n return this.localStream;\r\n } catch (error) {\r\n this.logger.error('Failed to get user media:', error);\r\n this.emit('error', {\r\n type: ErrorTypes.MEDIA_ACCESS_DENIED,\r\n message: this._getMediaErrorMessage(error),\r\n error\r\n });\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 미디어 에러 메시지 변환\r\n * @private\r\n * @param {Error} error - 에러 객체\r\n * @returns {string} 변환된 에러 메시지\r\n */\r\n _getMediaErrorMessage(error) {\r\n switch (error.name) {\r\n case 'NotAllowedError':\r\n return 'Camera/Microphone permission denied';\r\n case 'NotFoundError':\r\n return 'Camera/Microphone not found';\r\n case 'NotReadableError':\r\n return 'Camera/Microphone is already in use';\r\n case 'OverconstrainedError':\r\n return 'Camera/Microphone constraints cannot be satisfied';\r\n case 'AbortError':\r\n return 'Media access aborted';\r\n default:\r\n return error.message || 'Unknown media error';\r\n }\r\n }\r\n\r\n /**\r\n * 화면 공유 가져오기\r\n * @param {Object} [options] - 화면 공유 옵션\r\n * @returns {Promise<MediaStream>}\r\n */\r\n async getDisplayMedia(options = { video: true, audio: false }) {\r\n try {\r\n this.screenStream = await navigator.mediaDevices.getDisplayMedia(options);\r\n\r\n // 화면 공유 종료 감지\r\n this.screenStream.getVideoTracks()[0].onended = () => {\r\n this.emit('screenShareEnded', {});\r\n this.screenStream = null;\r\n };\r\n\r\n this.emit('screenShareStarted', { stream: this.screenStream });\r\n this.logger.info('Screen share stream started');\r\n return this.screenStream;\r\n } catch (error) {\r\n this.logger.error('Failed to get display media:', error);\r\n this.emit('error', {\r\n type: ErrorTypes.SCREEN_SHARE_DENIED,\r\n message: error.name === 'NotAllowedError'\r\n ? 'Screen share permission denied'\r\n : error.message,\r\n error\r\n });\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 비디오 토글\r\n * @returns {boolean} 현재 상태\r\n */\r\n toggleVideo() {\r\n if (!this.localStream) {\r\n this.logger.warn('No local stream available');\r\n return this.videoEnabled;\r\n }\r\n\r\n const videoTracks = this.localStream.getVideoTracks();\r\n if (videoTracks.length === 0) {\r\n this.logger.warn('No video track available');\r\n return this.videoEnabled;\r\n }\r\n\r\n this.videoEnabled = !this.videoEnabled;\r\n videoTracks.forEach(track => {\r\n track.enabled = this.videoEnabled;\r\n });\r\n\r\n this.emit('videoToggled', { enabled: this.videoEnabled });\r\n this.logger.debug(`Video toggled: ${this.videoEnabled}`);\r\n return this.videoEnabled;\r\n }\r\n\r\n /**\r\n * 오디오 토글\r\n * @returns {boolean} 현재 상태\r\n */\r\n toggleAudio() {\r\n if (!this.localStream) {\r\n this.logger.warn('No local stream available');\r\n return this.audioEnabled;\r\n }\r\n\r\n const audioTracks = this.localStream.getAudioTracks();\r\n if (audioTracks.length === 0) {\r\n this.logger.warn('No audio track available');\r\n return this.audioEnabled;\r\n }\r\n\r\n this.audioEnabled = !this.audioEnabled;\r\n audioTracks.forEach(track => {\r\n track.enabled = this.audioEnabled;\r\n });\r\n\r\n this.emit('audioToggled', { enabled: this.audioEnabled });\r\n this.logger.debug(`Audio toggled: ${this.audioEnabled}`);\r\n return this.audioEnabled;\r\n }\r\n\r\n /**\r\n * 비디오 상태 설정\r\n * @param {boolean} enabled\r\n */\r\n setVideoEnabled(enabled) {\r\n if (!this.localStream) {\r\n this.logger.warn('No local stream available');\r\n return;\r\n }\r\n\r\n const videoTracks = this.localStream.getVideoTracks();\r\n videoTracks.forEach(track => {\r\n track.enabled = enabled;\r\n });\r\n this.videoEnabled = enabled;\r\n this.emit('videoToggled', { enabled });\r\n }\r\n\r\n /**\r\n * 오디오 상태 설정\r\n * @param {boolean} enabled\r\n */\r\n setAudioEnabled(enabled) {\r\n if (!this.localStream) {\r\n this.logger.warn('No local stream available');\r\n return;\r\n }\r\n\r\n const audioTracks = this.localStream.getAudioTracks();\r\n audioTracks.forEach(track => {\r\n track.enabled = enabled;\r\n });\r\n this.audioEnabled = enabled;\r\n this.emit('audioToggled', { enabled });\r\n }\r\n\r\n /**\r\n * 로컬 스트림 가져오기\r\n * @returns {MediaStream|null}\r\n */\r\n getLocalStream() {\r\n return this.localStream;\r\n }\r\n\r\n /**\r\n * 화면 공유 스트림 가져오기\r\n * @returns {MediaStream|null}\r\n */\r\n getScreenStream() {\r\n return this.screenStream;\r\n }\r\n\r\n /**\r\n * 현재 비디오 상태\r\n * @returns {boolean}\r\n */\r\n isVideoEnabled() {\r\n return this.videoEnabled;\r\n }\r\n\r\n /**\r\n * 현재 오디오 상태\r\n * @returns {boolean}\r\n */\r\n isAudioEnabled() {\r\n return this.audioEnabled;\r\n }\r\n\r\n /**\r\n * 모든 트랙 중지\r\n */\r\n stopAll() {\r\n if (this.localStream) {\r\n this.localStream.getTracks().forEach(track => track.stop());\r\n this.localStream = null;\r\n this.emit('streamStopped', {});\r\n this.logger.info('All media tracks stopped');\r\n }\r\n\r\n if (this.screenStream) {\r\n this.screenStream.getTracks().forEach(track => track.stop());\r\n this.screenStream = null;\r\n }\r\n }\r\n\r\n /**\r\n * 특정 트랙 교체 (내부용 - switchDevice에서 호출)\r\n * @private\r\n * @param {MediaStreamTrack} oldTrack\r\n * @param {MediaStreamTrack} newTrack\r\n */\r\n replaceTrack(oldTrack, newTrack) {\r\n if (!this.localStream) {\r\n this.logger.warn('No local stream available');\r\n return;\r\n }\r\n\r\n this.localStream.removeTrack(oldTrack);\r\n this.localStream.addTrack(newTrack);\r\n oldTrack.stop();\r\n\r\n this.emit('trackReplaced', { oldTrack, newTrack });\r\n this.logger.debug(`Track replaced: ${oldTrack.kind}`);\r\n }\r\n\r\n /**\r\n * 특정 종류의 트랙 가져오기\r\n * @param {string} kind - 'video' or 'audio'\r\n * @returns {MediaStreamTrack|null}\r\n */\r\n getTrack(kind) {\r\n if (!this.localStream) return null;\r\n\r\n const tracks = kind === 'video'\r\n ? this.localStream.getVideoTracks()\r\n : this.localStream.getAudioTracks();\r\n\r\n return tracks.length > 0 ? tracks[0] : null;\r\n }\r\n\r\n /**\r\n * 디바이스 목록 가져오기\r\n * @returns {Promise<Object>}\r\n */\r\n async getDevices() {\r\n try {\r\n const devices = await navigator.mediaDevices.enumerateDevices();\r\n\r\n return {\r\n videoInputs: devices.filter(d => d.kind === 'videoinput'),\r\n audioInputs: devices.filter(d => d.kind === 'audioinput'),\r\n audioOutputs: devices.filter(d => d.kind === 'audiooutput')\r\n };\r\n } catch (error) {\r\n this.logger.error('Failed to enumerate devices:', error);\r\n this.emit('error', {\r\n type: ErrorTypes.ENUMERATE_DEVICES_FAILED,\r\n message: error.message,\r\n error\r\n });\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 특정 디바이스로 전환\r\n * @param {string} deviceId - 디바이스 ID\r\n * @param {string} kind - 'video' or 'audio'\r\n * @returns {Promise<MediaStreamTrack>}\r\n * @throws {Error} 스트림이 없거나 디바이스 전환 실패 시\r\n */\r\n async switchDevice(deviceId, kind) {\r\n if (!this.localStream) {\r\n throw new Error('No local stream available');\r\n }\r\n\r\n try {\r\n const constraints = kind === 'video'\r\n ? { video: { deviceId: { exact: deviceId } }, audio: false }\r\n : { video: false, audio: { deviceId: { exact: deviceId } } };\r\n\r\n const newStream = await navigator.mediaDevices.getUserMedia(constraints);\r\n const newTrack = newStream.getTracks()[0];\r\n\r\n const oldTrack = kind === 'video'\r\n ? this.localStream.getVideoTracks()[0]\r\n : this.localStream.getAudioTracks()[0];\r\n\r\n if (oldTrack) {\r\n this.replaceTrack(oldTrack, newTrack);\r\n\r\n this.emit('deviceSwitched', {\r\n kind,\r\n deviceId,\r\n newTrack\r\n });\r\n\r\n this.logger.info(`${kind} device switched to ${deviceId}`);\r\n }\r\n\r\n return newTrack;\r\n\r\n } catch (error) {\r\n this.logger.error(`Failed to switch ${kind} device:`, error);\r\n this.emit('error', {\r\n type: ErrorTypes.DEVICE_SWITCH_FAILED,\r\n message: error.message,\r\n error\r\n });\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 디바이스 변경 감지 시작\r\n */\r\n startDeviceChangeDetection() {\r\n if (this._deviceChangeHandler) {\r\n return;\r\n }\r\n\r\n this._deviceChangeHandler = async () => {\r\n try {\r\n const devices = await this.getDevices();\r\n this.emit('deviceChange', devices);\r\n this.logger.debug('Device change detected');\r\n } catch (error) {\r\n this.logger.warn('Failed to get devices on change:', error);\r\n }\r\n };\r\n\r\n navigator.mediaDevices.addEventListener('devicechange', this._deviceChangeHandler);\r\n this.logger.debug('Device change detection started');\r\n }\r\n\r\n /**\r\n * 디바이스 변경 감지 중지\r\n */\r\n stopDeviceChangeDetection() {\r\n if (this._deviceChangeHandler) {\r\n navigator.mediaDevices.removeEventListener('devicechange', this._deviceChangeHandler);\r\n this._deviceChangeHandler = null;\r\n this.logger.debug('Device change detection stopped');\r\n }\r\n }\r\n\r\n /**\r\n * 스트림 정보\r\n * @returns {Object}\r\n */\r\n getStreamInfo() {\r\n if (!this.localStream) {\r\n return {\r\n hasStream: false,\r\n videoEnabled: this.videoEnabled,\r\n audioEnabled: this.audioEnabled,\r\n tracks: []\r\n };\r\n }\r\n\r\n return {\r\n hasStream: true,\r\n videoEnabled: this.videoEnabled,\r\n audioEnabled: this.audioEnabled,\r\n tracks: this.localStream.getTracks().map(track => ({\r\n kind: track.kind,\r\n id: track.id,\r\n label: track.label,\r\n enabled: track.enabled,\r\n readyState: track.readyState,\r\n muted: track.muted\r\n }))\r\n };\r\n }\r\n\r\n /**\r\n * 비디오 해상도 변경\r\n * @param {Object} constraints - { width, height, frameRate }\r\n */\r\n async applyVideoConstraints(constraints) {\r\n if (!this.localStream) {\r\n throw new Error('No local stream available');\r\n }\r\n\r\n const videoTrack = this.localStream.getVideoTracks()[0];\r\n if (!videoTrack) {\r\n throw new Error('No video track available');\r\n }\r\n\r\n try {\r\n await videoTrack.applyConstraints(constraints);\r\n this.emit('videoConstraintsApplied', { constraints });\r\n this.logger.info('Video constraints applied:', constraints);\r\n } catch (error) {\r\n this.logger.error('Failed to apply video constraints:', error);\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 현재 비디오 설정 가져오기\r\n * @returns {MediaTrackSettings|null}\r\n */\r\n getVideoSettings() {\r\n if (!this.localStream) return null;\r\n\r\n const videoTrack = this.localStream.getVideoTracks()[0];\r\n return videoTrack ? videoTrack.getSettings() : null;\r\n }\r\n\r\n /**\r\n * 현재 오디오 설정 가져오기\r\n * @returns {MediaTrackSettings|null}\r\n */\r\n getAudioSettings() {\r\n if (!this.localStream) return null;\r\n\r\n const audioTrack = this.localStream.getAudioTracks()[0];\r\n return audioTrack ? audioTrack.getSettings() : null;\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n }\r\n\r\n /**\r\n * 리소스 정리\r\n */\r\n destroy() {\r\n this.stopAll();\r\n this.stopDeviceChangeDetection();\r\n this.removeAllListeners();\r\n this.logger.info('MediaStreamManager destroyed');\r\n }\r\n}\r\n\r\nexport default MediaStreamManager;\r\n","/**\r\n * PeerConnectionManager\r\n * WebRTC Peer 연결 관리 (Perfect Negotiation 패턴)\r\n */\r\n\r\nimport EventEmitter from '../utils/EventEmitter.js';\r\nimport Logger from '../utils/Logger.js';\r\nimport { ErrorTypes, DefaultConfig, LogLevel } from '../constants.js';\r\n\r\nclass PeerConnectionManager extends EventEmitter {\r\n /**\r\n * @param {Object} options\r\n * @param {Object[]} [options.iceServers] - ICE 서버 설정\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options = {}) {\r\n super();\r\n\r\n this.iceServers = options.iceServers || DefaultConfig.iceServers;\r\n this.logger = new Logger(options.logLevel || LogLevel.WARN, 'PeerConnectionManager');\r\n\r\n // 피어 연결 맵: peerId -> { connection, polite, makingOffer, ignoreOffer }\r\n this.peers = new Map();\r\n\r\n // 로컬 스트림\r\n this.localStream = null;\r\n }\r\n\r\n /**\r\n * 로컬 스트림 설정\r\n * @param {MediaStream} stream\r\n */\r\n setLocalStream(stream) {\r\n this.localStream = stream;\r\n\r\n // 기존 연결에 트랙 추가\r\n this.peers.forEach((peer) => {\r\n this._addTracksToConnection(peer.connection);\r\n });\r\n }\r\n\r\n /**\r\n * ICE 서버 설정\r\n * @param {Object[]} servers\r\n */\r\n setIceServers(servers) {\r\n this.iceServers = servers;\r\n }\r\n\r\n /**\r\n * 피어 연결 생성\r\n * @param {string} peerId - 피어 ID\r\n * @param {boolean} polite - Polite 피어 여부 (Perfect Negotiation)\r\n * @param {Object} [options] - 추가 옵션\r\n * @param {boolean} [options.skipAutoNegotiation=false] - 자동 negotiation 건너뛰기\r\n * @returns {RTCPeerConnection}\r\n */\r\n createPeerConnection(peerId, polite = false, options = {}) {\r\n if (this.peers.has(peerId)) {\r\n this.logger.warn(`Peer connection already exists: ${peerId}`);\r\n return this.peers.get(peerId).connection;\r\n }\r\n\r\n const config = {\r\n iceServers: this.iceServers,\r\n iceCandidatePoolSize: 10\r\n };\r\n\r\n const connection = new RTCPeerConnection(config);\r\n\r\n const peerState = {\r\n connection,\r\n polite,\r\n makingOffer: false,\r\n ignoreOffer: false,\r\n isSettingRemoteAnswerPending: false,\r\n skipAutoNegotiation: options.skipAutoNegotiation || false\r\n };\r\n\r\n this.peers.set(peerId, peerState);\r\n\r\n // 이벤트 핸들러 설정\r\n this._setupConnectionHandlers(peerId, connection, peerState);\r\n\r\n // 로컬 트랙 추가\r\n if (this.localStream) {\r\n this._addTracksToConnection(connection);\r\n }\r\n\r\n this.logger.info(`Peer connection created: ${peerId} (polite: ${polite}, skipAutoNegotiation: ${peerState.skipAutoNegotiation})`);\r\n return connection;\r\n }\r\n\r\n /** 그룹 시작 중 answer를 먼저 보낸 peer에 로컬 트랙을 보낼 협상을 재개한다. */\r\n async resumeAutoNegotiation(peerId) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer || peer.connection.signalingState !== 'stable') return false;\r\n peer.skipAutoNegotiation = false;\r\n await peer.connection.onnegotiationneeded();\r\n return true;\r\n }\r\n\r\n /**\r\n * 연결 이벤트 핸들러 설정\r\n * @private\r\n * @param {string} peerId - 피어 ID\r\n * @param {RTCPeerConnection} connection - 피어 연결\r\n * @param {Object} peerState - 피어 상태 객체\r\n */\r\n _setupConnectionHandlers(peerId, connection, peerState) {\r\n // ICE Candidate 이벤트\r\n connection.onicecandidate = (event) => {\r\n if (event.candidate) {\r\n this.emit('iceCandidate', {\r\n peerId,\r\n candidate: event.candidate\r\n });\r\n }\r\n };\r\n\r\n // ICE 연결 상태 변경\r\n connection.oniceconnectionstatechange = () => {\r\n this.logger.debug(`ICE connection state (${peerId}): ${connection.iceConnectionState}`);\r\n\r\n this.emit('iceConnectionStateChange', {\r\n peerId,\r\n state: connection.iceConnectionState\r\n });\r\n\r\n if (connection.iceConnectionState === 'failed') {\r\n this.emit('error', {\r\n type: ErrorTypes.ICE_CONNECTION_FAILED,\r\n peerId,\r\n message: 'ICE connection failed'\r\n });\r\n }\r\n };\r\n\r\n // 연결 상태 변경\r\n connection.onconnectionstatechange = () => {\r\n this.logger.debug(`Connection state (${peerId}): ${connection.connectionState}`);\r\n\r\n this.emit('connectionStateChange', {\r\n peerId,\r\n state: connection.connectionState\r\n });\r\n\r\n if (connection.connectionState === 'connected') {\r\n this.emit('peerConnected', { peerId });\r\n } else if (connection.connectionState === 'disconnected' || connection.connectionState === 'failed') {\r\n this.emit('peerDisconnected', { peerId });\r\n }\r\n };\r\n\r\n // 협상 필요 (Perfect Negotiation)\r\n connection.onnegotiationneeded = async () => {\r\n // 1:1 통화에서 수동으로 offer를 생성할 때는 자동 negotiation 건너뛰기\r\n if (peerState.skipAutoNegotiation || peerState.makingOffer || connection.signalingState !== 'stable') {\r\n this.logger.debug(`Skipping auto negotiation for ${peerId} (manual offer will be sent)`);\r\n return;\r\n }\r\n\r\n try {\r\n peerState.makingOffer = true;\r\n await connection.setLocalDescription();\r\n\r\n this.emit('negotiationNeeded', {\r\n peerId,\r\n description: connection.localDescription\r\n });\r\n\r\n } catch (error) {\r\n this.logger.error(`Negotiation error (${peerId}):`, error);\r\n } finally {\r\n peerState.makingOffer = false;\r\n }\r\n };\r\n\r\n // 원격 트랙 수신\r\n connection.ontrack = (event) => {\r\n this.logger.info(`Remote track received from ${peerId}:`, event.track.kind);\r\n\r\n this.emit('remoteTrack', {\r\n peerId,\r\n track: event.track,\r\n streams: event.streams\r\n });\r\n };\r\n\r\n // 데이터 채널 수신\r\n connection.ondatachannel = (event) => {\r\n this.logger.info(`Data channel received from ${peerId}`);\r\n this.emit('dataChannel', {\r\n peerId,\r\n channel: event.channel\r\n });\r\n };\r\n }\r\n\r\n /**\r\n * 연결에 트랙 추가\r\n * @private\r\n * @param {RTCPeerConnection} connection - 피어 연결\r\n */\r\n _addTracksToConnection(connection) {\r\n if (!this.localStream) return;\r\n\r\n const existingSenders = connection.getSenders();\r\n\r\n this.localStream.getTracks().forEach(track => {\r\n // 이미 추가된 트랙인지 확인\r\n const existingSender = existingSenders.find(sender =>\r\n sender.track && sender.track.kind === track.kind\r\n );\r\n\r\n if (!existingSender) {\r\n connection.addTrack(track, this.localStream);\r\n }\r\n });\r\n }\r\n\r\n /**\r\n * Offer 생성 및 전송\r\n * @param {string} peerId\r\n * @returns {Promise<RTCSessionDescription>}\r\n */\r\n async createOffer(peerId) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n throw new Error(`Peer not found: ${peerId}`);\r\n }\r\n\r\n try {\r\n const offer = await peer.connection.createOffer();\r\n await peer.connection.setLocalDescription(offer);\r\n\r\n this.logger.debug(`Offer created for ${peerId}`);\r\n return peer.connection.localDescription;\r\n\r\n } catch (error) {\r\n this.logger.error(`Failed to create offer for ${peerId}:`, error);\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * Answer 생성\r\n * @param {string} peerId\r\n * @returns {Promise<RTCSessionDescription>}\r\n */\r\n async createAnswer(peerId) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n throw new Error(`Peer not found: ${peerId}`);\r\n }\r\n\r\n try {\r\n const answer = await peer.connection.createAnswer();\r\n await peer.connection.setLocalDescription(answer);\r\n\r\n this.logger.debug(`Answer created for ${peerId}`);\r\n return peer.connection.localDescription;\r\n\r\n } catch (error) {\r\n this.logger.error(`Failed to create answer for ${peerId}:`, error);\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 원격 Description 설정 (Perfect Negotiation)\r\n * @param {string} peerId\r\n * @param {RTCSessionDescription} description\r\n */\r\n async handleRemoteDescription(peerId, description) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n throw new Error(`Peer not found: ${peerId}`);\r\n }\r\n\r\n const { connection, polite, makingOffer } = peer;\r\n\r\n // Perfect Negotiation: offer 충돌 감지\r\n const offerCollision = description.type === 'offer' &&\r\n (makingOffer || connection.signalingState !== 'stable');\r\n\r\n // Impolite는 충돌 시 상대 offer 무시\r\n peer.ignoreOffer = !polite && offerCollision;\r\n\r\n if (peer.ignoreOffer) {\r\n this.logger.debug(`Ignoring offer collision from ${peerId} (I am impolite)`);\r\n return;\r\n }\r\n\r\n try {\r\n // Perfect Negotiation: Polite가 충돌 시 자신의 offer를 roll back\r\n if (offerCollision && polite) {\r\n this.logger.debug(`Offer collision detected, rolling back local offer for ${peerId} (I am polite)`);\r\n await connection.setLocalDescription({ type: 'rollback' });\r\n }\r\n\r\n peer.isSettingRemoteAnswerPending = description.type === 'answer';\r\n await connection.setRemoteDescription(description);\r\n peer.isSettingRemoteAnswerPending = false;\r\n\r\n this.logger.debug(`Remote ${description.type} set for ${peerId}`);\r\n\r\n // Offer를 받았으면 Answer 생성\r\n if (description.type === 'offer') {\r\n const answer = await this.createAnswer(peerId);\r\n this.emit('answerCreated', { peerId, answer });\r\n }\r\n\r\n } catch (error) {\r\n this.logger.error(`Failed to set remote description for ${peerId}:`, error);\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * ICE Candidate 추가\r\n * @param {string} peerId\r\n * @param {RTCIceCandidate} candidate\r\n * @returns {Promise<boolean>} 추가 성공 여부 (false면 나중에 다시 시도 필요)\r\n */\r\n async addIceCandidate(peerId, candidate) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n this.logger.warn(`Peer not found for ICE candidate: ${peerId}`);\r\n return false;\r\n }\r\n\r\n // remoteDescription이 설정되지 않았으면 추가 불가\r\n if (!peer.connection.remoteDescription) {\r\n this.logger.debug(`Remote description not set yet for ${peerId}, ICE candidate queued`);\r\n return false;\r\n }\r\n\r\n try {\r\n await peer.connection.addIceCandidate(candidate);\r\n this.logger.debug(`ICE candidate added for ${peerId}`);\r\n return true;\r\n } catch (error) {\r\n if (!peer.ignoreOffer) {\r\n this.logger.error(`Failed to add ICE candidate for ${peerId}:`, error);\r\n }\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * 특정 피어 연결 가져오기\r\n * @param {string} peerId\r\n * @returns {RTCPeerConnection|null}\r\n */\r\n getPeerConnection(peerId) {\r\n const peer = this.peers.get(peerId);\r\n return peer ? peer.connection : null;\r\n }\r\n\r\n /**\r\n * 모든 피어 ID 목록\r\n * @returns {string[]}\r\n */\r\n getPeerIds() {\r\n return Array.from(this.peers.keys());\r\n }\r\n\r\n /**\r\n * 피어 연결 종료\r\n * @param {string} peerId\r\n */\r\n closePeerConnection(peerId) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n return;\r\n }\r\n\r\n peer.connection.close();\r\n this.peers.delete(peerId);\r\n\r\n this.emit('peerClosed', { peerId });\r\n this.logger.info(`Peer connection closed: ${peerId}`);\r\n }\r\n\r\n /**\r\n * 모든 피어 연결 종료\r\n */\r\n closeAllPeerConnections() {\r\n this.peers.forEach((peer, peerId) => {\r\n peer.connection.close();\r\n this.logger.debug(`Peer connection closed: ${peerId}`);\r\n });\r\n this.peers.clear();\r\n this.emit('allPeersClosed', {});\r\n }\r\n\r\n /**\r\n * 트랙 교체\r\n * @param {MediaStreamTrack} oldTrack - 교체할 기존 트랙\r\n * @param {MediaStreamTrack} newTrack - 새 트랙\r\n * @returns {Promise<void>}\r\n */\r\n async replaceTrack(oldTrack, newTrack) {\r\n const promises = [];\r\n\r\n this.peers.forEach((peer, peerId) => {\r\n const sender = peer.connection.getSenders().find(s =>\r\n s.track && s.track.kind === oldTrack.kind\r\n );\r\n\r\n if (sender) {\r\n promises.push(\r\n sender.replaceTrack(newTrack)\r\n .then(() => this.logger.debug(`Track replaced for ${peerId}`))\r\n .catch(error => this.logger.error(`Failed to replace track for ${peerId}:`, error))\r\n );\r\n }\r\n });\r\n\r\n await Promise.all(promises);\r\n }\r\n\r\n /**\r\n * 연결 통계 가져오기\r\n * @param {string} peerId\r\n * @returns {Promise<RTCStatsReport|null>}\r\n */\r\n async getStats(peerId) {\r\n const peer = this.peers.get(peerId);\r\n if (!peer) {\r\n return null;\r\n }\r\n\r\n return peer.connection.getStats();\r\n }\r\n\r\n /**\r\n * 모든 연결 상태 요약\r\n * @returns {Object}\r\n */\r\n getConnectionSummary() {\r\n const summary = {};\r\n\r\n this.peers.forEach((peer, peerId) => {\r\n summary[peerId] = {\r\n connectionState: peer.connection.connectionState,\r\n iceConnectionState: peer.connection.iceConnectionState,\r\n signalingState: peer.connection.signalingState,\r\n polite: peer.polite\r\n };\r\n });\r\n\r\n return summary;\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n }\r\n\r\n /**\r\n * 리소스 정리\r\n */\r\n destroy() {\r\n this.closeAllPeerConnections();\r\n this.localStream = null;\r\n this.removeAllListeners();\r\n this.logger.info('PeerConnectionManager destroyed');\r\n }\r\n}\r\n\r\nexport default PeerConnectionManager;\r\n","/**\r\n * WebRTCClient\r\n * WebRTC 통화 기능 클라이언트\r\n */\r\n\r\nimport EventEmitter from '../utils/EventEmitter.js';\r\nimport Logger from '../utils/Logger.js';\r\nimport MediaStreamManager from './MediaStreamManager.js';\r\nimport PeerConnectionManager from './PeerConnectionManager.js';\r\nimport { WebSocketPaths, SignalTypes, ErrorTypes, DefaultConfig, LogLevel } from '../constants.js';\r\n\r\n/** 1:1 통화 제어 신호. 받는 쪽은 roomId 가 없어야 1:1 신호로 받는다(_acceptsSignal). */\r\nconst DIRECT_CONTROL_SIGNALS = new Set([\r\n SignalTypes.CALL_REQUEST, SignalTypes.CALL_ACCEPT, SignalTypes.CALL_REJECT,\r\n SignalTypes.CALL_CANCEL, SignalTypes.CALL_BUSY\r\n]);\r\n\r\n/** 받은 초대·내가 연 방은 응답 전까지만 의미가 있다. 오래된 것부터 잊는다. */\r\nconst MAX_REMEMBERED_CALL_ROOMS = 32;\r\n\r\nclass WebRTCClient extends EventEmitter {\r\n /**\r\n * @param {Object} options\r\n * @param {Object} options.connectionManager - ConnectionManager 인스턴스\r\n * @param {Object} options.apiClient - ApiClient 인스턴스\r\n * @param {string} options.userId - 사용자 ID\r\n * @param {Object[]} [options.iceServers] - ICE 서버 설정\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options) {\r\n super();\r\n\r\n this.connectionManager = options.connectionManager;\r\n this.apiClient = options.apiClient;\r\n this.userId = options.userId;\r\n this.logLevel = options.logLevel || LogLevel.WARN;\r\n\r\n this.logger = new Logger(this.logLevel, 'WebRTCClient');\r\n\r\n // 미디어 및 피어 연결 매니저\r\n this.mediaManager = new MediaStreamManager({ logLevel: this.logLevel });\r\n this.peerManager = new PeerConnectionManager({\r\n iceServers: options.iceServers || DefaultConfig.iceServers,\r\n logLevel: this.logLevel\r\n });\r\n\r\n // 현재 통화 상태\r\n this.currentRoom = null;\r\n this.isGroupCall = false;\r\n this.participants = new Map();\r\n this._callActive = false;\r\n this._callGeneration = 0;\r\n this._deferredGroupPeers = new Map();\r\n this._joinedGroupRoom = null;\r\n this._groupJoin = null;\r\n this._directCallPeer = null;\r\n this._incomingCallPeer = null;\r\n // 서버(REST)가 roomId 를 붙여 보내는 그룹 초대 취소·거절을 받을 방. callRoomId -> host / 나\r\n this._receivedInvitations = new Map();\r\n this._hostedCallRooms = new Map();\r\n // 서버는 생성 응답 전에 초대를 보낸다(CreateCallRoomService). 응답보다 먼저 온 거절은 응답을 받을 때까지 보관한다.\r\n this._pendingRoomCreations = 0;\r\n this._earlyRoomRejects = [];\r\n this._groupRecoveryTimer = null;\r\n this._groupRecoveryAttempts = 0;\r\n this._onReconnected = () => {\r\n this._recoverGroupParticipation().catch(error => this.logger.error('Call recovery failed:', error));\r\n };\r\n this.connectionManager.on?.('reconnected', this._onReconnected);\r\n\r\n // ICE 서버 초기화 상태\r\n this._iceServersFetched = false;\r\n\r\n // 1:1 통화 대기 상태 (CALL_REQUEST 전송 후 CALL_ACCEPT 대기)\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n\r\n // ICE candidate 큐 (peer connection 생성 전에 도착한 candidate 저장)\r\n this._pendingIceCandidates = new Map();\r\n\r\n // 이벤트 연결\r\n this._setupEventHandlers();\r\n }\r\n\r\n /**\r\n * ICE 서버 설정 초기화 (TURN credentials 가져오기)\r\n * 통화 시작 전 호출하면 더 빠른 연결 가능\r\n * @returns {Promise<void>}\r\n */\r\n async initializeIceServers() {\r\n if (this._iceServersFetched) {\r\n return;\r\n }\r\n\r\n try {\r\n const data = await this.getTurnCredentials();\r\n if (data && data.iceServers) {\r\n this.peerManager.setIceServers(data.iceServers);\r\n this._iceServersFetched = true;\r\n this.logger.info('ICE servers fetched successfully');\r\n }\r\n } catch (error) {\r\n this.logger.warn('Failed to fetch TURN credentials, using fallback ICE servers:', error.message);\r\n this._setFallbackIceServers();\r\n }\r\n }\r\n\r\n /**\r\n * 기본 ICE 서버 설정 (Fallback)\r\n * @private\r\n */\r\n _setFallbackIceServers() {\r\n this.peerManager.setIceServers([\r\n { urls: 'stun:stun.l.google.com:19302' },\r\n { urls: 'stun:stun1.l.google.com:19302' }\r\n ]);\r\n }\r\n\r\n /**\r\n * 이벤트 핸들러 설정\r\n * @private\r\n */\r\n _setupEventHandlers() {\r\n // 미디어 이벤트\r\n this.mediaManager.on('streamStarted', (data) => this.emit('localStreamStarted', data));\r\n this.mediaManager.on('streamStopped', (data) => this.emit('localStreamStopped', data));\r\n this.mediaManager.on('videoToggled', () => this._sendMediaState());\r\n this.mediaManager.on('audioToggled', () => this._sendMediaState());\r\n this.mediaManager.on('screenShareStarted', (data) => this.emit('screenShareStarted', data));\r\n this.mediaManager.on('screenShareEnded', (data) => this.emit('screenShareEnded', data));\r\n this.mediaManager.on('error', (error) => this.emit('error', error));\r\n\r\n // 피어 연결 이벤트\r\n this.peerManager.on('remoteTrack', (data) => this.emit('remoteTrack', data));\r\n this.peerManager.on('peerConnected', (data) => this.emit('peerConnected', data));\r\n this.peerManager.on('peerDisconnected', (data) => this.emit('peerDisconnected', data));\r\n this.peerManager.on('peerClosed', (data) => this.emit('peerClosed', data));\r\n this.peerManager.on('error', (error) => this.emit('error', error));\r\n\r\n // ICE Candidate 전송\r\n this.peerManager.on('iceCandidate', ({ peerId, candidate }) => {\r\n this._sendSignal({\r\n type: SignalTypes.ICE_CANDIDATE,\r\n receiverId: peerId,\r\n data: { candidate }\r\n });\r\n });\r\n\r\n // Negotiation (Offer 전송)\r\n this.peerManager.on('negotiationNeeded', ({ peerId, description }) => {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_OFFER,\r\n receiverId: peerId,\r\n data: { sdp: description }\r\n });\r\n });\r\n\r\n // Answer 전송\r\n this.peerManager.on('answerCreated', ({ peerId, answer }) => {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_ANSWER,\r\n receiverId: peerId,\r\n data: { sdp: answer }\r\n });\r\n });\r\n }\r\n\r\n // ==================== REST API ====================\r\n\r\n /**\r\n * TURN 서버 인증 정보 가져오기\r\n * @returns {Promise<Object>}\r\n */\r\n async getTurnCredentials() {\r\n const response = await this.apiClient.get('/api/v1/webrtc/turn-credentials');\r\n return response.data;\r\n }\r\n\r\n /**\r\n * 통화방 생성\r\n * @public\r\n * @param {Object} data\r\n * @param {string[]} [data.invitedUsers] - 초대할 사용자 ID\r\n * @param {string} [data.title] - 통화방 제목\r\n * @param {number} [data.maxParticipants] - 최대 참가자 수 (생략 시 서버 기본값)\r\n * @returns {Promise<Object>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async createCallRoom(data) {\r\n this._pendingRoomCreations++;\r\n try {\r\n const response = await this.apiClient.post('/api/v1/webrtc/rooms', data);\r\n const callRoomId = response?.data?.callRoomId;\r\n if (callRoomId) this._rememberCallRoom(this._hostedCallRooms, callRoomId, this.userId);\r\n return response;\r\n } finally {\r\n this._pendingRoomCreations--;\r\n // 보관한 거절을 같은 경로로 다시 판정한다. 연 방의 것은 전달되고, 진행 중인 생성이 남았으면 다시 보관되며, 아니면 버려진다.\r\n for (const message of this._earlyRoomRejects.splice(0)) {\r\n this._handleSignal(message).catch(error => this.logger.error('Early rejection handling failed:', error));\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * 통화방 정보 조회\r\n * @public\r\n * @param {string} roomId - 통화방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async getCallRoom(roomId) {\r\n return this.apiClient.get(`/api/v1/webrtc/rooms/${roomId}`);\r\n }\r\n\r\n /**\r\n * 통화방 참여\r\n * @public\r\n * @param {string} roomId - 통화방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async joinCallRoomApi(roomId) {\r\n // REST 응답보다 PEER_JOINED에 대한 offer가 먼저 도착할 수 있다.\r\n // 로컬에서 요청한 방만 허용하고, 실패·종료 뒤에는 이 권한을 남기지 않는다.\r\n const join = { roomId, generation: this._callGeneration };\r\n this._groupJoin = join;\r\n try {\r\n const response = await this.apiClient.post(`/api/v1/webrtc/rooms/${roomId}/join`);\r\n if (this._groupJoin === join && this._callGeneration === join.generation) this._joinedGroupRoom = roomId;\r\n return response;\r\n } catch (error) {\r\n if (this._groupJoin === join && this.currentRoom !== roomId) this._clearDeferredGroup(roomId);\r\n throw error;\r\n } finally {\r\n if (this._groupJoin === join) this._groupJoin = null;\r\n }\r\n }\r\n\r\n /**\r\n * 통화방 나가기\r\n * @public\r\n * @param {string} roomId - 통화방 ID\r\n * @returns {Promise<Object>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async leaveCallRoomApi(roomId) {\r\n const response = await this.apiClient.post(`/api/v1/webrtc/rooms/${roomId}/leave`);\r\n if (this._joinedGroupRoom === roomId) this._joinedGroupRoom = null;\r\n if (this._groupJoin?.roomId === roomId) this._groupJoin = null;\r\n if (this.currentRoom !== roomId) this._clearDeferredGroup(roomId);\r\n return response;\r\n }\r\n\r\n // ==================== 통화 수신 대기 ====================\r\n\r\n /**\r\n * 통화 수신 대기 활성화\r\n * connect() 후 자동 호출되지만, 수동으로도 호출 가능\r\n * 개인 WebRTC 채널(/user/queue/webrtc)을 구독하여 초대 알림 수신\r\n * @public\r\n * @returns {Promise<void>}\r\n */\r\n async enableIncomingCalls() {\r\n if (this._incomingCallsEnabled) {\r\n this.logger.debug('Incoming calls already enabled');\r\n return;\r\n }\r\n\r\n await this._subscribeToDirectSignaling();\r\n this._incomingCallsEnabled = true;\r\n this.logger.info('Incoming calls enabled - now listening for call invitations');\r\n }\r\n\r\n /**\r\n * 통화 수신 대기 비활성화\r\n * @public\r\n */\r\n disableIncomingCalls() {\r\n if (!this._incomingCallsEnabled) {\r\n return;\r\n }\r\n\r\n const directDestination = WebSocketPaths.getWebRTCUserDestination();\r\n this.connectionManager.unsubscribe(directDestination);\r\n this._incomingCallsEnabled = false;\r\n this.logger.info('Incoming calls disabled');\r\n }\r\n\r\n /**\r\n * 통화 수신 대기 상태 확인\r\n * @public\r\n * @returns {boolean}\r\n */\r\n isIncomingCallsEnabled() {\r\n return this._incomingCallsEnabled || false;\r\n }\r\n\r\n // ==================== 통화 기능 ====================\r\n\r\n /**\r\n * 통화 시작 (미디어 및 구독)\r\n * @param {Object} options\r\n * @param {string} options.roomId - 통화방 ID\r\n * @param {boolean} [options.isGroup=false] - 그룹 통화 여부\r\n * @param {Object} [options.mediaConstraints] - 미디어 제약조건\r\n * @returns {Promise<MediaStream>}\r\n */\r\n async startCall(options) {\r\n const { roomId, isGroup = false, mediaConstraints = { video: true, audio: true } } = options;\r\n if (this._pendingStart) throw new Error('A call is already starting');\r\n // 조기 offer의 peer 존재만으로 막지 않는다. 이미 소유한 통화만 보호한다.\r\n if (this.currentRoom !== null || this._callActive || this._waitingForCallAccept) {\r\n throw new Error('A call is already active');\r\n }\r\n this._callGeneration++;\r\n this._cancelGroupRecoveryRetry();\r\n this._groupRecoveryAttempts = 0;\r\n this._groupRecovery = null;\r\n const start = { cancelled: false };\r\n this._pendingStart = start;\r\n const checkCancelled = () => { if (start.cancelled) throw new Error('Call start cancelled'); };\r\n\r\n try {\r\n this.currentRoom = roomId;\r\n this.isGroupCall = isGroup;\r\n for (const deferredRoom of new Set(this._deferredGroupPeers.values())) {\r\n if (!isGroup || deferredRoom !== roomId) this._clearDeferredGroup(deferredRoom);\r\n }\r\n // 그룹 참가 임대를 먼저 연결한다. 권한 팝업/TURN 조회가 90초를 넘어도 갱신된다.\r\n await this._subscribeToSignaling(roomId, isGroup);\r\n checkCancelled();\r\n await this.initializeIceServers();\r\n checkCancelled();\r\n const localStream = await this.mediaManager.getUserMedia(mediaConstraints);\r\n checkCancelled();\r\n // 먼저 수신한 offer의 peer에도 트랙을 추가하고 재협상한다.\r\n // 권한 대기 중 시그널을 버리면 상대의 기존 peer가 offer를 다시 보내지 않을 수 있다.\r\n this.peerManager.setLocalStream(localStream);\r\n for (const peerId of this._deferredGroupPeers.keys()) {\r\n await this._resumeGroupNegotiation(peerId);\r\n checkCancelled();\r\n }\r\n this._callActive = true;\r\n\r\n // 입장 시그널 전송\r\n this._sendSignal({\r\n type: SignalTypes.JOIN_ROOM,\r\n roomId\r\n });\r\n\r\n this.logger.info(`Call started in room: ${roomId}`);\r\n this.emit('callStarted', { roomId, isGroup, localStream });\r\n\r\n return localStream;\r\n\r\n } catch (error) {\r\n if (isGroup) this.connectionManager.unsubscribe(WebSocketPaths.getWebRTCDestination(roomId));\r\n this._unsubscribeFromSignaling();\r\n this.mediaManager.stopAll();\r\n this.peerManager.closeAllPeerConnections();\r\n this.peerManager.setLocalStream(null);\r\n this.participants.clear();\r\n this._pendingIceCandidates.clear();\r\n this._deferredGroupPeers.clear();\r\n this._callActive = false;\r\n this.currentRoom = null;\r\n this.isGroupCall = false;\r\n this._joinedGroupRoom = null;\r\n this._groupJoin = null;\r\n this.logger.error('Failed to start call:', error);\r\n this.emit('error', {\r\n type: ErrorTypes.PEER_CONNECTION_FAILED,\r\n message: error.message,\r\n error\r\n });\r\n throw error;\r\n } finally {\r\n this._pendingStart = null;\r\n }\r\n }\r\n\r\n /**\r\n * 1:1 통화 요청\r\n * @param {string} targetUserId - 상대방 사용자 ID\r\n * @param {Object} [mediaConstraints] - 미디어 제약조건\r\n */\r\n async callUser(targetUserId, mediaConstraints = { video: true, audio: true }) {\r\n try {\r\n // TURN 서버 설정 초기화\r\n await this.initializeIceServers();\r\n\r\n // 로컬 미디어 획득\r\n const localStream = await this.mediaManager.getUserMedia(mediaConstraints);\r\n this.peerManager.setLocalStream(localStream);\r\n\r\n // 1:1 통화 구독\r\n await this._subscribeToDirectSignaling();\r\n\r\n // CALL_ACCEPT 대기 상태 설정\r\n // PeerConnection은 CALL_ACCEPT를 받은 후에 생성하여 자동 negotiation 방지\r\n this._callActive = true;\r\n this._waitingForCallAccept = true;\r\n this._pendingCallTarget = targetUserId;\r\n this._incomingCallPeer = null;\r\n\r\n // 통화 요청 시그널\r\n this._sendSignal({\r\n type: SignalTypes.CALL_REQUEST,\r\n receiverId: targetUserId\r\n });\r\n\r\n this.currentRoom = null;\r\n this.isGroupCall = false;\r\n\r\n this.emit('callRequested', { targetUserId, localStream });\r\n\r\n } catch (error) {\r\n this.logger.error('Failed to call user:', error);\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 통화 수락\r\n * @param {string} callerId - 발신자 ID\r\n * @param {Object} [mediaConstraints] - 미디어 제약조건\r\n */\r\n async acceptCall(callerId, mediaConstraints = { video: true, audio: true }) {\r\n try {\r\n // TURN 서버 설정 초기화\r\n await this.initializeIceServers();\r\n\r\n // 로컬 미디어 획득 (수신자도 미디어 필요)\r\n const localStream = await this.mediaManager.getUserMedia(mediaConstraints);\r\n this.peerManager.setLocalStream(localStream);\r\n\r\n // 피어 연결 생성 (Polite - 수신자)\r\n // skipAutoNegotiation: true - 수신자는 offer를 받아서 answer를 보내므로 자동 negotiation 불필요\r\n this.peerManager.createPeerConnection(callerId, true, { skipAutoNegotiation: true });\r\n this._directCallPeer = callerId;\r\n this._incomingCallPeer = null;\r\n\r\n // 수락 시그널\r\n this._sendSignal({\r\n type: SignalTypes.CALL_ACCEPT,\r\n receiverId: callerId\r\n });\r\n\r\n this._callActive = true;\r\n this.emit('callAccepted', { callerId });\r\n\r\n } catch (error) {\r\n this.logger.error('Failed to accept call:', error);\r\n throw error;\r\n }\r\n }\r\n\r\n /**\r\n * 통화 거절\r\n * @param {string} callerId - 발신자 ID\r\n */\r\n rejectCall(callerId) {\r\n if (this._incomingCallPeer === callerId) this._incomingCallPeer = null;\r\n this._sendSignal({\r\n type: SignalTypes.CALL_REJECT,\r\n receiverId: callerId\r\n });\r\n\r\n this.emit('callRejected', { callerId });\r\n }\r\n\r\n /**\r\n * 통화 취소 (발신자가 수신자 응답 전 취소)\r\n */\r\n cancelCall() {\r\n if (this._waitingForCallAccept && this._pendingCallTarget) {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_CANCEL,\r\n receiverId: this._pendingCallTarget\r\n });\r\n\r\n this.emit('callCancelled', { targetUserId: this._pendingCallTarget });\r\n\r\n // 대기 상태 초기화\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n\r\n // 미디어 정리\r\n this._callActive = false;\r\n this.mediaManager.stopAll();\r\n }\r\n }\r\n\r\n /**\r\n * 통화 종료\r\n */\r\n endCall() {\r\n this._endCall(true);\r\n }\r\n\r\n _endCall(notifyPeers) {\r\n this._callGeneration++;\r\n this._cancelGroupRecoveryRetry();\r\n this._groupRecoveryAttempts = 0;\r\n this._groupRecovery = null;\r\n this._callActive = false;\r\n this._deferredGroupPeers.clear();\r\n this._joinedGroupRoom = null;\r\n this._groupJoin = null;\r\n this._incomingCallPeer = null;\r\n if (this._pendingStart) this._pendingStart.cancelled = true;\r\n // 대기 중인 통화가 있으면 취소 처리\r\n if (notifyPeers && this._waitingForCallAccept && this._pendingCallTarget) {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_CANCEL,\r\n receiverId: this._pendingCallTarget\r\n });\r\n }\r\n\r\n // 대기 상태 초기화\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n\r\n if (this.currentRoom) {\r\n // 퇴장 시그널 전송\r\n if (notifyPeers) this._sendSignal({\r\n type: SignalTypes.LEAVE_ROOM,\r\n roomId: this.currentRoom\r\n });\r\n\r\n // 구독 해제\r\n this._unsubscribeFromSignaling();\r\n }\r\n\r\n // 모든 피어에게 종료 시그널\r\n if (notifyPeers) this.peerManager.getPeerIds().forEach(peerId => {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_END,\r\n receiverId: peerId\r\n });\r\n });\r\n // 1:1 상대에게 가는 CALL_END 에 그룹 roomId 가 붙지 않도록 보낸 뒤에 지운다(_groupRoomFor).\r\n this._directCallPeer = null;\r\n\r\n // 정리\r\n this.peerManager.closeAllPeerConnections();\r\n this.peerManager.setLocalStream(null);\r\n this.mediaManager.stopAll();\r\n this.participants.clear();\r\n this._pendingIceCandidates.clear();\r\n\r\n const roomId = this.currentRoom;\r\n this.currentRoom = null;\r\n this.isGroupCall = false;\r\n\r\n this.emit('callEnded', { roomId });\r\n this.logger.info('Call ended');\r\n }\r\n\r\n // ==================== 미디어 제어 ====================\r\n\r\n /**\r\n * 비디오 토글\r\n * @returns {boolean}\r\n */\r\n toggleVideo() {\r\n return this.mediaManager.toggleVideo();\r\n }\r\n\r\n /**\r\n * 오디오 토글\r\n * @returns {boolean}\r\n */\r\n toggleAudio() {\r\n return this.mediaManager.toggleAudio();\r\n }\r\n\r\n /**\r\n * 화면 공유 시작\r\n * @returns {Promise<MediaStream>}\r\n */\r\n async startScreenShare() {\r\n const screenStream = await this.mediaManager.getDisplayMedia();\r\n\r\n // 피어들에게 화면 공유 트랙 전송\r\n const videoTrack = screenStream.getVideoTracks()[0];\r\n const localVideoTrack = this.mediaManager.getTrack('video');\r\n\r\n if (localVideoTrack && videoTrack) {\r\n await this.peerManager.replaceTrack(localVideoTrack, videoTrack);\r\n }\r\n\r\n // 화면 공유 종료 시 원복 — 복원 시점의 현재 카메라 트랙 사용 (공유 중 switchDevice 대응)\r\n videoTrack.onended = async () => {\r\n const currentVideoTrack = this.mediaManager.getTrack('video');\r\n if (currentVideoTrack) {\r\n await this.peerManager.replaceTrack(videoTrack, currentVideoTrack);\r\n }\r\n this.emit('screenShareEnded', {});\r\n };\r\n\r\n return screenStream;\r\n }\r\n\r\n /**\r\n * 화면 공유 중지\r\n */\r\n stopScreenShare() {\r\n const screenStream = this.mediaManager.getScreenStream();\r\n if (screenStream) {\r\n screenStream.getTracks().forEach(track => track.stop());\r\n }\r\n }\r\n\r\n /**\r\n * 로컬 스트림 가져오기\r\n * @public\r\n * @returns {MediaStream|null}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n getLocalStream() {\r\n return this.mediaManager.getLocalStream();\r\n }\r\n\r\n /**\r\n * 디바이스 목록 가져오기\r\n * @returns {Promise<Object>}\r\n */\r\n async getDevices() {\r\n return this.mediaManager.getDevices();\r\n }\r\n\r\n /**\r\n * 디바이스 전환\r\n * @param {string} deviceId\r\n * @param {string} kind - 'video' or 'audio'\r\n */\r\n async switchDevice(deviceId, kind) {\r\n // switchDevice가 localStream을 교체하므로, 교체 전 기존 트랙을 먼저 저장\r\n const oldTrack = this.mediaManager.getTrack(kind);\r\n const newTrack = await this.mediaManager.switchDevice(deviceId, kind);\r\n\r\n // 피어들에게 트랙 교체\r\n if (oldTrack) {\r\n await this.peerManager.replaceTrack(oldTrack, newTrack);\r\n }\r\n\r\n return newTrack;\r\n }\r\n\r\n /**\r\n * 비디오 상태 직접 설정\r\n * @public\r\n * @param {boolean} enabled\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n setVideoEnabled(enabled) {\r\n this.mediaManager.setVideoEnabled(enabled);\r\n this._sendMediaState();\r\n }\r\n\r\n /**\r\n * 오디오 상태 직접 설정\r\n * @public\r\n * @param {boolean} enabled\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n setAudioEnabled(enabled) {\r\n this.mediaManager.setAudioEnabled(enabled);\r\n this._sendMediaState();\r\n }\r\n\r\n /**\r\n * 디바이스 변경 감지 시작\r\n * @public\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n startDeviceChangeDetection() {\r\n this.mediaManager.startDeviceChangeDetection();\r\n this.mediaManager.on('deviceChange', (devices) => {\r\n this.emit('deviceChange', devices);\r\n });\r\n }\r\n\r\n /**\r\n * 디바이스 변경 감지 중지\r\n * @public\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n stopDeviceChangeDetection() {\r\n this.mediaManager.stopDeviceChangeDetection();\r\n }\r\n\r\n /**\r\n * 비디오 해상도/프레임레이트 변경\r\n * @public\r\n * @param {Object} constraints - { width, height, frameRate }\r\n * @returns {Promise<void>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async applyVideoConstraints(constraints) {\r\n return this.mediaManager.applyVideoConstraints(constraints);\r\n }\r\n\r\n /**\r\n * 현재 비디오 설정 가져오기\r\n * @public\r\n * @returns {MediaTrackSettings|null}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n getVideoSettings() {\r\n return this.mediaManager.getVideoSettings();\r\n }\r\n\r\n /**\r\n * 현재 오디오 설정 가져오기\r\n * @public\r\n * @returns {MediaTrackSettings|null}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n getAudioSettings() {\r\n return this.mediaManager.getAudioSettings();\r\n }\r\n\r\n // ==================== 시그널링 ====================\r\n\r\n /**\r\n * 시그널링 구독\r\n * @private\r\n */\r\n async _subscribeToSignaling(roomId, isGroup) {\r\n if (isGroup) {\r\n // 그룹 통화: /topic/webrtc/{roomId}\r\n const destination = WebSocketPaths.getWebRTCDestination(roomId);\r\n await this.connectionManager.subscribe(destination, (message) => {\r\n this._handleSignal(message);\r\n });\r\n }\r\n\r\n // 1:1 또는 그룹 모두에서 개인 큐 구독\r\n await this._subscribeToDirectSignaling();\r\n }\r\n\r\n /**\r\n * 1:1 시그널링 구독\r\n * @private\r\n */\r\n async _subscribeToDirectSignaling() {\r\n const destination = WebSocketPaths.getWebRTCUserDestination();\r\n await this.connectionManager.subscribe(destination, (message) => {\r\n this._handleSignal(message);\r\n });\r\n }\r\n\r\n /**\r\n * 시그널링 구독 해제\r\n * @private\r\n */\r\n _unsubscribeFromSignaling() {\r\n if (this.currentRoom && this.isGroupCall) {\r\n const destination = WebSocketPaths.getWebRTCDestination(this.currentRoom);\r\n this.connectionManager.unsubscribe(destination);\r\n }\r\n\r\n // 수신 대기가 활성화된 경우 direct 채널 구독 유지 (그룹 통화 종료 후에도 수신 가능)\r\n if (!this._incomingCallsEnabled) {\r\n const directDestination = WebSocketPaths.getWebRTCUserDestination();\r\n this.connectionManager.unsubscribe(directDestination);\r\n }\r\n }\r\n\r\n /**\r\n * 시그널 전송\r\n * @private\r\n */\r\n _sendSignal(signal) {\r\n const payload = {\r\n type: signal.type,\r\n roomId: signal.roomId || this._groupRoomFor(signal),\r\n receiverId: signal.receiverId,\r\n data: signal.data\r\n };\r\n\r\n this.connectionManager.send(WebSocketPaths.WEBRTC_SIGNAL, payload);\r\n this.logger.debug('Signal sent:', payload);\r\n }\r\n\r\n /**\r\n * 그룹 roomId 는 그룹 상대·그룹 방 신호에만 붙인다.\r\n * 1:1 신호에 붙으면 상대가 방 신호로 보고 버린다(그룹 통화 중 자동 BUSY 유실).\r\n * @private\r\n */\r\n _groupRoomFor({ type, receiverId }) {\r\n if (DIRECT_CONTROL_SIGNALS.has(type)) return undefined;\r\n if (receiverId && receiverId === this._directCallPeer) return undefined;\r\n return this.currentRoom || this._deferredGroupPeers.get(receiverId);\r\n }\r\n\r\n /**\r\n * 미디어 상태 전송\r\n * @private\r\n */\r\n _sendMediaState() {\r\n if (!this.currentRoom && this.peerManager.getPeerIds().length === 0) {\r\n return;\r\n }\r\n\r\n const mediaState = {\r\n videoEnabled: this.mediaManager.isVideoEnabled(),\r\n audioEnabled: this.mediaManager.isAudioEnabled()\r\n };\r\n\r\n this._sendSignal({\r\n type: SignalTypes.VIDEO_STATE_CHANGED,\r\n data: mediaState\r\n });\r\n\r\n this.emit('mediaStateChanged', mediaState);\r\n }\r\n\r\n /**\r\n * 시그널 처리\r\n * @private\r\n */\r\n async _handleSignal(message) {\r\n const { type, senderId, data } = message;\r\n\r\n // 자신의 시그널 무시\r\n if (senderId === this.userId) {\r\n return;\r\n }\r\n\r\n if (this._holdsEarlyRoomReject(message)) return;\r\n if (!this._acceptsSignal(message)) return;\r\n\r\n this.logger.debug(`Signal received: ${type} from ${senderId}`);\r\n\r\n switch (type) {\r\n case SignalTypes.JOIN_ROOM:\r\n case SignalTypes.PEER_JOINED:\r\n await this._handleUserJoined(senderId, message.roomId);\r\n break;\r\n\r\n case SignalTypes.LEAVE_ROOM:\r\n case SignalTypes.PEER_LEFT:\r\n this._handleUserLeft(senderId);\r\n break;\r\n\r\n case SignalTypes.CALL_OFFER:\r\n await this._handleOffer(senderId, data.sdp, message.roomId);\r\n break;\r\n\r\n case SignalTypes.CALL_ANSWER:\r\n await this._handleAnswer(senderId, data.sdp);\r\n break;\r\n\r\n case SignalTypes.ICE_CANDIDATE:\r\n await this._handleIceCandidate(senderId, data.candidate);\r\n break;\r\n\r\n case SignalTypes.VIDEO_STATE_CHANGED:\r\n case SignalTypes.AUDIO_STATE_CHANGED:\r\n this._handleMediaState(senderId, data);\r\n break;\r\n\r\n case SignalTypes.CALL_REQUEST:\r\n // 통화 중이거나 통화 요청 대기 중이면 BUSY 응답\r\n if (this.isInCall() || this._waitingForCallAccept) {\r\n this._sendSignal({\r\n type: SignalTypes.CALL_BUSY,\r\n receiverId: senderId\r\n });\r\n this.emit('incomingCallWhileBusy', { callerId: senderId });\r\n } else {\r\n this._incomingCallPeer = senderId;\r\n this.emit('incomingCall', { callerId: senderId });\r\n }\r\n break;\r\n\r\n case SignalTypes.CALL_BUSY:\r\n // 상대방이 통화 중\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n this._callActive = false;\r\n this.mediaManager.stopAll();\r\n this.emit('callBusy', { userId: senderId });\r\n break;\r\n\r\n case SignalTypes.CALL_INVITATION: {\r\n // 그룹 통화 초대 (createCallRoom으로 초대받은 경우)\r\n const callRoomId = data?.callRoomId || message.roomId;\r\n // 취소 수락 기준은 서버가 채운 roomId·senderId(host)다. data 는 보낸 쪽이 정한 값이라 기억 키로 쓰지 않는다.\r\n if (message.roomId) this._rememberCallRoom(this._receivedInvitations, message.roomId, senderId);\r\n this.emit('callInvitation', {\r\n callRoomId,\r\n title: data?.title,\r\n hostUserId: data?.hostUserId || senderId,\r\n maxParticipants: data?.maxParticipants,\r\n createdAt: data?.createdAt\r\n });\r\n break;\r\n }\r\n\r\n case SignalTypes.CALL_ACCEPT:\r\n await this._handleCallAccepted(senderId);\r\n break;\r\n\r\n case SignalTypes.CALL_REJECT:\r\n if (message.roomId) {\r\n // 그룹 초대 거절(서버 REST 발신). host 의 통화·미디어는 그대로 둔다.\r\n this.emit('callRejected', { userId: senderId, roomId: message.roomId });\r\n break;\r\n }\r\n // 대기 상태 초기화\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n this._callActive = false;\r\n // 발신자 미디어 정리 (거절당했으므로 통화 불가)\r\n this.mediaManager.stopAll();\r\n this.emit('callRejected', { userId: senderId });\r\n break;\r\n\r\n case SignalTypes.CALL_CANCEL:\r\n if (message.roomId) {\r\n // 그룹 초대 취소(서버 REST 발신). 1:1 대기 상태는 건드리지 않는다.\r\n this._receivedInvitations.delete(message.roomId);\r\n this.emit('callCancelled', { userId: senderId, roomId: message.roomId });\r\n break;\r\n }\r\n this._incomingCallPeer = null;\r\n // 대기 상태 초기화\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n this.emit('callCancelled', { userId: senderId });\r\n break;\r\n\r\n case SignalTypes.CALL_END:\r\n if (data?.callEndScope === 'ROOM') {\r\n this._endCall(false); // _acceptsSignal에서 현재/참가 요청한 그룹 방 일치를 확인했다.\r\n } else {\r\n this._handleCallEnded(senderId);\r\n }\r\n break;\r\n\r\n default:\r\n this.logger.warn(`Unknown signal type: ${type}`);\r\n }\r\n }\r\n\r\n /** 미디어를 공유할 상대/방은 로컬 참가·발신·수락으로만 결정한다. */\r\n _acceptsSignal({ type, senderId, roomId }) {\r\n if (!senderId) return false;\r\n if (type === SignalTypes.CALL_INVITATION) return true;\r\n if (type === SignalTypes.CALL_REQUEST) return !roomId;\r\n // 그룹 초대 거절·취소는 서버가 roomId 를 붙여 보낸다. 내가 연 방·받은 초대의 host 것만 받는다.\r\n if (roomId && type === SignalTypes.CALL_REJECT) return this._hostedCallRooms.has(roomId);\r\n if (roomId && type === SignalTypes.CALL_CANCEL) return this._receivedInvitations.get(roomId) === senderId;\r\n if ([SignalTypes.CALL_ACCEPT, SignalTypes.CALL_BUSY, SignalTypes.CALL_REJECT].includes(type)) {\r\n return !roomId && this._waitingForCallAccept && senderId === this._pendingCallTarget;\r\n }\r\n if (type === SignalTypes.CALL_CANCEL) return !roomId && senderId === this._incomingCallPeer;\r\n // 수락한 1:1 상대의 신호는 roomId 없이 온다. 그룹 통화 중에 수락했어도 받는다.\r\n if (!roomId && senderId === this._directCallPeer) return true;\r\n const groupRoom = this.isGroupCall ? this.currentRoom\r\n : (!this._callActive && !this._waitingForCallAccept ? this._groupJoin?.roomId || this._joinedGroupRoom : null);\r\n if (groupRoom) return roomId === groupRoom;\r\n return !roomId && senderId === this._pendingCallTarget;\r\n }\r\n\r\n /** 방 생성 응답 전에 온 그룹 거절은 응답의 방 ID 로 판정할 수 있을 때까지 보관한다. */\r\n _holdsEarlyRoomReject(message) {\r\n const { type, roomId } = message;\r\n if (type !== SignalTypes.CALL_REJECT || !roomId || this._pendingRoomCreations === 0\r\n || this._hostedCallRooms.has(roomId)) return false;\r\n this._earlyRoomRejects.push(message);\r\n if (this._earlyRoomRejects.length > MAX_REMEMBERED_CALL_ROOMS) this._earlyRoomRejects.shift();\r\n return true;\r\n }\r\n\r\n _rememberCallRoom(rooms, roomId, userId) {\r\n rooms.delete(roomId);\r\n rooms.set(roomId, userId);\r\n if (rooms.size > MAX_REMEMBERED_CALL_ROOMS) rooms.delete(rooms.keys().next().value);\r\n }\r\n\r\n _clearDeferredGroup(roomId) {\r\n for (const [peerId, peerRoom] of this._deferredGroupPeers) {\r\n if (peerRoom !== roomId) continue;\r\n this.peerManager.closePeerConnection(peerId);\r\n this.participants.delete(peerId);\r\n this._pendingIceCandidates.delete(peerId);\r\n this._deferredGroupPeers.delete(peerId);\r\n }\r\n }\r\n\r\n /**\r\n * 사용자 입장 처리\r\n * @private\r\n */\r\n async _handleUserJoined(userId, roomId) {\r\n this.participants.set(userId, { joinedAt: new Date() });\r\n\r\n // userId 비교로 Polite/Impolite 결정 (작은 쪽이 Polite)\r\n // Perfect Negotiation에서 충돌 시 한 쪽만 양보하도록 보장\r\n const polite = this.userId < userId;\r\n const defer = !!roomId && !this.peerManager.localStream;\r\n this.peerManager.createPeerConnection(userId, polite, { skipAutoNegotiation: defer });\r\n if (defer) this._deferredGroupPeers.set(userId, roomId);\r\n\r\n this.emit('userJoined', { userId });\r\n this.logger.info(`User joined: ${userId} (I am ${polite ? 'polite' : 'impolite'})`);\r\n }\r\n\r\n /**\r\n * 사용자 퇴장 처리\r\n * @private\r\n */\r\n _handleUserLeft(userId) {\r\n this.participants.delete(userId);\r\n this.peerManager.closePeerConnection(userId);\r\n\r\n this.emit('userLeft', { userId });\r\n this.logger.info(`User left: ${userId}`);\r\n }\r\n\r\n /**\r\n * Offer 처리\r\n * @private\r\n */\r\n async _handleOffer(senderId, sdp, roomId = this.isGroupCall ? this.currentRoom : null) {\r\n // 피어 연결이 없으면 생성\r\n if (!this.peerManager.getPeerConnection(senderId)) {\r\n // userId 비교로 Polite/Impolite 결정\r\n const polite = this.userId < senderId;\r\n // skipAutoNegotiation: true - offer를 먼저 처리한 후 answer 전송\r\n // 트랙 추가 시 자동 negotiation이 발생하면 offer 충돌 발생\r\n this.peerManager.createPeerConnection(senderId, polite, { skipAutoNegotiation: true });\r\n // REST join의 PEER_JOINED는 startCall보다 먼저 올 수 있다.\r\n // 메시지의 roomId로 그룹을 식별하되, 1:1 수동 협상에는 적용하지 않는다.\r\n if (roomId && !this.peerManager.localStream) this._deferredGroupPeers.set(senderId, roomId);\r\n }\r\n\r\n await this.peerManager.handleRemoteDescription(senderId, sdp);\r\n await this._resumeGroupNegotiation(senderId);\r\n\r\n // remoteDescription 설정 후 대기 중인 ICE candidates 처리\r\n await this._processPendingIceCandidates(senderId);\r\n }\r\n\r\n async _resumeGroupNegotiation(peerId) {\r\n if (!this.isGroupCall || this._deferredGroupPeers.get(peerId) !== this.currentRoom\r\n || !this.peerManager.localStream) return;\r\n if (await this.peerManager.resumeAutoNegotiation(peerId)) this._deferredGroupPeers.delete(peerId);\r\n }\r\n\r\n /** 재구독은 참가 이력을 복구하지 않는다. 동일 사용자의 다른 기기 참가도 이 조회에 반영된다. */\r\n async _recoverGroupParticipation() {\r\n if (!this.isGroupCall || !this.currentRoom || this._groupRecovery) return;\r\n this._cancelGroupRecoveryRetry();\r\n const roomId = this.currentRoom;\r\n const generation = this._callGeneration;\r\n const stillCurrent = () => this.currentRoom === roomId && this._callGeneration === generation;\r\n const recovery = (async () => {\r\n try {\r\n const response = await this.apiClient.get(`/api/v1/webrtc/rooms/${roomId}/participation`);\r\n if (!stillCurrent()) return;\r\n if (!response.data.participating) {\r\n await this.joinCallRoomApi(roomId);\r\n if (!stillCurrent()) return;\r\n this._sendSignal({ type: SignalTypes.JOIN_ROOM, roomId });\r\n }\r\n this._groupRecoveryAttempts = 0;\r\n } catch (error) {\r\n if (!stillCurrent()) return;\r\n if ([403, 404, 409].includes(error?.status ?? error?.response?.status)) {\r\n // 확정 실패만 로컬 종료한다. 다른 기기 참가를 지우는 LEAVE는 보내지 않는다.\r\n this._endCall(false);\r\n this.emit('callRecoveryFailed', { roomId, error });\r\n } else {\r\n // 일시 오류 동안 미디어·peer·구독을 유지하고 1s→2s→…→30s 간격으로 재시도한다.\r\n const delay = Math.min(30000, 1000 * 2 ** Math.min(this._groupRecoveryAttempts++, 5));\r\n this._groupRecoveryTimer = setTimeout(() => {\r\n this._groupRecoveryTimer = null;\r\n if (stillCurrent()) this._onReconnected();\r\n }, delay);\r\n this._groupRecoveryTimer.unref?.();\r\n this.logger.warn('Call participation recovery will retry:', error);\r\n }\r\n }\r\n })();\r\n this._groupRecovery = recovery;\r\n try { await recovery; }\r\n finally { if (this._groupRecovery === recovery) this._groupRecovery = null; }\r\n }\r\n\r\n _cancelGroupRecoveryRetry() {\r\n clearTimeout(this._groupRecoveryTimer);\r\n this._groupRecoveryTimer = null;\r\n }\r\n\r\n /**\r\n * Answer 처리\r\n * @private\r\n */\r\n async _handleAnswer(senderId, sdp) {\r\n await this.peerManager.handleRemoteDescription(senderId, sdp);\r\n // remoteDescription 설정 후 대기 중인 ICE candidates 처리\r\n await this._processPendingIceCandidates(senderId);\r\n }\r\n\r\n /**\r\n * ICE Candidate 처리\r\n * @private\r\n */\r\n async _handleIceCandidate(senderId, candidate) {\r\n // peer connection이 아직 없으면 큐에 저장\r\n if (!this.peerManager.getPeerConnection(senderId)) {\r\n this._queueIceCandidate(senderId, candidate);\r\n return;\r\n }\r\n\r\n // addIceCandidate가 false 반환하면 (remoteDescription 미설정) 큐에 저장\r\n const added = await this.peerManager.addIceCandidate(senderId, new RTCIceCandidate(candidate));\r\n if (!added) {\r\n this._queueIceCandidate(senderId, candidate);\r\n }\r\n }\r\n\r\n /**\r\n * ICE candidate를 큐에 저장\r\n * @private\r\n */\r\n _queueIceCandidate(peerId, candidate) {\r\n if (!this._pendingIceCandidates.has(peerId)) {\r\n this._pendingIceCandidates.set(peerId, []);\r\n }\r\n this._pendingIceCandidates.get(peerId).push(candidate);\r\n this.logger.debug(`ICE candidate queued for ${peerId}`);\r\n }\r\n\r\n /**\r\n * 대기 중인 ICE candidate 처리\r\n * @private\r\n */\r\n async _processPendingIceCandidates(peerId) {\r\n const candidates = this._pendingIceCandidates.get(peerId);\r\n if (!candidates || candidates.length === 0) {\r\n return;\r\n }\r\n\r\n this.logger.debug(`Processing ${candidates.length} pending ICE candidates for ${peerId}`);\r\n\r\n for (const candidate of candidates) {\r\n await this.peerManager.addIceCandidate(peerId, new RTCIceCandidate(candidate));\r\n }\r\n\r\n this._pendingIceCandidates.delete(peerId);\r\n }\r\n\r\n /**\r\n * 미디어 상태 처리\r\n * @private\r\n */\r\n _handleMediaState(userId, state) {\r\n this.emit('participantMediaState', {\r\n userId,\r\n videoEnabled: state.videoEnabled,\r\n audioEnabled: state.audioEnabled\r\n });\r\n }\r\n\r\n /**\r\n * 통화 수락 처리 (발신자 측에서 CALL_ACCEPT 수신 시)\r\n * @private\r\n */\r\n async _handleCallAccepted(userId) {\r\n this._directCallPeer = userId;\r\n // 대기 상태 초기화\r\n this._waitingForCallAccept = false;\r\n this._pendingCallTarget = null;\r\n\r\n // 이제 PeerConnection 생성 (Impolite - 발신자)\r\n // skipAutoNegotiation: true - 트랙 추가 시 자동 negotiation 건너뛰고 수동으로 offer 전송\r\n this.peerManager.createPeerConnection(userId, false, { skipAutoNegotiation: true });\r\n\r\n // Offer 생성 및 전송\r\n const offer = await this.peerManager.createOffer(userId);\r\n this._sendSignal({\r\n type: SignalTypes.CALL_OFFER,\r\n receiverId: userId,\r\n data: { sdp: offer }\r\n });\r\n\r\n this.emit('callAccepted', { userId });\r\n }\r\n\r\n /**\r\n * 통화 종료 처리\r\n * @private\r\n */\r\n _handleCallEnded(userId) {\r\n this.peerManager.closePeerConnection(userId);\r\n this.participants.delete(userId);\r\n // 끝난 1:1 상대는 더 이상 선택한 상대가 아니다. 그룹 통화가 남아도 그의 roomId 없는 신호를 받지 않는다.\r\n if (userId === this._directCallPeer) this._directCallPeer = null;\r\n\r\n this.emit('participantLeft', { userId });\r\n\r\n // 모든 참여자가 나갔으면 통화 종료\r\n if (this.peerManager.getPeerIds().length === 0) {\r\n this.endCall();\r\n }\r\n }\r\n\r\n // ==================== 유틸리티 ====================\r\n\r\n /**\r\n * 현재 통화 중인지 확인\r\n * @returns {boolean}\r\n */\r\n isInCall() {\r\n return this.currentRoom !== null || this.peerManager.getPeerIds().length > 0;\r\n }\r\n\r\n /**\r\n * 현재 통화방 ID\r\n * @returns {string|null}\r\n */\r\n getCurrentRoom() {\r\n return this.currentRoom;\r\n }\r\n\r\n /**\r\n * 참여자 목록\r\n * @returns {string[]}\r\n */\r\n getParticipants() {\r\n return Array.from(this.participants.keys());\r\n }\r\n\r\n /**\r\n * 연결 상태 요약\r\n * @public\r\n * @returns {Object}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n getConnectionSummary() {\r\n return this.peerManager.getConnectionSummary();\r\n }\r\n\r\n /**\r\n * 특정 피어의 연결 통계 가져오기 (디버깅/모니터링용)\r\n * @public\r\n * @param {string} peerId - 피어 ID\r\n * @returns {Promise<RTCStatsReport|null>}\r\n */\r\n // noinspection JSUnusedGlobalSymbols\r\n async getStats(peerId) {\r\n return this.peerManager.getStats(peerId);\r\n }\r\n\r\n /**\r\n * 미디어 상태\r\n * @returns {Object}\r\n */\r\n getMediaState() {\r\n return {\r\n videoEnabled: this.mediaManager.isVideoEnabled(),\r\n audioEnabled: this.mediaManager.isAudioEnabled(),\r\n streamInfo: this.mediaManager.getStreamInfo()\r\n };\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n this.mediaManager.setLogLevel(level);\r\n this.peerManager.setLogLevel(level);\r\n }\r\n\r\n /**\r\n * 리소스 정리\r\n */\r\n destroy() {\r\n this.connectionManager.off?.('reconnected', this._onReconnected);\r\n this.endCall();\r\n this.mediaManager.destroy();\r\n this.peerManager.destroy();\r\n this.removeAllListeners();\r\n this.logger.info('WebRTCClient destroyed');\r\n }\r\n}\r\n\r\nexport default WebRTCClient;\r\n","/**\r\n * PushManager\r\n * FCM 웹 푸시 알림 관리 (Firebase 초기화, 서비스 워커, 권한, 토큰)\r\n */\r\n\r\nimport Logger from '../utils/Logger.js';\r\nimport { LogLevel } from '../constants.js';\r\n\r\n/**\r\n * 푸시 활성화 실패 사유 분류.\r\n *\r\n * <p>호출자가 {@code error.message} 문자열 매칭 대신 {@code error.code} 로 분기할 수 있도록\r\n * 표준화된 enum 값을 제공한다.</p>\r\n *\r\n * <ul>\r\n * <li>{@code UNSUPPORTED_BROWSER} — Notification / Service Worker 미지원 환경</li>\r\n * <li>{@code FIREBASE_NOT_INSTALLED} — firebase 패키지 동적 import 실패</li>\r\n * <li>{@code SW_REGISTER_FAILED} — 서비스 워커 등록 실패 (firebase-messaging-sw.js 누락 등)</li>\r\n * <li>{@code PERMISSION_DENIED} — 브라우저 알림 권한 거부 (이미 거부 상태 또는 이번 요청에서 거부)</li>\r\n * <li>{@code TOKEN_FAILED} — FCM 토큰 발급 실패</li>\r\n * <li>{@code SERVER_REGISTER_FAILED} — TalkFlow 서버에 디바이스 토큰 등록 실패</li>\r\n * </ul>\r\n */\r\nexport const PushErrorCode = Object.freeze({\r\n UNSUPPORTED_BROWSER: 'UNSUPPORTED_BROWSER',\r\n FIREBASE_NOT_INSTALLED: 'FIREBASE_NOT_INSTALLED',\r\n SW_REGISTER_FAILED: 'SW_REGISTER_FAILED',\r\n PERMISSION_DENIED: 'PERMISSION_DENIED',\r\n TOKEN_FAILED: 'TOKEN_FAILED',\r\n SERVER_REGISTER_FAILED: 'SERVER_REGISTER_FAILED'\r\n});\r\n\r\n/**\r\n * 푸시 활성화 과정에서 발생한 분류된 에러.\r\n *\r\n * <p>일반 {@link Error} 와 달리 {@code code} 필드로 분류되어 있어\r\n * 호출자가 사유별로 다른 UX (권한 거부 → 설정 가이드, 토큰 실패 → 재시도 등) 를 적용할 수 있다.</p>\r\n */\r\nexport class PushError extends Error {\r\n /**\r\n * @param {string} code - {@link PushErrorCode} 값 중 하나\r\n * @param {string} message - 사람이 읽는 메시지\r\n * @param {Error} [cause] - 원인 에러 (네트워크 실패 등)\r\n */\r\n constructor(code, message, cause) {\r\n super(message);\r\n this.name = 'PushError';\r\n this.code = code;\r\n if (cause !== undefined) {\r\n this.cause = cause;\r\n }\r\n }\r\n}\r\n\r\n// TalkFlow 기본 Firebase 설정 (고객별 분리 시 서버에서 내려받는 구조로 확장 가능)\r\nconst DEFAULT_FIREBASE_CONFIG = {\r\n apiKey: \"AIzaSyB8VhRg6rSvUI7K3Ua7h6sBpLvaQGmIkRc\",\r\n authDomain: \"chatting-c3e5d.firebaseapp.com\",\r\n projectId: \"chatting-c3e5d\",\r\n storageBucket: \"chatting-c3e5d.firebasestorage.app\",\r\n messagingSenderId: \"1020496565673\",\r\n appId: \"1:1020496565673:web:5039167257fd83f5ce20b8\"\r\n};\r\n\r\nconst DEFAULT_VAPID_KEY = 'BGP8qaSm1ntjWd4n9pc0lX_rw4BmMxm9u4pvRIANCitbmYaV0iy-gn05suTKBo88kUBejxdxM8sb6x2nt3avu8c';\r\nconst PENDING_NAVIGATION_DB_NAME = 'talkflowPush';\r\nconst PENDING_NAVIGATION_STORE_NAME = 'pendingNavigation';\r\n// SW 와 동일한 key 규칙 사용 — 멀티테넌트에서 다른 projectId 의 pending 을 건드리지 않도록 한다.\r\nconst PENDING_NAVIGATION_KEY_PREFIX = 'pending-room';\r\nconst PENDING_NAVIGATION_LEGACY_KEY = 'pending-room';\r\n// Gmail 메일 알림 클릭 — 방과 다른 key 라 consumePendingRoom 과 섞이지 않는다. 프로젝트별로만 저장한다(레거시 전역 key 없음).\r\nconst PENDING_MAIL_KEY_PREFIX = 'pending-mail';\r\n// 앱이 이 시간 안에 꺼내지 않은 메일 이동은 버린다 — 며칠 뒤 앱을 열 때 엉뚱하게 메일 화면으로 가지 않게.\r\nconst PENDING_MAIL_MAX_AGE_MS = 10 * 60 * 1000;\r\n\r\n// deviceId 저장 — 같은 IndexedDB 의 별도 store 사용 (XSS 방어 + 기존 인프라 재사용).\r\n// DB 버전은 2 로 bump — v1 에서 pendingNavigation 만 있던 상태에서 deviceInfo store 추가.\r\nconst DEVICE_STORE_NAME = 'deviceInfo';\r\nconst DEVICE_ID_KEY = 'device-id';\r\nconst DEVICE_ID_DB_VERSION = 2;\r\n// 기존 localStorage 에 저장된 deviceId 를 IndexedDB 로 옮긴 뒤 제거하기 위한 legacy key.\r\nconst LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY = 'talkflow_device_id';\r\n\r\nfunction buildPendingNavigationKey(projectId) {\r\n if (projectId && typeof projectId === 'string' && projectId.trim() !== '') {\r\n return PENDING_NAVIGATION_KEY_PREFIX + ':' + projectId;\r\n }\r\n return PENDING_NAVIGATION_LEGACY_KEY;\r\n}\r\n\r\nfunction openPendingNavigationDb() {\r\n if (typeof indexedDB === 'undefined') {\r\n return Promise.resolve(null);\r\n }\r\n\r\n return new Promise((resolve, reject) => {\r\n const request = indexedDB.open(PENDING_NAVIGATION_DB_NAME, DEVICE_ID_DB_VERSION);\r\n\r\n request.onupgradeneeded = () => {\r\n const db = request.result;\r\n // v1 → v2: deviceInfo store 추가.\r\n // 기존 pendingNavigation store 는 그대로 유지 (contains 체크로 멱등).\r\n if (!db.objectStoreNames.contains(PENDING_NAVIGATION_STORE_NAME)) {\r\n db.createObjectStore(PENDING_NAVIGATION_STORE_NAME, { keyPath: 'id' });\r\n }\r\n if (!db.objectStoreNames.contains(DEVICE_STORE_NAME)) {\r\n db.createObjectStore(DEVICE_STORE_NAME, { keyPath: 'id' });\r\n }\r\n };\r\n\r\n request.onsuccess = () => resolve(request.result);\r\n request.onerror = () => reject(request.error || new Error('IndexedDB open failed'));\r\n });\r\n}\r\n\r\n/**\r\n * IndexedDB 에서 deviceId 를 읽는다.\r\n * @returns {Promise<string|null>} 저장된 deviceId, 없으면 null. IndexedDB 사용 불가 시 null.\r\n */\r\nfunction readDeviceIdFromIndexedDb() {\r\n return openPendingNavigationDb().then(db => {\r\n if (!db) return null;\r\n return new Promise((resolve, reject) => {\r\n const tx = db.transaction(DEVICE_STORE_NAME, 'readonly');\r\n const store = tx.objectStore(DEVICE_STORE_NAME);\r\n const request = store.get(DEVICE_ID_KEY);\r\n request.onsuccess = () => {\r\n const record = request.result;\r\n resolve(record && record.value ? record.value : null);\r\n };\r\n request.onerror = () => reject(request.error || new Error('IndexedDB read failed'));\r\n tx.oncomplete = () => db.close();\r\n tx.onerror = () => db.close();\r\n tx.onabort = () => db.close();\r\n });\r\n });\r\n}\r\n\r\n/**\r\n * IndexedDB 에 deviceId 를 저장한다.\r\n *\r\n * <p><b>반환값 의미</b>:</p>\r\n * <ul>\r\n * <li>{@code true} — transaction 이 complete 까지 성공적으로 완료됨.</li>\r\n * <li>{@code false} — open / transaction 실패, IDB 사용 불가, 기타 예외.</li>\r\n * </ul>\r\n *\r\n * <p>호출자는 반환값이 {@code true} 일 때만 \"영속성 확보\" 로 간주해야 한다.\r\n * 특히 legacy localStorage 정리는 read-back 검증까지 거친 뒤 수행.</p>\r\n *\r\n * @param {string} deviceId\r\n * @returns {Promise<boolean>}\r\n */\r\nfunction writeDeviceIdToIndexedDb(deviceId) {\r\n return openPendingNavigationDb().then(db => {\r\n if (!db) return false;\r\n return new Promise(resolve => {\r\n try {\r\n const tx = db.transaction(DEVICE_STORE_NAME, 'readwrite');\r\n tx.objectStore(DEVICE_STORE_NAME).put({ id: DEVICE_ID_KEY, value: deviceId });\r\n tx.oncomplete = () => { db.close(); resolve(true); };\r\n tx.onerror = () => { db.close(); resolve(false); };\r\n tx.onabort = () => { db.close(); resolve(false); };\r\n } catch (e) {\r\n try { db.close(); } catch (_) { /* ignore */ }\r\n resolve(false);\r\n }\r\n });\r\n }).catch(() => false);\r\n}\r\n\r\n/**\r\n * 신규 deviceId 생성.\r\n */\r\nfunction generateDeviceId() {\r\n const random = (typeof crypto !== 'undefined' && crypto.randomUUID)\r\n ? crypto.randomUUID()\r\n : Date.now().toString(36) + Math.random().toString(36).substring(2);\r\n return 'web-' + random;\r\n}\r\n\r\n/**\r\n * SW 가 저장한 메일 이동(알림 클릭 → 열린 창 없음)을 꺼내고 지운다. 같은 프로젝트·10분 안의 것만.\r\n *\r\n * @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}\r\n */\r\nasync function consumePendingMailFromIndexedDB(projectId) {\r\n if (!projectId || typeof projectId !== 'string' || projectId.trim() === '') {\r\n return null;\r\n }\r\n const db = await openPendingNavigationDb();\r\n if (!db) {\r\n return null;\r\n }\r\n const key = PENDING_MAIL_KEY_PREFIX + ':' + projectId;\r\n return new Promise((resolve, reject) => {\r\n const transaction = db.transaction(PENDING_NAVIGATION_STORE_NAME, 'readwrite');\r\n const store = transaction.objectStore(PENDING_NAVIGATION_STORE_NAME);\r\n let pending = null;\r\n const request = store.get(key);\r\n request.onsuccess = () => {\r\n const record = request.result;\r\n if (!record) {\r\n return;\r\n }\r\n store.delete(key);\r\n const fresh = typeof record.savedAt === 'number' && Date.now() - record.savedAt <= PENDING_MAIL_MAX_AGE_MS;\r\n if (fresh && record.projectId === projectId && record.connectionId) {\r\n pending = { connectionId: record.connectionId, messageId: record.messageId || null };\r\n }\r\n };\r\n request.onerror = () => {\r\n reject(request.error || new Error('IndexedDB read failed'));\r\n };\r\n transaction.oncomplete = () => {\r\n db.close();\r\n resolve(pending);\r\n };\r\n transaction.onerror = () => {\r\n db.close();\r\n reject(transaction.error || new Error('IndexedDB transaction failed'));\r\n };\r\n transaction.onabort = () => {\r\n db.close();\r\n reject(transaction.error || new Error('IndexedDB transaction aborted'));\r\n };\r\n });\r\n}\r\n\r\nasync function consumePendingRoomFromIndexedDB(projectId) {\r\n const db = await openPendingNavigationDb();\r\n if (!db) {\r\n return null;\r\n }\r\n\r\n // 1차: projectId 별 key 를 조회한다.\r\n // 2차 (mixed rollout 호환): projectId 가 있지만 1차 miss 인 경우,\r\n // legacy 전역 key 도 fallback 으로 조회한다. 서버 배포 전 또는 구버전 SW 에서 발생한\r\n // projectId 없는 payload 는 legacy key (pending-room) 에 저장되어 있을 수 있다.\r\n // legacy record 는 반드시 record.projectId 가 비어있어야 안전하게 소비한다 —\r\n // 다른 프로젝트의 값이 섞일 여지를 원천 차단.\r\n const primaryKey = buildPendingNavigationKey(projectId);\r\n const shouldTryLegacyFallback = !!projectId && primaryKey !== PENDING_NAVIGATION_LEGACY_KEY;\r\n\r\n return new Promise((resolve, reject) => {\r\n const transaction = db.transaction(PENDING_NAVIGATION_STORE_NAME, 'readwrite');\r\n const store = transaction.objectStore(PENDING_NAVIGATION_STORE_NAME);\r\n\r\n let pendingRoomId = null;\r\n let finalizedKey = null;\r\n\r\n const primaryRequest = store.get(primaryKey);\r\n\r\n primaryRequest.onsuccess = () => {\r\n const record = primaryRequest.result;\r\n // 저장된 record 의 projectId 와 요청한 projectId 가 일치할 때만 소비.\r\n // key 분리가 정상 작동하면 이 체크는 리던던트이지만, 레거시 전역 key 로 저장된\r\n // 레코드를 다른 프로젝트가 실수로 소비하는 것을 막기 위한 방어.\r\n if (record && record.roomId) {\r\n const recordProjectId = record.projectId || null;\r\n const requestedProjectId = projectId || null;\r\n if (recordProjectId === requestedProjectId) {\r\n pendingRoomId = record.roomId;\r\n finalizedKey = primaryKey;\r\n store.delete(primaryKey);\r\n return;\r\n }\r\n }\r\n\r\n // 1차 miss — legacy fallback 시도 (mixed rollout 호환 경로).\r\n if (!shouldTryLegacyFallback) {\r\n return;\r\n }\r\n\r\n const legacyRequest = store.get(PENDING_NAVIGATION_LEGACY_KEY);\r\n legacyRequest.onsuccess = () => {\r\n const legacyRecord = legacyRequest.result;\r\n // legacy record 는 projectId 가 null/undefined 인 경우에만 안전하게 소비.\r\n // SW v1 또는 구버전 서버가 projectId 없이 저장한 경우에 해당한다.\r\n if (\r\n legacyRecord &&\r\n legacyRecord.roomId &&\r\n !legacyRecord.projectId\r\n ) {\r\n pendingRoomId = legacyRecord.roomId;\r\n finalizedKey = PENDING_NAVIGATION_LEGACY_KEY;\r\n store.delete(PENDING_NAVIGATION_LEGACY_KEY);\r\n }\r\n };\r\n // legacyRequest.onerror 는 별도 처리하지 않는다 — primary 가 성공했다면 transaction 은\r\n // 이미 유효하고, 실패하면 transaction.onerror 로 넘어간다.\r\n };\r\n\r\n primaryRequest.onerror = () => {\r\n reject(primaryRequest.error || new Error('IndexedDB read failed'));\r\n };\r\n\r\n transaction.oncomplete = () => {\r\n db.close();\r\n // finalizedKey 를 쓰진 않지만, 추후 디버깅/로그 확장 시 어느 key 를 소비했는지 구분 가능.\r\n void finalizedKey;\r\n resolve(pendingRoomId);\r\n };\r\n\r\n transaction.onerror = () => {\r\n db.close();\r\n reject(transaction.error || new Error('IndexedDB transaction failed'));\r\n };\r\n\r\n transaction.onabort = () => {\r\n db.close();\r\n reject(transaction.error || new Error('IndexedDB transaction aborted'));\r\n };\r\n });\r\n}\r\n\r\nclass PushManager {\r\n /**\r\n * @param {Object} options\r\n * @param {Object} options.apiClient - ApiClient 인스턴스\r\n * @param {string} [options.projectId] - 프로젝트 ID (멀티테넌트 환경에서 알림 라우팅 격리용)\r\n * @param {Object} [options.firebaseConfig] - Firebase 설정 (기본: TalkFlow 설정)\r\n * @param {string} [options.vapidKey] - VAPID Key (기본: TalkFlow 키)\r\n * @param {string} [options.serviceWorkerPath] - 서비스 워커 경로 (기본: '/firebase-messaging-sw.js')\r\n * @param {number} [options.logLevel] - 로그 레벨\r\n */\r\n constructor(options) {\r\n this.apiClient = options.apiClient;\r\n this.projectId = options.projectId || null;\r\n this.firebaseConfig = options.firebaseConfig || DEFAULT_FIREBASE_CONFIG;\r\n this.vapidKey = options.vapidKey || DEFAULT_VAPID_KEY;\r\n this.serviceWorkerPath = options.serviceWorkerPath || '/firebase-messaging-sw.js';\r\n this.logger = new Logger(options.logLevel || LogLevel.WARN, 'PushManager');\r\n\r\n this._messaging = null;\r\n this._currentToken = null;\r\n this._enabled = false;\r\n this._enablePromise = null;\r\n this._foregroundMessageUnsubscribe = null;\r\n this._swRegistration = null;\r\n this._projectRegisteredToSW = false;\r\n this._visibilityHandler = null;\r\n this._deviceIdCache = null; // IndexedDB 조회 결과 메모리 캐시 (세션 단위)\r\n }\r\n\r\n /**\r\n * 저장된 pending roomId 를 소비한다.\r\n *\r\n * @param {string} [projectId] - 소비할 projectId. 지정 시 해당 projectId 로 저장된 pending 만 소비.\r\n * 미지정 시 레거시 전역 key 만 조회 (구버전 호환).\r\n * @returns {Promise<string|null>}\r\n */\r\n static async consumePendingRoom(projectId) {\r\n return consumePendingRoomFromIndexedDB(projectId);\r\n }\r\n\r\n /**\r\n * 서비스 워커가 cold start 시 저장한 메일 이동(Gmail 메일 알림 클릭)을 꺼내고 지운다.\r\n * 앱 부팅 때 호출해 메일 화면으로 라우팅한다 — {@code messageId} 가 있으면 그 메일 상세, 없으면(묶음 알림) 받은메일함.\r\n *\r\n * @param {string} projectId\r\n * @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}\r\n */\r\n static async consumePendingMail(projectId) {\r\n return consumePendingMailFromIndexedDB(projectId);\r\n }\r\n\r\n /**\r\n * 현재 브라우저의 푸시 권한 상태를 조회한다.\r\n *\r\n * <p>{@link PushManager} 인스턴스가 없어도 (즉, {@code enablePushNotifications()} 호출 전) 호출 가능하다.\r\n * 호출자가 enable 호출 <b>전에</b> UI 분기를 결정할 때 사용한다.</p>\r\n *\r\n * <p>반환값:</p>\r\n * <ul>\r\n * <li>{@code 'granted'} — 이미 허용. enable 호출 시 권한창 안 뜨고 토큰 발급으로 즉시 진행.</li>\r\n * <li>{@code 'default'} — 아직 묻지 않음. enable 호출 시 권한창 표시.</li>\r\n * <li>{@code 'denied'} — 거부됨. enable 호출 시 즉시 PERMISSION_DENIED 로 실패 (브라우저 정책상 권한창 안 뜸).</li>\r\n * <li>{@code 'unsupported'} — Notification 또는 Service Worker 미지원 환경 (Safari iOS 등).</li>\r\n * </ul>\r\n *\r\n * @returns {'granted'|'denied'|'default'|'unsupported'}\r\n */\r\n static getPermissionState() {\r\n if (typeof window === 'undefined' || typeof Notification === 'undefined') {\r\n return 'unsupported';\r\n }\r\n if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {\r\n return 'unsupported';\r\n }\r\n return Notification.permission; // 'granted' | 'denied' | 'default'\r\n }\r\n\r\n /**\r\n * 푸시 알림 활성화.\r\n * Firebase 초기화 → 서비스 워커 등록 → 권한 요청 → 토큰 획득 → 서버 등록.\r\n *\r\n * <p>토큰은 SDK 내부에서만 관리되므로 호출자는 반환값을 받을 필요가 없습니다.\r\n * 상태 확인은 {@link #isEnabled} 로, 에러는 throw 로 전달됩니다.</p>\r\n *\r\n * @returns {Promise<void>}\r\n * @throws {Error} 브라우저 미지원, 권한 거부, Firebase 미설치 등\r\n */\r\n async enable() {\r\n if (this._enabled) {\r\n this.logger.debug('푸시 알림이 이미 활성화되어 있습니다.');\r\n return;\r\n }\r\n\r\n if (this._enablePromise) {\r\n this.logger.debug('푸시 알림 활성화가 이미 진행 중입니다.');\r\n return this._enablePromise;\r\n }\r\n\r\n this._enablePromise = this._enableInternal();\r\n\r\n try {\r\n return await this._enablePromise;\r\n } finally {\r\n this._enablePromise = null;\r\n }\r\n }\r\n\r\n async _enableInternal() {\r\n // 브라우저 환경 체크\r\n if (typeof window === 'undefined' || !('Notification' in window)) {\r\n throw new PushError(\r\n PushErrorCode.UNSUPPORTED_BROWSER,\r\n '푸시 알림은 브라우저 환경에서만 사용 가능합니다'\r\n );\r\n }\r\n\r\n if (!('serviceWorker' in navigator)) {\r\n throw new PushError(\r\n PushErrorCode.UNSUPPORTED_BROWSER,\r\n '이 브라우저는 서비스 워커를 지원하지 않습니다'\r\n );\r\n }\r\n\r\n // 권한이 이미 거부된 상태면 requestPermission() 은 즉시 'denied' 를 반환하고\r\n // 권한창은 뜨지 않는다. 명확한 분류 에러로 즉시 throw 하여 호출자가\r\n // \"브라우저 설정에서 풀어달라\" 가이드를 띄울 수 있도록 한다.\r\n if (Notification.permission === 'denied') {\r\n throw new PushError(\r\n PushErrorCode.PERMISSION_DENIED,\r\n '알림 권한이 이미 거부되어 있습니다. 브라우저 사이트 설정에서 허용해주세요.'\r\n );\r\n }\r\n\r\n // Firebase 동적 로드\r\n let firebaseApp, firebaseMessaging;\r\n try {\r\n firebaseApp = await import('firebase/app');\r\n firebaseMessaging = await import('firebase/messaging');\r\n } catch (e) {\r\n throw new PushError(\r\n PushErrorCode.FIREBASE_NOT_INSTALLED,\r\n 'firebase 패키지가 설치되지 않았습니다. npm install firebase 를 실행하세요.',\r\n e\r\n );\r\n }\r\n\r\n // Firebase 초기화 (이미 초기화된 경우 기존 앱 사용)\r\n // 고객 앱의 기존 Firebase와 충돌 방지 — named app으로 분리\r\n let app;\r\n try {\r\n app = firebaseApp.getApp('talkflow');\r\n } catch {\r\n app = firebaseApp.initializeApp(this.firebaseConfig, 'talkflow');\r\n }\r\n\r\n // 서비스 워커 등록\r\n const registration = await this._registerServiceWorker();\r\n\r\n // 알림 권한 요청\r\n // - 'default' 상태: 브라우저가 권한창을 표시하고 사용자 응답을 대기\r\n // - 'granted' 상태: 재요청하지 않고 즉시 통과 (아래 주의사항 참고)\r\n // - 'denied' 상태: 위 가드에서 이미 throw 했으므로 여기 도달 불가\r\n //\r\n // !! Safari/WebKit 주의 !!\r\n // Chromium 은 이미 granted 인 상태에서 requestPermission() 을 호출하면 창 없이 즉시\r\n // 'granted' 를 반환하지만, Safari/WebKit 은 이 API 를 '사용자 제스처 안에서만' 허용한다.\r\n // 따라서 연결 직후 자동 활성화처럼 제스처가 없는 경로에서는 이미 허용된 상태여도 거부로 반환되어\r\n // PERMISSION_DENIED 로 throw 되고, 그 아래의 토큰 발급/서버 등록/포그라운드 onMessage 등록까지\r\n // 모두 건너뛰게 된다(iOS 홈 화면 앱에서 새로고침마다 권한 차단 배너 + 포그라운드 메시지 미수신).\r\n // 이미 허용된 상태에서는 재요청 자체가 불필요하므로 호출을 건너뛴다.\r\n const permission =\r\n Notification.permission === 'granted'\r\n ? 'granted'\r\n : await Notification.requestPermission();\r\n if (permission !== 'granted') {\r\n throw new PushError(\r\n PushErrorCode.PERMISSION_DENIED,\r\n '알림 권한이 거부되었습니다. 브라우저 설정에서 허용해주세요.'\r\n );\r\n }\r\n\r\n // FCM 토큰 획득\r\n this._messaging = firebaseMessaging.getMessaging(app);\r\n\r\n const tokenOptions = { serviceWorkerRegistration: registration };\r\n if (this.vapidKey) {\r\n tokenOptions.vapidKey = this.vapidKey;\r\n }\r\n\r\n try {\r\n this._currentToken = await firebaseMessaging.getToken(this._messaging, tokenOptions);\r\n } catch (e) {\r\n throw new PushError(\r\n PushErrorCode.TOKEN_FAILED,\r\n 'FCM 토큰 획득 중 오류 발생: ' + (e?.message || e),\r\n e\r\n );\r\n }\r\n\r\n if (!this._currentToken) {\r\n throw new PushError(\r\n PushErrorCode.TOKEN_FAILED,\r\n 'FCM 토큰 획득 실패 (응답이 비어있음)'\r\n );\r\n }\r\n\r\n // 서버에 토큰 등록\r\n await this._registerTokenToServer(this._currentToken);\r\n\r\n // 포그라운드 메시지 수신 핸들러\r\n if (this._foregroundMessageUnsubscribe) {\r\n this._foregroundMessageUnsubscribe();\r\n }\r\n\r\n this._foregroundMessageUnsubscribe = firebaseMessaging.onMessage(this._messaging, (payload) => {\r\n this.logger.debug('포그라운드 푸시 수신:', payload);\r\n this._onForegroundMessage(payload);\r\n });\r\n\r\n // SW 등록 객체를 보관 — 이후 projectId 재등록에 사용한다.\r\n this._swRegistration = registration;\r\n\r\n // SW 에 projectId 등록.\r\n // 멀티테넌트 환경에서 notificationclick 시 SW 가 \"어느 창이 어느 project 인지\"를 알아야\r\n // 다른 프로젝트 창에 NAVIGATE_TO_ROOM 을 보내지 않는다.\r\n await this._registerProjectToSW();\r\n\r\n // SW terminate 로 Map 이 소실될 수 있으므로 visibility 복귀 시 재등록.\r\n this._installVisibilityReRegister();\r\n\r\n // SW 업데이트 체크 (CDN handler 갱신 전파)\r\n try {\r\n await registration.update();\r\n } catch (e) {\r\n this.logger.debug('SW 업데이트 체크 실패 (무시):', e.message);\r\n }\r\n\r\n // SW 버전 감지 — 구버전 경고 용도로만 사용.\r\n // 이 값으로 서버/SDK 가 기능 분기(image 지원 여부 등)를 판단하면 안 된다.\r\n const swVersion = await this._getSWVersion(registration);\r\n if (!swVersion) {\r\n this.logger.warn(\r\n '[TalkFlow] 서비스 워커가 구버전(v1)입니다. ' +\r\n '이미지 미리보기 등 리치 알림 기능을 사용하려면 firebase-messaging-sw.js 를 ' +\r\n 'v2 로 업데이트하세요. 가이드: https://docs.talkflow.ai/push/sw-upgrade'\r\n );\r\n } else {\r\n this.logger.debug('SW 버전:', swVersion);\r\n }\r\n\r\n this._enabled = true;\r\n this.logger.info('푸시 알림 활성화 완료');\r\n }\r\n\r\n /**\r\n * 서비스 워커 등록.\r\n * updateViaCache: 'none' 으로 등록하여 registration.update() 시\r\n * importScripts 로 로드하는 CDN handler 까지 캐시 없이 갱신되도록 한다.\r\n * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register\r\n * @private\r\n */\r\n async _registerServiceWorker() {\r\n try {\r\n const registration = await navigator.serviceWorker.register(\r\n this.serviceWorkerPath,\r\n { updateViaCache: 'none' }\r\n );\r\n this.logger.debug('서비스 워커 등록 완료:', this.serviceWorkerPath);\r\n return registration;\r\n } catch (error) {\r\n throw new PushError(\r\n PushErrorCode.SW_REGISTER_FAILED,\r\n `서비스 워커 등록 실패: ${this.serviceWorkerPath}\\n` +\r\n '프로젝트 public 폴더에 firebase-messaging-sw.js 파일을 배치하세요.',\r\n error\r\n );\r\n }\r\n }\r\n\r\n /**\r\n * 서버에 디바이스 토큰 등록\r\n * @private\r\n */\r\n async _registerTokenToServer(token) {\r\n try {\r\n const deviceId = await this._getDeviceId();\r\n this.apiClient?.authProvider?.setDeviceId(deviceId);\r\n await this.apiClient.post('/api/v1/push/devices', {\r\n deviceToken: token,\r\n deviceType: 'WEB',\r\n deviceId\r\n });\r\n this.logger.info('디바이스 토큰 서버 등록 완료');\r\n } catch (error) {\r\n this.logger.error('디바이스 토큰 서버 등록 실패:', error);\r\n // PushError 가 아닌 경우 (네트워크 / 서버 5xx 등) SERVER_REGISTER_FAILED 로 wrap.\r\n // 이미 PushError 면 그대로 전파 (분류 보존).\r\n if (error instanceof PushError) {\r\n throw error;\r\n }\r\n throw new PushError(\r\n PushErrorCode.SERVER_REGISTER_FAILED,\r\n '디바이스 토큰 서버 등록 실패: ' + (error?.message || error),\r\n error\r\n );\r\n }\r\n }\r\n\r\n /**\r\n * 브라우저 고유 식별자 조회/생성 (IndexedDB 기반).\r\n *\r\n * <p>순서:</p>\r\n * <ol>\r\n * <li>IndexedDB 에서 기존 deviceId 조회</li>\r\n * <li>없으면 localStorage 에 있는 legacy 값 있는지 확인 (기존 SDK 버전 설치 이력)</li>\r\n * <li>legacy 있으면 IndexedDB 로 이관 + localStorage 키 제거</li>\r\n * <li>둘 다 없으면 신규 생성 + IndexedDB 저장</li>\r\n * </ol>\r\n *\r\n * <p>IndexedDB 사용 불가 환경 (private mode 등) 에서는 in-memory fallback — 세션 단위\r\n * 새 deviceId 사용. 완벽한 영속성은 없지만 기본 기능은 유지.</p>\r\n *\r\n * @private\r\n * @returns {Promise<string>}\r\n */\r\n async _getDeviceId() {\r\n // 메모리 캐시 — 동일 세션 내 반복 호출 방지.\r\n if (this._deviceIdCache) {\r\n return this._deviceIdCache;\r\n }\r\n\r\n try {\r\n // 1) IndexedDB 에서 조회\r\n let deviceId = await readDeviceIdFromIndexedDb();\r\n\r\n // 2) 없으면 localStorage legacy 값 확인 + 이관\r\n if (!deviceId && typeof localStorage !== 'undefined') {\r\n const legacy = localStorage.getItem(LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY);\r\n if (legacy) {\r\n deviceId = legacy;\r\n // IDB 저장 성공 + read-back 검증 둘 다 통과한 경우에만 localStorage 정리.\r\n // 실패하거나 검증 miss 면 legacy 값 보존해 다음 방문에 재시도 가능.\r\n const saved = await writeDeviceIdToIndexedDb(deviceId);\r\n if (saved) {\r\n const readBack = await readDeviceIdFromIndexedDb();\r\n if (readBack === deviceId) {\r\n try {\r\n localStorage.removeItem(LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY);\r\n this.logger.debug('deviceId migrated from localStorage to IndexedDB');\r\n } catch (_) { /* quota/private mode 예외 무시 — legacy 남아도 다음에 재이관 시도 */ }\r\n } else {\r\n // 저장 tx 는 complete 됐는데 read-back 은 다르게 나오는 극단 케이스.\r\n // localStorage 보존 — 다음 방문에 재시도.\r\n this.logger.warn('deviceId IDB read-back 불일치, localStorage 보존');\r\n }\r\n } else {\r\n this.logger.warn('deviceId IDB 저장 실패 (private mode / quota 등), localStorage 보존');\r\n }\r\n }\r\n }\r\n\r\n // 3) 그래도 없으면 신규 생성 + 저장 시도.\r\n // 저장 실패해도 in-memory 캐시로 세션 단위 유효하므로 푸시 등록은 진행됨.\r\n // (영속화 실패는 다음 방문에 새 deviceId 생성 → 서버 쪽 device 매핑 갱신은 불가피)\r\n if (!deviceId) {\r\n deviceId = generateDeviceId();\r\n const saved = await writeDeviceIdToIndexedDb(deviceId);\r\n if (!saved) {\r\n this.logger.warn('신규 deviceId IDB 저장 실패, 이번 세션만 유효');\r\n }\r\n }\r\n\r\n this._deviceIdCache = deviceId;\r\n this.apiClient?.authProvider?.setDeviceId(deviceId);\r\n return deviceId;\r\n } catch (error) {\r\n // IndexedDB 장애 (open / read / transaction throw 등) 시 fallback.\r\n // IDB 가 현재 실패하므로 localStorage 를 삭제하지 않는다 — 다음 방문에 IDB 회복되면\r\n // try 블록의 이관 경로가 다시 시도됨.\r\n this.logger.warn('IndexedDB deviceId 조회/저장 실패, fallback:', error);\r\n if (!this._deviceIdCache) {\r\n // 1) localStorage legacy 우선 확인 — 기존 사용자 device 연속성 유지.\r\n // IDB 가 실패해도 localStorage 가 읽히면 기존 deviceId 재사용.\r\n let fallbackId = null;\r\n if (typeof localStorage !== 'undefined') {\r\n try {\r\n fallbackId = localStorage.getItem(LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY);\r\n } catch (_) { /* private mode 등 — localStorage 도 접근 불가 */ }\r\n }\r\n // 2) legacy 도 없으면 신규 생성 (in-memory 단일 세션).\r\n this._deviceIdCache = fallbackId || generateDeviceId();\r\n }\r\n return this._deviceIdCache;\r\n }\r\n }\r\n\r\n /**\r\n * 포그라운드 메시지 수신 콜백.\r\n * TalkFlowClient에서 오버라이드하여 activeRoom 기반 suppress 처리.\r\n * @param {Object} payload - FCM 메시지 페이로드\r\n */\r\n _onForegroundMessage(payload) {\r\n // TalkFlowClient에서 오버라이드\r\n }\r\n\r\n /**\r\n * 서비스 워커가 cold start 시 저장한 pending roomId 를 소비하고 삭제한다.\r\n * 앱 부팅 시 호출하여 알림 클릭으로 열려야 할 방으로 라우팅할 때 사용한다.\r\n *\r\n * <p>내부 projectId 가 설정되어 있으면 해당 프로젝트의 pending 만 소비한다.\r\n * 다른 프로젝트의 pending 은 건드리지 않아 그 프로젝트가 나중에 꺼낼 수 있다.</p>\r\n *\r\n * @returns {Promise<string|null>} pending roomId 또는 null\r\n */\r\n async consumePendingRoom() {\r\n return PushManager.consumePendingRoom(this.projectId);\r\n }\r\n\r\n /**\r\n * 서비스 워커가 cold start 시 저장한 메일 이동을 꺼내고 지운다 — {@link PushManager.consumePendingMail}.\r\n *\r\n * @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}\r\n */\r\n async consumePendingMail() {\r\n return PushManager.consumePendingMail(this.projectId);\r\n }\r\n\r\n /**\r\n * 현재 푸시 상태를 정리한다.\r\n * 로그아웃 또는 다른 사용자로 전환할 때 foreground listener 와 활성화 상태를 초기화한다.\r\n * SW 에 등록된 projectId 도 해제한다.\r\n */\r\n reset() {\r\n if (this._foregroundMessageUnsubscribe) {\r\n try {\r\n this._foregroundMessageUnsubscribe();\r\n } catch (error) {\r\n this.logger.debug('포그라운드 푸시 리스너 해제 실패 (무시):', error?.message || error);\r\n }\r\n }\r\n\r\n // visibility 재등록 핸들러 제거 — reset 이후 새 enable 전까지 자동 재등록이 일어나지 않도록.\r\n this._uninstallVisibilityReRegister();\r\n\r\n // SW 의 projectId 등록도 해제 (best-effort).\r\n this._unregisterProjectFromSW();\r\n\r\n this._foregroundMessageUnsubscribe = null;\r\n this._enablePromise = null;\r\n this._messaging = null;\r\n this._currentToken = null;\r\n this._enabled = false;\r\n this._swRegistration = null;\r\n this._projectRegisteredToSW = false;\r\n }\r\n\r\n /**\r\n * 활성 SW 에 현재 projectId 를 등록한다.\r\n * SW 는 Client.id → projectId Map 을 유지해 notificationclick 시 올바른 창을 선택한다.\r\n * @private\r\n */\r\n async _registerProjectToSW() {\r\n if (!this.projectId) {\r\n return;\r\n }\r\n try {\r\n const readyRegistration = await navigator.serviceWorker.ready;\r\n const sw =\r\n readyRegistration.active ||\r\n (this._swRegistration && this._swRegistration.active);\r\n if (!sw) {\r\n this.logger.debug('SW active worker 없음 — projectId 등록 스킵');\r\n return;\r\n }\r\n sw.postMessage({ type: 'TALKFLOW_REGISTER_PROJECT', projectId: this.projectId });\r\n this._projectRegisteredToSW = true;\r\n } catch (error) {\r\n this.logger.debug('SW projectId 등록 실패 (무시):', error?.message || error);\r\n }\r\n }\r\n\r\n /**\r\n * SW 에 등록된 projectId 해제 (best-effort).\r\n * reset 시 호출되며, navigator.serviceWorker 접근이 실패해도 예외를 삼킨다.\r\n * @private\r\n */\r\n _unregisterProjectFromSW() {\r\n if (!this._projectRegisteredToSW) {\r\n return;\r\n }\r\n try {\r\n if (typeof navigator === 'undefined' || !navigator.serviceWorker) {\r\n return;\r\n }\r\n const controller = navigator.serviceWorker.controller;\r\n if (!controller) {\r\n return;\r\n }\r\n controller.postMessage({ type: 'TALKFLOW_UNREGISTER_PROJECT' });\r\n } catch (error) {\r\n this.logger.debug('SW projectId 해제 실패 (무시):', error?.message || error);\r\n }\r\n }\r\n\r\n /**\r\n * visibility 복귀 시 SW 에 projectId 를 재등록한다.\r\n * SW 는 idle 시 terminate 되며 이때 clientProjectMap 이 소실되므로,\r\n * 창이 다시 visible 상태가 될 때 방어적으로 재등록한다.\r\n * @private\r\n */\r\n _installVisibilityReRegister() {\r\n if (typeof document === 'undefined' || this._visibilityHandler) {\r\n return;\r\n }\r\n this._visibilityHandler = () => {\r\n if (document.visibilityState === 'visible' && this._enabled && this.projectId) {\r\n // best-effort — 재등록 실패는 다음 visibility 이벤트에 다시 시도된다.\r\n this._registerProjectToSW().catch(() => {});\r\n }\r\n };\r\n document.addEventListener('visibilitychange', this._visibilityHandler);\r\n }\r\n\r\n /**\r\n * @private\r\n */\r\n _uninstallVisibilityReRegister() {\r\n if (typeof document === 'undefined' || !this._visibilityHandler) {\r\n return;\r\n }\r\n try {\r\n document.removeEventListener('visibilitychange', this._visibilityHandler);\r\n } catch (error) {\r\n // 무시\r\n }\r\n this._visibilityHandler = null;\r\n }\r\n\r\n // noinspection JSUnusedGlobalSymbols\r\n /**\r\n * 현재 FCM 토큰\r\n * @returns {string|null}\r\n */\r\n getToken() {\r\n return this._currentToken;\r\n }\r\n\r\n /**\r\n * 푸시 활성화 여부\r\n * @returns {boolean}\r\n */\r\n isEnabled() {\r\n return this._enabled;\r\n }\r\n\r\n /**\r\n * 현재 사용자의 등록된 디바이스 목록 조회 (푸시 on/off UI 용).\r\n *\r\n * <p>서버가 내려주는 필드: {@code deviceId}, {@code deviceType}, {@code enabled}, {@code createdAt}.\r\n * {@code deviceToken} 은 보안상 응답에 포함되지 않는다.</p>\r\n *\r\n * @returns {Promise<Array<{deviceId: string, deviceType: string, enabled: boolean, createdAt: string}>>}\r\n */\r\n async getMyDevices() {\r\n const response = await this.apiClient.get('/api/v1/push/devices/me');\r\n return response && typeof response === 'object' && 'data' in response\r\n ? response.data\r\n : response;\r\n }\r\n\r\n /**\r\n * 특정 디바이스의 푸시 수신 여부 토글.\r\n *\r\n * <p>본인 소유 디바이스만 수정 가능 (서버가 {@code (projectId, userId, deviceId)} 조합으로 검증).\r\n * 소유자 불일치 또는 미등록 디바이스 시 서버가 404 로 응답 → 호출자에 throw.</p>\r\n *\r\n * @param {string} deviceId - 대상 디바이스 ID (현재 디바이스면 {@link #setCurrentDeviceEnabled} 사용 권장)\r\n * @param {boolean} enabled - true 면 수신, false 면 수신 중단\r\n * @returns {Promise<void>}\r\n */\r\n async setDeviceEnabled(deviceId, enabled) {\r\n await this.apiClient.patch('/api/v1/push/devices/me/enabled', {\r\n deviceId,\r\n enabled\r\n });\r\n this.logger.info(`디바이스 푸시 enabled=${enabled}: deviceId=${deviceId}`);\r\n }\r\n\r\n /**\r\n * 현재 디바이스의 푸시 수신 여부 토글 (편의 메서드).\r\n *\r\n * <p>SDK 가 관리하는 현재 브라우저의 deviceId 로 {@link #setDeviceEnabled} 를 호출한다.\r\n * {@link #enable} 이 선행되지 않아도 호출 가능 — 서버에 디바이스가 등록돼 있으면 동작.</p>\r\n *\r\n * @param {boolean} enabled\r\n * @returns {Promise<void>}\r\n */\r\n async setCurrentDeviceEnabled(enabled) {\r\n const deviceId = await this._getDeviceId();\r\n await this.setDeviceEnabled(deviceId, enabled);\r\n }\r\n\r\n /**\r\n * 등록된 서비스 워커의 TalkFlow 버전을 조회.\r\n *\r\n * navigator.serviceWorker.ready 를 기다려 active worker 가 보장된 상태에서,\r\n * readyRegistration → register() 반환 registration 양쪽의\r\n * waiting → installing → active 순으로 SW 를 찾는다.\r\n * v1→v2 교체 직후 새 SW 가 waiting/installing 상태에 있을 때\r\n * 구 active(v1) 대신 신규(v2)를 우선 감지하여 거짓 경고를 방지한다.\r\n * 같은 scope 에서는 두 객체가 동일 참조이지만, 타이밍 차이에 대한 방어 코드.\r\n *\r\n * v1(구버전) SW 는 메시지 핸들러가 없으므로 2초 timeout 후 null 반환.\r\n *\r\n * @param {ServiceWorkerRegistration} registration - register() 가 반환한 등록 객체\r\n * @returns {Promise<string|null>} 버전 문자열 또는 null (구버전/미응답)\r\n * @private\r\n */\r\n async _getSWVersion(registration) {\r\n try {\r\n const readyRegistration = await navigator.serviceWorker.ready;\r\n\r\n const sw =\r\n readyRegistration.waiting ||\r\n readyRegistration.installing ||\r\n readyRegistration.active ||\r\n registration.waiting ||\r\n registration.installing ||\r\n registration.active;\r\n if (!sw) return null;\r\n\r\n return new Promise((resolve) => {\r\n const timeout = setTimeout(() => resolve(null), 2000);\r\n\r\n const channel = new MessageChannel();\r\n channel.port1.onmessage = (event) => {\r\n clearTimeout(timeout);\r\n resolve(event.data?.version || null);\r\n };\r\n\r\n try {\r\n sw.postMessage({ type: 'TALKFLOW_SW_VERSION' }, [channel.port2]);\r\n } catch (e) {\r\n clearTimeout(timeout);\r\n resolve(null);\r\n }\r\n });\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level\r\n */\r\n setLogLevel(level) {\r\n this.logger.setLevel(level);\r\n }\r\n}\r\n\r\nexport default PushManager;\r\n","/**\r\n * Gmail 새 메일 알림 — 웹소켓 {@code MAIL_CHANGED} 의 미리보기와 포그라운드 FCM 메일 푸시를 합쳐 메일마다 한 번만\r\n * {@code newMail} 을 낸다 (서버 GMAIL_MAILBOX_FEATURES_DESIGN.md §6-7).\r\n *\r\n * <p>같은 메일이 두 길로 온다: 서버는 앱 접속 여부와 상관없이 FCM 을 보내고(채팅과 같음), 화면이 떠 있으면 웹소켓\r\n * 이벤트도 받는다. 중복 판정 키는 {@code projectId:connectionId:messageId} — 어느 쪽이 먼저 와도 두 번째는 버린다.\r\n * 묶음 푸시({@code kind=SUMMARY})도 묶은 메시지 ID({@code data.messageIds})로 같은 판정을 하고, 처음 보는 메일이 있을 때만\r\n * 그 수로 {@code newMailSummary} 를 낸다(같은 묶음이 다시 와도, 개별 알림을 이미 받았어도 다시 띄우지 않는다).</p>\r\n *\r\n * <p>보낸 사람·제목은 로그에 남기지 않는다.</p>\r\n */\r\n\r\n/** 같은 메일을 다시 보지 않는 시간 — 서버 푸시 수명(수신 후 30분)보다 길게. */\r\nconst DEDUP_TTL_MS = 60 * 60 * 1000;\r\n/** 기억하는 메일 수 상한 — 넘으면 오래된 것부터 잊는다. */\r\nconst DEDUP_MAX = 1000;\r\n\r\nexport function createMailNotificationDeduper({ ttlMs = DEDUP_TTL_MS, max = DEDUP_MAX, now = () => Date.now() } = {}) {\r\n const seen = new Map();\r\n return {\r\n /** 처음 보는 키면 true(그리고 기억), 이미 본 키면 false. */\r\n accept(key) {\r\n const at = now();\r\n for (const [seenKey, seenAt] of seen) {\r\n if (at - seenAt < ttlMs) {\r\n break; // Map 은 넣은 순서 — 첫 살아 있는 항목부터는 다 살아 있다\r\n }\r\n seen.delete(seenKey);\r\n }\r\n if (seen.has(key)) {\r\n return false;\r\n }\r\n seen.set(key, at);\r\n while (seen.size > max) {\r\n seen.delete(seen.keys().next().value);\r\n }\r\n return true;\r\n },\r\n size() {\r\n return seen.size;\r\n }\r\n };\r\n}\r\n\r\nfunction deduperOf(client) {\r\n if (!client._mailNotificationDeduper) {\r\n client._mailNotificationDeduper = createMailNotificationDeduper();\r\n }\r\n return client._mailNotificationDeduper;\r\n}\r\n\r\nfunction projectIdOf(client) {\r\n return (client.options && client.options.projectId) || null;\r\n}\r\n\r\nfunction keyOf(projectId, connectionId, messageId) {\r\n return `${projectId || ''}:${connectionId}:${messageId}`;\r\n}\r\n\r\nfunction senderOf(from) {\r\n if (!from || typeof from !== 'object') {\r\n return null;\r\n }\r\n return (typeof from.name === 'string' && from.name.trim() !== '') ? from.name : (from.email || null);\r\n}\r\n\r\n/**\r\n * 웹소켓 {@code MAIL_CHANGED} → 새 메일마다 {@code newMail}. 읽음·보관 같은 변화(미리보기 없음)는 아무것도 내지 않는다.\r\n */\r\nexport function emitNewMailFromSocket(client, event) {\r\n if (!event || event.eventType !== 'MAIL_CHANGED' || !Array.isArray(event.messages) || !event.connectionId) {\r\n return;\r\n }\r\n const projectId = projectIdOf(client);\r\n const deduper = deduperOf(client);\r\n for (const preview of event.messages) {\r\n if (!preview || !preview.messageId) {\r\n continue;\r\n }\r\n if (!deduper.accept(keyOf(projectId, event.connectionId, preview.messageId))) {\r\n continue;\r\n }\r\n client.emit('newMail', {\r\n projectId,\r\n connectionId: event.connectionId,\r\n messageId: preview.messageId,\r\n threadId: preview.threadId || null,\r\n sender: senderOf(preview.from),\r\n from: preview.from || null,\r\n subject: preview.subject || null,\r\n receivedAt: preview.receivedAt || null,\r\n source: 'socket'\r\n });\r\n }\r\n}\r\n\r\n/**\r\n * 포그라운드 FCM 메일 푸시({@code data.target === 'MAIL'}) → {@code newMail}(한 통) 또는 {@code newMailSummary}(묶음).\r\n * 메일 푸시면 {@code true} — 호출자는 {@code pushNotification} 을 내지 않는다(같은 알림을 두 번 띄우지 않게).\r\n */\r\nexport function handleMailPush(client, payload) {\r\n const data = (payload && payload.data) || {};\r\n if (data.target !== 'MAIL') {\r\n return false;\r\n }\r\n const projectId = projectIdOf(client);\r\n if (!data.connectionId) {\r\n return true;\r\n }\r\n if (data.kind === 'SUMMARY') {\r\n const ids = typeof data.messageIds === 'string'\r\n ? data.messageIds.split(',').map((id) => id.trim()).filter(Boolean)\r\n : [];\r\n if (ids.length === 0) {\r\n // 메시지 ID 없는 묶음(구버전 서버) — 거를 기준이 없어 건수 그대로 낸다.\r\n const count = Number.parseInt(data.count, 10);\r\n client.emit('newMailSummary', {\r\n projectId,\r\n connectionId: data.connectionId,\r\n count: Number.isFinite(count) ? count : null,\r\n messageIds: [],\r\n source: 'push'\r\n });\r\n return true;\r\n }\r\n const deduper = deduperOf(client);\r\n const unseen = ids.filter((id) => deduper.accept(keyOf(projectId, data.connectionId, id)));\r\n if (unseen.length > 0) {\r\n client.emit('newMailSummary', {\r\n projectId,\r\n connectionId: data.connectionId,\r\n count: unseen.length,\r\n messageIds: unseen,\r\n source: 'push'\r\n });\r\n }\r\n return true;\r\n }\r\n if (!data.messageId || !deduperOf(client).accept(keyOf(projectId, data.connectionId, data.messageId))) {\r\n return true;\r\n }\r\n client.emit('newMail', {\r\n projectId,\r\n connectionId: data.connectionId,\r\n messageId: data.messageId,\r\n threadId: data.threadId || null,\r\n sender: (payload.notification && payload.notification.title) || data.title || null,\r\n from: null,\r\n subject: (payload.notification && payload.notification.body) || data.body || null,\r\n receivedAt: null,\r\n source: 'push'\r\n });\r\n return true;\r\n}\r\n","import { emitNewMailFromSocket } from './mailNotifications.js';\r\n\r\nconst CONNECTION_EVENT_MAP = [\r\n ['connected', 'connected'],\r\n ['disconnected', 'disconnected'],\r\n ['reconnecting', 'reconnecting'],\r\n // 재연결 '성공' + 포그라운드 복귀 감지 (RECONNECT_SYNC_DESIGN §6.1/§6.4)\r\n ['reconnected', 'reconnected'],\r\n ['resumed', 'resumed'],\r\n // 회사 구독 대기(서버 구독 관문) — 연결 오류와 따로 알린다. 결제 뒤 connect() 를 다시 부르면 풀린다.\r\n ['subscriptionRequired', 'subscriptionRequired'],\r\n ['error', 'connectionError']\r\n];\r\n\r\nconst CHAT_EVENT_MAP = [\r\n // 갭 복구 (RECONNECT_SYNC_DESIGN §6.4) — 기본 메시지 수신 코드는 변경 불필요, 선택 구독용.\r\n ['syncStarted', 'syncStarted'],\r\n ['syncCompleted', 'syncCompleted'],\r\n ['gapTruncated', 'gapTruncated'],\r\n ['roomListSynced', 'roomListSynced'],\r\n ['message', 'chatMessage'],\r\n ['newMessage', 'newChatMessage'],\r\n ['messageUpdated', 'messageUpdated'],\r\n ['messageDeleted', 'messageDeleted'],\r\n ['reactionChanged', 'reactionChanged'],\r\n ['linkPreviewAttached', 'linkPreviewAttached'],\r\n ['messageTranslated', 'messageTranslated'],\r\n ['messageRead', 'messageRead'],\r\n ['typing', 'typing'],\r\n ['assistantProgress', 'assistantProgress'],\r\n ['assistantRoomProgress', 'assistantRoomProgress'],\r\n ['summaryUpdate', 'summaryUpdate'],\r\n ['memberJoined', 'memberJoined'],\r\n ['memberLeft', 'memberLeft'],\r\n ['roomSubscribed', 'roomSubscribed'],\r\n ['roomUnsubscribed', 'roomUnsubscribed'],\r\n ['roomListSubscribed', 'roomListSubscribed'],\r\n ['roomListUnsubscribed', 'roomListUnsubscribed'],\r\n ['roomListUpdate', 'roomListUpdate'],\r\n ['roomListMessage', 'roomListMessage'],\r\n ['roomListMessageDeleted', 'roomListMessageDeleted'],\r\n ['roomListMessageUpdated', 'roomListMessageUpdated'],\r\n ['roomListCreated', 'roomListCreated'],\r\n ['roomListJoined', 'roomListJoined'],\r\n ['roomListLeft', 'roomListLeft'],\r\n ['roomListSelfLeft', 'roomListSelfLeft'],\r\n ['roomListKicked', 'roomListKicked'],\r\n ['roomListSelfKicked', 'roomListSelfKicked'],\r\n ['roomListBanned', 'roomListBanned'],\r\n ['roomListSelfBanned', 'roomListSelfBanned'],\r\n // 방장 위임 · 방 삭제 — 타입에 선언한 이벤트는 여기 없으면 최상위 client.on 에서 안 울린다.\r\n ['roomListOwnerChanged', 'roomListOwnerChanged'],\r\n ['roomListDeleted', 'roomListDeleted'],\r\n ['roomListRoomUpdated', 'roomListRoomUpdated'],\r\n ['roomListUnreadUpdated', 'roomListUnreadUpdated'],\r\n // @PM 을 불렀는데 회사 AI 가 막힘 — 부른 사람에게만 오는 방 목록 채널 알림(AI_UNAVAILABLE).\r\n ['aiUnavailable', 'aiUnavailable'],\r\n // Gmail 메일함 — mailChanged 는 받은 그대로. 새 메일 알림(newMail)은 아래에서 푸시와 합쳐 따로 낸다.\r\n ['mailSubscribed', 'mailSubscribed'],\r\n ['mailUnsubscribed', 'mailUnsubscribed'],\r\n ['mailChanged', 'mailChanged'],\r\n ['retentionCleanup', 'retentionCleanup']\r\n];\r\n\r\nconst WEBRTC_EVENT_MAP = [\r\n ['localStreamStarted', 'localStreamStarted'],\r\n ['localStreamStopped', 'localStreamStopped'],\r\n ['remoteTrack', 'remoteTrack'],\r\n ['screenShareStarted', 'screenShareStarted'],\r\n ['screenShareEnded', 'screenShareEnded'],\r\n ['deviceChange', 'deviceChange'],\r\n ['mediaStateChanged', 'mediaStateChanged'],\r\n ['callStarted', 'callStarted'],\r\n ['callEnded', 'callEnded'],\r\n ['callRecoveryFailed', 'callRecoveryFailed'],\r\n ['callRequested', 'callRequested'],\r\n ['callAccepted', 'callAccepted'],\r\n ['callRejected', 'callRejected'],\r\n ['callCancelled', 'callCancelled'],\r\n ['callBusy', 'callBusy'],\r\n ['incomingCall', 'incomingCall'],\r\n ['incomingCallWhileBusy', 'incomingCallWhileBusy'],\r\n ['callInvitation', 'callInvitation'],\r\n ['userJoined', 'userJoined'],\r\n ['userLeft', 'userLeft'],\r\n ['participantLeft', 'participantLeft'],\r\n ['participantMediaState', 'participantMediaState'],\r\n ['peerConnected', 'peerConnected'],\r\n ['peerDisconnected', 'peerDisconnected'],\r\n ['peerClosed', 'peerClosed'],\r\n ['error', 'webrtcError']\r\n];\r\n\r\nfunction forwardMappedEvents(source, target, eventMap) {\r\n eventMap.forEach(([sourceEvent, targetEvent]) => {\r\n source.on(sourceEvent, (data) => {\r\n target.emit(targetEvent, data);\r\n });\r\n });\r\n}\r\n\r\nexport function setupEventForwarding(client) {\r\n if (!client.connectionManager) {\r\n return;\r\n }\r\n\r\n client.connectionManager.on('stateChange', ({ state, prevState }) => {\r\n client._state = state;\r\n client.emit('stateChange', { state, prevState });\r\n });\r\n\r\n forwardMappedEvents(client.connectionManager, client, CONNECTION_EVENT_MAP);\r\n\r\n if (client.chat) {\r\n forwardMappedEvents(client.chat, client, CHAT_EVENT_MAP);\r\n client.chat.on('mailChanged', (event) => emitNewMailFromSocket(client, event));\r\n }\r\n\r\n if (client.webrtc) {\r\n forwardMappedEvents(client.webrtc, client, WEBRTC_EVENT_MAP);\r\n }\r\n}\r\n","const CHAT_DELEGATE_METHODS = [\r\n 'getRooms',\r\n 'getRoom',\r\n 'getRoomInfo',\r\n 'setMyRoomLanguage',\r\n 'createOneToOneRoom',\r\n 'createGroupRoom',\r\n 'joinGroupRoom',\r\n 'leaveRoom',\r\n // 방장 위임(방에 남음) · 방 삭제 — 방장의 «위임 후 나가기» 는 leaveRoom(roomId, { newOwnerId }).\r\n 'transferOwnership',\r\n 'deleteRoom',\r\n 'updateGroupRoom',\r\n 'inviteToGroupRoom',\r\n 'kickMember',\r\n 'banMember',\r\n 'unbanMember',\r\n 'getBannedMembers',\r\n 'getAvailableGroupRooms',\r\n 'getAllGroupRooms',\r\n 'enterRoom',\r\n 'getMessages',\r\n // 파일함 — 참여 중인 방들의 파일 목록(방 하나로 거르기 가능).\r\n 'getFiles',\r\n 'fetchLinkPreview',\r\n 'sendMessage',\r\n 'sendMessageOptimistic',\r\n 'sendTextMessage',\r\n 'sendTextMessageOptimistic',\r\n 'sendReply',\r\n 'sendReplyOptimistic',\r\n // AI 의사결정 카드의 선택 — 답글·라벨 본문·optionId 를 한 번에 맞춰 보낸다.\r\n 'sendDecisionChoice',\r\n 'uploadFile',\r\n 'sendFileMessage',\r\n 'sendFileMessageOptimistic',\r\n // 첨부 분류 — legacy GALLERY 복원용 공개 헬퍼. 타입 선언이 최상위 클라이언트에도 있으므로\r\n // 여기에 없으면 talkflow.resolveMessageCategory() 가 런타임 undefined 가 된다(타입이 거짓말).\r\n 'classifyFileType',\r\n 'resolveMessageCategory',\r\n 'editMessage',\r\n 'deleteMessage',\r\n 'markAsRead',\r\n 'pinMessage',\r\n 'unpinMessage',\r\n 'toggleReaction',\r\n 'subscribeRoom',\r\n 'unsubscribeRoom',\r\n 'unsubscribeAllRooms',\r\n 'setActiveRoom',\r\n 'clearActiveRoom',\r\n 'getActiveRoom',\r\n 'subscribeRoomList',\r\n 'unsubscribeRoomList',\r\n 'isRoomListSubscribed',\r\n 'subscribeMail',\r\n 'unsubscribeMail',\r\n 'isMailSubscribed',\r\n 'getSubscribedRooms',\r\n 'isSubscribed',\r\n 'startTyping',\r\n 'stopTyping',\r\n // AI 진행 표시 — 마운트 즉시 복원용 동기 조회 + 수동 재개. 여기 없으면 최상위 클라이언트에서\r\n // 런타임 undefined 가 된다(타입 선언만 있고 배선이 없으면 타입이 거짓말을 한다).\r\n 'getAssistantProgress',\r\n 'resumeAssistantStreams',\r\n 'getAssistants',\r\n 'getRoomAiMeta',\r\n 'rateAssistantMessage',\r\n 'summarizeWithAssistant',\r\n 'translateWithAssistant',\r\n 'uploadVideoAsset',\r\n 'analyzeVideo',\r\n 'analyzeVideoWithAssistant',\r\n 'getVideoAnalysis',\r\n 'cancelVideoAnalysis',\r\n 'getVideoAnalysisCapabilities',\r\n 'refreshSummary',\r\n 'getSummary',\r\n 'getRoomPmPrompt',\r\n 'upsertRoomPmPrompt',\r\n 'activateRoomPmPrompt',\r\n 'deactivateRoomPmPrompt',\r\n 'getRoomPmPromptVersions',\r\n 'activateRoomPmPromptVersion',\r\n 'previewRoomPmPrompt',\r\n // 투표 — 투표 서버는 클러스터 내부에만 있어 클라이언트가 직접 부를 수 없다. 전부 채팅 서버 경유.\r\n 'createVote',\r\n 'getVotes',\r\n 'getVote',\r\n 'updateVote',\r\n 'deleteVote',\r\n 'closeVote',\r\n 'submitVote',\r\n 'uploadVoteImage'\r\n];\r\n\r\nconst WEBRTC_DELEGATE_METHODS = [\r\n 'initializeIceServers',\r\n 'getTurnCredentials',\r\n 'createCallRoom',\r\n 'getCallRoom',\r\n 'joinCallRoomApi',\r\n 'leaveCallRoomApi',\r\n 'enableIncomingCalls',\r\n 'disableIncomingCalls',\r\n 'isIncomingCallsEnabled',\r\n 'startCall',\r\n 'callUser',\r\n 'acceptCall',\r\n 'rejectCall',\r\n 'cancelCall',\r\n 'endCall',\r\n 'toggleVideo',\r\n 'toggleAudio',\r\n 'setVideoEnabled',\r\n 'setAudioEnabled',\r\n 'startScreenShare',\r\n 'stopScreenShare',\r\n 'getLocalStream',\r\n 'getDevices',\r\n 'switchDevice',\r\n 'startDeviceChangeDetection',\r\n 'stopDeviceChangeDetection',\r\n 'applyVideoConstraints',\r\n 'getVideoSettings',\r\n 'getAudioSettings',\r\n 'isInCall',\r\n 'getCurrentRoom',\r\n 'getParticipants',\r\n 'getMediaState',\r\n 'getConnectionSummary',\r\n 'getStats'\r\n];\r\n\r\nfunction defineDelegates(prototype, targetKey, methods) {\r\n methods.forEach((method) => {\r\n prototype[method] = function delegatedMethod(...args) {\r\n return this[targetKey][method](...args);\r\n };\r\n });\r\n}\r\n\r\nexport function applyDelegatedMethods(TalkFlowClient) {\r\n defineDelegates(TalkFlowClient.prototype, 'chat', CHAT_DELEGATE_METHODS);\r\n defineDelegates(TalkFlowClient.prototype, 'webrtc', WEBRTC_DELEGATE_METHODS);\r\n}\r\n","/**\r\n * TalkFlowClient\r\n * 채팅 + WebRTC 통합 클라이언트\r\n */\r\n\r\nimport EventEmitter from './utils/EventEmitter.js';\r\nimport Logger from './utils/Logger.js';\r\nimport ApiClient from './utils/ApiClient.js';\r\nimport AuthSession, { tokenClaims } from './auth/AuthSession.js';\r\nimport { extractUserIdFromJWT } from './utils/jwtUtils.js';\r\nimport { ConnectionState, ErrorTypes, DefaultConfig, LogLevel, Environment, getServerUrl } from './constants.js';\r\nimport { initializeSubClients, applySessionMethods } from './talkflow/session.js';\r\nimport { setupEventForwarding } from './talkflow/eventForwarding.js';\r\nimport { applyDelegatedMethods } from './talkflow/delegates.js';\r\n\r\n// noinspection JSUnresolvedReference\r\nclass TalkFlowClient extends EventEmitter {\r\n /**\r\n * TalkFlowClient 생성\r\n *\r\n * <p>JWT 토큰은 고객사 backend 에서 발급받아서 SDK 에 전달해야 합니다.\r\n * 브라우저에서 직접 JWT 를 발급하는 기능은 제공하지 않습니다 (보안상).\r\n * 자세한 내용은 README \"프로덕션 인증 플로우\" 섹션 참고.</p>\r\n *\r\n * @param {Object} options - 설정 옵션\r\n * @param {string} options.apiKey - Client API 키 (필수, 브라우저 노출 안전)\r\n * @param {string} options.projectId - 프로젝트 ID (필수)\r\n * @param {string} options.jwtToken - JWT 토큰 (필수, 고객사 backend 에서 발급)\r\n * @param {string} [options.env='production'] - 환경 ('development' | 'staging' | 'production')\r\n * @param {string} [options.serverUrl] - 서버 URL (직접 지정 시 env 무시)\r\n * @param {boolean} [options.useSockJS=true] - SockJS 사용 여부\r\n * @param {number} [options.reconnectDelay=5000] - 재연결 지연 (ms)\r\n * @param {number} [options.maxReconnectAttempts=10] - 최대 재연결 시도\r\n * @param {Object[]} [options.iceServers] - ICE 서버 설정\r\n * @param {boolean} [options.autoSubscribeRoomList=true] - connect() 시 채팅방 리스트 자동 구독 여부 (카톡 스타일)\r\n * @param {number} [options.logLevel=LogLevel.WARN] - 로그 레벨\r\n *\r\n * @example\r\n * // 1단계: 로그인 — 클라이언트 생성 «전» 에 부른다. ck/projectId/JWT 를 한 번에 받는다.\r\n * // const { clientKey, projectId, accessToken } = await TalkFlowClient.loginUser({\r\n * // serverUrl, email, password });\r\n * // (회사 계정이 둘 이상이면 먼저 workspaceSelectionRequired 결과 → projectId 를 실어 다시 부른다)\r\n *\r\n * // 2단계: 받은 값으로 SDK 초기화\r\n * const client = new TalkFlowClient({\r\n * apiKey: 'CLIENT_KEY', // 브라우저에 노출되는 Client Key\r\n * projectId: 'your-project-id',\r\n * jwtToken: receivedJwt, // 고객사 backend 로부터 받은 JWT\r\n * env: 'production'\r\n * });\r\n *\r\n * await client.connect();\r\n */\r\n constructor(options) {\r\n super();\r\n\r\n // 필수 옵션 검증\r\n this._validateOptions(options);\r\n\r\n // 환경에 따른 서버 URL 결정\r\n // serverUrl이 직접 지정되면 우선 사용, 아니면 env에 따라 자동 결정\r\n const env = options.env || DefaultConfig.environment;\r\n const serverUrl = (options.serverUrl || getServerUrl(env)).replace(/\\/$/, '');\r\n\r\n this.options = {\r\n serverUrl,\r\n env,\r\n apiKey: options.apiKey,\r\n projectId: options.projectId,\r\n jwtToken: options.jwtToken || null,\r\n sessionMode: options.sessionMode || (tokenClaims(options.jwtToken || '').sid ? 'refresh' : 'legacy'),\r\n useSockJS: options.useSockJS !== false,\r\n reconnectDelay: options.reconnectDelay || DefaultConfig.reconnectDelay,\r\n maxReconnectDelay: options.maxReconnectDelay || DefaultConfig.maxReconnectDelay,\r\n maxReconnectAttempts: options.maxReconnectAttempts || DefaultConfig.maxReconnectAttempts,\r\n reconnectRecoveryDelay: options.reconnectRecoveryDelay ?? DefaultConfig.reconnectRecoveryDelay,\r\n heartbeatIncoming: options.heartbeatIncoming || DefaultConfig.heartbeatIncoming,\r\n heartbeatOutgoing: options.heartbeatOutgoing || DefaultConfig.heartbeatOutgoing,\r\n // 탭 복귀 / 네트워크 복구 시 자동 재연결. false 로 꺼야 하므로 !== false 로 보존 —\r\n // options 에 담지 않으면 undefined 가 전달되어 ConnectionManager 기본값 true 로 되살아난다.\r\n resumeOnVisibility: options.resumeOnVisibility !== false,\r\n // 갭 복구 (재연결/복귀 후 끊긴 구간 메시지 재조회). false 로 꺼야 하므로 !== false 로 보존.\r\n syncOnReconnect: options.syncOnReconnect !== false,\r\n // 진행 중 AI 스트림 재개 (구독/재연결 시 서버 스냅샷으로 앞부분 이어받기). 갭 복구와 별개 축.\r\n // ⚠️ 여기 담지 않으면 ChatClient 기본값 true 로 되살아나 롤백 스위치가 동작하지 않는다.\r\n assistantStreamReplay: options.assistantStreamReplay !== false,\r\n rewindMillis: options.rewindMillis !== undefined\r\n ? options.rewindMillis\r\n : DefaultConfig.rewindMillis,\r\n maxSyncPages: options.maxSyncPages || DefaultConfig.maxSyncPages,\r\n syncPageSize: options.syncPageSize || DefaultConfig.syncPageSize,\r\n iceServers: options.iceServers || DefaultConfig.iceServers,\r\n // connect() 시 채팅방 리스트 자동 구독 여부 (카톡 스타일).\r\n // 기본 true — 앱 어디서든 리스트 이벤트를 놓치지 않음 (배지/알림 등).\r\n // false 로 설정 시 수동으로 client.chat.subscribeRoomList() 호출 필요.\r\n autoSubscribeRoomList: options.autoSubscribeRoomList !== false,\r\n logLevel: options.logLevel !== undefined ? options.logLevel : LogLevel.WARN\r\n };\r\n\r\n // 로거 초기화\r\n this.logger = new Logger(this.options.logLevel, 'TalkFlowClient');\r\n\r\n // 상태\r\n this._initialized = false;\r\n this._state = ConnectionState.DISCONNECTED;\r\n\r\n // public 읽기 전용 surface 의 backing field.\r\n // types/index.d.ts 는 chat/webrtc/pushManager/userId 를 readonly 로 선언 —\r\n // 외부 계약(readonly) 과 내부 재할당 필요성을 동시에 만족시키기 위해 private\r\n // backing field 에 저장하고 동명 getter 로 공개한다.\r\n this._userId = null;\r\n\r\n // JWT가 있으면 userId 추출\r\n if (this.options.jwtToken) {\r\n this._userId = extractUserIdFromJWT(this.options.jwtToken);\r\n }\r\n\r\n // API 클라이언트 초기화 (JWT 없이도 동작)\r\n this.apiClient = new ApiClient({\r\n baseUrl: this.options.serverUrl,\r\n apiKey: this.options.apiKey,\r\n projectId: this.options.projectId,\r\n jwtToken: this.options.jwtToken,\r\n logLevel: this.options.logLevel\r\n });\r\n\r\n // ConnectionManager, ChatClient, WebRTCClient는 지연 초기화\r\n this.authSession = null;\r\n this._authConnectionRequested = false;\r\n this.connectionManager = null;\r\n this._chat = null;\r\n this._webrtc = null;\r\n this._pushManager = null;\r\n this._pushEnablePromise = null;\r\n\r\n // JWT가 있으면 즉시 서브 클라이언트 초기화\r\n if (this.options.jwtToken && this._userId) {\r\n this._initializeSubClients();\r\n }\r\n\r\n this._initialized = true;\r\n if (this.options.sessionMode === 'refresh') this._enableRefreshSession();\r\n this.logger.info('TalkFlowClient initialized', {\r\n userId: this._userId,\r\n hasToken: !!this.options.jwtToken\r\n });\r\n }\r\n\r\n // ==================== Public readonly surface (getter) ====================\r\n\r\n /** @returns {ChatClient|null} */\r\n get chat() { return this._chat; }\r\n\r\n /** @returns {WebRTCClient|null} */\r\n get webrtc() { return this._webrtc; }\r\n\r\n /** @returns {PushManager|null} */\r\n get pushManager() { return this._pushManager; }\r\n\r\n /** @returns {string|null} */\r\n get userId() { return this._userId; }\r\n\r\n /**\r\n * 옵션 검증\r\n * @private\r\n * @param {Object} options - 설정 옵션\r\n * @throws {Error} 필수 옵션이 없는 경우\r\n */\r\n _validateOptions(options) {\r\n if (!options) {\r\n throw new Error('Options are required');\r\n }\r\n if (!options.apiKey && options.sessionMode !== 'refresh') {\r\n throw new Error('apiKey is required');\r\n }\r\n if (!options.projectId && options.sessionMode !== 'refresh') {\r\n throw new Error('projectId is required');\r\n }\r\n\r\n // Server Key (sk-) 는 SDK 가 쓸 일이 없다 — 유일한 용도였던 registerUser(/users/auth) 가\r\n // 사라졌다. 그래도 들어오면 잘못된 키를 넣은 것이므로 알린다.\r\n if (options.apiKey?.startsWith('sk-')) {\r\n console.error(\r\n '🚨 [TalkFlow] Server Key (sk-) 로 SDK 를 초기화했습니다. ' +\r\n 'SDK 는 Client Key (ck-) 만 사용합니다 — loginUser() 응답의 clientKey 를 그대로 쓰세요.'\r\n );\r\n }\r\n }\r\n\r\n /**\r\n * 서브 클라이언트 초기화\r\n * @private\r\n */\r\n _initializeSubClients() {\r\n initializeSubClients(this);\r\n }\r\n\r\n /**\r\n * 이벤트 포워딩 설정\r\n * @private\r\n */\r\n _setupEventForwarding() {\r\n setupEventForwarding(this);\r\n }\r\n\r\n /**\r\n * 연결 상태 확인\r\n * @returns {boolean}\r\n */\r\n isConnected() {\r\n return this.connectionManager ? this.connectionManager.isConnected() : false;\r\n }\r\n\r\n /**\r\n * 회사 구독 대기 중인가 — 서버가 웹소켓을 NOT_SUBSCRIBED 로 거부해 연결을 멈췄다(결제 전·해지 회사).\r\n * 결제가 확인되면(`GET /api/v1/company/billing` 의 `subscription === 'ACTIVE'`) 같은 토큰으로 {@link #connect} 를 다시 부른다.\r\n * @returns {boolean}\r\n */\r\n isSubscriptionRequired() {\r\n return this.connectionManager ? this.connectionManager.isSubscriptionRequired() : false;\r\n }\r\n\r\n _enableRefreshSession() {\r\n if (this.authSession) return;\r\n this.options.sessionMode = 'refresh';\r\n this.authSession = AuthSession.forServer(this.options.serverUrl);\r\n this.authSession.attach(this);\r\n this.apiClient.authProvider = this.authSession;\r\n this._authUnsubscribe = this.authSession.on('authStateChanged', event => this.emit('authStateChanged', event));\r\n this._authErrorUnsubscribe = this.authSession.on('authError', error => this.emit('authError', error));\r\n if (!this.authSession.data && this.options.jwtToken && tokenClaims(this.options.jwtToken).sid) {\r\n this.authSession.data = { accessToken: this.options.jwtToken, projectId: this.options.projectId,\r\n clientKey: this.options.apiKey, user: { userId: this.userId } };\r\n this.authSession._setState('authenticated');\r\n }\r\n this._authReady = Promise.resolve();\r\n if (this.authSession.data) this._authReady = this._acceptAuthSession(this.authSession.data).catch(error => {\r\n this._authReady = Promise.resolve();\r\n throw error;\r\n });\r\n }\r\n\r\n async _acceptAuthSession(data) {\r\n if (this.options.projectId && this.options.projectId !== data.projectId && this.connectionManager) {\r\n await this._clearAuthSession();\r\n }\r\n this.options.apiKey = data.clientKey;\r\n this.options.projectId = data.projectId;\r\n this.apiClient.apiKey = data.clientKey;\r\n this.apiClient.projectId = data.projectId;\r\n await this.setToken(data.accessToken);\r\n }\r\n\r\n async _clearAuthSession() {\r\n this._authConnectionRequested = false;\r\n this.options.jwtToken = null;\r\n this._userId = null;\r\n this.apiClient.setJwtToken(null);\r\n await this.disconnect();\r\n this._chat?.destroy();\r\n this._webrtc?.destroy();\r\n await this.connectionManager?.destroy();\r\n this._chat = null;\r\n this._webrtc = null;\r\n this.connectionManager = null;\r\n this.pushManager?.reset();\r\n this._pushManager = null;\r\n this._pushEnablePromise = null;\r\n this.emit('loggedOut');\r\n }\r\n\r\n getAuthState() {\r\n return this.authSession?.getAuthState()\r\n || (Number(tokenClaims(this.options.jwtToken || '').exp || 0) * 1000 > Date.now() ? 'authenticated' : 'unauthenticated');\r\n }\r\n isLoggedIn() { return this.getAuthState() === 'authenticated'; }\r\n async getAccessToken(options = {}) {\r\n if (this.authSession) { await this._authReady; return this.authSession.getAccessToken(options); }\r\n if (!this.isLoggedIn()) { const error = new Error('Sign in is required'); error.code = 'SESSION_REVOKED'; throw error; }\r\n return this.options.jwtToken;\r\n }\r\n async restoreSession() {\r\n this._enableRefreshSession();\r\n await this._authReady;\r\n return this.authSession.restoreSession();\r\n }\r\n static async restoreSession({ serverUrl }) {\r\n if (!serverUrl) throw new Error('serverUrl is required');\r\n return AuthSession.forServer(serverUrl).restoreSession();\r\n }\r\n\r\n /** FE의 관리/캘린더 직접 호출도 동일한 인증·1회 재시도 경로를 사용한다. */\r\n async request(method, path, options = {}) {\r\n if (!path.startsWith('/') || path.startsWith('//')) throw new Error('Use an API-relative path');\r\n await this._authReady;\r\n return this.apiClient.request(method, path, options);\r\n }\r\n\r\n /**\r\n * 현재 연결 상태\r\n * @returns {string}\r\n */\r\n getState() {\r\n return this._state;\r\n }\r\n\r\n /**\r\n * 토큰 보유 여부\r\n * @returns {boolean}\r\n */\r\n hasToken() {\r\n return !!this.options.jwtToken;\r\n }\r\n\r\n // ==================== 사용자 API ====================\r\n\r\n /**\r\n * 회원 로그인 — 이메일/비밀번호로 채팅 접속 일체를 받는다. **클라이언트 생성 전에 호출한다.**\r\n *\r\n * <p>Server Key 도 고객사 BFF 도 필요 없다. 응답의\r\n * {@code clientKey}/{@code projectId}/{@code accessToken} 이 그대로 SDK 초기화 입력이다.</p>\r\n *\r\n * <p><b>회사 선택</b>: 같은 이메일이 여러 회사에 계정을 가질 수 있다(다른 회사의 게스트 등). 비밀번호가 맞는 계정이\r\n * 둘 이상이면 토큰 없이 {@code {workspaceSelectionRequired: true, workspaces: [{projectId, companyName, companyRole}]}}\r\n * 를 돌려준다 — 저장된 세션은 그대로다. 고른 {@code projectId} 를 실어 같은 이메일·비밀번호로 다시 부른다.\r\n * 계정이 하나면 지금처럼 바로 로그인된다.</p>\r\n *\r\n * @param {Object} options\r\n * @param {string} options.serverUrl - 채팅 서버 base URL\r\n * @param {string} options.email\r\n * @param {string} options.password\r\n * @param {string} [options.projectId] - 회사 선택 응답에서 고른 회사\r\n * @returns {Promise<Object>} `{accessToken, tokenType, expiresIn, projectId, clientKey, user}` 또는 회사 선택 결과\r\n *\r\n * @example\r\n * let login = await TalkFlowClient.loginUser({\r\n * serverUrl: 'https://chat.apiorbit.net',\r\n * email: 'dan@example.com',\r\n * password: '********'\r\n * });\r\n * if (login.workspaceSelectionRequired) {\r\n * const picked = await showWorkspacePicker(login.workspaces); // 화면에서 고른 회사\r\n * login = await TalkFlowClient.loginUser({ serverUrl, email, password, projectId: picked.projectId });\r\n * }\r\n *\r\n * const client = new TalkFlowClient({\r\n * apiKey: login.clientKey,\r\n * projectId: login.projectId,\r\n * jwtToken: login.accessToken\r\n * });\r\n * await client.connect();\r\n */\r\n static async loginUser({ serverUrl, email, password, projectId }) {\r\n if (!serverUrl) throw new Error('serverUrl is required');\r\n if (!email) throw new Error('email is required');\r\n if (!password) throw new Error('password is required');\r\n\r\n // 로그인은 키를 «받기 위해» 부르는 경로라 X-API-KEY/X-PROJECT-ID 가 없다(서버에서 필터 제외).\r\n return AuthSession.forServer(serverUrl).login({ email, password, projectId });\r\n }\r\n\r\n /**\r\n * 회원 로그인 (인스턴스) — 성공 시 JWT 를 자동 설정한다.\r\n *\r\n * <p>이미 클라이언트가 있는 상태의 재로그인·계정 전환용이다. 최초 진입은 클라이언트 생성 전에\r\n * 부르는 정적 {@link TalkFlowClient.loginUser} 를 쓴다 — 그쪽이 {@code clientKey} 까지 준다.</p>\r\n *\r\n * <p>회사 선택 응답이면 토큰을 설정하지 않고 그 결과를 돌려준다 — 정적 {@link TalkFlowClient.loginUser} 와 같다.</p>\r\n *\r\n * @param {Object} credentials\r\n * @param {string} credentials.email\r\n * @param {string} credentials.password\r\n * @param {string} [credentials.projectId] - 회사 선택 응답에서 고른 회사\r\n * @returns {Promise<Object>} 로그인 응답 또는 회사 선택 결과\r\n */\r\n async loginUser({ email, password, projectId }) {\r\n if (!email || !password) throw new Error('email and password are required');\r\n this._enableRefreshSession();\r\n await this._authReady;\r\n return this.authSession.login({ email, password, projectId });\r\n }\r\n\r\n /**\r\n * 내 정보 조회.\r\n *\r\n * `metadata` 를 볼 수 있는 **유일한** 곳이다. 고치는 문은 {@link #updateMyProfile} 하나뿐이고\r\n * 그쪽은 **전체 교체**라, 부분 수정하려면 여기서 먼저 읽어 병합한 뒤 통째로 보내야 한다:\r\n *\r\n * ```js\r\n * const me = await client.getMyProfile();\r\n * await client.updateMyProfile({ metadata: { ...me.metadata, statusMessage: '회의 중' } });\r\n * ```\r\n *\r\n * @returns {Promise<Object>} `{userId, email, name, phone, profileUrl, companyRole,\r\n * orgNodeId, orgName, preferredLanguage, metadata, mustChangePassword, ...}`\r\n */\r\n async getMyProfile() {\r\n this._checkToken();\r\n const response = await this.apiClient.get('/api/v1/me');\r\n return (response && response.data) ? response.data : response;\r\n }\r\n\r\n /**\r\n * 내 정보 수정 — 이름·전화번호·프로필 이미지.\r\n *\r\n * <p>서버가 <b>새 JWT</b> 를 함께 준다: {@code name}/{@code profileUrl} 이 토큰 claim 이라\r\n * 갱신하지 않으면 옛 값이 토큰 만료(7일)까지 계속 나간다. 여기서 자동 채택한다.</p>\r\n *\r\n * <p>이메일·조직·역할은 바꿀 수 없다 — 이메일은 로그인 키, 조직은 파트너가 정하고,\r\n * 역할은 본인이 승급할 수 없어야 한다.</p>\r\n *\r\n * <p><b>내 정보를 고치는 유일한 메서드다.</b> {@code updateMyInfo} 와\r\n * {@code PUT /api/v1/users/update} 는 v7.0.0 에서 걷어냈다 — 같은 칸(이름·프로필 이미지)을\r\n * 고치는 문이 둘이라 한 화면에서 순서 없이 두 번 부르면 나중 호출이 앞을 덮었고, 어느 쪽도\r\n * 혼자서는 부족했다(그쪽엔 {@code phone} 이, 이쪽엔 나머지 셋이 없었다).</p>\r\n *\r\n * <p>서버는 갱신된 프로필이 담긴 <b>새 JWT</b> 를 함께 준다. 채택하지 않으면 옛 토큰의\r\n * name/profileUrl 클레임이 최대 7일(토큰 TTL) 동안 계속 나간다. 같은 사용자의 토큰 교체라\r\n * {@code setToken} 은 재연결을 일으키지 않는다 — STOMP connectHeaders 만 갱신된다.</p>\r\n *\r\n * @param {Object} profile — 넘기지 않은 필드는 «변경 안 함»\r\n * @param {string} [profile.name] - 이름 (max 100자)\r\n * @param {string} [profile.phone] - 전화번호 (max 30자)\r\n * @param {string} [profile.profileUrl] - 프로필 이미지 URL (max 500자)\r\n * @param {Object} [profile.metadata] - 메타데이터 (**전체 교체** — 부분 수정하려면\r\n * {@link #getMyProfile} 로 현재 값을 읽어 병합한 뒤 통째로 보내라. 서버는 병합하지 않는다)\r\n * @param {string|null} [profile.preferredLanguage] - 기본 선호 언어 (BCP-47 / 'OFF'(원문) / null)\r\n * @param {string} [profile.timeZone] - IANA zone id. 어시가 대화 시각을 이 기준으로 렌더한다.\r\n * `Intl.DateTimeFormat().resolvedOptions().timeZone` 값을 그대로 보내면 된다.\r\n * @returns {Promise<Object>} 갱신된 내 정보 (accessToken 포함)\r\n */\r\n async updateMyProfile(profile) {\r\n this._checkToken();\r\n const response = await this.apiClient.put('/api/v1/me', profile);\r\n const data = (response && response.data) ? response.data : response;\r\n\r\n if (data && data.accessToken) {\r\n // 토큰 교체 실패로 수정 자체를 실패시키지 않는다 — 서버 반영은 이미 끝났고 옛 토큰도\r\n // 유효(낡았을 뿐)하므로 여기서 throw 하면 호출자가 이미 적용된 수정을 다시 시도한다.\r\n try {\r\n await this.setToken(data.accessToken);\r\n } catch (error) {\r\n this.logger.warn('Profile updated but token refresh failed; keeping the old token', error);\r\n }\r\n }\r\n return data;\r\n }\r\n\r\n /**\r\n * 비밀번호 변경 — 현재 비밀번호를 재확인한다.\r\n *\r\n * <p>토큰만 탈취해도 비밀번호를 바꿀 수 있으면 계정을 통째로 빼앗긴다. 비밀번호는 바뀌어도\r\n * 현재 클라이언트는 새 세션을 받아 유지하고, 다른 세션은 종료한다.</p>\r\n *\r\n * @param {Object} params\r\n * @param {string} params.currentPassword\r\n * @param {string} params.newPassword - 8자 이상\r\n */\r\n async changeMyPassword({ currentPassword, newPassword }) {\r\n this._checkToken();\r\n if (this.authSession) {\r\n return this.authSession.changePassword(() => this.apiClient.request('PUT', '/api/v1/me/password', {\r\n body: { currentPassword, newPassword }, skipAuth: true\r\n }));\r\n }\r\n const response = await this.apiClient.put('/api/v1/me/password', { currentPassword, newPassword });\r\n const data = response?.data ?? response;\r\n if (data?.accessToken && data?.refreshToken) {\r\n this._enableRefreshSession();\r\n this.authSession._newMarker(false);\r\n await this.authSession._accept(data);\r\n }\r\n return response;\r\n }\r\n\r\n /**\r\n * 내 기본 선호 언어 설정 (전 방 공통, 다국어). 명시 변경 — 방별 오버라이드는 {@link #setMyRoomLanguage}.\r\n * @param {string|null} language - BCP-47 코드(예 'ko','en') / 'OFF'(원문) / null·''(미설정)\r\n * @returns {Promise<Object>}\r\n */\r\n async setPreferredLanguage(language) {\r\n return this.updateMyProfile({ preferredLanguage: language });\r\n }\r\n\r\n /**\r\n * 브라우저 locale 감지 — {@code navigator.language} 를 BCP-47 소문자로 정규화하고 지역 변형을 primary 로\r\n * 축약한다({@code ko-KR → 'ko'}, {@code en-US → 'en'}). 단 {@code zh} 계열은 간체/번체 구분을 위해\r\n * 변형을 보존한다({@code zh-TW → 'zh-tw'}). 비브라우저/미가용 시 null.\r\n *\r\n * <p>축약 이유(값공간 통일): 서버 저장·번역·요약 키는 짧은 코드({@code 'ko'}/{@code 'en'}) 기준이다. 시드가\r\n * 지역 변형을 그대로 보내면 명시 요청({@code lang: 'en'})과 키가 분열되어 같은 언어 이중 생성·조회 miss 가\r\n * 발생한다. 서버 {@code LanguageCode} 도 동일 규칙으로 축약하므로 어느 쪽으로 들어와도 같은 값이 된다.</p>\r\n *\r\n * <p>예전에는 {@code registerUser} 가 이 값을 자동으로 실어 보냈는데 그 경로가 사라졌다.\r\n * 지금은 호출자가 직접 감지해 {@link #setPreferredLanguage} (또는 {@link #updateMyProfile} 의\r\n * {@code preferredLanguage}) 로 넣어야 한다 — 넣지 않으면 사용자 기본 언어가 미설정으로 남아\r\n * 자동 번역이 서버 기본값을 쓴다.</p>\r\n * @returns {string|null}\r\n */\r\n static detectBrowserLanguage() {\r\n if (typeof navigator === 'undefined' || !navigator.language) {\r\n return null;\r\n }\r\n return TalkFlowClient.normalizeLanguageCode(navigator.language);\r\n }\r\n\r\n /**\r\n * 언어 코드 canonical 정규화 — 소문자 + 지역 변형 축약(zh 계열 보존). 서버 {@code LanguageCode} 규칙의 미러.\r\n * <p>{@code 'OFF'}(원문 sentinel)는 대문자 그대로 유지한다. 빈 값은 null.</p>\r\n * @param {string|null|undefined} code - BCP-47 코드(예 'ko-KR') / 'OFF' / null\r\n * @returns {string|null}\r\n */\r\n static normalizeLanguageCode(code) {\r\n if (!code || typeof code !== 'string') {\r\n return null;\r\n }\r\n const trimmed = code.trim();\r\n if (!trimmed) {\r\n return null;\r\n }\r\n if (trimmed.toUpperCase() === 'OFF') {\r\n return 'OFF';\r\n }\r\n const lower = trimmed.toLowerCase();\r\n const dash = lower.indexOf('-');\r\n if (dash < 0) {\r\n return lower;\r\n }\r\n const primary = lower.slice(0, dash);\r\n return primary === 'zh' ? lower : primary;\r\n }\r\n\r\n /**\r\n * 표시 텍스트 헬퍼 (다국어) — 내 언어 번역이 있으면 그것, 없으면 원문.\r\n * <p>{@code myLang} 은 내 effectiveLanguage (방 participant.language ?? user.preferredLanguage).\r\n * {@code messageTranslated} 이벤트로 갱신된 message 에 적용.</p>\r\n * @param {Object} message - 메시지 (translations 맵 포함 가능)\r\n * @param {string} [myLang] - 내 언어 코드\r\n * @returns {string|null}\r\n */\r\n static displayText(message, myLang) {\r\n // myLang 도 canonical 로 정규화 — 앱이 'ko-KR' 처럼 지역 변형을 넘겨도 서버 저장 키('ko')와 일치시킨다.\r\n const lang = TalkFlowClient.normalizeLanguageCode(myLang);\r\n if (message && message.translations && lang && message.translations[lang]) {\r\n return message.translations[lang];\r\n }\r\n return message ? message.content : null;\r\n }\r\n\r\n /**\r\n * 회사 조직도 + 인원 — <b>방 만들 때 «조직으로 초대»</b> 가 쓴다.\r\n *\r\n * <p>조직을 고르면 <b>하위 조직 인원까지</b> 초대된다. 그래서 각 노드의 {@code memberCount} 는\r\n * 자손을 포함한 누적이고, {@code members} 는 그 노드 <b>직속</b>만이다 — 누적 목록이 필요하면\r\n * {@code children} 을 따라 내려가며 모은다(상위마다 중복해 실으면 응답이 제곱으로 커진다).</p>\r\n *\r\n * <p>부서가 없는 회원은 {@code unassignedMembers} 에 있다. 퇴사·차단 회원은 어디에도 없다.</p>\r\n *\r\n * @returns {Promise<Object>} `{tree, unassignedMembers, totalMemberCount, membersOmitted}`\r\n *\r\n * @example\r\n * const dir = await client.getCompanyDirectory();\r\n * // 조직 하나를 고르면 그 자손까지 전부 모은다\r\n * const collect = (node) => [...node.members, ...node.children.flatMap(collect)];\r\n */\r\n async getCompanyDirectory() {\r\n this._checkToken();\r\n return this.apiClient.get('/api/v1/company/directory');\r\n }\r\n\r\n /**\r\n * 사용자 목록 조회\r\n *\r\n * <p>응답 행에 {@code email}/{@code phone}/{@code orgNodeId}/{@code orgName} 이 포함된다 —\r\n * 부서명은 저장값이 아니라 조회 시점에 조직 마스터에서 해소한 값이다.</p>\r\n *\r\n * <p>{@code email} 은 <b>연락용</b>이다. 로그인 키인 {@code loginEmail} 은 어떤 목록에도\r\n * 실리지 않는다 — 둘은 다른 값이고, 퇴사 시 {@code loginEmail} 만 비워진다.</p>\r\n *\r\n * <p>퇴사·차단된 회원은 목록·검색 어디에도 나오지 않는다.</p>\r\n *\r\n * @param {Object} [params]\r\n * @param {number} [params.size=50] - 페이지 크기 (1-100)\r\n * @param {string} [params.lastId] - 마지막 사용자 ID\r\n * @param {number} [params.lastSortValue] - 마지막 정렬값\r\n * @returns {Promise<Object>}\r\n */\r\n async getUsers(params = {}) {\r\n this._checkToken();\r\n const { size = 50, lastId, lastSortValue } = params;\r\n return this.apiClient.get('/api/v1/users', { size, lastId, lastSortValue });\r\n }\r\n\r\n /**\r\n * 사용자 검색\r\n * @param {Object} params\r\n * @param {string} params.keyword - 검색 키워드 (필수, max 50자)\r\n * @param {number} [params.limit=20] - 결과 수 (1-50)\r\n * @returns {Promise<Object>}\r\n */\r\n async searchUsers(params) {\r\n this._checkToken();\r\n const { keyword, limit = 20 } = params;\r\n return this.apiClient.get('/api/v1/users/search', { keyword, limit });\r\n }\r\n\r\n /**\r\n * 토큰 필요 여부 확인\r\n * @private\r\n * @throws {Error} 토큰이 없는 경우\r\n */\r\n _checkToken() {\r\n if (!this.options.jwtToken) {\r\n throw new Error(\r\n 'JWT token is required for this operation. Sign in with ' +\r\n 'TalkFlowClient.loginUser({ serverUrl, email, password }) and pass the ' +\r\n 'returned accessToken via setToken(). See README \"로그인\".'\r\n );\r\n }\r\n }\r\n\r\n // ==================== 유틸리티 ====================\r\n\r\n /**\r\n * 현재 사용자 ID\r\n * @returns {string|null}\r\n */\r\n getUserId() {\r\n return this.userId;\r\n }\r\n\r\n /**\r\n * 초기화 상태 확인\r\n * @returns {boolean}\r\n */\r\n isInitialized() {\r\n return this._initialized;\r\n }\r\n\r\n /**\r\n * 서브 클라이언트 초기화 상태 확인\r\n * @returns {boolean}\r\n */\r\n isReady() {\r\n return this._initialized && !!this.connectionManager;\r\n }\r\n\r\n /**\r\n * 로그 레벨 설정\r\n * @param {number} level - LogLevel 값\r\n */\r\n setLogLevel(level) {\r\n this.options.logLevel = level;\r\n this.logger.setLevel(level);\r\n this.apiClient.logger?.setLevel(level);\r\n\r\n if (this.connectionManager) {\r\n this.connectionManager.setLogLevel(level);\r\n }\r\n if (this.chat) {\r\n this.chat.setLogLevel(level);\r\n }\r\n if (this.webrtc) {\r\n this.webrtc.setLogLevel(level);\r\n }\r\n }\r\n\r\n /**\r\n * 전체 상태 요약\r\n * @returns {Object}\r\n */\r\n getStatus() {\r\n return {\r\n initialized: this._initialized,\r\n ready: this.isReady(),\r\n hasToken: this.hasToken(),\r\n connectionState: this._state,\r\n isConnected: this.isConnected(),\r\n userId: this.userId,\r\n chat: this.chat ? {\r\n subscribedRooms: this.chat.getSubscribedRooms()\r\n } : null,\r\n webrtc: this.webrtc ? {\r\n isInCall: this.webrtc.isInCall(),\r\n currentRoom: this.webrtc.getCurrentRoom(),\r\n participants: this.webrtc.getParticipants(),\r\n mediaState: this.webrtc.getMediaState()\r\n } : null\r\n };\r\n }\r\n\r\n /**\r\n * 리소스 정리\r\n */\r\n async destroy() {\r\n await this.disconnect();\r\n this._authUnsubscribe?.();\r\n this._authErrorUnsubscribe?.();\r\n this.authSession?.detach(this);\r\n\r\n if (this.chat) {\r\n this.chat.destroy();\r\n }\r\n if (this.webrtc) {\r\n this.webrtc.destroy();\r\n }\r\n if (this.connectionManager) {\r\n await this.connectionManager.destroy();\r\n }\r\n\r\n this.removeAllListeners();\r\n\r\n this._initialized = false;\r\n this.logger.info('TalkFlowClient destroyed');\r\n }\r\n}\r\n\r\n// 정적 속성으로 상수 노출\r\nTalkFlowClient.ConnectionState = ConnectionState;\r\nTalkFlowClient.ErrorTypes = ErrorTypes;\r\nTalkFlowClient.LogLevel = LogLevel;\r\nTalkFlowClient.Environment = Environment;\r\n\r\napplySessionMethods(TalkFlowClient);\r\napplyDelegatedMethods(TalkFlowClient);\r\n\r\nexport default TalkFlowClient;\r\n","import { validateAndParseJWT, isJWTExpired } from '../utils/jwtUtils.js';\r\nimport ConnectionManager from '../core/ConnectionManager.js';\r\nimport ChatClient from '../chat/ChatClient.js';\r\nimport WebRTCClient from '../webrtc/WebRTCClient.js';\r\nimport PushManager from '../push/PushManager.js';\r\nimport { handleMailPush } from './mailNotifications.js';\r\n\r\nexport function initializeSubClients(client) {\r\n if (client.connectionManager) {\r\n return;\r\n }\r\n\r\n if (!client.userId) {\r\n throw new Error('userId is required to initialize sub-clients. Please set JWT token first.');\r\n }\r\n\r\n client.connectionManager = new ConnectionManager({\r\n serverUrl: client.options.serverUrl,\r\n jwtToken: client.options.jwtToken,\r\n apiKey: client.options.apiKey,\r\n projectId: client.options.projectId,\r\n useSockJS: client.options.useSockJS,\r\n reconnectDelay: client.options.reconnectDelay,\r\n maxReconnectDelay: client.options.maxReconnectDelay,\r\n maxReconnectAttempts: client.options.maxReconnectAttempts,\r\n reconnectRecoveryDelay: client.options.reconnectRecoveryDelay,\r\n heartbeatIncoming: client.options.heartbeatIncoming,\r\n heartbeatOutgoing: client.options.heartbeatOutgoing,\r\n resumeOnVisibility: client.options.resumeOnVisibility,\r\n logLevel: client.options.logLevel\r\n });\r\n client.connectionManager.getAccessToken = async ({ forceRefresh = false } = {}) => {\r\n if (!client.authSession) return client.options.jwtToken;\r\n return client.authSession.getAccessToken({ forceRefresh, rejectedToken: client.connectionManager?.jwtToken });\r\n };\r\n // 레거시 모드의 공급자는 정적 토큰을 돌려줄 뿐이다. 새 토큰은 updateToken() 으로만 온다.\r\n client.connectionManager.canRefreshToken = () => !!client.authSession;\r\n\r\n client._chat = new ChatClient({\r\n connectionManager: client.connectionManager,\r\n apiClient: client.apiClient,\r\n userId: client._userId,\r\n syncOnReconnect: client.options.syncOnReconnect,\r\n assistantStreamReplay: client.options.assistantStreamReplay,\r\n rewindMillis: client.options.rewindMillis,\r\n maxSyncPages: client.options.maxSyncPages,\r\n syncPageSize: client.options.syncPageSize,\r\n logLevel: client.options.logLevel\r\n });\r\n\r\n client._webrtc = new WebRTCClient({\r\n connectionManager: client.connectionManager,\r\n apiClient: client.apiClient,\r\n userId: client._userId,\r\n iceServers: client.options.iceServers,\r\n logLevel: client.options.logLevel\r\n });\r\n\r\n client._setupEventForwarding();\r\n client.logger.debug('Sub-clients initialized');\r\n}\r\n\r\nexport function applySessionMethods(TalkFlowClient) {\r\n TalkFlowClient.prototype.connect = async function connect(jwt, options = {}) {\r\n const { enablePush = false } = options;\r\n\r\n if (jwt) {\r\n await this.setToken(jwt);\r\n }\r\n\r\n if (this.authSession) {\r\n await this._authReady;\r\n this._authConnectionRequested = true;\r\n const token = await this.authSession.getAccessToken();\r\n await this.setToken(token);\r\n this.authSession.schedule();\r\n }\r\n\r\n if (!this.options.jwtToken) {\r\n throw new Error(\r\n 'JWT token is required. Sign in with TalkFlowClient.loginUser({ serverUrl, ' +\r\n 'email, password }) and pass the returned accessToken to the SDK via the ' +\r\n 'jwtToken option, setToken(), or connect(jwt). See README \"로그인\".'\r\n );\r\n }\r\n\r\n if (!this.authSession && isJWTExpired(this.options.jwtToken)) {\r\n throw new Error('JWT token has expired. Please update the token before connecting.');\r\n }\r\n\r\n if (!this.connectionManager) {\r\n this._initializeSubClients();\r\n }\r\n\r\n await this.connectionManager.connect();\r\n\r\n if (this.webrtc) {\r\n await this.webrtc.enableIncomingCalls();\r\n }\r\n\r\n if (this.chat && this.options.autoSubscribeRoomList) {\r\n try {\r\n await this.chat.subscribeRoomList();\r\n } catch (error) {\r\n this.logger.warn('Failed to auto-subscribe room list (non-fatal):', error);\r\n }\r\n }\r\n\r\n if (enablePush) {\r\n this.enablePushNotifications();\r\n }\r\n\r\n this.logger.info('Connected to server');\r\n };\r\n\r\n TalkFlowClient.prototype.disconnect = async function disconnect() {\r\n this._authConnectionRequested = false;\r\n this.authSession?.schedule();\r\n if (!this.connectionManager) {\r\n return;\r\n }\r\n\r\n if (this.webrtc && this.webrtc.isInCall()) {\r\n this.webrtc.endCall();\r\n }\r\n\r\n if (this.webrtc) {\r\n this.webrtc.disableIncomingCalls();\r\n }\r\n\r\n if (this.chat) {\r\n this.chat.unsubscribeAllRooms();\r\n this.chat.unsubscribeRoomList();\r\n this.chat.unsubscribeMail();\r\n }\r\n\r\n await this.connectionManager.disconnect();\r\n this.logger.info('Disconnected from server');\r\n };\r\n\r\n TalkFlowClient.prototype.logout = async function logout() {\r\n if (this.authSession) return this.authSession.logout();\r\n try {\r\n await this.apiClient.post('/v1/auth/signout');\r\n this.logger.info('Logged out from server');\r\n } catch (error) {\r\n this.logger.warn('Server logout failed (proceeding with local cleanup):', error.message);\r\n }\r\n\r\n await this.disconnect();\r\n\r\n if (this.pushManager) {\r\n this.pushManager.reset();\r\n this._pushEnablePromise = null;\r\n }\r\n\r\n this.options.jwtToken = null;\r\n this._userId = null;\r\n this.apiClient.setJwtToken(null);\r\n\r\n this.emit('loggedOut');\r\n };\r\n\r\n /**\r\n * 웹 푸시 알림을 활성화한다.\r\n *\r\n * <p>전체 흐름: Firebase 초기화 → 서비스 워커 등록 → 권한 요청 → FCM 토큰 발급 → 서버 등록 →\r\n * 포그라운드 메시지 핸들러 등록 → SW 에 projectId 등록.</p>\r\n *\r\n * <h3>호출 패턴</h3>\r\n * <ul>\r\n * <li><b>자동 활성화 (비차단)</b> — {@code client.enablePushNotifications();} 처럼\r\n * await 없이 호출하면 fire-and-forget 으로 동작한다. 메인 흐름 (UI 렌더링 등) 을 차단하지 않는다.\r\n * 이 경우 결과는 {@code 'pushEnabled'} / {@code 'pushFailed'} 이벤트로 받는다.\r\n * <b>중요</b>: 이 패턴을 쓸 때는 호출 <b>전에</b> 이벤트 리스너를 등록해야 한다 — 그렇지 않으면\r\n * 즉시 emit 되는 실패 이벤트를 놓칠 수 있다.</li>\r\n * <li><b>사용자 액션 (await)</b> — 버튼 클릭 등 사용자 명시적 액션에서는\r\n * {@code const result = await client.enablePushNotifications();} 으로 결과를 즉시 받아서\r\n * UI 분기에 사용한다. throw 하지 않으므로 try/catch 불필요.</li>\r\n * </ul>\r\n *\r\n * <p>이 메서드는 throw 하지 않는다. 실패는 {@code { ok: false, reason, error }} 형태의 객체로 반환된다.\r\n * 따라서 fire-and-forget 으로 호출해도 unhandled rejection 이 발생하지 않는다.</p>\r\n *\r\n * @param {Object} [options]\r\n * @param {Object} [options.firebaseConfig] - 커스텀 Firebase 설정 (테스트/검증용)\r\n * @param {string} [options.vapidKey] - 커스텀 VAPID 키\r\n * @param {string} [options.serviceWorkerPath] - 서비스 워커 경로\r\n * @returns {Promise<{ok: true, alreadyEnabled?: boolean} | {ok: false, reason: string, error: Error}>}\r\n * 성공 시 {@code { ok: true }}, 실패 시 {@code { ok: false, reason, error }}.\r\n * {@code reason} 은 {@link PushErrorCode} 값 또는 {@code 'NO_TOKEN'}.\r\n */\r\n TalkFlowClient.prototype.enablePushNotifications = async function enablePushNotifications(options = {}) {\r\n // JWT 토큰 미설정 시 명확한 분류 에러로 통보 (throw 하지 않고 결과 객체 반환).\r\n if (!this.options.jwtToken) {\r\n const error = new Error('JWT token is required to enable push notifications.');\r\n this.emit('pushFailed', { reason: 'NO_TOKEN', error });\r\n return { ok: false, reason: 'NO_TOKEN', error };\r\n }\r\n\r\n if (!this.pushManager) {\r\n this._pushManager = new PushManager({\r\n apiClient: this.apiClient,\r\n projectId: this.options.projectId,\r\n firebaseConfig: options.firebaseConfig,\r\n vapidKey: options.vapidKey,\r\n serviceWorkerPath: options.serviceWorkerPath,\r\n logLevel: this.options.logLevel\r\n });\r\n\r\n // 포그라운드 메시지 콜백 — projectId 필터링 + activeRoom suppress + messageId dedup 후 client emit.\r\n // PushManager 자체는 EventEmitter 가 아니므로 콜백 오버라이드로 우회한다.\r\n //\r\n // dedup: 같은 messageId 의 푸시가 서버측 멱등 race 복구 / FCM 재전송 등으로 중복 도착할 수 있어\r\n // 5분 TTL Map 으로 한 번만 emit. setTimeout 으로 자동 cleanup.\r\n const PUSH_DEDUP_TTL_MS = 5 * 60 * 1000;\r\n this.pushManager._onForegroundMessage = (payload) => {\r\n const data = payload.data || {};\r\n\r\n if (data.projectId && data.projectId !== this.options.projectId) {\r\n this.logger.debug('다른 프로젝트 푸시 무시:', data.projectId);\r\n return;\r\n }\r\n\r\n // Gmail 메일 푸시 — 웹소켓 미리보기와 합쳐 newMail/newMailSummary 로만 낸다(pushNotification 아님).\r\n if (handleMailPush(this, payload)) {\r\n return;\r\n }\r\n\r\n const activeRoomId = this.chat?.getActiveRoom();\r\n\r\n if (activeRoomId && activeRoomId === data.roomId) {\r\n this.logger.debug('포그라운드 푸시 suppress (현재 방):', data.roomId);\r\n return;\r\n }\r\n\r\n // messageId 기반 dedup — 키가 없는 시스템 푸시 등은 통과.\r\n if (data.messageId) {\r\n if (!this._recentPushMessageIds) {\r\n this._recentPushMessageIds = new Map();\r\n }\r\n if (this._recentPushMessageIds.has(data.messageId)) {\r\n this.logger.debug('포그라운드 푸시 dedup:', data.messageId);\r\n return;\r\n }\r\n const timer = setTimeout(() => {\r\n this._recentPushMessageIds.delete(data.messageId);\r\n }, PUSH_DEDUP_TTL_MS);\r\n this._recentPushMessageIds.set(data.messageId, timer);\r\n }\r\n\r\n this.emit('pushNotification', {\r\n title: payload.notification?.title || data.title,\r\n body: payload.notification?.body || data.body,\r\n data\r\n });\r\n };\r\n }\r\n\r\n // 가드 1: 이미 활성화됨 — 중복 호출은 성공으로 간주.\r\n if (this.pushManager.isEnabled()) {\r\n this.logger.debug('웹 푸시가 이미 활성화되어 있어 중복 호출을 건너뜁니다.');\r\n return { ok: true, alreadyEnabled: true };\r\n }\r\n\r\n // 가드 2: 진행 중 — 같은 Promise 를 공유해 두 호출자가 동일 결과를 받도록 한다.\r\n if (this._pushEnablePromise) {\r\n this.logger.debug('웹 푸시 활성화가 이미 진행 중입니다.');\r\n return this._pushEnablePromise;\r\n }\r\n\r\n // 본 활성화 흐름 — throw 하지 않고 결과 객체로 통일.\r\n // try/catch 안에서 emit + return 모두 처리하여 fire-and-forget 호출자도 안전하게 한다.\r\n const promise = (async () => {\r\n try {\r\n await this.pushManager.enable();\r\n this.emit('pushEnabled');\r\n return { ok: true };\r\n } catch (error) {\r\n // PushError 면 분류 코드 사용, 아니면 'UNKNOWN'.\r\n const reason = (error && error.code) ? error.code : 'UNKNOWN';\r\n this.logger.warn('Failed to enable push notifications:', reason, error?.message);\r\n this.emit('pushFailed', { reason, error });\r\n return { ok: false, reason, error };\r\n }\r\n })();\r\n\r\n this._pushEnablePromise = promise;\r\n\r\n // self-clear — 본인이 현재 진행 중인 Promise 일 때만 null 처리 (race 방지).\r\n promise.finally(() => {\r\n if (this._pushEnablePromise === promise) {\r\n this._pushEnablePromise = null;\r\n }\r\n });\r\n\r\n return promise;\r\n };\r\n\r\n TalkFlowClient.prototype.consumePendingRoom = async function consumePendingRoom() {\r\n return PushManager.consumePendingRoom(this.options.projectId);\r\n };\r\n\r\n TalkFlowClient.prototype.consumePendingMail = async function consumePendingMail() {\r\n return PushManager.consumePendingMail(this.options.projectId);\r\n };\r\n\r\n TalkFlowClient.prototype.setCurrentDeviceEnabled = function setCurrentDeviceEnabled(enabled) {\r\n return this.pushManager?.setCurrentDeviceEnabled(enabled);\r\n };\r\n\r\n TalkFlowClient.prototype.setDeviceEnabled = function setDeviceEnabled(deviceId, enabled) {\r\n return this.pushManager?.setDeviceEnabled(deviceId, enabled);\r\n };\r\n\r\n TalkFlowClient.prototype.getMyDevices = function getMyDevices() {\r\n return this.pushManager?.getMyDevices();\r\n };\r\n\r\n /**\r\n * 현재 브라우저의 푸시 권한 상태를 조회한다.\r\n *\r\n * <p>{@code enablePushNotifications()} 호출 <b>전에도</b> 사용 가능 — pushManager 인스턴스 불필요.\r\n * UI 분기 (granted → 자동 enable / default → 버튼 노출 / denied → 설정 가이드 / unsupported → 숨김) 에 사용한다.</p>\r\n *\r\n * @returns {'granted'|'denied'|'default'|'unsupported'}\r\n */\r\n TalkFlowClient.prototype.getPushPermissionState = function getPushPermissionState() {\r\n return PushManager.getPermissionState();\r\n };\r\n\r\n /**\r\n * SDK 내부 푸시 활성화 상태 (FCM 토큰 발급 + 서버 등록까지 완료 여부).\r\n *\r\n * <p>주의: 이 값은 SDK 인스턴스의 메모리 상태일 뿐, 브라우저 권한 상태와 다르다.\r\n * 페이지 새로고침 후에는 enable 호출 전까지 false. 권한 상태는 {@link #getPushPermissionState} 사용.</p>\r\n *\r\n * @returns {boolean}\r\n */\r\n TalkFlowClient.prototype.isPushEnabled = function isPushEnabled() {\r\n return this.pushManager?.isEnabled() ?? false;\r\n };\r\n\r\n /**\r\n * 현재 발급된 FCM 토큰. enable 호출 후에만 유효, 그 외에는 null.\r\n * @returns {string|null}\r\n */\r\n TalkFlowClient.prototype.getPushToken = function getPushToken() {\r\n return this.pushManager?.getToken() ?? null;\r\n };\r\n\r\n /**\r\n * 푸시 상태 초기화 — 포그라운드 리스너 / FCM 토큰 / SW projectId 등록을 해제한다.\r\n * 로그아웃 또는 사용자 전환 시 호출. (logout() 내부에서도 자동 호출되므로 일반 흐름에선 불필요.)\r\n */\r\n TalkFlowClient.prototype.resetPush = function resetPush() {\r\n this.pushManager?.reset();\r\n this._pushEnablePromise = null;\r\n };\r\n\r\n TalkFlowClient.prototype.setToken = async function setToken(token) {\r\n const { userId } = validateAndParseJWT(token, { validateExpiry: false });\r\n const needReinitialize = this.userId && this.userId !== userId;\r\n\r\n this.options.jwtToken = token;\r\n this._userId = userId;\r\n this.apiClient.setJwtToken(token);\r\n\r\n if (needReinitialize && this.connectionManager) {\r\n await this.disconnect();\r\n this._chat?.destroy();\r\n this._webrtc?.destroy();\r\n // destroy() 로 버려야 lifecycle listener(visibilitychange/pageshow/online)가 해제된다.\r\n // disconnect() 만 하고 참조를 끊으면 죽은 매니저가 window 에 매달린 채 남아,\r\n // 사용자 전환이 잦은 앱에서 복귀 이벤트마다 유령 매니저가 함께 깨어난다.\r\n await this.connectionManager.destroy();\r\n if (this.pushManager) {\r\n this.pushManager.reset();\r\n this._pushEnablePromise = null;\r\n this._pushManager = null;\r\n }\r\n this.connectionManager = null;\r\n this._chat = null;\r\n this._webrtc = null;\r\n\r\n this._initializeSubClients();\r\n } else if (this.connectionManager) {\r\n this.connectionManager.updateToken(token);\r\n } else {\r\n this._initializeSubClients();\r\n }\r\n\r\n this.logger.info('JWT token set', { userId });\r\n this.authSession?.replaceAccessToken(token);\r\n this.emit('tokenSet', { userId });\r\n };\r\n\r\n TalkFlowClient.prototype.updateToken = async function updateToken(newToken) {\r\n await this.setToken(newToken);\r\n };\r\n}\r\n","/**\r\n * 방별 미독 카운트 계산기 (READ_SYNC_DESIGN v2.1 §6).\r\n *\r\n * <p>SDK 는 방 목록 상태를 소유하지 않는다(페이징·필터·프레임워크 상태와 충돌). 대신 순서 역전에\r\n * 안전한 \"카운트 계산\"만 이 헬퍼로 제공하고, 앱이 결과를 자신의 room store 에 반영한다.</p>\r\n *\r\n * <p><b>왜 단순 절대값 세팅이 안 되나</b>: 서로 다른 발신 흐름에서 나온 이벤트는 도착 순서가 논리적\r\n * 발생 순서와 다를 수 있다. 예컨대 내가 방을 읽어 나간 {@code ROOM_UNREAD_UPDATED(count=0, v=13)} 이\r\n * 신규 {@code MESSAGE_RECEIVED(+1, v=12)} 보다 먼저 도착할 수 있다. 단순히 절대값을 덮으면 뒤늦게 온\r\n * v12 delta 가 badge 를 1 로 되돌린다. 그래서 방마다 스냅샷(마지막 절대값)과 그 이후의 delta 를 분리\r\n * 보관하고, version 으로 이미 반영된 delta 를 정리한다.</p>\r\n *\r\n * <p><b>방별 상태</b>: {@code snapshotCount}/{@code snapshotVersion}(마지막 절대값) +\r\n * {@code pendingDeltas}(messageId → {version, delta}). 표시값 = snapshotCount + Σ pending.delta.</p>\r\n *\r\n * <p><b>버전 전제</b>: 순서 정합성은 서버가 이벤트/응답에 {@code version} 을 실어줄 때만 보장된다.\r\n * version 이 아예 없는 구서버 구간에서는 순서 역전을 판별할 수 없다 — 이 경우 이미 versioned baseline 이\r\n * 있으면 비교 불가한 legacy 스냅샷을 무시해 최신 pair 를 지키고, 최종 정합은 REST 재조회에 의존한다.</p>\r\n *\r\n * @example\r\n * const reconciler = new RoomUnreadReconciler(client.userId); // JWT 에서 SDK 가 추출한 내 userId\r\n * // 초기 REST 페이지를 baseline 으로 투입\r\n * rooms.forEach(r => reconciler.applySnapshot(r.id, r.unreadCount, r.unreadVersion));\r\n * client.on('roomListMessage', e => { reconciler.applyMessageReceived(e); render(e.roomId); });\r\n * client.on('roomListUnreadUpdated', e => { reconciler.applySnapshot(e.roomId, e.roomUnreadCount, e.unreadVersion); render(e.roomId); });\r\n * function render(roomId) { store.setBadge(roomId, reconciler.getCount(roomId)); } // null 이면 REST 로 표시\r\n */\r\nexport class RoomUnreadReconciler {\r\n /**\r\n * @param {string} currentUserId - 내 사용자 ID. SDK 가 JWT 에서 추출한 값을 {@code client.userId} 로\r\n * 얻는다(별도 관리 불필요). 내가 보낸 메시지의 MESSAGE_RECEIVED 가 내 badge 를 올리지 않도록 걸러내는\r\n * 데만 쓰는 <b>표시용 값</b>이라 보안 결정에 관여하지 않는다. (서버도 발신자를 unread 대상에서 제외하지만,\r\n * 방 리스트 이벤트는 발신자에게도 전달되므로 클라에서 한 번 더 거른다.)\r\n */\r\n constructor(currentUserId) {\r\n this._currentUserId = currentUserId;\r\n /** @type {Map<string, {snapshotCount: (number|null), snapshotVersion: (number|null), pendingDeltas: Map<string, {version: (number|null), delta: number}>}>} */\r\n this._rooms = new Map();\r\n }\r\n\r\n _room(roomId) {\r\n let state = this._rooms.get(roomId);\r\n if (!state) {\r\n state = { snapshotCount: null, snapshotVersion: null, pendingDeltas: new Map() };\r\n this._rooms.set(roomId, state);\r\n }\r\n return state;\r\n }\r\n\r\n /**\r\n * MESSAGE_RECEIVED(delta +1) 반영.\r\n * @param {{roomId: string, messageId: string, actorId?: string, unreadVersion?: (number|null)}} event\r\n */\r\n applyMessageReceived(event) {\r\n if (!event || !event.roomId || !event.messageId) return;\r\n // 발신자 본인 메시지 → 내 badge 증가 금지.\r\n if (event.actorId != null && event.actorId === this._currentUserId) return;\r\n\r\n const state = this._room(event.roomId);\r\n // 같은 메시지 중복 수신(서버측 멱등 race 복구 등) → 한 번만.\r\n if (state.pendingDeltas.has(event.messageId)) return;\r\n\r\n const version = event.unreadVersion == null ? null : event.unreadVersion;\r\n // 이미 baseline 에 반영된 delta(스냅샷 version 이하) → 무시.\r\n if (state.snapshotVersion != null && version != null && version <= state.snapshotVersion) return;\r\n\r\n state.pendingDeltas.set(event.messageId, { version, delta: 1 });\r\n }\r\n\r\n /**\r\n * 절대값 스냅샷 반영 — REST 응답 또는 ROOM_UNREAD_UPDATED.\r\n * @param {string} roomId\r\n * @param {(number|null|undefined)} count 방별 내 미독 절대값. null/undefined 면 무시(미계산 방).\r\n * @param {(number|null|undefined)} version 그 카운트가 확정된 방 version.\r\n */\r\n applySnapshot(roomId, count, version) {\r\n if (!roomId || count == null) return;\r\n const v = version == null ? null : version;\r\n const state = this._room(roomId);\r\n\r\n // 이미 versioned baseline 이 있는데 version 없는(구서버) 스냅샷이 오면 비교 불가 → 무시.\r\n // 그대로 count 를 덮으면 snapshotVersion 은 최신인데 count 는 옛 값인 (옛 count, 최신 version) 조합이\r\n // 되어, 백엔드가 원자 read 로 막은 순서 훼손이 SDK 에서 재현된다.\r\n if (state.snapshotVersion != null && v == null) return;\r\n\r\n // 더 오래된 스냅샷은 무시(stale).\r\n if (state.snapshotVersion != null && v != null && v < state.snapshotVersion) return;\r\n\r\n state.snapshotCount = count;\r\n if (v != null) state.snapshotVersion = v;\r\n\r\n // 이 스냅샷에 이미 반영된 pending 제거, 더 최신 pending 은 보존(v13 유실 방지).\r\n // version 이 있을 때만 정리 가능 — version 없는 스냅샷(구서버)은 pending 을 건드리지 않는다.\r\n if (v != null) {\r\n for (const [messageId, d] of state.pendingDeltas) {\r\n if (d.version == null || d.version <= v) {\r\n state.pendingDeltas.delete(messageId);\r\n }\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * 방의 현재 표시 카운트.\r\n * @param {string} roomId\r\n * @returns {(number|null)} baseline(스냅샷)이 아직 없으면 null — 앱은 REST 조회 결과로 표시한다.\r\n */\r\n getCount(roomId) {\r\n const state = this._rooms.get(roomId);\r\n if (!state || state.snapshotCount == null) return null;\r\n let sum = state.snapshotCount;\r\n for (const d of state.pendingDeltas.values()) sum += d.delta;\r\n return sum;\r\n }\r\n\r\n /** 방을 상태에서 제거(퇴장/추방 등). */\r\n removeRoom(roomId) {\r\n this._rooms.delete(roomId);\r\n }\r\n\r\n /** 전체 초기화(로그아웃/재연결 리셋 등). */\r\n clear() {\r\n this._rooms.clear();\r\n }\r\n}\r\n"],"names":["EventEmitter","constructor","this","_events","Map","on","event","listener","has","set","Set","get","add","off","once","onceWrapper","args","apply","_originalListener","listeners","l","delete","size","emit","data","forEach","error","console","removeAllListeners","clear","listenerCount","eventNames","Array","from","keys","ConnectionState","DISCONNECTED","CONNECTING","CONNECTED","RECONNECTING","ERROR","ErrorTypes","CONNECTION_FAILED","CONNECTION_LOST","CONNECTION_TIMEOUT","SUBSCRIPTION_REQUIRED","JWT_INVALID","JWT_EXPIRED","JWT_PARSE_FAILED","UNAUTHORIZED","API_ERROR","API_TIMEOUT","CHAT_ROOM_NOT_FOUND","CHAT_MESSAGE_FAILED","CHAT_SUBSCRIPTION_FAILED","MEDIA_ACCESS_DENIED","SCREEN_SHARE_DENIED","PEER_CONNECTION_FAILED","ICE_CONNECTION_FAILED","SIGNALING_FAILED","DEVICE_SWITCH_FAILED","ENUMERATE_DEVICES_FAILED","CALL_ROOM_NOT_FOUND","INVALID_STATE","UNKNOWN_ERROR","SignalTypes","CALL_OFFER","CALL_ANSWER","ICE_CANDIDATE","JOIN_ROOM","LEAVE_ROOM","PEER_JOINED","PEER_LEFT","VIDEO_STATE_CHANGED","AUDIO_STATE_CHANGED","SCREEN_SHARE_STARTED","SCREEN_SHARE_ENDED","CALL_REQUEST","CALL_ACCEPT","CALL_REJECT","CALL_CANCEL","CALL_END","CALL_INVITATION","CALL_BUSY","ChatRoomType","DIRECT","GROUP","PRIVATE_GROUP","TEAM","RoomListEventType","MESSAGE_RECEIVED","MESSAGE_DELETED","MESSAGE_UPDATED","ROOM_CREATED","ROOM_JOINED","ROOM_LEFT","ROOM_UPDATED","ROOM_KICKED","ROOM_BANNED","ROOM_OWNER_CHANGED","ROOM_DELETED","MESSAGE_RETENTION_CLEANUP","ROOM_UNREAD_UPDATED","AI_UNAVAILABLE","WebSocketPaths","SOCKJS_ENDPOINT","NATIVE_ENDPOINT","APP_PREFIX","TOPIC_PREFIX","QUEUE_PREFIX","USER_PREFIX","getChatDestination","roomId","getChatReadDestination","getChatTypingDestination","getChatAssistantStreamDestination","getChatSummaryDestination","ROOM_LIST_USER_DESTINATION","MAIL_USER_DESTINATION","getWebRTCDestination","getWebRTCUserDestination","CHAT_SEND","CHAT_READ","CHAT_TYPING","WEBRTC_SIGNAL","Environment","DEVELOPMENT","STAGING","PRODUCTION","Endpoints","serverUrl","wsEndpoint","DefaultConfig","environment","reconnectDelay","maxReconnectDelay","maxReconnectAttempts","reconnectRecoveryDelay","heartbeatIncoming","heartbeatOutgoing","resumeOnVisibility","syncOnReconnect","rewindMillis","maxSyncPages","syncPageSize","apiTimeout","iceServers","urls","logLevel","getServerUrl","env","LogLevel","DEBUG","INFO","WARN","NONE","Logger","level","prefix","setLevel","setPrefix","_format","Date","toISOString","debug","info","warn","ApiClient","options","baseUrl","replace","apiKey","projectId","jwtToken","timeout","logger","setJwtToken","token","_getHeaders","headers","startsWith","request","method","path","authProvider","skipAuth","_request","getAccessToken","sentToken","generation","status","includes","response","code","forceRefresh","rejectedToken","retryToken","retryGeneration","retryError","authenticationFailed","body","params","url","searchParams","URLSearchParams","Object","entries","key","value","append","queryString","toString","controller","AbortController","timeoutId","setTimeout","abort","fetch","credentials","JSON","stringify","undefined","signal","clearTimeout","name","timeoutError","Error","contentType","json","text","ok","message","post","put","patch","upload","file","_upload","fieldName","onProgress","Promise","resolve","reject","xhr","XMLHttpRequest","form","FormData","open","setRequestHeader","onprogress","e","lengthComputable","loaded","total","percent","Math","round","onAbort","aborted","addEventListener","cleanupSignal","removeEventListener","onload","parsed","responseText","getResponseHeader","parse","onerror","err","ontimeout","onabort","send","sessions","TERMINAL_CODES","isTerminalAuthError","tokenClaims","split","atob","AuthSession","forServer","super","state","clients","refreshPromise","timer","peerWaiters","markerKey","marker","_readMarker","serverClock","channel","storageListener","stored","revision","logout","_clear","catch","globalThis","window","BroadcastChannel","onmessage","_receive","unref","localStorage","getItem","_storageError","_writeMarker","setItem","_newMarker","pending","previous","crypto","randomUUID","String","now","random","deviceId","pendingRefreshToken","refreshToken","setDeviceId","_monotonic","performance","_serverNow","server","max","mono","wall","_shareData","serverTime","_publish","type","postMessage","_setState","force","getAuthState","isLoggedIn","_fresh","accessToken","Number","exp","refreshMargin","attach","client","detach","schedule","_enqueue","work","operation","transition","then","_accept","publish","async","_cancelled","clientKey","user","userId","identityChanged","refreshRequestId","isFinite","_roundTripMillis","min","all","map","_acceptAuthSession","_reconnectClients","_authConnectionRequested","isConnected","manager","connectionManager","_authenticatedToken","forceReconnect","_clearAuthSession","requestId","session","incoming","current","sid","_peerToken","done","_locked","locks","navigator","httpQueue","_newRefreshRequestId","bytes","Uint8Array","getRandomValues","byte","padStart","join","started","defineProperty","login","email","password","previousRefreshToken","workspaceSelectionRequired","workspaces","isArray","length","some","workspace","companyName","companyRole","workspaceSelection","restoreSession","_loggedOut","peer","finally","cleanup","result","Authorization","changePassword","reauthenticationRequired","replaceAccessToken","claims","sub","expiresIn","delay","wait","before","filter","destroy","close","stripBearer","base64UrlDecode","str","base64","padding","repeat","binaryString","c","charCodeAt","TextDecoder","decode","validateAndParseJWT","bufferSeconds","validateExpiry","validateIssuedAt","parts","payload","originalError","expiryTime","bufferTime","expiredAt","iat","clockSkew","user_id","extractUserIdFromJWT","isJWTExpired","SUBSCRIPTION_REQUIRED_CODE","getSockJS","SockJS","ConnectionManager","useSockJS","stompClient","reconnectAttempts","subscriptions","pendingSubscriptions","_intentionalDisconnect","_tearingDown","_connectRequested","_forceReconnectPromise","_lifecycleHandlers","_recoveryTimer","_terminalAuthError","_recoveryEligible","_subscriptionRequired","_installLifecycleListeners","onVisibilityChange","document","visibilityState","_onResume","onPageShow","onOnline","_removeLifecycleListeners","reason","_isSocketAlive","socket","webSocket","readyState","StompSocketState","OPEN","_doForceReconnect","_connectPromise","_cancelReconnectRecovery","deactivate","connect","prevState","_getWebSocketUrl","endpoint","_createStompClient","wsUrl","stomp","config","beforeConnect","_connectionToken","attempt","active","_authRejected","_rejectedToken","updateToken","_stopForAuthError","_cancelAuthRetry","_authRetryTimer","connectHeaders","reconnectTimeMode","ReconnectionTimeMode","EXPONENTIAL","onConnect","frame","_onConnect","onDisconnect","_onDisconnect","onStompError","_onStompError","onWebSocketClose","_onWebSocketClose","onWebSocketError","_onWebSocketError","webSocketFactory","wsProtocol","wsHost","brokerURL","Client","StompJs","StompClient","_connectResolve","_connectReject","connected","activate","_drainPendingSubscriptions","attempts","isReconnect","_hasConnectedBefore","_notifiedRejectedToken","_restoreSubscriptions","_processPendingSubscriptions","serverMessage","_stopForSubscription","_recordAuthRejection","terminal","_canRecoverConnection","_handleReconnect","exhaustedClient","deactivated","_scheduleReconnectRecovery","notify","_usesStaticToken","failure","canRefreshToken","isSubscriptionRequired","entering","assign","previousSubscriptions","callback","destination","_subscribeInternal","shift","subscription","subscribe","unsubscribe","push","entry","unsubscribeAll","disconnect","getState","setLogLevel","PLAIN_TEXT_DOCUMENT_MIME_TYPES","ChatClient","apiClient","subscribedRooms","roomListSubscribed","mailSubscribed","_activeRoomId","_typingTimers","_assistantTypingTimers","_assistantTypingTimeoutMs","_assistantTypingUserId","_assistantTypingUserName","_assistantProgress","_assistantStreamTombstones","_assistantTombstoneTtlMs","_assistantTombstoneWarnThreshold","_assistantTombstoneWarned","_assistantSlotSeq","_assistantRoomGeneration","_assistantSnapshotPending","_assistantSnapshotInFlight","assistantStreamReplay","_assistantPhaseLabels","THINKING","SEARCHING","PLANNING","DOCUMENT_ANALYSIS","VIDEO_ANALYSIS","WEB_READING","WRITING","_assistantProgressFallbackTtlMs","_seenChatMessageIdsByRoom","_seenRoomListMessageIdsByRoom","_anchor","_anchorGen","_syncInFlight","_replaying","_maxSeenPerRoom","_reconnectSyncGeneration","_delayedReconnectSyncTimer","_installSyncTriggers","_onReconnectedForSync","_cancelDelayedReconnectSync","syncAfterReconnect","cursors","_supplementAfterReconnect","resumeAssistantStreams","_onResumedForSync","_onStateChangeForSync","_shouldDedupMessage","bucket","messageId","seen","oldestKey","next","getRooms","lastId","lastSortValue","getRoom","genAtStart","detail","_unwrapSuccessResponse","messages","_seedAnchorFromLatest","enterRoom","wasAlreadySubscribed","_armAssistantSnapshot","_loadAssistantSnapshot","subscribeRoom","setActiveRoom","getActiveRoom","clearActiveRoom","unsubscribeRoom","getRoomInfo","setMyRoomLanguage","language","createOneToOneRoom","friendId","createGroupRoom","roomType","roomName","description","invitedUserIds","messageRetentionHours","invitedAssistantPersonaIds","assistantMode","roomAiType","engagementIntensity","getAssistants","getRoomAiMeta","rateAssistantMessage","rating","comment","isInteger","summarizeWithAssistant","personaId","format","messageCount","uploadVideoAsset","analyzeVideo","analysis","idempotencyKey","trim","analyzeVideoWithAssistant","triggerMessageId","encodeURIComponent","getVideoAnalysis","jobId","cancelVideoAnalysis","getVideoAnalysisCapabilities","translateWithAssistant","targetLang","sourceMessageId","refreshSummary","lang","getSummary","afterSeq","getRoomPmPrompt","upsertRoomPmPrompt","content","activateRoomPmPrompt","deactivateRoomPmPrompt","getRoomPmPromptVersions","activateRoomPmPromptVersion","version","previewRoomPmPrompt","joinGroupRoom","leaveRoom","newOwnerId","transferOwnership","deleteRoom","updateGroupRoom","inviteToGroupRoom","userIdsOrData","userIds","assistantPersonaIds","kickMember","banMember","unbanMember","getBannedMembers","pinMessage","unpinMessage","createVote","vote","getVotes","cursor","limit","getVote","voteId","updateVote","deleteVote","closeVote","submitVote","optionIds","uploadVoteImage","toggleReaction","emoji","getAvailableGroupRooms","getAllGroupRooms","getFiles","getMessages","isLatestPage","page","m","_normalizeMessageTimestamps","descendingMessages","newest","id","_commitAnchorFromSync","syncMessages","afterId","_advanceAnchorFromLive","_replayMessages","emitted","_handleChatMessage","eventType","_reloadLatestPage","ascending","slice","reverse","_isCursorExpired","_syncRoom","anchor","quiet","startedAt","buffer","hasMore","pages","hasNext","fetched","reloaded","recovered","truncated","durationMs","roomIds","_refreshRoomList","rooms","totalRecovered","reduce","sum","r","fetchLinkPreview","normalizedUrl","sendMessage","_generateMessageId","_sendMessageWithId","sendMessageOptimistic","baseMessageId","messageIds","promise","_attachOptimisticMetadata","stopTyping","fileInfos","replyToMessageId","decisionResponse","sendTextMessage","sendTextMessageOptimistic","uploadFile","sendFileMessage","files","fileArray","_normalizeFileArray","metas","_uploadFiles","_applyFileMetadata","metadata","sendFileMessageOptimistic","index","onUploadProgress","p","fileIndex","meta","itemMetadata","sendReply","sendReplyOptimistic","sendDecisionChoice","card","option","label","optionId","deleteMessage","deleteType","editMessage","_hasTextMessage","_baseMimeType","mimeType","raw","sep","indexOf","toLowerCase","classifyFileType","mime","_classifyFileType","resolveMessageCategory","chatMessageType","fileMetaDataList","categories","f","fileType","values","predictedMessageIds","actualMessageIds","predictionMatched","every","i","predicted","actual","optimistic","prototype","hasOwnProperty","call","sentAt","_toEpochMillis","editedAt","replyTo","isNaN","chatDestination","readDestination","_handleReadEvent","typingDestination","_handleTypingEvent","snapshotGeneration","assistantStreamDestination","_handleAssistantStreamEvent","summaryDestination","_handleSummaryEvent","memberJoinedHandler","members","participantCount","activeParticipantCount","timestamp","memberLeftHandler","subscribedAt","_clearTypingTimer","_clearAssistantTypingTimer","_clearAssistantProgress","unsubscribeAllRooms","_","subscribeRoomList","_handleRoomListEvent","unsubscribeRoomList","isRoomListSubscribed","subscribeMail","_handleMailEvent","unsubscribeMail","isMailSubscribed","connectionId","markAsRead","translations","sourceLang","translationsOf","senderType","userName","typing","actorId","_isSelfInMembers","cutoffTime","roomUnreadCount","unreadVersion","aiBlocked","resolvedRoomId","events","remainingUnreadCount","startTyping","existingTimer","_startAssistantTypingTimer","streamId","_isAssistantStreamClosed","prevSlot","_assistantSlot","createdAt","phaseUpdatedAt","phase","samePhase","Boolean","progress","_normalizeAssistantProgress","prev","slot","_setAssistantSlot","lastSeq","_seqOf","textAvailable","_emitAssistantStream","seq","delta","_deleteAssistantSlot","_tombstoneAssistantStream","inFlight","task","streams","_assistantGeneration","_deleteAssistantPendingIfGeneration","_applyAssistantSnapshot","_drainAssistantPending","s","doneStatus","resumed","expiresAt","_assistantSlots","slots","graves","languages","updatedAt","fields","ttl","_expireAssistantStream","order","overrides","_assistantPhaseLabel","_emitAssistantRoomProgress","_assistantRoomProgressPayload","alive","streamCount","a","b","sort","stage","unit","suffix","getAssistantProgress","getSubscribedRooms","isSubscribed","MediaStreamManager","localStream","screenStream","videoEnabled","audioEnabled","_deviceChangeHandler","getUserMedia","constraints","video","audio","mediaDevices","stream","_getMediaErrorMessage","getDisplayMedia","getVideoTracks","onended","toggleVideo","videoTracks","track","enabled","toggleAudio","audioTracks","getAudioTracks","setVideoEnabled","setAudioEnabled","getLocalStream","getScreenStream","isVideoEnabled","isAudioEnabled","stopAll","getTracks","stop","replaceTrack","oldTrack","newTrack","removeTrack","addTrack","kind","getTrack","tracks","getDevices","devices","enumerateDevices","videoInputs","d","audioInputs","audioOutputs","switchDevice","exact","startDeviceChangeDetection","stopDeviceChangeDetection","getStreamInfo","hasStream","muted","applyVideoConstraints","videoTrack","applyConstraints","getVideoSettings","getSettings","getAudioSettings","audioTrack","PeerConnectionManager","peers","setLocalStream","_addTracksToConnection","connection","setIceServers","servers","createPeerConnection","peerId","polite","iceCandidatePoolSize","RTCPeerConnection","peerState","makingOffer","ignoreOffer","isSettingRemoteAnswerPending","skipAutoNegotiation","_setupConnectionHandlers","resumeAutoNegotiation","signalingState","onnegotiationneeded","onicecandidate","candidate","oniceconnectionstatechange","iceConnectionState","onconnectionstatechange","connectionState","setLocalDescription","localDescription","ontrack","ondatachannel","existingSenders","getSenders","find","sender","createOffer","offer","createAnswer","answer","handleRemoteDescription","offerCollision","setRemoteDescription","addIceCandidate","remoteDescription","getPeerConnection","getPeerIds","closePeerConnection","closeAllPeerConnections","promises","getStats","getConnectionSummary","summary","DIRECT_CONTROL_SIGNALS","WebRTCClient","mediaManager","peerManager","currentRoom","isGroupCall","participants","_callActive","_callGeneration","_deferredGroupPeers","_joinedGroupRoom","_groupJoin","_directCallPeer","_incomingCallPeer","_receivedInvitations","_hostedCallRooms","_pendingRoomCreations","_earlyRoomRejects","_groupRecoveryTimer","_groupRecoveryAttempts","_onReconnected","_recoverGroupParticipation","_iceServersFetched","_waitingForCallAccept","_pendingCallTarget","_pendingIceCandidates","_setupEventHandlers","initializeIceServers","getTurnCredentials","_setFallbackIceServers","_sendMediaState","_sendSignal","receiverId","sdp","createCallRoom","callRoomId","_rememberCallRoom","splice","_handleSignal","getCallRoom","joinCallRoomApi","_clearDeferredGroup","leaveCallRoomApi","enableIncomingCalls","_incomingCallsEnabled","_subscribeToDirectSignaling","disableIncomingCalls","directDestination","isIncomingCallsEnabled","startCall","isGroup","mediaConstraints","_pendingStart","_cancelGroupRecoveryRetry","_groupRecovery","start","cancelled","checkCancelled","deferredRoom","_subscribeToSignaling","_resumeGroupNegotiation","_unsubscribeFromSignaling","callUser","targetUserId","acceptCall","callerId","rejectCall","cancelCall","endCall","_endCall","notifyPeers","startScreenShare","localVideoTrack","currentVideoTrack","stopScreenShare","_groupRoomFor","mediaState","senderId","_holdsEarlyRoomReject","_acceptsSignal","_handleUserJoined","_handleUserLeft","_handleOffer","_handleAnswer","_handleIceCandidate","_handleMediaState","isInCall","title","hostUserId","maxParticipants","_handleCallAccepted","callEndScope","_handleCallEnded","groupRoom","peerRoom","joinedAt","defer","_processPendingIceCandidates","stillCurrent","recovery","participating","_queueIceCandidate","RTCIceCandidate","candidates","getCurrentRoom","getParticipants","getMediaState","streamInfo","PushErrorCode","freeze","UNSUPPORTED_BROWSER","FIREBASE_NOT_INSTALLED","SW_REGISTER_FAILED","PERMISSION_DENIED","TOKEN_FAILED","SERVER_REGISTER_FAILED","PushError","cause","DEFAULT_FIREBASE_CONFIG","authDomain","storageBucket","messagingSenderId","appId","PENDING_NAVIGATION_STORE_NAME","PENDING_NAVIGATION_LEGACY_KEY","DEVICE_STORE_NAME","DEVICE_ID_KEY","LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY","openPendingNavigationDb","indexedDB","onupgradeneeded","db","objectStoreNames","contains","createObjectStore","keyPath","onsuccess","readDeviceIdFromIndexedDb","tx","transaction","objectStore","record","oncomplete","writeDeviceIdToIndexedDb","generateDeviceId","substring","consumePendingRoomFromIndexedDB","primaryKey","PENDING_NAVIGATION_KEY_PREFIX","buildPendingNavigationKey","shouldTryLegacyFallback","store","pendingRoomId","primaryRequest","legacyRequest","legacyRecord","PushManager","firebaseConfig","vapidKey","serviceWorkerPath","_messaging","_currentToken","_enabled","_enablePromise","_foregroundMessageUnsubscribe","_swRegistration","_projectRegisteredToSW","_visibilityHandler","_deviceIdCache","consumePendingRoom","consumePendingMail","PENDING_MAIL_KEY_PREFIX","savedAt","consumePendingMailFromIndexedDB","getPermissionState","Notification","permission","enable","_enableInternal","firebaseApp","firebaseMessaging","app","import","getApp","initializeApp","registration","_registerServiceWorker","requestPermission","getMessaging","tokenOptions","serviceWorkerRegistration","getToken","_registerTokenToServer","onMessage","_onForegroundMessage","_registerProjectToSW","_installVisibilityReRegister","update","swVersion","_getSWVersion","serviceWorker","register","updateViaCache","_getDeviceId","deviceToken","deviceType","legacy","removeItem","fallbackId","reset","_uninstallVisibilityReRegister","_unregisterProjectFromSW","sw","ready","isEnabled","getMyDevices","setDeviceEnabled","setCurrentDeviceEnabled","readyRegistration","waiting","installing","MessageChannel","port1","port2","deduperOf","_mailNotificationDeduper","ttlMs","accept","at","seenKey","seenAt","createMailNotificationDeduper","projectIdOf","keyOf","senderOf","CONNECTION_EVENT_MAP","CHAT_EVENT_MAP","WEBRTC_EVENT_MAP","forwardMappedEvents","source","target","eventMap","sourceEvent","targetEvent","setupEventForwarding","_state","chat","deduper","preview","threadId","subject","receivedAt","emitNewMailFromSocket","webrtc","CHAT_DELEGATE_METHODS","WEBRTC_DELEGATE_METHODS","defineDelegates","targetKey","methods","TalkFlowClient","_validateOptions","sessionMode","autoSubscribeRoomList","_initialized","_userId","authSession","_chat","_webrtc","_pushManager","_pushEnablePromise","_initializeSubClients","_enableRefreshSession","hasToken","pushManager","_setupEventForwarding","initializeSubClients","_authUnsubscribe","_authErrorUnsubscribe","_authReady","setToken","loginUser","getMyProfile","_checkToken","updateMyProfile","profile","changeMyPassword","currentPassword","newPassword","setPreferredLanguage","preferredLanguage","detectBrowserLanguage","normalizeLanguageCode","trimmed","toUpperCase","lower","dash","primary","displayText","myLang","getCompanyDirectory","getUsers","searchUsers","keyword","getUserId","isInitialized","isReady","getStatus","initialized","jwt","enablePush","enablePushNotifications","PUSH_DEDUP_TTL_MS","ids","count","parseInt","unseen","notification","handleMailPush","activeRoomId","_recentPushMessageIds","alreadyEnabled","getPushPermissionState","isPushEnabled","getPushToken","resetPush","needReinitialize","newToken","applySessionMethods","applyDelegatedMethods","GENERAL","PEOPLE_ONLY","CALL_ONLY","TEXT","IMAGE","FILE","VIDEO","AUDIO","DOCUMENT","MIXED","GALLERY","SYSTEM","MIN_OPTIONS","MAX_OPTIONS","LABEL_MAX_LENGTH","QUESTION_MAX_LENGTH","QUIET","NORMAL","ACTIVE","LEGAL_ADVISOR","MARKETING","PRODUCT_PLANNING","HR","FINANCE","CUSTOMER_SUPPORT","SALES","ENGINEERING","DATA_ANALYST","PROJECT_MANAGEMENT","RESEARCH","TRANSLATION","DESIGN","PM","SUPER_ADMIN","PROJECT_ADMIN","ROOM_OWNER","PROJECT","ROOM","PERSONA_MULTI","PM_BACKSTAGE","currentUserId","_currentUserId","_rooms","_room","snapshotCount","snapshotVersion","pendingDeltas","applyMessageReceived","applySnapshot","v","getCount","removeRoom","USER","ASSISTANT","MINUTES","SHORT","TIMELINE","ACTIONS","OPTIONS","Infinity"],"mappings":"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"}