@vibexnpm/talkx 2.3.1

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.
@@ -0,0 +1,2221 @@
1
+ // noinspection JSValidateJSDoc
2
+
3
+ /**
4
+ * ChatClient
5
+ * 채팅 기능 클라이언트
6
+ */
7
+
8
+ import EventEmitter from '../utils/EventEmitter.js';
9
+ import Logger from '../utils/Logger.js';
10
+ import { WebSocketPaths, LogLevel, RoomListEventType, ChatRoomType } from '../constants.js';
11
+
12
+ const MAX_FILES_PER_MESSAGE = 20;
13
+
14
+ class ChatClient extends EventEmitter {
15
+ /**
16
+ * @param {Object} options
17
+ * @param {Object} options.connectionManager - ConnectionManager 인스턴스
18
+ * @param {Object} options.apiClient - ApiClient 인스턴스
19
+ * @param {string} options.userId - 사용자 ID
20
+ * @param {number} [options.logLevel] - 로그 레벨
21
+ */
22
+ constructor(options) {
23
+ super();
24
+
25
+ this.connectionManager = options.connectionManager;
26
+ this.apiClient = options.apiClient;
27
+ this.userId = options.userId;
28
+
29
+ this.logger = new Logger(options.logLevel || LogLevel.WARN, 'ChatClient');
30
+
31
+ // 구독 중인 채팅방
32
+ this.subscribedRooms = new Map();
33
+
34
+ // 채팅방 리스트 구독 상태 (카톡 스타일 리스트 실시간 업데이트)
35
+ this.roomListSubscribed = false;
36
+
37
+ // 현재 보고 있는 채팅방 ID (이 방에서만 자동 읽음 처리)
38
+ this._activeRoomId = null;
39
+
40
+ // 타이핑 표시 debounce 타이머 (roomId → setTimeout ID)
41
+ // 이 Map 은 outgoing 전용 — 사용자가 startTyping() 호출 시 N초 후 자동 stopTyping() 발신용.
42
+ this._typingTimers = new Map();
43
+
44
+ // 어시스턴트 응답 준비 중 typing=true 수신 후 client-side timeout (roomId → setTimeout ID).
45
+ // 서버는 typing=false 를 발행하지 않으며 (멘션 N personas / 락 실패 / LLM 실패 / skip 등 종료 추적
46
+ // 부담 회피), SDK 가 (1) assistant 메시지 도착 시 즉시 해제 (2) 본 timeout 만료 시 자동 해제 책임.
47
+ // outgoing 타이머 (_typingTimers) 와 의미가 달라 별도 Map 유지.
48
+ this._assistantTypingTimers = new Map();
49
+ // 어시 typing 자동 해제 시한 (ms) — LLM 호출 최대 시간 (Sonnet 5-10초) + 부수 작업 + 여유.
50
+ // 운영 환경 LLM 응답 지연 분포에 따라 조정 가능.
51
+ this._assistantTypingTimeoutMs = 30000;
52
+ // 어시 typing 의 generic sender 식별자 — 서버 ChatMessageSentAssistantListener 의
53
+ // ASSISTANT_TYPING_USER_ID 와 정확히 일치해야 함. UI 가 roomId+userId 로 typing state 추적 시
54
+ // typing=true / typing=false 가 같은 키로 매칭되도록 일관 유지 필수.
55
+ this._assistantTypingUserId = '__assistant__';
56
+ this._assistantTypingUserName = 'AI';
57
+
58
+ // 메시지 dedup 보호 — 같은 messageId 의 중복 수신 차단.
59
+ // 서버측 멱등 race 복구 / 네트워크 재전송 / WebSocket 재연결 직후 등 다양한 중복 시나리오 커버.
60
+ //
61
+ // chat WebSocket (MESSAGE_CREATED) 와 roomList (MESSAGE_RECEIVED) 는 같은 messageId 의 별개 이벤트라
62
+ // 각각 다른 키 공간 사용. 정상 흐름에서 두 이벤트가 거의 동시에 도착하므로 키를 공유하면 둘 중
63
+ // 하나가 차단되는 부작용 발생.
64
+ //
65
+ // 구조: roomId → insertion-order Map<messageId, true> (LRU). size 초과 시 가장 오래된 항목 제거.
66
+ this._seenChatMessageIdsByRoom = new Map();
67
+ this._seenRoomListMessageIdsByRoom = new Map();
68
+ // 방 하나당 최근 N개의 messageId 만 dedup 대상 — 정상 흐름에서 충돌 가능성 없는 적정 크기.
69
+ this._maxSeenPerRoom = 200;
70
+ }
71
+
72
+ /**
73
+ * 방별 LRU dedup 체크 — 이미 본 messageId 면 {@code true} 반환 (중복).
74
+ * 처음 보는 messageId 면 기록 후 {@code false} 반환.
75
+ *
76
+ * <p>Map 의 insertion-order 보존 특성을 이용한 가벼운 LRU. 외부 lib 없음.</p>
77
+ *
78
+ * @private
79
+ * @param {Map<string, Map<string, true>>} bucket - 키 공간 (chat / roomList 별도)
80
+ * @param {string} roomId
81
+ * @param {string} messageId
82
+ * @returns {boolean} 이미 본 메시지면 true (호출자가 무시)
83
+ */
84
+ _shouldDedupMessage(bucket, roomId, messageId) {
85
+ if (!roomId || !messageId) return false;
86
+ let seen = bucket.get(roomId);
87
+ if (!seen) {
88
+ seen = new Map();
89
+ bucket.set(roomId, seen);
90
+ }
91
+ if (seen.has(messageId)) {
92
+ return true;
93
+ }
94
+ seen.set(messageId, true);
95
+ if (seen.size > this._maxSeenPerRoom) {
96
+ // 가장 오래된 키 제거 (Map insertion-order 의 첫 항목).
97
+ const oldestKey = seen.keys().next().value;
98
+ seen.delete(oldestKey);
99
+ }
100
+ return false;
101
+ }
102
+
103
+ // ==================== REST API ====================
104
+
105
+ /**
106
+ * 내 채팅방 목록 조회
107
+ * @param {Object} [params]
108
+ * @param {number} [params.size=50] - 페이지 크기 (1-100)
109
+ * @param {string} [params.lastId] - 마지막 채팅방 ID (커서)
110
+ * @param {number} [params.lastSortValue] - 마지막 정렬값 (epoch millis)
111
+ * @param {string} [params.type] - 채팅방 타입 필터 ('DIRECT' | 'GROUP'). 생략 시 전체.
112
+ * 'GROUP' 은 "그룹 탭" 시맨틱 — PRIVATE_GROUP/TEAM 을 포함한 그룹 전체를 반환
113
+ * (PRIVATE_GROUP/TEAM 값을 보내도 동일하게 그룹 전체 — 타입별 정확 필터 아님).
114
+ * 특정 타입만 필요하면 응답의 {@code roomType} 필드로 클라이언트에서 거른다.
115
+ * @returns {Promise<Object>}
116
+ */
117
+ async getRooms(params = {}) {
118
+ const { size = 50, lastId, lastSortValue, type } = params;
119
+ return this.apiClient.get('/api/v1/rooms/my', { size, lastId, lastSortValue, type });
120
+ }
121
+
122
+ /**
123
+ * 채팅방 입장 (상세 조회).
124
+ *
125
+ * <p>서버 {@code markAllAsRead} 를 트리거하므로 방 안의 누적 unread 가 전부 읽음 처리된다.
126
+ * 실시간 읽음 이벤트 보정 수신이 필요하면 {@link #enterRoom} 사용을 권장 — subscribe 를
127
+ * {@link #getRoom} 보다 먼저 수행해서 "응답 후, 구독 전" 구간의 read 이벤트 유실을 막는다.</p>
128
+ *
129
+ * @param {string} roomId - 채팅방 ID
130
+ * @returns {Promise<Object>}
131
+ */
132
+ async getRoom(roomId) {
133
+ return this.apiClient.get(`/api/v1/rooms/${roomId}`);
134
+ }
135
+
136
+ /**
137
+ * 채팅방 진입 — <b>권장 진입 경로</b>.
138
+ *
139
+ * <p>순서를 고정해서 race-free 진입을 보장:</p>
140
+ * <ol>
141
+ * <li><b>subscribeRoom</b> — WebSocket 구독 먼저. 서버가 발행하는 read/message/typing 이벤트의
142
+ * 도달을 이 시점부터 보장.</li>
143
+ * <li><b>setActiveRoom</b> — 이후 수신되는 신규 메시지 자동 읽음 처리 활성화.</li>
144
+ * <li><b>getRoom</b> — 서버 {@code markAllAsRead} + 초기 메시지 50개 fetch. 이 시점에 서버가
145
+ * 발행하는 read 이벤트는 (1) 의 구독으로 모두 수신됨.</li>
146
+ * </ol>
147
+ *
148
+ * <p>구독이 {@code getRoom} 보다 먼저 완료돼야 "서버에서 발행된 read 이벤트가 아직 구독 안 된
149
+ * 클라에 유실" 되는 케이스가 제거된다.</p>
150
+ *
151
+ * @param {string} roomId - 채팅방 ID
152
+ * @returns {Promise<Object>} {@link #getRoom} 의 응답 (방 상세 + 초기 메시지)
153
+ *
154
+ * @example
155
+ * // 방 클릭 시
156
+ * const detail = await client.chat.enterRoom('room-id');
157
+ * renderRoom(detail);
158
+ */
159
+ async enterRoom(roomId) {
160
+ // "이 호출이 새로 만든 구독인지" 기억 — getRoom 실패 시 rollback 시에 이전 구독은 유지.
161
+ const wasAlreadySubscribed = this.subscribedRooms.has(roomId);
162
+
163
+ // 1) WebSocket 구독을 먼저 — 이후 서버 read 이벤트 유실 방지
164
+ if (!wasAlreadySubscribed) {
165
+ await this.subscribeRoom(roomId);
166
+ }
167
+ // 2) 자동 읽음 처리 활성 방 지정
168
+ this.setActiveRoom(roomId);
169
+
170
+ try {
171
+ // 3) 서버 입장 API — markAllAsRead + 초기 메시지 조회
172
+ return await this.getRoom(roomId);
173
+ } catch (e) {
174
+ // getRoom 실패 시 half-open 상태 방지 — 이 호출이 켠 리소스만 되돌림.
175
+ if (this.getActiveRoom() === roomId) {
176
+ this.clearActiveRoom();
177
+ }
178
+ if (!wasAlreadySubscribed) {
179
+ this.unsubscribeRoom(roomId);
180
+ }
181
+ throw e;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * 채팅방 정보 조회 (입장하지 않고 정보만)
187
+ * @param {string} roomId - 채팅방 ID
188
+ * @returns {Promise<Object>}
189
+ */
190
+ async getRoomInfo(roomId) {
191
+ return this.apiClient.get(`/api/v1/rooms/${roomId}/info`);
192
+ }
193
+
194
+ /**
195
+ * 내 방 언어 설정 (다국어). 이 방에서 내가 볼 언어를 지정한다.
196
+ * @param {string} roomId - 채팅방 ID
197
+ * @param {string|null} language - BCP-47 코드(예 'ko','en','ja') / 'OFF'(원문 보기) /
198
+ * null·''(오버라이드 해제 → 사용자 기본 언어로 복귀)
199
+ * @returns {Promise<Object>} 갱신된 방 상세
200
+ */
201
+ async setMyRoomLanguage(roomId, language) {
202
+ return this.apiClient.put(`/api/v1/rooms/${roomId}/my-language`, { language });
203
+ }
204
+
205
+ /**
206
+ * 1:1 채팅방 생성/조회
207
+ * @param {string} friendId - 상대방 사용자 ID (projectUserId)
208
+ * @returns {Promise<Object>}
209
+ */
210
+ async createOneToOneRoom(friendId) {
211
+ return this.apiClient.post(`/api/v1/rooms/direct/${friendId}`);
212
+ }
213
+
214
+ /**
215
+ * 그룹 채팅방 생성.
216
+ *
217
+ * <p>방 타입 {@code GROUP} (공개) / {@code PRIVATE_GROUP} (비밀) / {@code TEAM} (팀) 지정 가능.
218
+ * 비밀방인 경우 {@code password} 필수 (서버가 bcrypt 해시로 저장). 팀방은 비밀번호 금지 —
219
+ * 초대 전용이라 비밀번호 개념이 없고, 멤버는 입장 시점과 무관하게 방 전체 히스토리를 본다.</p>
220
+ *
221
+ * <p>{@code invitedAssistantPersonaIds} 로 AI 어시스턴트 페르소나도 함께 추가 가능.
222
+ * 페르소나 ID 는 {@link #getAssistants} 로 사전 조회. 서버가 해당 프로젝트에서 사용 가능한
223
+ * 페르소나인지 검증 (글로벌 또는 자체/오버라이드). 활성 페르소나만 허용.</p>
224
+ *
225
+ * @param {Object} data
226
+ * @param {string} data.roomName - 채팅방 이름 (필수, max 100자)
227
+ * @param {string} [data.description] - 채팅방 설명 (max 500자)
228
+ * @param {string[]} [data.invitedUserIds] - 초대할 사용자 ID 배열 (max 50명)
229
+ * @param {string[]} [data.invitedAssistantPersonaIds] - 추가할 어시스턴트 ID 배열 (max 10개).
230
+ * {@link #getAssistants} 로 조회한 어시스턴트의 {@code id} 를 전달.
231
+ * @param {string} [data.roomType='GROUP'] - 방 타입 ('GROUP' | 'PRIVATE_GROUP' | 'TEAM'). 기본값 'GROUP'.
232
+ * 'TEAM' = 초대 전용(직접 입장 불가) + 멤버 전체 히스토리 열람 + 비참여자에게 목록 비노출.
233
+ * @param {string} [data.password] - 비밀번호 (roomType='PRIVATE_GROUP' 일 때 필수, 4~50자.
234
+ * 'GROUP'/'TEAM' 에는 금지)
235
+ * @param {number} [data.messageRetentionHours] - 메시지 보관 기간 (시간, 1~8760). 미설정 시 무제한.
236
+ * 설정 시 메시지를 삭제하지 않고 조회 시 기간 필터링만 적용. 변경 시 즉시 반영.
237
+ * @param {string} [data.assistantMode='GENERAL'] - 어시스턴트 참여 모드
238
+ * ('GENERAL' | 'PEOPLE_ONLY' | 'CALL_ONLY'). 생략 시 서버 기본값 GENERAL.
239
+ * @param {string} [data.roomAiType] - 방 AI 사용 방식 intent ('NONE' | 'PERSONA_MULTI' | 'PM_BACKSTAGE').
240
+ * 생략 시 서버가 API 키 권한 + 첨부 페르소나로 파생 (레거시 호환). {@code 'NONE'} 이면
241
+ * {@code invitedAssistantPersonaIds}/{@code assistantMode} 동시 지정 불가 (서버 400).
242
+ * {@code 'PM_BACKSTAGE'} 는 PM 자동부착 — persona/assistantMode 대신 {@code engagementIntensity} 로 제어.
243
+ * 권한 없는 값 지정 시 403.
244
+ * <b>주의(함정):</b> PERSONA_MULTI 권한이 없는 PM 전용 키는 {@code roomAiType} 을 생략하면 서버 파생이
245
+ * {@code 'NONE'}(사람 방)으로 떨어진다 (PM_BACKSTAGE 로 자동 승격되지 않음). {@link #getRoomAiMeta} 의
246
+ * {@code availableRoomAiTypes} 로 확인한 타입을 반드시 명시 전송할 것.
247
+ * @param {string} [data.engagementIntensity] - PM 개입 강도 ('QUIET' | 'NORMAL' | 'ACTIVE').
248
+ * {@code 'PM_BACKSTAGE'} 방 전용 — 그 외 타입에 지정 시 서버 거절. 생략 시 서버 기본값 'NORMAL'.
249
+ * @returns {Promise<Object>}
250
+ *
251
+ * @example
252
+ * // 공개 그룹방
253
+ * await chat.createGroupRoom({
254
+ * roomName: '개발팀 잡담방',
255
+ * invitedUserIds: ['user-1', 'user-2']
256
+ * });
257
+ *
258
+ * @example
259
+ * // 어시스턴트 포함 그룹방
260
+ * const assistants = await chat.getAssistants();
261
+ * // UI 에 표시하고 사용자가 선택한 id 들을 그대로 전달
262
+ * await chat.createGroupRoom({
263
+ * roomName: '계약 검토방',
264
+ * invitedUserIds: ['user-1', 'user-2'],
265
+ * invitedAssistantPersonaIds: [/* 사용자가 선택한 id 배열 *\/],
266
+ * assistantMode: 'GENERAL'
267
+ * });
268
+ *
269
+ * @example
270
+ * // 비밀 그룹방
271
+ * await chat.createGroupRoom({
272
+ * roomName: '비공개 회의실',
273
+ * invitedUserIds: ['user-1'],
274
+ * roomType: ChatRoomType.PRIVATE_GROUP,
275
+ * password: 'secret1234'
276
+ * });
277
+ *
278
+ * @example
279
+ * // 팀 채팅방 — 초대 전용 + 전체 히스토리 (중간에 들어와도 이전 대화 열람)
280
+ * await chat.createGroupRoom({
281
+ * roomName: '백엔드팀',
282
+ * invitedUserIds: ['user-1', 'user-2'],
283
+ * roomType: ChatRoomType.TEAM
284
+ * });
285
+ */
286
+ async createGroupRoom(data) {
287
+ const roomType = data.roomType || ChatRoomType.GROUP;
288
+
289
+ // 클라이언트 측 사전 검증 (서버에서도 재검증됨)
290
+ if (roomType === ChatRoomType.PRIVATE_GROUP) {
291
+ if (!data.password || data.password.length < 4) {
292
+ throw new Error('PRIVATE_GROUP requires a password of at least 4 characters');
293
+ }
294
+ } else if (roomType === ChatRoomType.GROUP) {
295
+ if (data.password) {
296
+ throw new Error('Public GROUP rooms cannot have a password');
297
+ }
298
+ } else if (roomType === ChatRoomType.TEAM) {
299
+ if (data.password) {
300
+ throw new Error('TEAM rooms cannot have a password (invite-only)');
301
+ }
302
+ }
303
+
304
+ return this.apiClient.post('/api/v1/rooms/group', {
305
+ roomName: data.roomName,
306
+ description: data.description,
307
+ invitedUserIds: data.invitedUserIds,
308
+ roomType,
309
+ password: data.password,
310
+ messageRetentionHours: data.messageRetentionHours,
311
+ invitedAssistantPersonaIds: data.invitedAssistantPersonaIds,
312
+ assistantMode: data.assistantMode,
313
+ roomAiType: data.roomAiType,
314
+ engagementIntensity: data.engagementIntensity
315
+ });
316
+ }
317
+
318
+ /**
319
+ * 사용 가능한 어시스턴트 리스트 조회.
320
+ *
321
+ * <p>받은 리스트를 UI 에 표시하고, 사용자가 선택한 어시스턴트의 {@code id} 를
322
+ * {@link #createGroupRoom} 의 {@code invitedAssistantPersonaIds} 로 전달.</p>
323
+ *
324
+ * @returns {Promise<Array<{id: string, displayName: string, mentionKey: string, role: string}>>}
325
+ *
326
+ * @example
327
+ * const assistants = await chat.getAssistants();
328
+ * // [{ id, displayName, mentionKey, role }, ...]
329
+ */
330
+ async getAssistants() {
331
+ const response = await this.apiClient.get('/api/v1/assistants');
332
+ return this._unwrapSuccessResponse(response);
333
+ }
334
+
335
+ /**
336
+ * 현재 API 키로 생성 가능한 방 AI 타입 조회 (discovery).
337
+ *
338
+ * <p>방 생성 UI 에서 {@link #createGroupRoom} 의 {@code roomAiType} 으로 만들 수 있는 타입만 노출하는 데 사용한다.
339
+ * 키 권한(capability)으로 결정 — {@code 'NONE'}(사람 채팅)은 항상, {@code 'PERSONA_MULTI'}/{@code 'PM_BACKSTAGE'}
340
+ * 는 권한 보유 시 포함.</p>
341
+ *
342
+ * <p><b>중요:</b> 여기서 고른 타입을 {@link #createGroupRoom} 의 {@code roomAiType} 으로 <b>반드시 명시 전송</b>해야 한다.
343
+ * 생략하면 서버가 키 권한 + persona 로 파생하는데, PERSONA_MULTI 권한이 없는 PM 전용 키는 {@code 'NONE'}(사람 방)으로
344
+ * 떨어진다 (PM_BACKSTAGE 자동 승격 없음).</p>
345
+ *
346
+ * @returns {Promise<{availableRoomAiTypes: string[]}>}
347
+ *
348
+ * @example
349
+ * const meta = await chat.getRoomAiMeta();
350
+ * // meta.availableRoomAiTypes 예: ['NONE', 'PM_BACKSTAGE']
351
+ * // → UI 에서 이 타입들만 방 생성 옵션으로 노출, 고른 타입을 createGroupRoom 의 roomAiType 으로 명시 전송
352
+ */
353
+ async getRoomAiMeta() {
354
+ const response = await this.apiClient.get('/api/v1/rooms/ai-meta');
355
+ return this._unwrapSuccessResponse(response);
356
+ }
357
+
358
+ /**
359
+ * 어시스턴트 메시지 평점 등록 (1~5점).
360
+ *
361
+ * <p>AI 어시스턴트가 생성한 메시지({@code senderType === 'ASSISTANT'})에 대해서만 의미가 있다.
362
+ * UI 는 어시 메시지이면서 삭제되지 않은 메시지에만 평점 버튼을 노출하는 것을 권장한다
363
+ * (서버도 사람 메시지 / 삭제 메시지는 거부).</p>
364
+ *
365
+ * <p>같은 메시지를 다시 평가하면 갱신된다 (단, 서버에서 해당 평점이 아직 검토 전일 때만).</p>
366
+ *
367
+ * @param {string} roomId - 채팅방 ID
368
+ * @param {string} messageId - 평가할 어시스턴트 메시지의 {@code messageId}
369
+ * @param {number} rating - 평점 (1~5 정수)
370
+ * @param {string} [comment] - 자유 코멘트 (max 2000자)
371
+ * @returns {Promise<void>}
372
+ *
373
+ * @example
374
+ * await chat.rateAssistantMessage(roomId, msg.messageId, 5, '정확한 요약이었어요');
375
+ */
376
+ async rateAssistantMessage(roomId, messageId, rating, comment) {
377
+ // 클라이언트 측 사전 검증 (서버에서도 @Min(1)@Max(5) 재검증됨)
378
+ if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
379
+ throw new Error('rating must be an integer between 1 and 5');
380
+ }
381
+
382
+ await this.apiClient.post(
383
+ `/api/v1/chat-messages/${roomId}/messages/${messageId}/rating`,
384
+ { rating, comment }
385
+ );
386
+ }
387
+
388
+ /**
389
+ * 어시스턴트로 대화 요약 (버튼 진입점, 동기).
390
+ *
391
+ * <p>지정한 페르소나가 최근 메시지 범위를 요약해 방에 어시스턴트 메시지로 broadcast 한다.
392
+ * HTTP 응답은 LLM 처리(수 초) 후 결과를 반환하며, 생성된 메시지는 일반 {@code chatMessage}
393
+ * 이벤트로도 수신된다.</p>
394
+ *
395
+ * @param {string} roomId - 채팅방 ID
396
+ * @param {Object} options
397
+ * @param {string} options.personaId - 요약을 수행할 어시스턴트 ID ({@link #getAssistants} 의 {@code id}). 필수.
398
+ * @param {string} [options.format] - 요약 포맷 ({@link SummarizeFormat}). 미지정 시 서버 기본값 {@code SHORT}.
399
+ * @param {number} [options.messageCount] - 요약 대상 최근 메시지 수 (1~50). 미지정 시 서버 기본값.
400
+ * @returns {Promise<{outcome: string, messageId: (string|null), detail: (string|null)}>}
401
+ * {@code outcome}: {@code 'EMITTED'} (생성됨, {@code messageId} 존재) |
402
+ * {@code 'SKIPPED'} / {@code 'FAILED'} ({@code detail} 에 사유).
403
+ *
404
+ * @example
405
+ * const result = await chat.summarizeWithAssistant(roomId, {
406
+ * personaId,
407
+ * format: SummarizeFormat.MINUTES,
408
+ * messageCount: 30
409
+ * });
410
+ * if (result.outcome !== 'EMITTED') console.warn(result.detail);
411
+ */
412
+ async summarizeWithAssistant(roomId, options = {}) {
413
+ const { personaId, format, messageCount } = options;
414
+ if (!personaId) {
415
+ throw new Error('summarizeWithAssistant requires options.personaId');
416
+ }
417
+
418
+ const response = await this.apiClient.post(
419
+ `/api/v1/rooms/${roomId}/assistant/tools/summarize`,
420
+ { personaId, format, messageCount }
421
+ );
422
+ return this._unwrapSuccessResponse(response);
423
+ }
424
+
425
+ /**
426
+ * 어시스턴트로 특정 메시지 번역 (버튼 진입점, 동기).
427
+ *
428
+ * <p>지정한 페르소나가 {@code sourceMessageId} 메시지를 {@code targetLang} 으로 번역해
429
+ * 방에 어시스턴트 메시지로 broadcast 한다. 생성된 메시지는 일반 {@code chatMessage}
430
+ * 이벤트로도 수신된다.</p>
431
+ *
432
+ * @param {string} roomId - 채팅방 ID
433
+ * @param {Object} options
434
+ * @param {string} options.personaId - 번역을 수행할 어시스턴트 ID ({@link #getAssistants} 의 {@code id}). 필수.
435
+ * @param {string} options.sourceMessageId - 번역 대상 메시지의 {@code messageId}. 필수.
436
+ * @param {string} [options.targetLang] - 목표 언어 (예: {@code 'en'}, {@code '日本語'}). 미지정 시 서버 정책.
437
+ * @returns {Promise<{outcome: string, messageId: (string|null), detail: (string|null)}>}
438
+ * {@code outcome}: {@code 'EMITTED'} (생성됨, {@code messageId} 존재) |
439
+ * {@code 'SKIPPED'} / {@code 'FAILED'} ({@code detail} 에 사유).
440
+ *
441
+ * @example
442
+ * const result = await chat.translateWithAssistant(roomId, {
443
+ * personaId,
444
+ * sourceMessageId: msg.messageId,
445
+ * targetLang: 'en'
446
+ * });
447
+ */
448
+ async translateWithAssistant(roomId, options = {}) {
449
+ const { personaId, targetLang, sourceMessageId } = options;
450
+ if (!personaId) {
451
+ throw new Error('translateWithAssistant requires options.personaId');
452
+ }
453
+ if (!sourceMessageId) {
454
+ throw new Error('translateWithAssistant requires options.sourceMessageId');
455
+ }
456
+
457
+ const response = await this.apiClient.post(
458
+ `/api/v1/rooms/${roomId}/assistant/tools/translate`,
459
+ { personaId, targetLang, sourceMessageId }
460
+ );
461
+ return this._unwrapSuccessResponse(response);
462
+ }
463
+
464
+ /**
465
+ * 방 PM 프롬프트 레이어 조회 — PM_BACKSTAGE 방 전용.
466
+ *
467
+ * <p>방 레이어는 전역 PM 프롬프트(+회사 레이어)에 <b>합성</b>되는 방 단위 추가 지시문이다 (교체 아님).
468
+ * 등록된 적이 없으면 404 ({@code PM_PROMPT_LAYER_NOT_FOUND}) — "아직 없음" 상태로 처리할 것.</p>
469
+ *
470
+ * <p>권한: 방 참여자 전체 조회 가능. PM_BACKSTAGE 가 아닌 방이면 400
471
+ * ({@code PM_PROMPT_ROOM_TYPE_UNSUPPORTED}).</p>
472
+ *
473
+ * @param {string} roomId - 채팅방 ID
474
+ * @returns {Promise<Object>} PmPromptLayer — {@code {id, scope:'ROOM', projectId, roomId, content, active, activeVersion, editorType, updatedAt}}
475
+ *
476
+ * @example
477
+ * try {
478
+ * const layer = await chat.getRoomPmPrompt(roomId);
479
+ * console.log(layer.active, layer.content);
480
+ * } catch (e) {
481
+ * if (e.status === 404) {
482
+ * // 레이어 미등록 — 입력 UI 노출
483
+ * }
484
+ * }
485
+ */
486
+ async getRoomPmPrompt(roomId) {
487
+ const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/pm-prompt`);
488
+ return this._unwrapSuccessResponse(response);
489
+ }
490
+
491
+ /**
492
+ * 방 PM 프롬프트 레이어 등록/수정 (upsert) — <b>방 주인(OWNER) 전용</b>.
493
+ *
494
+ * <p>저장 시 새 버전이 자동 기록된다 ({@link #getRoomPmPromptVersions}). 본문 한도 2,000자.
495
+ * 비주인 호출은 403 ({@code NOT_ROOM_ADMIN}).</p>
496
+ *
497
+ * @param {string} roomId - 채팅방 ID
498
+ * @param {string} content - 레이어 본문 (1~2,000자). 방의 목적/맥락/금지사항 등 PM 에게 줄 추가 지시문.
499
+ * @returns {Promise<Object>} 저장된 PmPromptLayer
500
+ *
501
+ * @example
502
+ * await chat.upsertRoomPmPrompt(roomId, '이 방은 모바일앱 리뉴얼 TF. 결정사항은 표로 정리해줘.');
503
+ */
504
+ async upsertRoomPmPrompt(roomId, content) {
505
+ if (typeof content !== 'string' || !content.trim()) {
506
+ throw new Error('upsertRoomPmPrompt requires non-blank content');
507
+ }
508
+ if (content.length > 2000) {
509
+ throw new Error('upsertRoomPmPrompt content exceeds 2000 characters');
510
+ }
511
+
512
+ const response = await this.apiClient.put(`/api/v1/rooms/${roomId}/pm-prompt`, { content });
513
+ return this._unwrapSuccessResponse(response);
514
+ }
515
+
516
+ /**
517
+ * 방 PM 프롬프트 레이어 합성 복귀 (활성화) — 방 주인(OWNER) 전용.
518
+ *
519
+ * @param {string} roomId - 채팅방 ID
520
+ * @returns {Promise<Object>} 변경된 PmPromptLayer ({@code active: true})
521
+ */
522
+ async activateRoomPmPrompt(roomId) {
523
+ const response = await this.apiClient.patch(`/api/v1/rooms/${roomId}/pm-prompt/activate`);
524
+ return this._unwrapSuccessResponse(response);
525
+ }
526
+
527
+ /**
528
+ * 방 PM 프롬프트 레이어 합성 제외 (비활성화, soft delete) — 방 주인(OWNER) 전용.
529
+ *
530
+ * <p>본문/이력은 보존되고 PM 응답 합성에서만 빠진다. {@link #activateRoomPmPrompt} 로 복귀.</p>
531
+ *
532
+ * @param {string} roomId - 채팅방 ID
533
+ * @returns {Promise<Object>} 변경된 PmPromptLayer ({@code active: false})
534
+ */
535
+ async deactivateRoomPmPrompt(roomId) {
536
+ const response = await this.apiClient.patch(`/api/v1/rooms/${roomId}/pm-prompt/deactivate`);
537
+ return this._unwrapSuccessResponse(response);
538
+ }
539
+
540
+ /**
541
+ * 방 PM 프롬프트 레이어 버전 이력 조회 — 방 참여자 전체.
542
+ *
543
+ * @param {string} roomId - 채팅방 ID
544
+ * @returns {Promise<Array<Object>>} 버전 목록 — 각 항목 {@code {version, content, active, editorType, createdAt}} ({@code active}=현재 활성 버전)
545
+ */
546
+ async getRoomPmPromptVersions(roomId) {
547
+ const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/pm-prompt/versions`);
548
+ return this._unwrapSuccessResponse(response);
549
+ }
550
+
551
+ /**
552
+ * 방 PM 프롬프트 레이어 버전 활성화 — 방 주인(OWNER) 전용.
553
+ *
554
+ * <p>active 포인터 이동 방식 — 지정 버전을 활성으로 전환하고 라이브 본문을 그 버전으로 투영한다.
555
+ * <b>새 버전을 만들지 않는다</b>(버전 수 불변, 글로벌/회사/방 PM 통일 모델). 존재하지 않는
556
+ * 버전이면 404 ({@code PM_PROMPT_LAYER_VERSION_NOT_FOUND}).</p>
557
+ *
558
+ * @param {string} roomId - 채팅방 ID
559
+ * @param {number} version - 활성화할 버전 번호 ({@link #getRoomPmPromptVersions} 의 {@code version})
560
+ * @returns {Promise<Object>} 활성 버전이 반영된 PmPromptLayer
561
+ */
562
+ async activateRoomPmPromptVersion(roomId, version) {
563
+ if (!Number.isInteger(version) || version < 1) {
564
+ throw new Error('activateRoomPmPromptVersion requires a positive integer version');
565
+ }
566
+
567
+ const response = await this.apiClient.post(
568
+ `/api/v1/rooms/${roomId}/pm-prompt/versions/${version}/activate`
569
+ );
570
+ return this._unwrapSuccessResponse(response);
571
+ }
572
+
573
+ /**
574
+ * PM 프롬프트 합성 미리보기 — 방 참여자 전체.
575
+ *
576
+ * <p>전역(+회사)(+방) 레이어가 합성된 최종 SYSTEM 본문을 반환한다. 컨텍스트 정책 등
577
+ * 런타임 부착분은 미포함 — 레이어 합성 결과까지만.</p>
578
+ *
579
+ * @param {string} roomId - 채팅방 ID
580
+ * @returns {Promise<{composedPrompt: string}>}
581
+ *
582
+ * @example
583
+ * const { composedPrompt } = await chat.previewRoomPmPrompt(roomId);
584
+ */
585
+ async previewRoomPmPrompt(roomId) {
586
+ const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/pm-prompt/preview`);
587
+ return this._unwrapSuccessResponse(response);
588
+ }
589
+
590
+ /**
591
+ * 그룹 채팅방 입장.
592
+ *
593
+ * <p>비밀방 ({@code PRIVATE_GROUP}) 은 최초 입장 시 또는 나갔다 재입장 시 비밀번호 필요.
594
+ * 이미 active 참가자라면 비밀번호 없이 재접근 가능 (서버의 fast path).</p>
595
+ *
596
+ * <p>팀방 ({@code TEAM}) 은 초대 전용 — 직접 입장 시도는 {@code TEAM_ROOM_INVITE_ONLY}(403).
597
+ * {@link #inviteToGroupRoom} 으로 초대받아야 입장된다 (이미 멤버면 fast path 멱등 통과).</p>
598
+ *
599
+ * @param {string} roomId - 채팅방 ID
600
+ * @param {string} [password] - 비밀방 입장 비밀번호 (비밀방 최초/재입장 시 필수)
601
+ * @returns {Promise<Object>}
602
+ *
603
+ * @example
604
+ * // 공개방 입장
605
+ * await chat.joinGroupRoom('room-id');
606
+ *
607
+ * @example
608
+ * // 비밀방 입장
609
+ * await chat.joinGroupRoom('room-id', 'secret1234');
610
+ */
611
+ async joinGroupRoom(roomId, password) {
612
+ const body = password ? { password } : undefined;
613
+ return this.apiClient.post(`/api/v1/rooms/group/${roomId}/join`, body);
614
+ }
615
+
616
+ /**
617
+ * 채팅방 나가기 (구독도 자동 해제됨)
618
+ * @param {string} roomId - 채팅방 ID
619
+ * @returns {Promise<Object>}
620
+ */
621
+ async leaveRoom(roomId) {
622
+ const result = await this.apiClient.post(`/api/v1/rooms/${roomId}/leave`);
623
+ this.unsubscribeRoom(roomId);
624
+ return result;
625
+ }
626
+
627
+ /**
628
+ * 그룹 채팅방 설정 수정 (방장만).
629
+ * @param {string} roomId - 채팅방 ID
630
+ * @param {Object} data - 수정할 필드 (null/undefined 이면 변경 안 함)
631
+ * @param {string} [data.roomName] - 방 이름
632
+ * @param {string} [data.description] - 설명
633
+ * @param {string} [data.roomType] - 공개/비공개 전환 ('GROUP' | 'PRIVATE_GROUP').
634
+ * 'PRIVATE_GROUP' 전환 시 password 필수, 'GROUP' 전환 시 기존 비번 제거. 생략 시 변경 안 함.
635
+ * @param {string} [data.password] - 비밀번호 (PRIVATE_GROUP). 공백만으로는 설정 불가, 4~50자.
636
+ * @param {number} [data.messageRetentionHours] - 보관 기간 (시간)
637
+ * @param {boolean} [data.anyoneCanInvite] - 누구나 초대 가능 여부
638
+ * @param {string} [data.assistantMode] - 어시스턴트 참여 모드
639
+ * ('GENERAL' | 'PEOPLE_ONLY' | 'CALL_ONLY'). PERSONA_MULTI 방 전용.
640
+ * @param {string} [data.engagementIntensity] - PM 개입 강도 ('QUIET' | 'NORMAL' | 'ACTIVE').
641
+ * PM_BACKSTAGE 방 전용 — PM 참여 레벨 변경. 키 PM_BACKSTAGE 권한 필요(없으면 403).
642
+ * @returns {Promise<Object>}
643
+ *
644
+ * @example
645
+ * // PERSONA_MULTI 방 — 어시스턴트 모드 변경
646
+ * await chat.updateGroupRoom('room-id', { assistantMode: 'CALL_ONLY' });
647
+ *
648
+ * @example
649
+ * // PM_BACKSTAGE 방 — PM 개입 강도 변경
650
+ * await chat.updateGroupRoom('room-id', { engagementIntensity: 'QUIET' });
651
+ *
652
+ * @example
653
+ * // 공개 → 비공개 전환 (비밀번호 필수)
654
+ * await chat.updateGroupRoom('room-id', { roomType: 'PRIVATE_GROUP', password: 'secret12' });
655
+ *
656
+ * @example
657
+ * // 비공개 → 공개 전환 (기존 비밀번호 제거됨)
658
+ * await chat.updateGroupRoom('room-id', { roomType: 'GROUP' });
659
+ */
660
+ async updateGroupRoom(roomId, data) {
661
+ return this.apiClient.put(`/api/v1/rooms/group/${roomId}`, data);
662
+ }
663
+
664
+ /**
665
+ * 그룹 채팅방에 회원 / 어시스턴트 추가 초대.
666
+ *
667
+ * <p>두 가지 호출 형식 지원 (backward-compat):</p>
668
+ * <ul>
669
+ * <li>{@code inviteToGroupRoom(roomId, ['user-1', 'user-2'])} — 회원만 초대 (기존)</li>
670
+ * <li>{@code inviteToGroupRoom(roomId, { userIds, assistantPersonaIds })} — 회원 + 어시 (신규)</li>
671
+ * </ul>
672
+ *
673
+ * @param {string} roomId - 채팅방 ID
674
+ * @param {string[]|{userIds?: string[], assistantPersonaIds?: string[]}} userIdsOrData
675
+ * 배열이면 회원 ID 목록, 객체면 {@code userIds} / {@code assistantPersonaIds}.
676
+ * 둘 중 하나 이상 필수.
677
+ * @returns {Promise<Object>}
678
+ *
679
+ * @example
680
+ * // 회원만
681
+ * await chat.inviteToGroupRoom(roomId, ['user-1', 'user-2']);
682
+ *
683
+ * @example
684
+ * // 회원 + 어시스턴트
685
+ * await chat.inviteToGroupRoom(roomId, {
686
+ * userIds: ['user-3'],
687
+ * assistantPersonaIds: [/* 어시스턴트 id 배열 *\/]
688
+ * });
689
+ */
690
+ async inviteToGroupRoom(roomId, userIdsOrData) {
691
+ const body = Array.isArray(userIdsOrData)
692
+ ? { userIds: userIdsOrData }
693
+ : {
694
+ userIds: userIdsOrData?.userIds,
695
+ assistantPersonaIds: userIdsOrData?.assistantPersonaIds
696
+ };
697
+ return this.apiClient.post(`/api/v1/rooms/group/${roomId}/invite`, body);
698
+ }
699
+
700
+ /**
701
+ * 그룹 채팅방에서 참가자 단순 추방 — 방 관리자(OWNER/ADMIN) 만 호출 가능.
702
+ *
703
+ * <p>대상은 방에서 제거되지만 재입장 / 재초대 가능. 영구 차단이 필요하면
704
+ * {@link #banMember} 사용.</p>
705
+ *
706
+ * <p>서버 응답:</p>
707
+ * <ul>
708
+ * <li>400 {@code CANNOT_KICK_SELF} — 자기 자신 추방 시도</li>
709
+ * <li>403 {@code NOT_ROOM_ADMIN} — 방장 아님</li>
710
+ * <li>404 {@code USER_NOT_IN_ROOM} — 대상이 현재 참가자 아님</li>
711
+ * </ul>
712
+ *
713
+ * @param {string} roomId
714
+ * @param {string} userId - 추방할 사용자 ID
715
+ * @returns {Promise<Object>}
716
+ */
717
+ async kickMember(roomId, userId) {
718
+ return this.apiClient.delete(`/api/v1/rooms/${roomId}/members/${userId}`);
719
+ }
720
+
721
+ /**
722
+ * 그룹 채팅방에서 참가자 추방 + 영구 차단 — 방 관리자(OWNER/ADMIN) 만 호출 가능.
723
+ *
724
+ * <p>{@link #kickMember} 와 동일하게 방에서 제거하되 {@code bannedUserIds} 에 추가되어
725
+ * {@link #unbanMember} 호출 전까지 재입장 / 재초대가 거부된다. MVP 정책상 현재 또는
726
+ * 과거 참가자만 차단 가능 (선제적 차단 불가 — 404 {@code USER_NOT_IN_ROOM}).</p>
727
+ *
728
+ * @param {string} roomId
729
+ * @param {string} userId - 차단할 사용자 ID
730
+ * @returns {Promise<Object>}
731
+ */
732
+ async banMember(roomId, userId) {
733
+ return this.apiClient.post(`/api/v1/rooms/${roomId}/banned-members`, { userId });
734
+ }
735
+
736
+ /**
737
+ * 영구 차단 해제 — 방 관리자(OWNER/ADMIN) 만 호출 가능.
738
+ *
739
+ * <p>{@code bannedUserIds} 에서 제거만 수행. 실제 재입장은 별도 초대 / join 호출 필요.</p>
740
+ *
741
+ * @param {string} roomId
742
+ * @param {string} userId - 해제할 사용자 ID
743
+ * @returns {Promise<Object>}
744
+ */
745
+ async unbanMember(roomId, userId) {
746
+ return this.apiClient.delete(`/api/v1/rooms/${roomId}/banned-members/${userId}`);
747
+ }
748
+
749
+ /**
750
+ * 방의 영구 차단 사용자 목록 조회 — 방 관리자(OWNER/ADMIN) 만 호출 가능.
751
+ *
752
+ * <p>응답 각 item: {@code { userId, nickname, profileUrl }}.
753
+ * 일반 참가자가 호출 시 403 {@code NOT_ROOM_ADMIN}.</p>
754
+ *
755
+ * @param {string} roomId
756
+ * @returns {Promise<Array<{userId: string, nickname: string, profileUrl: string}>>}
757
+ */
758
+ async getBannedMembers(roomId) {
759
+ const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/banned-members`);
760
+ return response && typeof response === 'object' && 'data' in response
761
+ ? response.data
762
+ : response;
763
+ }
764
+
765
+ /**
766
+ * 메시지 고정 (방장만).
767
+ * @param {string} roomId - 채팅방 ID
768
+ * @param {string} messageId - 고정할 메시지 ID
769
+ * @returns {Promise<Object>}
770
+ */
771
+ async pinMessage(roomId, messageId) {
772
+ return this.apiClient.post(`/api/v1/rooms/group/${roomId}/pin/${messageId}`);
773
+ }
774
+
775
+ /**
776
+ * 메시지 고정 해제 (방장만).
777
+ * @param {string} roomId - 채팅방 ID
778
+ * @returns {Promise<Object>}
779
+ */
780
+ async unpinMessage(roomId) {
781
+ return this.apiClient.post(`/api/v1/rooms/group/${roomId}/unpin`);
782
+ }
783
+
784
+ /**
785
+ * 이모지 리액션 토글.
786
+ * <p>한 사용자는 메시지당 하나의 이모지만 보유.
787
+ * 같은 이모지 재전송 시 해제, 다른 이모지 전송 시 교체.</p>
788
+ * @param {string} roomId - 채팅방 ID
789
+ * @param {string} messageId - 메시지 ID
790
+ * @param {string} emoji - 이모지 (예: "👍", "😂")
791
+ * @returns {Promise<Object>}
792
+ */
793
+ async toggleReaction(roomId, messageId, emoji) {
794
+ return this.apiClient.post(`/api/v1/chat-messages/${roomId}/messages/${messageId}/reaction`, { emoji });
795
+ }
796
+
797
+ /**
798
+ * 참가 가능한 그룹 채팅방 목록 조회
799
+ * @param {Object} [params]
800
+ * @param {number} [params.size=50] - 페이지 크기
801
+ * @param {string} [params.lastId] - 마지막 채팅방 ID
802
+ * @param {number} [params.lastSortValue] - 마지막 정렬값
803
+ * @returns {Promise<Object>}
804
+ */
805
+ async getAvailableGroupRooms(params = {}) {
806
+ const { size = 50, lastId, lastSortValue } = params;
807
+ return this.apiClient.get('/api/v1/rooms/groups/available', { size, lastId, lastSortValue });
808
+ }
809
+
810
+ /**
811
+ * 모든 그룹 채팅방 목록 조회
812
+ * @param {Object} [params]
813
+ * @param {number} [params.size=50] - 페이지 크기
814
+ * @param {string} [params.lastId] - 마지막 채팅방 ID
815
+ * @param {number} [params.lastSortValue] - 마지막 정렬값
816
+ * @returns {Promise<Object>}
817
+ */
818
+ async getAllGroupRooms(params = {}) {
819
+ const { size = 50, lastId, lastSortValue } = params;
820
+ return this.apiClient.get('/api/v1/rooms/groups/all', { size, lastId, lastSortValue });
821
+ }
822
+
823
+ /**
824
+ * 메시지 목록 조회
825
+ * @param {string} roomId - 채팅방 ID
826
+ * @param {Object} [params]
827
+ * @param {number} [params.size=50] - 페이지 크기 (1-100)
828
+ * @param {string} [params.lastId] - 마지막 메시지 ID (커서)
829
+ * @param {number} [params.lastSortValue] - 마지막 정렬값 (epoch millis)
830
+ * @returns {Promise<Object>}
831
+ */
832
+ async getMessages(roomId, params = {}) {
833
+ const { size = 50, lastId, lastSortValue } = params;
834
+ const response = await this.apiClient.get(
835
+ `/api/v1/chat-messages/${roomId}/list`, { size, lastId, lastSortValue }
836
+ );
837
+ // REST 와 WebSocket 의 타임스탬프 직렬화가 다르다 (REST=ISO 문자열, WS=epoch number).
838
+ // 선언된 타입(number)으로 통일 — 기존 응답 구조(envelope)는 그대로 둔다.
839
+ const page = this._unwrapSuccessResponse(response);
840
+ if (page && Array.isArray(page.content)) {
841
+ page.content.forEach((m) => this._normalizeMessageTimestamps(m));
842
+ }
843
+ return response;
844
+ }
845
+
846
+ /**
847
+ * 링크 미리보기 조회.
848
+ *
849
+ * <p>서버가 OG / Twitter Card / HTML fallback 을 파싱해 미리보기 카드를 반환합니다.
850
+ * 외부 사이트 차단 등으로 HTML 을 못 가져오면 공개 DNS 로 확인되는 URL 에 한해
851
+ * 도메인 + favicon 기반 최소 카드를 반환할 수 있습니다.
852
+ * 이 경우 {@code previewSource} 는 {@code FAVICON_FALLBACK} 입니다.
853
+ * 미리보기를 만들 수 없는 URL 이면 {@code null} 을 반환합니다.</p>
854
+ *
855
+ * <p>민감한 URL 이 query string 으로 남지 않도록 POST body 로 호출합니다.</p>
856
+ *
857
+ * @param {string} url - 미리보기를 조회할 URL
858
+ * @returns {Promise<Object|null>} LinkPreview 또는 null
859
+ */
860
+ async fetchLinkPreview(url) {
861
+ const normalizedUrl = typeof url === 'string' ? url.trim() : '';
862
+ if (!normalizedUrl) {
863
+ throw new Error('url is required');
864
+ }
865
+
866
+ const response = await this.apiClient.post('/api/v1/link-preview', {
867
+ url: normalizedUrl
868
+ });
869
+
870
+ return this._unwrapSuccessResponse(response);
871
+ }
872
+
873
+ /**
874
+ * 메시지 전송 (REST API).
875
+ *
876
+ * <p>멱등 POST — SDK 가 내부에서 UUID 를 자동 생성하며, 호출자는 {@code messageId} 를
877
+ * 지정할 수 없다. 같은 요청의 네트워크 재시도(SDK 내부 구현 시) 에도 같은 UUID 가 쓰이도록
878
+ * 설계된다. 서버 Unique 제약 + pre-check 로 중복 저장이 차단되며, 재시도 히트 시
879
+ * {@code duplicate: true} 로 기존 결과가 그대로 반환된다.</p>
880
+ *
881
+ * <p>파일 분리 시 ({@code separateFiles: true}) 하나의 요청이 여러 메시지로 저장된다.
882
+ * 각 메시지는 고유한 서버 {@code messageId} 를 가지며 응답의 {@code messages} 배열로 전달된다.</p>
883
+ *
884
+ * <p>답글: {@code data.replyToMessageId} 에 원본 메시지의 서버 {@code messageId} 를 지정.
885
+ * 서버가 원본을 조회해서 snapshot 을 생성 후 저장하며, 응답/수신 메시지의 {@code replyTo}
886
+ * 필드에 원본 요약이 포함됨. 원본이 존재하지 않으면 서버가 400 (REPLY_TARGET_NOT_FOUND) 반환.</p>
887
+ *
888
+ * @param {string} roomId - 채팅방 ID
889
+ * @param {Object} data
890
+ * @param {string} [data.message] - 메시지 내용 (message 또는 fileInfos 중 하나 필수)
891
+ * @param {Object[]} [data.fileInfos] - 파일 메타데이터 배열
892
+ * @param {boolean} [data.separateFiles=true] - 파일 분리 여부
893
+ * @param {string} [data.replyToMessageId] - 답글 대상 원본 메시지 ID (옵션)
894
+ * @returns {Promise<{duplicate: boolean, messages: Object[]}>}
895
+ * - {@code duplicate}: 멱등 재시도로 기존 결과를 반환한 경우 {@code true}
896
+ * - {@code messages}: 저장된 메시지 배열 (파일 분리 시 여러 개). 각 원소의 {@code messageId} 는 서버가 부여한 고유 ID.
897
+ *
898
+ * @example
899
+ * // 일반 메시지
900
+ * const { messages } = await chat.sendMessage('room-id', { message: '안녕하세요' });
901
+ * console.log(messages[0].messageId); // 서버가 생성한 ID — 답글/삭제/리액션에 사용
902
+ *
903
+ * @example
904
+ * // 답글
905
+ * await chat.sendMessage('room-id', {
906
+ * message: '네 동감이에요',
907
+ * replyToMessageId: 'original-msg-id'
908
+ * });
909
+ *
910
+ * @example
911
+ * // 파일 분리 전송 — messages 배열에 텍스트 + 파일별 메시지가 모두 담김
912
+ * const { messages } = await chat.sendMessage('room-id', {
913
+ * message: '사진 보냅니다',
914
+ * fileInfos: [f0, f1, f2],
915
+ * separateFiles: true
916
+ * });
917
+ * // messages.length === 4 (텍스트 1 + 이미지 3)
918
+ */
919
+ async sendMessage(roomId, data) {
920
+ const messageId = this._generateMessageId();
921
+ return this._sendMessageWithId(roomId, messageId, data);
922
+ }
923
+
924
+ /**
925
+ * 메시지 전송 — Optimistic UI 버전.
926
+ *
927
+ * <p>{@link #sendMessage} 와 동일하게 서버에 메시지를 전송하지만, 호출 즉시
928
+ * SDK 가 생성한 {@code baseMessageId} 와 응답 {@code messages} 배열과 1-1 매칭되는
929
+ * 예측 {@code messageIds} 배열을 동기 반환한다. 호출자는 promise 가 resolve 되기 전에
930
+ * 미리 placeholder 메시지를 UI 에 그려둘 수 있고, resolve 후 같은 ID 로 안전하게 교체할 수 있다.</p>
931
+ *
932
+ * <p>{@code messageIds} 는 서버의 messageId derivation 규칙 (텍스트=base, 파일=base#N) 을
933
+ * SDK 가 mirror 하여 사전 계산한 값이다. 정상 케이스에서는 응답
934
+ * {@code messages.map(m => m.messageId)} 와 길이/순서가 정확히 일치한다.</p>
935
+ *
936
+ * <p><b>분류 규칙 drift 방어</b>: 서버 파일 분류 규칙이 변경되어 예측이 어긋나면
937
+ * SDK 가 warn 로그를 남기며, 이 경우 응답의 실제 {@code messageId} 로 reconcile 해야 한다.</p>
938
+ *
939
+ * @param {string} roomId - 채팅방 ID
940
+ * @param {Object} data - {@link #sendMessage} 와 동일한 페이로드
941
+ * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}
942
+ *
943
+ * @example
944
+ * const { messageIds, promise } = chat.sendMessageOptimistic('room-id', {
945
+ * message: '안녕하세요'
946
+ * });
947
+ * messageIds.forEach(id => appendMessage({ messageId: id, status: 'sending' }));
948
+ * try {
949
+ * const { messages } = await promise;
950
+ * messages.forEach(m => replaceMessage(m.messageId, m));
951
+ * } catch (e) {
952
+ * messageIds.forEach(id => markMessageFailed(id, e));
953
+ * }
954
+ */
955
+ sendMessageOptimistic(roomId, data) {
956
+ const baseMessageId = this._generateMessageId();
957
+ const messageIds = this._predictMessageIdsFromData(baseMessageId, data);
958
+
959
+ const promise = this._sendMessageWithId(roomId, baseMessageId, data)
960
+ .then((result) => this._attachOptimisticMetadata(messageIds, result));
961
+
962
+ return { baseMessageId, messageIds, promise };
963
+ }
964
+
965
+ /**
966
+ * messageId 를 외부에서 주입받아 실제 send API 를 호출하는 내부 메서드.
967
+ * <p>{@link #sendMessage} 와 {@link #sendMessageOptimistic} 가 공유하는 단일 코어 — base messageId 를
968
+ * 양쪽이 같은 값으로 사용해야 optimistic 핸들의 예측 ID 가 서버 저장 ID 와 일치한다.</p>
969
+ *
970
+ * @private
971
+ * @param {string} roomId
972
+ * @param {string} messageId - SDK 가 생성한 base messageId (UUID)
973
+ * @param {Object} data
974
+ * @returns {Promise<{duplicate: boolean, messages: Object[]}>}
975
+ */
976
+ async _sendMessageWithId(roomId, messageId, data) {
977
+ // 메시지 전송 시 타이핑 표시 자동 중단
978
+ this.stopTyping(roomId);
979
+
980
+ // separateFiles 는 SDK 가 명시 전송 — 서버 default 가 변경되어도 SDK 의 prediction
981
+ // 가정 (`undefined === true`) 과 wire 값이 어긋나지 않도록 fix.
982
+ const response = await this.apiClient.post(`/api/v1/chat-messages/${roomId}/send`, {
983
+ messageId,
984
+ message: data.message,
985
+ fileInfos: data.fileInfos,
986
+ separateFiles: data.separateFiles !== false,
987
+ replyToMessageId: data.replyToMessageId
988
+ });
989
+
990
+ const result = this._unwrapSuccessResponse(response);
991
+ if (result && Array.isArray(result.messages)) {
992
+ result.messages.forEach((m) => this._normalizeMessageTimestamps(m));
993
+ }
994
+ return result;
995
+ }
996
+
997
+ /**
998
+ * 텍스트 메시지 전송 (간편 버전).
999
+ *
1000
+ * @param {string} roomId - 채팅방 ID
1001
+ * @param {string} message - 메시지 내용
1002
+ * @returns {Promise<{duplicate: boolean, messages: Object[]}>}
1003
+ *
1004
+ * @example
1005
+ * const { messages } = await chat.sendTextMessage('room-id', '안녕하세요');
1006
+ * console.log(messages[0].messageId);
1007
+ */
1008
+ async sendTextMessage(roomId, message) {
1009
+ return this.sendMessage(roomId, { message });
1010
+ }
1011
+
1012
+ /**
1013
+ * 텍스트 메시지 전송 — Optimistic UI 버전.
1014
+ *
1015
+ * @param {string} roomId - 채팅방 ID
1016
+ * @param {string} message - 메시지 내용
1017
+ * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}
1018
+ *
1019
+ * @example
1020
+ * const { baseMessageId, promise } = chat.sendTextMessageOptimistic('room-id', '안녕');
1021
+ * appendMessage({ messageId: baseMessageId, content: '안녕', status: 'sending' });
1022
+ * const { messages } = await promise;
1023
+ * replaceMessage(baseMessageId, messages[0]);
1024
+ */
1025
+ sendTextMessageOptimistic(roomId, message) {
1026
+ return this.sendMessageOptimistic(roomId, { message });
1027
+ }
1028
+
1029
+ /**
1030
+ * 채팅 첨부 파일 업로드 (Low-level).
1031
+ *
1032
+ * <p>파일 바이트를 서버로 업로드하고 서버가 생성한 {@code FileMetaData} 를 반환.
1033
+ * 반환값은 {@link #sendMessage} 의 {@code fileInfos} 에 그대로 넣어 전송.</p>
1034
+ *
1035
+ * <p>업로드와 메시지 전송을 분리 호출해야 하는 케이스 (업로드 후 프리뷰 표시 →
1036
+ * 사용자가 전송 버튼 클릭 시 메시지 전송 등) 에 사용. 한 번에 처리하려면
1037
+ * {@link #sendFileMessage} 가 편리.</p>
1038
+ *
1039
+ * @param {string} roomId - 업로드 대상 채팅방 ID (권한 검증 + S3 namespace)
1040
+ * @param {File|Blob} file - 업로드할 파일
1041
+ * @param {Object} [options]
1042
+ * @param {Function} [options.onProgress] - {@code ({loaded, total, percent}) => void} — 진행률 콜백
1043
+ * @param {AbortSignal} [options.signal] - 업로드 취소용 AbortSignal
1044
+ * @returns {Promise<Object>} FileMetaData (fileId/fileUrl/fileName/fileType/fileSize/imageInfo/videoInfo/audioInfo/documentInfo)
1045
+ *
1046
+ * @example
1047
+ * const meta = await chat.uploadFile('room-id', fileInput.files[0], {
1048
+ * onProgress: ({ percent }) => console.log(`${percent}%`)
1049
+ * });
1050
+ * await chat.sendMessage('room-id', {
1051
+ * fileInfos: [meta],
1052
+ * message: '첨부합니다'
1053
+ * });
1054
+ */
1055
+ async uploadFile(roomId, file, options = {}) {
1056
+ if (!roomId) {
1057
+ throw new Error('roomId is required');
1058
+ }
1059
+ if (!file) {
1060
+ throw new Error('file is required');
1061
+ }
1062
+
1063
+ const response = await this.apiClient.upload(
1064
+ `/api/v1/files/${roomId}/upload`,
1065
+ file,
1066
+ {
1067
+ onProgress: options.onProgress,
1068
+ signal: options.signal
1069
+ }
1070
+ );
1071
+
1072
+ // 서버 SuccessResponse 래퍼 안의 data 가 FileMetaData
1073
+ return this._unwrapSuccessResponse(response);
1074
+ }
1075
+
1076
+ /**
1077
+ * 파일 업로드 + 메시지 전송 통합 (High-level).
1078
+ *
1079
+ * <p>내부적으로:</p>
1080
+ * <ol>
1081
+ * <li>각 파일을 {@link #uploadFile} 로 업로드 (배열이면 병렬)</li>
1082
+ * <li>수신된 {@code FileMetaData} 들을 모아 {@link #sendMessage} 로 전송</li>
1083
+ * </ol>
1084
+ *
1085
+ * <p>업로드 중 하나라도 실패하면 전체 rejected — 이미 업로드된 S3 객체는
1086
+ * 고아로 남으며 라이프사이클 정책으로 정리됨 (설계 결정).</p>
1087
+ *
1088
+ * @param {string} roomId - 채팅방 ID
1089
+ * @param {File|Blob|Array<File|Blob>} files - 단일 파일 또는 파일 배열
1090
+ * @param {Object} [options]
1091
+ * @param {string} [options.message] - 함께 보낼 텍스트 본문
1092
+ * @param {boolean} [options.separateFiles] - 파일별 개별 메시지 분리 여부 (서버 설정)
1093
+ * @param {string} [options.replyToMessageId] - 답글 대상 원본 메시지의 서버 {@code messageId}
1094
+ * @param {Function} [options.onUploadProgress] - {@code ({loaded,total,percent,fileIndex}) => void} — 파일별 진행률
1095
+ * @param {AbortSignal} [options.signal] - 취소 시그널 — 업로드 중에만 유효
1096
+ * @returns {Promise<{duplicate: boolean, messages: Object[]}>}
1097
+ *
1098
+ * @example
1099
+ * // 단일 파일
1100
+ * await chat.sendFileMessage('room-id', file, {
1101
+ * message: '첨부',
1102
+ * onUploadProgress: ({ percent }) => console.log(`${percent}%`)
1103
+ * });
1104
+ *
1105
+ * @example
1106
+ * // 다중 파일 (병렬 업로드)
1107
+ * await chat.sendFileMessage('room-id', [file1, file2, file3], {
1108
+ * message: '여러 파일',
1109
+ * onUploadProgress: ({ fileIndex, percent }) =>
1110
+ * console.log(`파일 ${fileIndex}: ${percent}%`)
1111
+ * });
1112
+ */
1113
+ async sendFileMessage(roomId, files, options = {}) {
1114
+ const fileArray = this._normalizeFileArray(files);
1115
+ const metas = await this._uploadFiles(roomId, fileArray, options);
1116
+ const fileInfos = this._applyFileMetadata(metas, options.metadata);
1117
+
1118
+ return this.sendMessage(roomId, {
1119
+ message: options.message,
1120
+ fileInfos,
1121
+ separateFiles: options.separateFiles,
1122
+ replyToMessageId: options.replyToMessageId
1123
+ });
1124
+ }
1125
+
1126
+ /**
1127
+ * 파일 업로드 + 메시지 전송 — Optimistic UI 버전.
1128
+ *
1129
+ * <p>호출 즉시 {@code baseMessageId} 와 응답 {@code messages} 와 1-1 매칭되는 예측
1130
+ * {@code messageIds} 를 동기 반환한다. 호출자는 업로드 시작 전에 placeholder 메시지를
1131
+ * UI 에 그릴 수 있다.</p>
1132
+ *
1133
+ * <p><b>입력 검증은 동기 throw</b>: 빈 배열 / max 초과 등 {@link #_normalizeFileArray} 의
1134
+ * 검증 실패는 promise 가 만들어지기 전에 즉시 throw 되므로, 잘못된 입력에 대해 placeholder 가
1135
+ * 먼저 그려지는 일은 없다.</p>
1136
+ *
1137
+ * <p><b>업로드 실패 처리</b>: 업로드 단계에서 실패하면 promise 가 reject 된다. 호출자는 UI 의
1138
+ * placeholder 들을 모두 실패 상태로 표시하고, S3 에 부분 업로드된 객체는 라이프사이클 정책으로 정리된다.</p>
1139
+ *
1140
+ * @param {string} roomId
1141
+ * @param {File|Blob|Array<File|Blob>} files
1142
+ * @param {Object} [options] - {@link #sendFileMessage} 와 동일
1143
+ * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}
1144
+ *
1145
+ * @example
1146
+ * const { messageIds, promise } = chat.sendFileMessageOptimistic('room-id', files, {
1147
+ * message: '사진 보냅니다'
1148
+ * });
1149
+ * messageIds.forEach((id, i) => appendMessage({
1150
+ * messageId: id,
1151
+ * localFile: files[i] || null,
1152
+ * status: 'uploading'
1153
+ * }));
1154
+ * try {
1155
+ * const { messages } = await promise;
1156
+ * messages.forEach(m => replaceMessage(m.messageId, m));
1157
+ * } catch (e) {
1158
+ * messageIds.forEach(id => markMessageFailed(id, e));
1159
+ * }
1160
+ */
1161
+ sendFileMessageOptimistic(roomId, files, options = {}) {
1162
+ // validation 은 동기 — placeholder 가 그려지기 전에 throw 되어야 함
1163
+ const fileArray = this._normalizeFileArray(files);
1164
+
1165
+ const baseMessageId = this._generateMessageId();
1166
+ const messageIds = this._predictMessageIds(
1167
+ baseMessageId,
1168
+ this._predictGroupCount(fileArray, options.separateFiles !== false),
1169
+ this._hasTextMessage(options.message)
1170
+ );
1171
+
1172
+ const promise = (async () => {
1173
+ const metas = await this._uploadFiles(roomId, fileArray, options);
1174
+ const fileInfos = this._applyFileMetadata(metas, options.metadata);
1175
+ const result = await this._sendMessageWithId(roomId, baseMessageId, {
1176
+ message: options.message,
1177
+ fileInfos,
1178
+ separateFiles: options.separateFiles,
1179
+ replyToMessageId: options.replyToMessageId
1180
+ });
1181
+ return this._attachOptimisticMetadata(messageIds, result);
1182
+ })();
1183
+
1184
+ return { baseMessageId, messageIds, promise };
1185
+ }
1186
+
1187
+ /**
1188
+ * 파일 입력 정규화 — 단일 파일 / 배열 양쪽 허용 + 개수 검증.
1189
+ * <p>{@link #sendFileMessage} 와 {@link #sendFileMessageOptimistic} 가 공유.</p>
1190
+ *
1191
+ * @private
1192
+ * @param {File|Blob|Array<File|Blob>} files
1193
+ * @returns {Array<File|Blob>}
1194
+ * @throws {Error} 빈 배열 또는 MAX_FILES_PER_MESSAGE 초과
1195
+ */
1196
+ _normalizeFileArray(files) {
1197
+ const fileArray = Array.isArray(files) ? files : [files];
1198
+ if (fileArray.length === 0) {
1199
+ throw new Error('At least one file is required');
1200
+ }
1201
+ if (fileArray.length > MAX_FILES_PER_MESSAGE) {
1202
+ throw new Error(`A maximum of ${MAX_FILES_PER_MESSAGE} files can be sent in one message`);
1203
+ }
1204
+ return fileArray;
1205
+ }
1206
+
1207
+ /**
1208
+ * 파일 배열 병렬 업로드 — 각 파일의 진행률은 fileIndex 로 구분.
1209
+ * <p>{@link #sendFileMessage} 와 {@link #sendFileMessageOptimistic} 가 공유.</p>
1210
+ *
1211
+ * @private
1212
+ * @param {string} roomId
1213
+ * @param {Array<File|Blob>} fileArray
1214
+ * @param {Object} options - signal, onUploadProgress 사용
1215
+ * @returns {Promise<Object[]>} FileMetaData 배열
1216
+ */
1217
+ async _uploadFiles(roomId, fileArray, options = {}) {
1218
+ return Promise.all(
1219
+ fileArray.map((file, index) =>
1220
+ this.uploadFile(roomId, file, {
1221
+ signal: options.signal,
1222
+ onProgress: options.onUploadProgress
1223
+ ? (p) => options.onUploadProgress({ ...p, fileIndex: index })
1224
+ : undefined
1225
+ })
1226
+ )
1227
+ );
1228
+ }
1229
+
1230
+ /**
1231
+ * 업로드된 FileMetaData 배열에 고객사 pass-through metadata 를 병합.
1232
+ *
1233
+ * <p>{@link #sendFileMessage} / {@link #sendFileMessageOptimistic} 가 공유.
1234
+ * 호출자가 {@code options.metadata} 를 안 주면 metas 가 그대로 반환되어 기존 동작과 100% 호환.</p>
1235
+ *
1236
+ * @private
1237
+ * @param {Object[]} metas - uploadFile 결과의 FileMetaData 배열
1238
+ * @param {Object|Array<Object|null|undefined>|null|undefined} metadata - 단일 객체(broadcast) 또는 index 별 배열
1239
+ * @returns {Object[]} metadata 가 병합된 새 배열 (기존 meta 객체는 수정하지 않음)
1240
+ */
1241
+ _applyFileMetadata(metas, metadata) {
1242
+ if (metadata === undefined || metadata === null) {
1243
+ return metas;
1244
+ }
1245
+
1246
+ return metas.map((meta, index) => {
1247
+ const itemMetadata = Array.isArray(metadata)
1248
+ ? metadata[index]
1249
+ : metadata;
1250
+
1251
+ if (itemMetadata === undefined || itemMetadata === null) {
1252
+ return meta;
1253
+ }
1254
+
1255
+ return {
1256
+ ...meta,
1257
+ metadata: itemMetadata
1258
+ };
1259
+ });
1260
+ }
1261
+
1262
+ /**
1263
+ * 답글 전송 (간편 버전) — 텍스트 답글 전용.
1264
+ *
1265
+ * @param {string} roomId - 채팅방 ID
1266
+ * @param {string} message - 답글 내용
1267
+ * @param {string} replyToMessageId - 원본 메시지의 서버 {@code messageId}
1268
+ * @returns {Promise<{duplicate: boolean, messages: Object[]}>}
1269
+ *
1270
+ * @example
1271
+ * // "안녕하세요" 메시지에 답장
1272
+ * await chat.sendReply('room-id', '네 반가워요', 'original-msg-id');
1273
+ */
1274
+ async sendReply(roomId, message, replyToMessageId) {
1275
+ return this.sendMessage(roomId, { message, replyToMessageId });
1276
+ }
1277
+
1278
+ /**
1279
+ * 답글 전송 — Optimistic UI 버전.
1280
+ *
1281
+ * @param {string} roomId - 채팅방 ID
1282
+ * @param {string} message - 답글 내용
1283
+ * @param {string} replyToMessageId - 원본 메시지의 서버 {@code messageId}
1284
+ * @returns {{baseMessageId: string, messageIds: string[], promise: Promise<{duplicate: boolean, messages: Object[]}>}}
1285
+ */
1286
+ sendReplyOptimistic(roomId, message, replyToMessageId) {
1287
+ return this.sendMessageOptimistic(roomId, { message, replyToMessageId });
1288
+ }
1289
+
1290
+ /**
1291
+ * 메시지 삭제.
1292
+ *
1293
+ * @param {string} roomId - 채팅방 ID
1294
+ * @param {string} messageId - 메시지 ID
1295
+ * @param {string} [deleteType='ALL'] - 삭제 유형.
1296
+ * {@code 'ALL'} — 모두에게 삭제 (양쪽 "삭제된 메시지입니다" 표시).
1297
+ * {@code 'SELF'} — 나만 삭제 (내 화면에서만 숨김, 상대방은 원본 유지).
1298
+ * @returns {Promise<Object>}
1299
+ *
1300
+ * @example
1301
+ * // 모두에게 삭제
1302
+ * await chat.deleteMessage('room-id', 'msg-id');
1303
+ *
1304
+ * @example
1305
+ * // 나만 삭제
1306
+ * await chat.deleteMessage('room-id', 'msg-id', 'SELF');
1307
+ */
1308
+ async deleteMessage(roomId, messageId, deleteType = 'ALL') {
1309
+ return this.apiClient.post(`/api/v1/chat-messages/${roomId}/messages/${messageId}/delete`, {
1310
+ deleteType
1311
+ });
1312
+ }
1313
+
1314
+ /**
1315
+ * 메시지 수정.
1316
+ *
1317
+ * <p>본인 메시지만 수정 가능. 수정 후 '수정됨' 라벨이 표시됩니다.</p>
1318
+ *
1319
+ * @param {string} roomId - 채팅방 ID
1320
+ * @param {string} messageId - 메시지 ID
1321
+ * @param {string} message - 수정할 내용 (max 5000자)
1322
+ * @returns {Promise<Object>}
1323
+ *
1324
+ * @example
1325
+ * await chat.editMessage('room-id', 'msg-id', '수정된 내용입니다');
1326
+ */
1327
+ async editMessage(roomId, messageId, message) {
1328
+ return this.apiClient.put(`/api/v1/chat-messages/${roomId}/messages/${messageId}`, {
1329
+ message
1330
+ });
1331
+ }
1332
+
1333
+ /**
1334
+ * 서버의 {@code SendMessageService.hasTextMessage} mirror — null/공백 문자열을 모두 "텍스트 없음" 으로 취급.
1335
+ * <p>서버 기준 ({@code message != null && !message.isBlank()}) 과 동일해야 messageIds prediction 이 일치한다.</p>
1336
+ *
1337
+ * @private
1338
+ * @param {string|null|undefined} message
1339
+ * @returns {boolean}
1340
+ */
1341
+ _hasTextMessage(message) {
1342
+ return typeof message === 'string' && message.trim().length > 0;
1343
+ }
1344
+
1345
+ /**
1346
+ * MIME 타입 → 서버 {@code ChatMessageType} 분류 (서버 {@code FileGroupingService.classifyFileType} mirror).
1347
+ * <p>서버 도메인 규칙과 동일해야 group count 예측이 일치한다. 서버 변경 시 본 메서드도 동기화 필요.</p>
1348
+ *
1349
+ * @private
1350
+ * @param {string} mimeType
1351
+ * @returns {string} 'IMAGE' | 'VIDEO' | 'AUDIO' | 'DOCUMENT' | 'FILE'
1352
+ */
1353
+ _classifyFileType(mimeType) {
1354
+ const mime = (mimeType || '').toLowerCase();
1355
+ if (mime.startsWith('image/')) return 'IMAGE';
1356
+ if (mime.startsWith('video/')) return 'VIDEO';
1357
+ if (mime.startsWith('audio/')) return 'AUDIO';
1358
+ if (mime.includes('pdf') || mime.includes('document')) return 'DOCUMENT';
1359
+ return 'FILE';
1360
+ }
1361
+
1362
+ /**
1363
+ * 파일 그룹 수 예측 (서버 {@code FileGroupingService.groupFiles} mirror).
1364
+ * <p>{@code separateFiles=true} 면 파일 개수, {@code false} 면 타입별 distinct 개수.
1365
+ * File 객체 ({@code .type}) / FileMetaData 객체 ({@code .fileType}) 양쪽 입력 모두 처리.</p>
1366
+ *
1367
+ * @private
1368
+ * @param {Array<File|Blob|Object>} fileArray
1369
+ * @param {boolean} separateFiles
1370
+ * @returns {number}
1371
+ */
1372
+ _predictGroupCount(fileArray, separateFiles) {
1373
+ if (!fileArray || fileArray.length === 0) return 0;
1374
+ if (separateFiles) return fileArray.length;
1375
+
1376
+ // 입력 순서 보존하면서 distinct 타입 카운트 (서버 LinkedHashMap 동작과 동일)
1377
+ const typesSeen = new Set();
1378
+ for (const file of fileArray) {
1379
+ const mime = file && (file.type || file.fileType) || '';
1380
+ typesSeen.add(this._classifyFileType(mime));
1381
+ }
1382
+ return typesSeen.size;
1383
+ }
1384
+
1385
+ /**
1386
+ * 서버 저장 messageId 배열 예측 (서버 {@code SendMessageService} mirror).
1387
+ * <p>규칙 ({@code SendMessageService.java:102-106, 197}):
1388
+ * {@code expectedMessageCount = (hasMessage?1:0) + groupCount}, 1 이면 base, 2 이상이면
1389
+ * 텍스트=base / 파일=base#0..#(N-1).</p>
1390
+ *
1391
+ * @private
1392
+ * @param {string} baseMessageId
1393
+ * @param {number} groupCount
1394
+ * @param {boolean} hasMessage
1395
+ * @returns {string[]} 응답 messages 와 같은 순서
1396
+ */
1397
+ _predictMessageIds(baseMessageId, groupCount, hasMessage) {
1398
+ const expectedCount = (hasMessage ? 1 : 0) + groupCount;
1399
+ if (expectedCount === 0) return [];
1400
+ if (expectedCount === 1) return [baseMessageId];
1401
+
1402
+ const ids = [];
1403
+ if (hasMessage) ids.push(baseMessageId);
1404
+ for (let i = 0; i < groupCount; i++) {
1405
+ ids.push(`${baseMessageId}#${i}`);
1406
+ }
1407
+ return ids;
1408
+ }
1409
+
1410
+ /**
1411
+ * {@code SendMessageData} 페이로드로부터 messageIds 예측.
1412
+ * <p>{@link #sendMessageOptimistic} 가 사용 — {@code data.fileInfos} 가 있으면 그걸로
1413
+ * group count 예측, 없으면 텍스트 단건으로 처리.</p>
1414
+ *
1415
+ * @private
1416
+ */
1417
+ _predictMessageIdsFromData(baseMessageId, data) {
1418
+ const fileInfos = (data && data.fileInfos) || [];
1419
+ const separate = !data || data.separateFiles !== false;
1420
+ const groupCount = this._predictGroupCount(fileInfos, separate);
1421
+ const hasMessage = this._hasTextMessage(data && data.message);
1422
+ return this._predictMessageIds(baseMessageId, groupCount, hasMessage);
1423
+ }
1424
+
1425
+ /**
1426
+ * 예측 messageIds 와 서버 응답을 비교하여 result 에 optimistic metadata 첨부.
1427
+ * <p>서버 분류 규칙 drift 를 런타임에 감지하기 위한 방어 로직. mismatch 발생 시
1428
+ * warn 로그 + 응답에 {@code optimistic.predictionMatched: false} 를 실어 호출자가
1429
+ * 프로그래밍적으로 fallback reconcile 을 결정할 수 있게 한다.</p>
1430
+ *
1431
+ * <p>길이 mismatch 와 같은 길이의 값/순서 mismatch 모두 동일하게 {@code predictionMatched: false}
1432
+ * 로 표시된다. 호출자는 README "Drift 방어" 섹션의 fallback 패턴을 사용하면 된다.</p>
1433
+ *
1434
+ * @private
1435
+ * @param {string[]} predictedMessageIds
1436
+ * @param {{duplicate: boolean, messages: Array<{messageId: string}>}} result
1437
+ * @returns {{duplicate: boolean, messages: Array, optimistic: {predictedMessageIds: string[], actualMessageIds: string[], predictionMatched: boolean}}}
1438
+ */
1439
+ _attachOptimisticMetadata(predictedMessageIds, result) {
1440
+ const actualMessageIds = (result && Array.isArray(result.messages))
1441
+ ? result.messages.map(m => (m && m.messageId) || null)
1442
+ : [];
1443
+
1444
+ const predictionMatched =
1445
+ actualMessageIds.length === predictedMessageIds.length &&
1446
+ predictedMessageIds.every((id, i) => id === actualMessageIds[i]);
1447
+
1448
+ if (!predictionMatched) {
1449
+ this.logger.warn('Optimistic messageId prediction mismatch — server may have changed grouping rules. Use result.optimistic.actualMessageIds to reconcile.', {
1450
+ predicted: predictedMessageIds,
1451
+ actual: actualMessageIds
1452
+ });
1453
+ }
1454
+
1455
+ return {
1456
+ ...result,
1457
+ optimistic: {
1458
+ predictedMessageIds,
1459
+ actualMessageIds,
1460
+ predictionMatched
1461
+ }
1462
+ };
1463
+ }
1464
+
1465
+ /**
1466
+ * 메시지 ID 생성 (UUID v4 형식)
1467
+ * @private
1468
+ * @returns {string}
1469
+ */
1470
+ _generateMessageId() {
1471
+ // crypto.randomUUID() 우선 사용 (높은 엔트로피), 미지원 환경 fallback
1472
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
1473
+ return crypto.randomUUID();
1474
+ }
1475
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
1476
+ const r = Math.random() * 16 | 0;
1477
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
1478
+ return v.toString(16);
1479
+ });
1480
+ }
1481
+
1482
+ /**
1483
+ * 서버 SuccessResponse 래퍼가 있으면 data 만 꺼내고, 아니면 원본 응답을 그대로 반환합니다.
1484
+ * data 가 null 이어도 그대로 null 을 반환해야 하므로 nullish coalescing 을 쓰지 않습니다.
1485
+ *
1486
+ * @private
1487
+ * @param {*} response
1488
+ * @returns {*}
1489
+ */
1490
+ _unwrapSuccessResponse(response) {
1491
+ return response
1492
+ && typeof response === 'object'
1493
+ && Object.prototype.hasOwnProperty.call(response, 'data')
1494
+ ? response.data
1495
+ : response;
1496
+ }
1497
+
1498
+ /**
1499
+ * REST 메시지 응답의 타임스탬프를 epoch millis(number)로 정규화합니다 (in-place).
1500
+ *
1501
+ * <p>서버의 두 전달 경로가 서로 다르게 직렬화한다 — WebSocket 이벤트는 epoch number,
1502
+ * REST(목록/전송 응답)는 ISO 문자열(타임존 없는 LocalDateTime). 선언된 타입
1503
+ * ({@code ChatMessage.sentAt: number})으로 통일해 소비자 분기를 제거한다.</p>
1504
+ *
1505
+ * <p>⚠️ 한계: 서버 ISO 문자열에 타임존이 없어 브라우저 로컬 타임존으로 해석된다 —
1506
+ * 서버(KST)와 사용자 타임존이 다르면 오프셋만큼 어긋날 수 있다. 근본 해소는
1507
+ * 백엔드 직렬화 통일(후속 결정) 영역.</p>
1508
+ *
1509
+ * @private
1510
+ * @param {Object} message - REST 응답의 메시지 객체 (수정됨)
1511
+ * @returns {Object} 같은 객체
1512
+ */
1513
+ _normalizeMessageTimestamps(message) {
1514
+ if (!message || typeof message !== 'object') {
1515
+ return message;
1516
+ }
1517
+ message.sentAt = this._toEpochMillis(message.sentAt);
1518
+ message.editedAt = this._toEpochMillis(message.editedAt);
1519
+ if (message.replyTo && typeof message.replyTo === 'object') {
1520
+ message.replyTo.sentAt = this._toEpochMillis(message.replyTo.sentAt);
1521
+ }
1522
+ return message;
1523
+ }
1524
+
1525
+ /**
1526
+ * ISO 문자열이면 epoch millis 로 변환, number/null/undefined 는 그대로 통과.
1527
+ * 파싱 불가 문자열은 원본 유지 (조용한 데이터 손실 방지).
1528
+ *
1529
+ * @private
1530
+ * @param {*} value
1531
+ * @returns {*}
1532
+ */
1533
+ _toEpochMillis(value) {
1534
+ if (typeof value !== 'string') {
1535
+ return value;
1536
+ }
1537
+ const parsed = Date.parse(value);
1538
+ return Number.isNaN(parsed) ? value : parsed;
1539
+ }
1540
+
1541
+ // ==================== WebSocket 구독 ====================
1542
+
1543
+ /**
1544
+ * 채팅방 구독.
1545
+ *
1546
+ * <p>구독 시 메시지, 읽음, 타이핑, 멤버 변경 이벤트를 자동으로 수신.
1547
+ * 멤버 변경은 roomList 구독 이벤트를 내부 변환하여
1548
+ * {@code memberJoined} / {@code memberLeft} 로 emit.</p>
1549
+ *
1550
+ * <p>자동 읽음 처리는 {@link #setActiveRoom} 으로 현재 보고 있는 방을 설정해야 동작합니다.
1551
+ * 구독만으로는 읽음 처리되지 않으며, 다른 방을 보고 있으면 안읽음 카운트가 유지됩니다.</p>
1552
+ *
1553
+ * @param {string} roomId - 채팅방 ID
1554
+ * @returns {Promise<void>}
1555
+ *
1556
+ * @fires memberJoined - { roomId, members: [{ userId, nickname, profileUrl }], participantCount, timestamp }
1557
+ * @fires memberLeft - { roomId, members: [{ userId, nickname, profileUrl }], participantCount, timestamp }
1558
+ */
1559
+ async subscribeRoom(roomId) {
1560
+ if (this.subscribedRooms.has(roomId)) {
1561
+ this.logger.warn(`Already subscribed to room: ${roomId}`);
1562
+ return;
1563
+ }
1564
+
1565
+ // 메시지 구독
1566
+ const chatDestination = WebSocketPaths.getChatDestination(roomId);
1567
+ await this.connectionManager.subscribe(chatDestination, (message) => {
1568
+ this._handleChatMessage(roomId, message);
1569
+ });
1570
+
1571
+ // 읽음 이벤트 구독
1572
+ const readDestination = WebSocketPaths.getChatReadDestination(roomId);
1573
+ await this.connectionManager.subscribe(readDestination, (message) => {
1574
+ this._handleReadEvent(roomId, message);
1575
+ });
1576
+
1577
+ // 타이핑 이벤트 구독
1578
+ const typingDestination = WebSocketPaths.getChatTypingDestination(roomId);
1579
+ await this.connectionManager.subscribe(typingDestination, (event) => {
1580
+ this._handleTypingEvent(roomId, event);
1581
+ });
1582
+
1583
+ // 멤버 변경 이벤트 — roomList 이벤트를 방 수준으로 변환하여 자동 emit
1584
+ // 서버가 members 배열(입장/퇴장 사용자들) + participantCount(정확한 인원수) 포함
1585
+ const memberJoinedHandler = (event) => {
1586
+ if (event.roomId === roomId) {
1587
+ this.emit('memberJoined', {
1588
+ roomId,
1589
+ members: event.members || [],
1590
+ participantCount: event.activeParticipantCount,
1591
+ timestamp: event.timestamp
1592
+ });
1593
+ }
1594
+ };
1595
+ const memberLeftHandler = (event) => {
1596
+ if (event.roomId === roomId) {
1597
+ this.emit('memberLeft', {
1598
+ roomId,
1599
+ members: event.members || [],
1600
+ participantCount: event.activeParticipantCount,
1601
+ timestamp: event.timestamp
1602
+ });
1603
+ }
1604
+ };
1605
+ this.on('roomListJoined', memberJoinedHandler);
1606
+ this.on('roomListLeft', memberLeftHandler);
1607
+
1608
+ this.subscribedRooms.set(roomId, {
1609
+ chatDestination,
1610
+ readDestination,
1611
+ typingDestination,
1612
+ memberJoinedHandler,
1613
+ memberLeftHandler,
1614
+ subscribedAt: new Date()
1615
+ });
1616
+
1617
+ this.logger.info(`Subscribed to room: ${roomId}`);
1618
+ this.emit('roomSubscribed', { roomId });
1619
+ }
1620
+
1621
+ /**
1622
+ * 채팅방 구독 해제
1623
+ * @param {string} roomId - 채팅방 ID
1624
+ */
1625
+ unsubscribeRoom(roomId) {
1626
+ const subscription = this.subscribedRooms.get(roomId);
1627
+ if (!subscription) {
1628
+ this.logger.warn(`Not subscribed to room: ${roomId}`);
1629
+ return;
1630
+ }
1631
+
1632
+ this.connectionManager.unsubscribe(subscription.chatDestination);
1633
+ this.connectionManager.unsubscribe(subscription.readDestination);
1634
+ if (subscription.typingDestination) {
1635
+ this.connectionManager.unsubscribe(subscription.typingDestination);
1636
+ }
1637
+
1638
+ // 멤버 변경 리스너 해제
1639
+ if (subscription.memberJoinedHandler) {
1640
+ this.off('roomListJoined', subscription.memberJoinedHandler);
1641
+ }
1642
+ if (subscription.memberLeftHandler) {
1643
+ this.off('roomListLeft', subscription.memberLeftHandler);
1644
+ }
1645
+
1646
+ // 타이핑 타이머 정리 (outgoing + incoming assistant 둘 다)
1647
+ this._clearTypingTimer(roomId);
1648
+ this._clearAssistantTypingTimer(roomId);
1649
+
1650
+ // 현재 보고 있는 방이면 activeRoom 해제
1651
+ if (this._activeRoomId === roomId) {
1652
+ this._activeRoomId = null;
1653
+ }
1654
+
1655
+ this.subscribedRooms.delete(roomId);
1656
+
1657
+ // 방별 dedup bucket 정리 — 장시간 세션에서 많은 방을 오갈 때 메모리 누수 차단.
1658
+ // 각 bucket 은 방당 최대 _maxSeenPerRoom(200) entries 라 개별은 작지만 방 수가 늘면 누적.
1659
+ this._seenChatMessageIdsByRoom.delete(roomId);
1660
+ this._seenRoomListMessageIdsByRoom.delete(roomId);
1661
+
1662
+ this.logger.info(`Unsubscribed from room: ${roomId}`);
1663
+ this.emit('roomUnsubscribed', { roomId });
1664
+ }
1665
+
1666
+ /**
1667
+ * 모든 채팅방 구독 해제
1668
+ */
1669
+ unsubscribeAllRooms() {
1670
+ this.subscribedRooms.forEach((_, roomId) => {
1671
+ this.unsubscribeRoom(roomId);
1672
+ });
1673
+ }
1674
+
1675
+ /**
1676
+ * 현재 보고 있는 채팅방 설정.
1677
+ * 이 방에서 수신되는 다른 사용자의 메시지는 자동으로 읽음 처리됩니다.
1678
+ * 다른 방이나 리스트 화면으로 이동 시 {@link #clearActiveRoom} 호출.
1679
+ *
1680
+ * @param {string} roomId - 현재 보고 있는 채팅방 ID
1681
+ */
1682
+ setActiveRoom(roomId) {
1683
+ this._activeRoomId = roomId;
1684
+ this.logger.debug(`Active room set: ${roomId}`);
1685
+ }
1686
+
1687
+ /**
1688
+ * 현재 보고 있는 채팅방 해제 (리스트 화면 등으로 이동 시).
1689
+ * 자동 읽음 처리가 중단됩니다.
1690
+ */
1691
+ clearActiveRoom() {
1692
+ this.logger.debug(`Active room cleared (was: ${this._activeRoomId})`);
1693
+ this._activeRoomId = null;
1694
+ }
1695
+
1696
+ /**
1697
+ * 현재 보고 있는 채팅방 ID 반환.
1698
+ * @returns {string|null}
1699
+ */
1700
+ getActiveRoom() {
1701
+ return this._activeRoomId;
1702
+ }
1703
+
1704
+ /**
1705
+ * 채팅방 리스트 실시간 업데이트 구독 (카톡 스타일).
1706
+ *
1707
+ * 리스트 화면에서 호출. 구독 후 다음 이벤트를 받을 수 있음:
1708
+ * - roomListUpdate — 모든 이벤트 (통합, eventType 으로 분기)
1709
+ * - roomListMessage — 새 메시지 수신 (MESSAGE_RECEIVED)
1710
+ * - roomListMessageDeleted — 현재 lastMessage 삭제 (MESSAGE_DELETED)
1711
+ * - roomListMessageUpdated — 현재 lastMessage 편집 (MESSAGE_UPDATED)
1712
+ * - roomListCreated — 새 방 생성 (ROOM_CREATED)
1713
+ * - roomListJoined — 방 입장 (ROOM_JOINED)
1714
+ * - roomListLeft — 방 퇴장 (ROOM_LEFT)
1715
+ * - roomListSelfLeft — 본인이 나간 경우 (리스트에서 제거 신호)
1716
+ *
1717
+ * @returns {Promise<void>}
1718
+ *
1719
+ * @example
1720
+ * await client.chat.subscribeRoomList();
1721
+ *
1722
+ * client.on('roomListMessage', (event) => {
1723
+ * // 해당 방을 리스트 최상단으로 이동
1724
+ * // event.actorId !== currentUserId 일 때만 unread +1
1725
+ * moveRoomToTop(event.roomId);
1726
+ * updateLastMessage(event.roomId, event.lastMessage, event.lastMessageAt);
1727
+ * if (event.actorId !== currentUserId) {
1728
+ * incrementUnread(event.roomId, event.unreadCountDelta);
1729
+ * }
1730
+ * });
1731
+ *
1732
+ * client.on('roomListSelfLeft', (event) => {
1733
+ * // 본인이 나간 방 → 리스트에서 제거
1734
+ * removeRoomFromList(event.roomId);
1735
+ * });
1736
+ */
1737
+ async subscribeRoomList() {
1738
+ if (this.roomListSubscribed) {
1739
+ this.logger.warn('Already subscribed to room list');
1740
+ return;
1741
+ }
1742
+
1743
+ const destination = WebSocketPaths.ROOM_LIST_USER_DESTINATION;
1744
+ await this.connectionManager.subscribe(destination, (event) => {
1745
+ // noinspection JSCheckFunctionSignatures
1746
+ this._handleRoomListEvent(event);
1747
+ });
1748
+
1749
+ this.roomListSubscribed = true;
1750
+ this.logger.info('Subscribed to room list');
1751
+ this.emit('roomListSubscribed', {});
1752
+ }
1753
+
1754
+ /**
1755
+ * 채팅방 리스트 구독 해제.
1756
+ */
1757
+ unsubscribeRoomList() {
1758
+ if (!this.roomListSubscribed) {
1759
+ this.logger.warn('Not subscribed to room list');
1760
+ return;
1761
+ }
1762
+
1763
+ this.connectionManager.unsubscribe(WebSocketPaths.ROOM_LIST_USER_DESTINATION);
1764
+ this.roomListSubscribed = false;
1765
+
1766
+ this.logger.info('Unsubscribed from room list');
1767
+ this.emit('roomListUnsubscribed', {});
1768
+ }
1769
+
1770
+ /**
1771
+ * 채팅방 리스트 구독 여부.
1772
+ * @returns {boolean}
1773
+ */
1774
+ isRoomListSubscribed() {
1775
+ return this.roomListSubscribed;
1776
+ }
1777
+
1778
+ /**
1779
+ * 메시지 읽음 처리
1780
+ * @param {string} roomId - 채팅방 ID
1781
+ * @param {string} messageId - 메시지 ID
1782
+ */
1783
+ markAsRead(roomId, messageId) {
1784
+ if (!this.connectionManager.isConnected()) {
1785
+ this.logger.warn('Cannot mark as read: not connected');
1786
+ return false;
1787
+ }
1788
+
1789
+ return this.connectionManager.send(WebSocketPaths.CHAT_READ, {
1790
+ roomId,
1791
+ messageId
1792
+ });
1793
+ }
1794
+
1795
+ // ==================== 이벤트 핸들러 ====================
1796
+
1797
+ /**
1798
+ * 채팅 메시지 수신 처리.
1799
+ *
1800
+ * <p>서버는 동일 채널(/topic/chat/{roomId})로 신규/수정/삭제/리액션/OG 첨부
1801
+ * 이벤트를 모두 발행하고, 페이로드의 {@code eventType} 으로 구분한다.
1802
+ * 클라는 eventType 에 따라 별도 이벤트로 re-emit.</p>
1803
+ *
1804
+ * <ul>
1805
+ * <li>{@code MESSAGE_CREATED} → {@code message} + {@code newMessage} (상대 메시지면) + 자동 읽음</li>
1806
+ * <li>{@code MESSAGE_UPDATED} → {@code message} + {@code messageUpdated}</li>
1807
+ * <li>{@code MESSAGE_DELETED} → {@code message} + {@code messageDeleted}</li>
1808
+ * <li>{@code REACTION_CHANGED} → {@code message} + {@code reactionChanged}</li>
1809
+ * <li>{@code LINK_PREVIEW_ATTACHED} → {@code message} + {@code linkPreviewAttached}</li>
1810
+ * <li>{@code MESSAGE_TRANSLATED} → {@code message} + {@code messageTranslated} (translations 머지)</li>
1811
+ * </ul>
1812
+ *
1813
+ * <p>{@code message} 이벤트는 모든 케이스에서 발행되어 하위 호환을 유지한다 —
1814
+ * 기존 구독 코드는 messageId 로 merge 하면 계속 작동.</p>
1815
+ *
1816
+ * @private
1817
+ * @param {string} roomId - 채팅방 ID
1818
+ * @param {Object} message - 수신된 메시지 (eventType 필드 포함)
1819
+ */
1820
+ _handleChatMessage(roomId, message) {
1821
+ this.logger.debug(`Message received in room ${roomId} (${message.eventType}):`, message);
1822
+
1823
+ // MESSAGE_CREATED 만 dedup 대상 — 다른 eventType (UPDATED/DELETED/REACTION/LINK_PREVIEW) 는
1824
+ // 같은 messageId 의 의도된 재발행이라 dedup 하면 안 됨.
1825
+ if (message.eventType === 'MESSAGE_CREATED'
1826
+ && this._shouldDedupMessage(this._seenChatMessageIdsByRoom, roomId, message.messageId)) {
1827
+ this.logger.debug(`Duplicate MESSAGE_CREATED suppressed: room=${roomId}, messageId=${message.messageId}`);
1828
+ return;
1829
+ }
1830
+
1831
+ // 편집(MESSAGE_UPDATED) 시 서버가 번역을 무효화한다. payload 에 번역 필드가 없으면 generic emit 前에
1832
+ // 명시적 null 처리 — generic 'message' / 상위 'chatMessage' 소비자도 stale 번역을 즉시 지우도록 (다국어 §9).
1833
+ if (message.eventType === 'MESSAGE_UPDATED') {
1834
+ if (message.translations === undefined) message.translations = null;
1835
+ if (message.sourceLang === undefined) message.sourceLang = null;
1836
+ if (message.translationsOf === undefined) message.translationsOf = null;
1837
+ }
1838
+
1839
+ // 모든 이벤트는 통합 이벤트로 pass-through (하위 호환)
1840
+ this.emit('message', { roomId, message });
1841
+
1842
+ switch (message.eventType) {
1843
+ case 'MESSAGE_CREATED':
1844
+ // 어시 메시지 도착 = "AI 응답 준비중" 종료 신호. typing timer 즉시 해제 + typing=false emit.
1845
+ // 서버는 typing=false 를 발행하지 않고 client-side 가 책임 (assistant typing 설계).
1846
+ // userId / userName 은 서버 typing=true 발행과 정확히 일치하는 generic marker 사용 —
1847
+ // UI 가 roomId+userId 로 typing state 추적 시 true/false 가 같은 키로 매칭되어야 안 꺼지는
1848
+ // 케이스 방지. message.userId (페르소나 ID) 를 그대로 쓰면 키 불일치로 영구 표시 위험.
1849
+ if (message.senderType === 'ASSISTANT') {
1850
+ this._clearAssistantTypingTimer(roomId);
1851
+ this.emit('typing', {
1852
+ roomId,
1853
+ userId: this._assistantTypingUserId,
1854
+ userName: this._assistantTypingUserName,
1855
+ typing: false,
1856
+ senderType: 'ASSISTANT'
1857
+ });
1858
+ }
1859
+
1860
+ // 상대방 메시지일 때만 newMessage + 자동 읽음 처리
1861
+ if (message.userId !== this.userId) {
1862
+ this.emit('newMessage', { roomId, message });
1863
+
1864
+ // 현재 보고 있는 방에서만 자동 읽음 처리
1865
+ if (this._activeRoomId === roomId && message.messageId) {
1866
+ this.markAsRead(roomId, message.messageId);
1867
+ }
1868
+ }
1869
+ break;
1870
+
1871
+ case 'MESSAGE_UPDATED':
1872
+ // 번역 stale 클리어(번역 필드 null 보정)는 generic emit 前에 이미 수행됨 (_handleChatMessage 상단).
1873
+ this.emit('messageUpdated', { roomId, message });
1874
+ break;
1875
+
1876
+ case 'MESSAGE_DELETED':
1877
+ this.emit('messageDeleted', { roomId, message });
1878
+ break;
1879
+
1880
+ case 'REACTION_CHANGED':
1881
+ this.emit('reactionChanged', { roomId, message });
1882
+ break;
1883
+
1884
+ case 'LINK_PREVIEW_ATTACHED':
1885
+ this.emit('linkPreviewAttached', { roomId, message });
1886
+ break;
1887
+
1888
+ case 'MESSAGE_TRANSLATED':
1889
+ // 서버가 비동기 번역 완료 후 패치 — message.translations/sourceLang 채워져 옴.
1890
+ // 소비자는 messageId 로 기존 메시지에 머지 (linkPreviewAttached 와 동일 패턴).
1891
+ this.emit('messageTranslated', { roomId, message });
1892
+ break;
1893
+
1894
+ default:
1895
+ // 미지의 eventType — 구버전 서버(eventType 없음)와 호환 유지.
1896
+ // 안전장치로 기존 동작(신규 메시지 알림) 수행.
1897
+ this.logger.warn(`Unknown eventType "${message.eventType}" — falling back to legacy behavior`);
1898
+ if (message.userId !== this.userId) {
1899
+ this.emit('newMessage', { roomId, message });
1900
+ if (this._activeRoomId === roomId && message.messageId) {
1901
+ this.markAsRead(roomId, message.messageId);
1902
+ }
1903
+ }
1904
+ }
1905
+ }
1906
+
1907
+ /**
1908
+ * 채팅방 리스트 업데이트 이벤트 수신 처리.
1909
+ * @private
1910
+ * @param {Object} event - 서버 RoomListEvent
1911
+ * @param {string} event.eventType - RoomListEventType
1912
+ * @param {string} event.roomId
1913
+ * @param {string} event.actorId - 이벤트 발생 사용자
1914
+ * @param {number} [event.unreadCountDelta]
1915
+ * @param {string} [event.lastMessage]
1916
+ * @param {string} [event.lastMessageType]
1917
+ * @param {string} [event.lastMessageAt]
1918
+ * @param {string} event.timestamp
1919
+ */
1920
+ _handleRoomListEvent(event) {
1921
+ this.logger.debug('Room list event:', event);
1922
+
1923
+ // MESSAGE_RECEIVED 의 messageId 기반 dedup — chat WebSocket 측과 별도 키 공간.
1924
+ // 서버가 messageId 를 안 내려준 구버전 페이로드면 dedup 통과 (backward compatible).
1925
+ if (event.eventType === RoomListEventType.MESSAGE_RECEIVED
1926
+ && event.messageId
1927
+ && this._shouldDedupMessage(this._seenRoomListMessageIdsByRoom, event.roomId, event.messageId)) {
1928
+ this.logger.debug(`Duplicate roomList MESSAGE_RECEIVED suppressed: room=${event.roomId}, messageId=${event.messageId}`);
1929
+ return;
1930
+ }
1931
+
1932
+ // 1. 통합 이벤트 (모든 타입)
1933
+ this.emit('roomListUpdate', event);
1934
+
1935
+ // 2. 타입별 이벤트
1936
+ switch (event.eventType) {
1937
+ case RoomListEventType.MESSAGE_RECEIVED:
1938
+ this.emit('roomListMessage', event);
1939
+ break;
1940
+ case RoomListEventType.MESSAGE_DELETED:
1941
+ this.emit('roomListMessageDeleted', event);
1942
+ break;
1943
+ case RoomListEventType.MESSAGE_UPDATED:
1944
+ this.emit('roomListMessageUpdated', event);
1945
+ break;
1946
+ case RoomListEventType.ROOM_CREATED:
1947
+ this.emit('roomListCreated', event);
1948
+ break;
1949
+ case RoomListEventType.ROOM_JOINED:
1950
+ this.emit('roomListJoined', event);
1951
+ break;
1952
+ case RoomListEventType.ROOM_LEFT:
1953
+ this.emit('roomListLeft', event);
1954
+ // 본인이 나간 경우 편의 이벤트 (리스트에서 제거해야 함)
1955
+ if (event.actorId === this.userId) {
1956
+ this.emit('roomListSelfLeft', event);
1957
+ }
1958
+ break;
1959
+ case RoomListEventType.ROOM_KICKED:
1960
+ this.emit('roomListKicked', event);
1961
+ // 본인이 추방당한 경우 편의 이벤트 — UI 에서 "추방되었습니다" 표시 + 리스트 제거
1962
+ if (this._isSelfInMembers(event)) {
1963
+ this.emit('roomListSelfKicked', event);
1964
+ }
1965
+ break;
1966
+ case RoomListEventType.ROOM_BANNED:
1967
+ this.emit('roomListBanned', event);
1968
+ // 본인이 영구 차단당한 경우 편의 이벤트 — "영구 차단되었습니다" 표시 + 리스트 제거
1969
+ if (this._isSelfInMembers(event)) {
1970
+ this.emit('roomListSelfBanned', event);
1971
+ }
1972
+ break;
1973
+ case RoomListEventType.ROOM_UPDATED:
1974
+ this.emit('roomListRoomUpdated', event);
1975
+ break;
1976
+ case RoomListEventType.MESSAGE_RETENTION_CLEANUP:
1977
+ // noinspection JSUnresolvedReference
1978
+ this.emit('retentionCleanup', {
1979
+ roomId: event.roomId,
1980
+ cutoffTime: event.cutoffTime
1981
+ });
1982
+ break;
1983
+ default:
1984
+ this.logger.warn('Unknown room list event type:', event.eventType);
1985
+ }
1986
+ }
1987
+
1988
+ /**
1989
+ * 읽음 이벤트 수신 처리
1990
+ * @private
1991
+ * @param {string} roomId - 채팅방 ID
1992
+ * @param {Object} event - 읽음 이벤트 (단일 또는 배치)
1993
+ */
1994
+ _handleReadEvent(roomId, event) {
1995
+ this.logger.debug(`Read event in room ${roomId}:`, event);
1996
+
1997
+ const resolvedRoomId = event.roomId || roomId;
1998
+
1999
+ // 배치 이벤트인 경우 (방 입장 시 일괄 읽음 처리)
2000
+ if (event.events && Array.isArray(event.events)) {
2001
+ event.events.forEach(e => {
2002
+ this.emit('messageRead', {
2003
+ roomId: resolvedRoomId,
2004
+ messageId: e.messageId,
2005
+ userId: event.userId,
2006
+ remainingUnreadCount: e.remainingUnreadCount
2007
+ });
2008
+ });
2009
+ }
2010
+ // 단일 이벤트인 경우 (실시간 개별 읽음 처리)
2011
+ else {
2012
+ this.emit('messageRead', {
2013
+ roomId: resolvedRoomId,
2014
+ messageId: event.messageId,
2015
+ userId: event.userId,
2016
+ remainingUnreadCount: event.remainingUnreadCount
2017
+ });
2018
+ }
2019
+ }
2020
+
2021
+ // ==================== 타이핑 표시 ====================
2022
+
2023
+ /**
2024
+ * 타이핑 시작 알림.
2025
+ *
2026
+ * <p>호출할 때마다 내부 타이머가 리셋됩니다 (debounce).
2027
+ * 3초간 추가 호출이 없으면 자동으로 {@link #stopTyping} 이 호출됩니다.
2028
+ * 메시지 전송 ({@link #sendMessage}) 시에도 자동 중단됩니다.</p>
2029
+ *
2030
+ * @param {string} roomId - 채팅방 ID
2031
+ *
2032
+ * @example
2033
+ * // input 이벤트에 연결
2034
+ * inputField.addEventListener('input', () => {
2035
+ * client.chat.startTyping('room-id');
2036
+ * });
2037
+ */
2038
+ startTyping(roomId) {
2039
+ if (!this.connectionManager.isConnected()) return;
2040
+
2041
+ const existingTimer = this._typingTimers.get(roomId);
2042
+
2043
+ // throttle: 이미 typing 상태면 stop 타이머만 리셋 (STOMP 재전송 안 함)
2044
+ if (existingTimer) {
2045
+ clearTimeout(existingTimer);
2046
+ } else {
2047
+ // 최초 또는 stop 이후 첫 호출에만 STOMP 전송
2048
+ this.connectionManager.send(WebSocketPaths.CHAT_TYPING, {
2049
+ roomId,
2050
+ typing: true
2051
+ });
2052
+ }
2053
+
2054
+ // 3초 후 자동 stopTyping
2055
+ const timer = setTimeout(() => {
2056
+ this.stopTyping(roomId);
2057
+ }, 3000);
2058
+
2059
+ this._typingTimers.set(roomId, timer);
2060
+ }
2061
+
2062
+ /**
2063
+ * 타이핑 중단 알림.
2064
+ *
2065
+ * <p>직접 호출하거나, {@link #startTyping} 의 3초 타이머 만료 시,
2066
+ * 또는 메시지 전송 시 자동 호출됩니다.</p>
2067
+ *
2068
+ * @param {string} roomId - 채팅방 ID
2069
+ */
2070
+ stopTyping(roomId) {
2071
+ this._clearTypingTimer(roomId);
2072
+
2073
+ if (!this.connectionManager.isConnected()) return;
2074
+
2075
+ this.connectionManager.send(WebSocketPaths.CHAT_TYPING, {
2076
+ roomId,
2077
+ typing: false
2078
+ });
2079
+ }
2080
+
2081
+ /**
2082
+ * 이벤트 members 배열에 본인이 포함돼 있는지 확인.
2083
+ * <p>ROOM_KICKED / ROOM_BANNED 이벤트에서 "내가 추방당했는가" 판별용. actorId (추방한 방장) 가 아니라
2084
+ * members 배열(추방된 사용자) 을 보는 이유는, 같은 이벤트를 방장 본인도 수신하기 때문.</p>
2085
+ * @private
2086
+ */
2087
+ _isSelfInMembers(event) {
2088
+ return Array.isArray(event.members)
2089
+ && event.members.some(m => m && m.userId === this.userId);
2090
+ }
2091
+
2092
+ /**
2093
+ * 타이핑 이벤트 수신 처리.
2094
+ * <p>본인 이벤트는 필터링하여 emit 하지 않음.</p>
2095
+ * @private
2096
+ */
2097
+ _handleTypingEvent(roomId, event) {
2098
+ // 본인 이벤트 필터 — USER 타이핑만 적용 (어시는 본인 userId 매칭 불가, generic marker 사용)
2099
+ if (event.senderType !== 'ASSISTANT' && event.userId === this.userId) return;
2100
+
2101
+ // 어시 typing=true 수신 시 client-side timeout 시작 — 서버 측 typing=false 발행이 없으므로
2102
+ // (1) 어시 메시지 도착 (_handleChatMessage) 또는 (2) 본 timeout 만료 중 먼저 도달 시 typing=false emit.
2103
+ if (event.senderType === 'ASSISTANT' && event.typing === true) {
2104
+ this._startAssistantTypingTimer(roomId);
2105
+ }
2106
+
2107
+ this.emit('typing', {
2108
+ roomId,
2109
+ userId: event.userId,
2110
+ userName: event.userName,
2111
+ typing: event.typing,
2112
+ senderType: event.senderType || 'USER' // 구버전 서버 호환 — senderType 없으면 USER
2113
+ });
2114
+ }
2115
+
2116
+ /**
2117
+ * 어시스턴트 typing 자동 해제 타이머 시작/재시작.
2118
+ * 같은 방에 이미 timer 있으면 reset (새로운 어시 응답 라운드).
2119
+ * @private
2120
+ */
2121
+ _startAssistantTypingTimer(roomId) {
2122
+ this._clearAssistantTypingTimer(roomId);
2123
+ const timer = setTimeout(() => {
2124
+ this._assistantTypingTimers.delete(roomId);
2125
+ // timeout 만료 = LLM 지연 또는 서버 실패 — UI 가 영구 "답변 준비중" 에 갇히지 않도록 해제 emit.
2126
+ // userId / userName 은 generic marker 일관 (typing=true 발행과 매칭 — P1 fix).
2127
+ this.emit('typing', {
2128
+ roomId,
2129
+ userId: this._assistantTypingUserId,
2130
+ userName: this._assistantTypingUserName,
2131
+ typing: false,
2132
+ senderType: 'ASSISTANT'
2133
+ });
2134
+ this.logger.debug(`Assistant typing auto-cleared (timeout): room=${roomId}`);
2135
+ }, this._assistantTypingTimeoutMs);
2136
+ this._assistantTypingTimers.set(roomId, timer);
2137
+ }
2138
+
2139
+ /**
2140
+ * 어시스턴트 typing 자동 해제 타이머 취소.
2141
+ * 어시 메시지 도착 시 즉시 호출 (timeout 도달 전 해제).
2142
+ * @private
2143
+ */
2144
+ _clearAssistantTypingTimer(roomId) {
2145
+ const timer = this._assistantTypingTimers.get(roomId);
2146
+ if (timer) {
2147
+ clearTimeout(timer);
2148
+ this._assistantTypingTimers.delete(roomId);
2149
+ }
2150
+ }
2151
+
2152
+ /**
2153
+ * 타이핑 타이머 정리.
2154
+ * @private
2155
+ */
2156
+ _clearTypingTimer(roomId) {
2157
+ const timer = this._typingTimers.get(roomId);
2158
+ if (timer) {
2159
+ clearTimeout(timer);
2160
+ this._typingTimers.delete(roomId);
2161
+ }
2162
+ }
2163
+
2164
+ // ==================== 유틸리티 ====================
2165
+
2166
+ /**
2167
+ * 구독 중인 채팅방 목록
2168
+ * @returns {string[]}
2169
+ */
2170
+ getSubscribedRooms() {
2171
+ return Array.from(this.subscribedRooms.keys());
2172
+ }
2173
+
2174
+ /**
2175
+ * 채팅방 구독 여부 확인
2176
+ * @param {string} roomId
2177
+ * @returns {boolean}
2178
+ */
2179
+ isSubscribed(roomId) {
2180
+ return this.subscribedRooms.has(roomId);
2181
+ }
2182
+
2183
+ /**
2184
+ * 로그 레벨 설정
2185
+ * @param {number} level
2186
+ */
2187
+ setLogLevel(level) {
2188
+ this.logger.setLevel(level);
2189
+ }
2190
+
2191
+ /**
2192
+ * 리소스 정리.
2193
+ *
2194
+ * <p>{@link #unsubscribeRoom} 에서 방별 타이머를 정리하지만, 구독 목록에 없는
2195
+ * roomId 의 debounce 타이머가 남아 있을 수 있어 Map 전체를 명시적으로 비운다.</p>
2196
+ */
2197
+ destroy() {
2198
+ this.unsubscribeAllRooms();
2199
+ if (this.roomListSubscribed) {
2200
+ this.unsubscribeRoomList();
2201
+ }
2202
+
2203
+ this._typingTimers.forEach((timer) => clearTimeout(timer));
2204
+ this._typingTimers.clear();
2205
+
2206
+ // 어시 typing 수신 타이머도 전체 cleanup — unsubscribeRoom 이 방별로 정리하지만 구독 목록에
2207
+ // 없는 roomId 의 타이머가 남아 있을 수 있어 명시 정리 (메모리 누수 차단).
2208
+ this._assistantTypingTimers.forEach((timer) => clearTimeout(timer));
2209
+ this._assistantTypingTimers.clear();
2210
+
2211
+ // dedup bucket 전체 clear — unsubscribeRoom 이 방별로 정리하지만 구독 외 경로로 누적된 bucket
2212
+ // (예: 구독 안 한 방의 push/listener) 이 있을 수 있어 안전하게 명시 정리.
2213
+ this._seenChatMessageIdsByRoom.clear();
2214
+ this._seenRoomListMessageIdsByRoom.clear();
2215
+
2216
+ this.removeAllListeners();
2217
+ this.logger.info('ChatClient destroyed');
2218
+ }
2219
+ }
2220
+
2221
+ export default ChatClient;