@vibexnpm/talkx 8.2.0 → 8.5.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.
- package/README.md +3 -0
- package/dist/index.d.ts +189 -6
- package/dist/talkflow-sdk.esm.js +1 -1
- package/dist/talkflow-sdk.esm.js.map +1 -1
- package/dist/talkflow-sdk.standalone.js +1 -1
- package/dist/talkflow-sdk.standalone.js.map +1 -1
- package/dist/talkflow-sdk.umd.js +1 -1
- package/dist/talkflow-sdk.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/chat/ChatClient.js +193 -3
- package/src/constants.js +16 -0
- package/src/push/PushManager.js +72 -0
- package/src/talkflow/delegates.js +12 -0
- package/src/talkflow/eventForwarding.js +12 -0
- package/src/talkflow/mailNotifications.js +154 -0
- package/src/talkflow/session.js +11 -0
- package/types/__typetests__/roomOwnership.types.ts +51 -0
- package/types/__typetests__/videoAnalysis.types.ts +131 -0
- package/types/index.d.ts +189 -6
package/package.json
CHANGED
package/src/chat/ChatClient.js
CHANGED
|
@@ -49,6 +49,9 @@ class ChatClient extends EventEmitter {
|
|
|
49
49
|
// 채팅방 리스트 구독 상태 (카톡 스타일 리스트 실시간 업데이트)
|
|
50
50
|
this.roomListSubscribed = false;
|
|
51
51
|
|
|
52
|
+
// Gmail 메일함 변화 구독 상태 — 자동 구독하지 않는다(Gmail 을 연결한 화면에서 subscribeMail()).
|
|
53
|
+
this.mailSubscribed = false;
|
|
54
|
+
|
|
52
55
|
// 현재 보고 있는 채팅방 ID (이 방에서만 자동 읽음 처리)
|
|
53
56
|
this._activeRoomId = null;
|
|
54
57
|
|
|
@@ -143,6 +146,7 @@ class ChatClient extends EventEmitter {
|
|
|
143
146
|
SEARCHING: '검색 중…',
|
|
144
147
|
PLANNING: '기획 중…',
|
|
145
148
|
DOCUMENT_ANALYSIS: '문서 분석 중…',
|
|
149
|
+
VIDEO_ANALYSIS: '영상 분석 중…',
|
|
146
150
|
WRITING: '작성 중…'
|
|
147
151
|
};
|
|
148
152
|
// PHASE 의 expiresAt 누락/이상 시 fallback TTL (ms) — 서버 ttl(최소 30s) 보다 약간 길게.
|
|
@@ -595,6 +599,50 @@ class ChatClient extends EventEmitter {
|
|
|
595
599
|
return this._unwrapSuccessResponse(response);
|
|
596
600
|
}
|
|
597
601
|
|
|
602
|
+
/** 영상 분석용 비공개 업로드. 반환한 assetId를 analyzeVideo에 전달한다. */
|
|
603
|
+
async uploadVideoAsset(file, options = {}) {
|
|
604
|
+
if (!file) throw new Error('file is required');
|
|
605
|
+
return this._unwrapSuccessResponse(await this.apiClient.upload('/api/v1/video-assets', file, {
|
|
606
|
+
onProgress: options.onProgress, signal: options.signal
|
|
607
|
+
}));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** 비공개 분석 접수. 네트워크 재시도에는 같은 idempotencyKey를 사용한다. */
|
|
611
|
+
async analyzeVideo(analysis, options = {}) {
|
|
612
|
+
if (typeof options.idempotencyKey !== 'string' || !options.idempotencyKey.trim()) {
|
|
613
|
+
throw new Error('idempotencyKey is required for video analysis');
|
|
614
|
+
}
|
|
615
|
+
return this._unwrapSuccessResponse(await this.apiClient.post('/api/v1/video-analyses', analysis,
|
|
616
|
+
undefined, { 'Idempotency-Key': options.idempotencyKey }));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** 같은 분석 코어를 호출하고 결과를 지정한 방에 게시한다. */
|
|
620
|
+
async analyzeVideoWithAssistant(roomId, request, options = {}) {
|
|
621
|
+
if (!roomId || !request?.personaId || !request?.triggerMessageId) {
|
|
622
|
+
throw new Error('roomId, personaId and triggerMessageId are required');
|
|
623
|
+
}
|
|
624
|
+
if (typeof options.idempotencyKey !== 'string' || !options.idempotencyKey.trim()) {
|
|
625
|
+
throw new Error('idempotencyKey is required for video analysis');
|
|
626
|
+
}
|
|
627
|
+
return this._unwrapSuccessResponse(await this.apiClient.post(
|
|
628
|
+
`/api/v1/rooms/${encodeURIComponent(roomId)}/assistant/tools/analyze-video`, request,
|
|
629
|
+
undefined, { 'Idempotency-Key': options.idempotencyKey }));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async getVideoAnalysis(jobId) {
|
|
633
|
+
if (!jobId) throw new Error('jobId is required');
|
|
634
|
+
return this._unwrapSuccessResponse(await this.apiClient.get(`/api/v1/video-analyses/${encodeURIComponent(jobId)}`));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async cancelVideoAnalysis(jobId) {
|
|
638
|
+
if (!jobId) throw new Error('jobId is required');
|
|
639
|
+
return this._unwrapSuccessResponse(await this.apiClient.post(`/api/v1/video-analyses/${encodeURIComponent(jobId)}/cancel`));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async getVideoAnalysisCapabilities() {
|
|
643
|
+
return this._unwrapSuccessResponse(await this.apiClient.get('/api/v1/video-analyses/capabilities'));
|
|
644
|
+
}
|
|
645
|
+
|
|
598
646
|
/**
|
|
599
647
|
* 특정 메시지 온디맨드 번역 (버튼 진입점, 동기).
|
|
600
648
|
*
|
|
@@ -868,12 +916,84 @@ class ChatClient extends EventEmitter {
|
|
|
868
916
|
}
|
|
869
917
|
|
|
870
918
|
/**
|
|
871
|
-
* 채팅방 나가기 (구독도 자동 해제됨)
|
|
919
|
+
* 채팅방 나가기 (구독도 자동 해제됨).
|
|
920
|
+
*
|
|
921
|
+
* <p>일반 참가자는 {@code leaveRoom(roomId)} 그대로. <b>방장</b>은 그냥 나갈 수 없다 — 나가면 방에 관리자가
|
|
922
|
+
* 0명이 되기 때문이다. 방장은 둘 중 하나를 고른다:</p>
|
|
923
|
+
* <ul>
|
|
924
|
+
* <li>위임 후 나가기 — {@code leaveRoom(roomId, { newOwnerId })}</li>
|
|
925
|
+
* <li>방 삭제 — {@link #deleteRoom}</li>
|
|
926
|
+
* </ul>
|
|
927
|
+
*
|
|
928
|
+
* <p>서버 응답:</p>
|
|
929
|
+
* <ul>
|
|
930
|
+
* <li>409 {@code ROOM_OWNER_CANNOT_LEAVE} — 방장이 newOwnerId 없이 나가려 함 (선택 화면을 띄울 신호)</li>
|
|
931
|
+
* <li>403 {@code NOT_ROOM_ADMIN} — 방장이 아닌데 newOwnerId 를 보냄</li>
|
|
932
|
+
* <li>400 {@code CANNOT_TRANSFER_OWNERSHIP_TO_SELF} / {@code INVALID_OWNERSHIP_TARGET}, 404 {@code USER_NOT_IN_ROOM}
|
|
933
|
+
* — 위임 대상 오류 ({@link #transferOwnership} 와 같다). 이때는 나가지도 않는다.</li>
|
|
934
|
+
* </ul>
|
|
935
|
+
*
|
|
872
936
|
* @param {string} roomId - 채팅방 ID
|
|
937
|
+
* @param {Object} [options]
|
|
938
|
+
* @param {string} [options.newOwnerId] - 방장이 나갈 때 방장을 넘겨받을 사람 (방의 활성 사람 참가자 · 활성 계정)
|
|
939
|
+
* @returns {Promise<Object>}
|
|
940
|
+
*
|
|
941
|
+
* @example
|
|
942
|
+
* try {
|
|
943
|
+
* await chat.leaveRoom(roomId);
|
|
944
|
+
* } catch (e) {
|
|
945
|
+
* // 서버 코드는 e.response.code — 방장이면 «위임 후 나가기 / 방 삭제» 선택 화면
|
|
946
|
+
* if (e.response?.code === 'ROOM_OWNER_CANNOT_LEAVE') openOwnerExitDialog(roomId);
|
|
947
|
+
* }
|
|
948
|
+
* // 위임 후 나가기
|
|
949
|
+
* await chat.leaveRoom(roomId, { newOwnerId: 'user-2' });
|
|
950
|
+
*/
|
|
951
|
+
async leaveRoom(roomId, options = {}) {
|
|
952
|
+
const body = options.newOwnerId ? { newOwnerId: options.newOwnerId } : undefined;
|
|
953
|
+
const result = await this.apiClient.post(`/api/v1/rooms/${roomId}/leave`, body);
|
|
954
|
+
this.unsubscribeRoom(roomId);
|
|
955
|
+
return result;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* 방장 위임 — 방장은 방에 남고 MEMBER 가 된다. 방장(OWNER) 전용.
|
|
960
|
+
*
|
|
961
|
+
* <p>나가면서 넘기려면 {@code leaveRoom(roomId, { newOwnerId })}. 방 참가자 전원에게
|
|
962
|
+
* {@code roomListOwnerChanged} 가 간다(actorId = 이전 방장, members[0] = 새 방장).</p>
|
|
963
|
+
*
|
|
964
|
+
* <p>서버 응답:</p>
|
|
965
|
+
* <ul>
|
|
966
|
+
* <li>403 {@code NOT_ROOM_ADMIN} — 방장 아님 (DIRECT 방은 방장이 없다)</li>
|
|
967
|
+
* <li>400 {@code CANNOT_TRANSFER_OWNERSHIP_TO_SELF} — 자기 자신에게 위임</li>
|
|
968
|
+
* <li>404 {@code USER_NOT_IN_ROOM} — 대상이 현재 참가자가 아님</li>
|
|
969
|
+
* <li>400 {@code INVALID_OWNERSHIP_TARGET} — 대상이 어시스턴트이거나 퇴사·차단 계정</li>
|
|
970
|
+
* </ul>
|
|
971
|
+
*
|
|
972
|
+
* @param {string} roomId
|
|
973
|
+
* @param {string} newOwnerId - 새 방장 userId
|
|
974
|
+
* @returns {Promise<Object>} 갱신된 방 상세 (participants[].role 반영 — 봉투를 벗긴 data)
|
|
975
|
+
*/
|
|
976
|
+
async transferOwnership(roomId, newOwnerId) {
|
|
977
|
+
const response = await this.apiClient.put(`/api/v1/rooms/${roomId}/owner`, { newOwnerId });
|
|
978
|
+
return response && typeof response === 'object' && 'data' in response
|
|
979
|
+
? response.data
|
|
980
|
+
: response;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* 방 삭제 — 방장(OWNER) 전용. 참가자 전원이 나가게 되고 방은 목록에서 사라진다.
|
|
985
|
+
* 이후 enterRoom·getRoomInfo 는 403(ACCESS_FORBIDDEN — 더는 참가자가 아님), joinGroupRoom 은 404(CHAT_ROOM_NOT_FOUND).
|
|
986
|
+
*
|
|
987
|
+
* <p>참가자 전원(방장 포함)에게 {@code roomListDeleted} 가 간다. 본인 구독은 자동 해제된다.
|
|
988
|
+
* 메시지·첨부는 서버에 보존된다(소프트 삭제) — 되살리는 API 는 없다.</p>
|
|
989
|
+
*
|
|
990
|
+
* <p>서버 응답: 403 {@code NOT_ROOM_ADMIN} — 방장 아님.</p>
|
|
991
|
+
*
|
|
992
|
+
* @param {string} roomId
|
|
873
993
|
* @returns {Promise<Object>}
|
|
874
994
|
*/
|
|
875
|
-
async
|
|
876
|
-
const result = await this.apiClient.
|
|
995
|
+
async deleteRoom(roomId) {
|
|
996
|
+
const result = await this.apiClient.delete(`/api/v1/rooms/${roomId}`);
|
|
877
997
|
this.unsubscribeRoom(roomId);
|
|
878
998
|
return result;
|
|
879
999
|
}
|
|
@@ -2551,6 +2671,8 @@ class ChatClient extends EventEmitter {
|
|
|
2551
2671
|
* - roomListJoined — 방 입장 (ROOM_JOINED)
|
|
2552
2672
|
* - roomListLeft — 방 퇴장 (ROOM_LEFT)
|
|
2553
2673
|
* - roomListSelfLeft — 본인이 나간 경우 (리스트에서 제거 신호)
|
|
2674
|
+
* - roomListOwnerChanged — 방장 위임 (ROOM_OWNER_CHANGED, members[0] = 새 방장)
|
|
2675
|
+
* - roomListDeleted — 방장이 방을 삭제 (ROOM_DELETED, 리스트 제거 + 방 화면 닫기)
|
|
2554
2676
|
*
|
|
2555
2677
|
* @returns {Promise<void>}
|
|
2556
2678
|
*
|
|
@@ -2613,6 +2735,61 @@ class ChatClient extends EventEmitter {
|
|
|
2613
2735
|
return this.roomListSubscribed;
|
|
2614
2736
|
}
|
|
2615
2737
|
|
|
2738
|
+
/**
|
|
2739
|
+
* Gmail 메일함 변화 구독 — {@code /user/queue/mail}. 받은 이벤트를 {@code mailChanged} 로 그대로 낸다.
|
|
2740
|
+
*
|
|
2741
|
+
* <p>새 메일 알림은 최상위 클라이언트의 {@code newMail} 을 쓴다 — 웹소켓 이벤트의 미리보기와 포그라운드 FCM 메일 푸시를
|
|
2742
|
+
* 합쳐 메일마다 한 번만 낸다. 재연결 때는 연결 관리자가 구독을 되살린다(이벤트는 최대 한 번 전달이라 재연결·창 포커스 때
|
|
2743
|
+
* 안읽은 수와 목록을 다시 읽는다).</p>
|
|
2744
|
+
*/
|
|
2745
|
+
async subscribeMail() {
|
|
2746
|
+
if (this.mailSubscribed) {
|
|
2747
|
+
this.logger.debug('Already subscribed to mail');
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
await this.connectionManager.subscribe(WebSocketPaths.MAIL_USER_DESTINATION, (event) => {
|
|
2752
|
+
this._handleMailEvent(event);
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2755
|
+
this.mailSubscribed = true;
|
|
2756
|
+
this.logger.info('Subscribed to mail');
|
|
2757
|
+
this.emit('mailSubscribed', {});
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
/**
|
|
2761
|
+
* Gmail 메일함 변화 구독 해제.
|
|
2762
|
+
*/
|
|
2763
|
+
unsubscribeMail() {
|
|
2764
|
+
if (!this.mailSubscribed) {
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
this.connectionManager.unsubscribe(WebSocketPaths.MAIL_USER_DESTINATION);
|
|
2769
|
+
this.mailSubscribed = false;
|
|
2770
|
+
|
|
2771
|
+
this.logger.info('Unsubscribed from mail');
|
|
2772
|
+
this.emit('mailUnsubscribed', {});
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
/**
|
|
2776
|
+
* Gmail 메일함 변화 구독 여부.
|
|
2777
|
+
* @returns {boolean}
|
|
2778
|
+
*/
|
|
2779
|
+
isMailSubscribed() {
|
|
2780
|
+
return this.mailSubscribed;
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
/** 메일 이벤트 — 보낸 사람·제목이 실리므로 내용은 로그에 남기지 않는다(연결 ID·건수만). */
|
|
2784
|
+
_handleMailEvent(event) {
|
|
2785
|
+
if (!event || event.eventType !== 'MAIL_CHANGED') {
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
this.logger.debug('Mail changed:', event.connectionId,
|
|
2789
|
+
Array.isArray(event.messages) ? event.messages.length : 0);
|
|
2790
|
+
this.emit('mailChanged', event);
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2616
2793
|
/**
|
|
2617
2794
|
* 메시지 읽음 처리
|
|
2618
2795
|
* @param {string} roomId - 채팅방 ID
|
|
@@ -2819,6 +2996,19 @@ class ChatClient extends EventEmitter {
|
|
|
2819
2996
|
this.emit('roomListSelfKicked', event);
|
|
2820
2997
|
}
|
|
2821
2998
|
break;
|
|
2999
|
+
case RoomListEventType.ROOM_OWNER_CHANGED:
|
|
3000
|
+
// 방장 위임 — actorId=이전 방장, members[0]=새 방장. 방장 표시·관리 메뉴를 갱신한다.
|
|
3001
|
+
this.emit('roomListOwnerChanged', event);
|
|
3002
|
+
break;
|
|
3003
|
+
case RoomListEventType.ROOM_DELETED:
|
|
3004
|
+
// 방장이 방을 삭제 — 모든 참가자가 리스트에서 제거하고 열린 방 화면을 닫는다.
|
|
3005
|
+
// 🔴 그 방 구독은 SDK 가 «먼저» 푼다. 남겨 두면 아무것도 못 받으면서 재연결 때마다 되살아나 서버에
|
|
3006
|
+
// 거부된다 — 그 거부로 연결을 끊던 서버 버전에서는 «재연결 → 거부 → 끊김» 이 반복돼 다른 방 실시간까지 멈췄다.
|
|
3007
|
+
if (this.subscribedRooms.has(event.roomId)) {
|
|
3008
|
+
this.unsubscribeRoom(event.roomId);
|
|
3009
|
+
}
|
|
3010
|
+
this.emit('roomListDeleted', event);
|
|
3011
|
+
break;
|
|
2822
3012
|
case RoomListEventType.ROOM_BANNED:
|
|
2823
3013
|
this.emit('roomListBanned', event);
|
|
2824
3014
|
// 본인이 영구 차단당한 경우 편의 이벤트 — "영구 차단되었습니다" 표시 + 리스트 제거
|
package/src/constants.js
CHANGED
|
@@ -333,6 +333,18 @@ export const RoomListEventType = {
|
|
|
333
333
|
ROOM_KICKED: 'ROOM_KICKED',
|
|
334
334
|
/** 방장에 의한 추방 + 영구 차단 → payload 는 ROOM_KICKED 와 동일, 재입장 거부됨. */
|
|
335
335
|
ROOM_BANNED: 'ROOM_BANNED',
|
|
336
|
+
/**
|
|
337
|
+
* 방장 위임 → actorId 는 이전 방장, members[0] 이 새 방장. 방장 표시·관리 권한을 갱신한다.
|
|
338
|
+
* «위임 후 나가기» 면 이어서 같은 이전 방장의 ROOM_LEFT 가 온다.
|
|
339
|
+
* SDK 는 이를 roomListOwnerChanged 이벤트로 emit 한다.
|
|
340
|
+
*/
|
|
341
|
+
ROOM_OWNER_CHANGED: 'ROOM_OWNER_CHANGED',
|
|
342
|
+
/**
|
|
343
|
+
* 방장이 방을 삭제 → 리스트에서 제거 + 열린 방 화면 닫기. actorId 는 삭제한 방장.
|
|
344
|
+
* 이후 enterRoom·getRoomInfo 는 403(ACCESS_FORBIDDEN — 더는 참가자가 아님), joinGroupRoom 은 404.
|
|
345
|
+
* SDK 는 이를 roomListDeleted 이벤트로 emit 한다.
|
|
346
|
+
*/
|
|
347
|
+
ROOM_DELETED: 'ROOM_DELETED',
|
|
336
348
|
/** 메시지 보관 기간 정리 → cutoffTime 이전 메시지를 UI에서 제거. */
|
|
337
349
|
MESSAGE_RETENTION_CLEANUP: 'MESSAGE_RETENTION_CLEANUP',
|
|
338
350
|
/**
|
|
@@ -372,6 +384,10 @@ export const WebSocketPaths = {
|
|
|
372
384
|
// 클라이언트는 "/user/queue/rooms" 구독 (Spring STOMP 가 세션별 자동 격리)
|
|
373
385
|
ROOM_LIST_USER_DESTINATION: '/user/queue/rooms',
|
|
374
386
|
|
|
387
|
+
// Gmail 메일함 변화 (서버 GMAIL_MAILBOX_FEATURES_DESIGN.md §6-6·§6-7)
|
|
388
|
+
// 서버가 convertAndSendToUser(userId, "/queue/mail", MAIL_CHANGED) 로 전송 — 새 메일이면 messages[] 미리보기 포함
|
|
389
|
+
MAIL_USER_DESTINATION: '/user/queue/mail',
|
|
390
|
+
|
|
375
391
|
// WebRTC destinations
|
|
376
392
|
getWebRTCDestination: (roomId) => `/topic/webrtc/${roomId}`,
|
|
377
393
|
getWebRTCUserDestination: () => `/user/queue/webrtc`,
|
package/src/push/PushManager.js
CHANGED
|
@@ -68,6 +68,10 @@ const PENDING_NAVIGATION_STORE_NAME = 'pendingNavigation';
|
|
|
68
68
|
// SW 와 동일한 key 규칙 사용 — 멀티테넌트에서 다른 projectId 의 pending 을 건드리지 않도록 한다.
|
|
69
69
|
const PENDING_NAVIGATION_KEY_PREFIX = 'pending-room';
|
|
70
70
|
const PENDING_NAVIGATION_LEGACY_KEY = 'pending-room';
|
|
71
|
+
// Gmail 메일 알림 클릭 — 방과 다른 key 라 consumePendingRoom 과 섞이지 않는다. 프로젝트별로만 저장한다(레거시 전역 key 없음).
|
|
72
|
+
const PENDING_MAIL_KEY_PREFIX = 'pending-mail';
|
|
73
|
+
// 앱이 이 시간 안에 꺼내지 않은 메일 이동은 버린다 — 며칠 뒤 앱을 열 때 엉뚱하게 메일 화면으로 가지 않게.
|
|
74
|
+
const PENDING_MAIL_MAX_AGE_MS = 10 * 60 * 1000;
|
|
71
75
|
|
|
72
76
|
// deviceId 저장 — 같은 IndexedDB 의 별도 store 사용 (XSS 방어 + 기존 인프라 재사용).
|
|
73
77
|
// DB 버전은 2 로 bump — v1 에서 pendingNavigation 만 있던 상태에서 deviceInfo store 추가.
|
|
@@ -175,6 +179,54 @@ function generateDeviceId() {
|
|
|
175
179
|
return 'web-' + random;
|
|
176
180
|
}
|
|
177
181
|
|
|
182
|
+
/**
|
|
183
|
+
* SW 가 저장한 메일 이동(알림 클릭 → 열린 창 없음)을 꺼내고 지운다. 같은 프로젝트·10분 안의 것만.
|
|
184
|
+
*
|
|
185
|
+
* @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}
|
|
186
|
+
*/
|
|
187
|
+
async function consumePendingMailFromIndexedDB(projectId) {
|
|
188
|
+
if (!projectId || typeof projectId !== 'string' || projectId.trim() === '') {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const db = await openPendingNavigationDb();
|
|
192
|
+
if (!db) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const key = PENDING_MAIL_KEY_PREFIX + ':' + projectId;
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
const transaction = db.transaction(PENDING_NAVIGATION_STORE_NAME, 'readwrite');
|
|
198
|
+
const store = transaction.objectStore(PENDING_NAVIGATION_STORE_NAME);
|
|
199
|
+
let pending = null;
|
|
200
|
+
const request = store.get(key);
|
|
201
|
+
request.onsuccess = () => {
|
|
202
|
+
const record = request.result;
|
|
203
|
+
if (!record) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
store.delete(key);
|
|
207
|
+
const fresh = typeof record.savedAt === 'number' && Date.now() - record.savedAt <= PENDING_MAIL_MAX_AGE_MS;
|
|
208
|
+
if (fresh && record.projectId === projectId && record.connectionId) {
|
|
209
|
+
pending = { connectionId: record.connectionId, messageId: record.messageId || null };
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
request.onerror = () => {
|
|
213
|
+
reject(request.error || new Error('IndexedDB read failed'));
|
|
214
|
+
};
|
|
215
|
+
transaction.oncomplete = () => {
|
|
216
|
+
db.close();
|
|
217
|
+
resolve(pending);
|
|
218
|
+
};
|
|
219
|
+
transaction.onerror = () => {
|
|
220
|
+
db.close();
|
|
221
|
+
reject(transaction.error || new Error('IndexedDB transaction failed'));
|
|
222
|
+
};
|
|
223
|
+
transaction.onabort = () => {
|
|
224
|
+
db.close();
|
|
225
|
+
reject(transaction.error || new Error('IndexedDB transaction aborted'));
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
178
230
|
async function consumePendingRoomFromIndexedDB(projectId) {
|
|
179
231
|
const db = await openPendingNavigationDb();
|
|
180
232
|
if (!db) {
|
|
@@ -302,6 +354,17 @@ class PushManager {
|
|
|
302
354
|
return consumePendingRoomFromIndexedDB(projectId);
|
|
303
355
|
}
|
|
304
356
|
|
|
357
|
+
/**
|
|
358
|
+
* 서비스 워커가 cold start 시 저장한 메일 이동(Gmail 메일 알림 클릭)을 꺼내고 지운다.
|
|
359
|
+
* 앱 부팅 때 호출해 메일 화면으로 라우팅한다 — {@code messageId} 가 있으면 그 메일 상세, 없으면(묶음 알림) 받은메일함.
|
|
360
|
+
*
|
|
361
|
+
* @param {string} projectId
|
|
362
|
+
* @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}
|
|
363
|
+
*/
|
|
364
|
+
static async consumePendingMail(projectId) {
|
|
365
|
+
return consumePendingMailFromIndexedDB(projectId);
|
|
366
|
+
}
|
|
367
|
+
|
|
305
368
|
/**
|
|
306
369
|
* 현재 브라우저의 푸시 권한 상태를 조회한다.
|
|
307
370
|
*
|
|
@@ -669,6 +732,15 @@ class PushManager {
|
|
|
669
732
|
return PushManager.consumePendingRoom(this.projectId);
|
|
670
733
|
}
|
|
671
734
|
|
|
735
|
+
/**
|
|
736
|
+
* 서비스 워커가 cold start 시 저장한 메일 이동을 꺼내고 지운다 — {@link PushManager.consumePendingMail}.
|
|
737
|
+
*
|
|
738
|
+
* @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}
|
|
739
|
+
*/
|
|
740
|
+
async consumePendingMail() {
|
|
741
|
+
return PushManager.consumePendingMail(this.projectId);
|
|
742
|
+
}
|
|
743
|
+
|
|
672
744
|
/**
|
|
673
745
|
* 현재 푸시 상태를 정리한다.
|
|
674
746
|
* 로그아웃 또는 다른 사용자로 전환할 때 foreground listener 와 활성화 상태를 초기화한다.
|
|
@@ -7,6 +7,9 @@ const CHAT_DELEGATE_METHODS = [
|
|
|
7
7
|
'createGroupRoom',
|
|
8
8
|
'joinGroupRoom',
|
|
9
9
|
'leaveRoom',
|
|
10
|
+
// 방장 위임(방에 남음) · 방 삭제 — 방장의 «위임 후 나가기» 는 leaveRoom(roomId, { newOwnerId }).
|
|
11
|
+
'transferOwnership',
|
|
12
|
+
'deleteRoom',
|
|
10
13
|
'updateGroupRoom',
|
|
11
14
|
'inviteToGroupRoom',
|
|
12
15
|
'kickMember',
|
|
@@ -48,6 +51,9 @@ const CHAT_DELEGATE_METHODS = [
|
|
|
48
51
|
'subscribeRoomList',
|
|
49
52
|
'unsubscribeRoomList',
|
|
50
53
|
'isRoomListSubscribed',
|
|
54
|
+
'subscribeMail',
|
|
55
|
+
'unsubscribeMail',
|
|
56
|
+
'isMailSubscribed',
|
|
51
57
|
'getSubscribedRooms',
|
|
52
58
|
'isSubscribed',
|
|
53
59
|
'startTyping',
|
|
@@ -61,6 +67,12 @@ const CHAT_DELEGATE_METHODS = [
|
|
|
61
67
|
'rateAssistantMessage',
|
|
62
68
|
'summarizeWithAssistant',
|
|
63
69
|
'translateWithAssistant',
|
|
70
|
+
'uploadVideoAsset',
|
|
71
|
+
'analyzeVideo',
|
|
72
|
+
'analyzeVideoWithAssistant',
|
|
73
|
+
'getVideoAnalysis',
|
|
74
|
+
'cancelVideoAnalysis',
|
|
75
|
+
'getVideoAnalysisCapabilities',
|
|
64
76
|
'refreshSummary',
|
|
65
77
|
'getSummary',
|
|
66
78
|
'getRoomPmPrompt',
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { emitNewMailFromSocket } from './mailNotifications.js';
|
|
2
|
+
|
|
1
3
|
const CONNECTION_EVENT_MAP = [
|
|
2
4
|
['connected', 'connected'],
|
|
3
5
|
['disconnected', 'disconnected'],
|
|
@@ -34,6 +36,8 @@ const CHAT_EVENT_MAP = [
|
|
|
34
36
|
['roomListUnsubscribed', 'roomListUnsubscribed'],
|
|
35
37
|
['roomListUpdate', 'roomListUpdate'],
|
|
36
38
|
['roomListMessage', 'roomListMessage'],
|
|
39
|
+
['roomListMessageDeleted', 'roomListMessageDeleted'],
|
|
40
|
+
['roomListMessageUpdated', 'roomListMessageUpdated'],
|
|
37
41
|
['roomListCreated', 'roomListCreated'],
|
|
38
42
|
['roomListJoined', 'roomListJoined'],
|
|
39
43
|
['roomListLeft', 'roomListLeft'],
|
|
@@ -42,8 +46,15 @@ const CHAT_EVENT_MAP = [
|
|
|
42
46
|
['roomListSelfKicked', 'roomListSelfKicked'],
|
|
43
47
|
['roomListBanned', 'roomListBanned'],
|
|
44
48
|
['roomListSelfBanned', 'roomListSelfBanned'],
|
|
49
|
+
// 방장 위임 · 방 삭제 — 타입에 선언한 이벤트는 여기 없으면 최상위 client.on 에서 안 울린다.
|
|
50
|
+
['roomListOwnerChanged', 'roomListOwnerChanged'],
|
|
51
|
+
['roomListDeleted', 'roomListDeleted'],
|
|
45
52
|
['roomListRoomUpdated', 'roomListRoomUpdated'],
|
|
46
53
|
['roomListUnreadUpdated', 'roomListUnreadUpdated'],
|
|
54
|
+
// Gmail 메일함 — mailChanged 는 받은 그대로. 새 메일 알림(newMail)은 아래에서 푸시와 합쳐 따로 낸다.
|
|
55
|
+
['mailSubscribed', 'mailSubscribed'],
|
|
56
|
+
['mailUnsubscribed', 'mailUnsubscribed'],
|
|
57
|
+
['mailChanged', 'mailChanged'],
|
|
47
58
|
['retentionCleanup', 'retentionCleanup']
|
|
48
59
|
];
|
|
49
60
|
|
|
@@ -97,6 +108,7 @@ export function setupEventForwarding(client) {
|
|
|
97
108
|
|
|
98
109
|
if (client.chat) {
|
|
99
110
|
forwardMappedEvents(client.chat, client, CHAT_EVENT_MAP);
|
|
111
|
+
client.chat.on('mailChanged', (event) => emitNewMailFromSocket(client, event));
|
|
100
112
|
}
|
|
101
113
|
|
|
102
114
|
if (client.webrtc) {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gmail 새 메일 알림 — 웹소켓 {@code MAIL_CHANGED} 의 미리보기와 포그라운드 FCM 메일 푸시를 합쳐 메일마다 한 번만
|
|
3
|
+
* {@code newMail} 을 낸다 (서버 GMAIL_MAILBOX_FEATURES_DESIGN.md §6-7).
|
|
4
|
+
*
|
|
5
|
+
* <p>같은 메일이 두 길로 온다: 서버는 앱 접속 여부와 상관없이 FCM 을 보내고(채팅과 같음), 화면이 떠 있으면 웹소켓
|
|
6
|
+
* 이벤트도 받는다. 중복 판정 키는 {@code projectId:connectionId:messageId} — 어느 쪽이 먼저 와도 두 번째는 버린다.
|
|
7
|
+
* 묶음 푸시({@code kind=SUMMARY})도 묶은 메시지 ID({@code data.messageIds})로 같은 판정을 하고, 처음 보는 메일이 있을 때만
|
|
8
|
+
* 그 수로 {@code newMailSummary} 를 낸다(같은 묶음이 다시 와도, 개별 알림을 이미 받았어도 다시 띄우지 않는다).</p>
|
|
9
|
+
*
|
|
10
|
+
* <p>보낸 사람·제목은 로그에 남기지 않는다.</p>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 같은 메일을 다시 보지 않는 시간 — 서버 푸시 수명(수신 후 30분)보다 길게. */
|
|
14
|
+
const DEDUP_TTL_MS = 60 * 60 * 1000;
|
|
15
|
+
/** 기억하는 메일 수 상한 — 넘으면 오래된 것부터 잊는다. */
|
|
16
|
+
const DEDUP_MAX = 1000;
|
|
17
|
+
|
|
18
|
+
export function createMailNotificationDeduper({ ttlMs = DEDUP_TTL_MS, max = DEDUP_MAX, now = () => Date.now() } = {}) {
|
|
19
|
+
const seen = new Map();
|
|
20
|
+
return {
|
|
21
|
+
/** 처음 보는 키면 true(그리고 기억), 이미 본 키면 false. */
|
|
22
|
+
accept(key) {
|
|
23
|
+
const at = now();
|
|
24
|
+
for (const [seenKey, seenAt] of seen) {
|
|
25
|
+
if (at - seenAt < ttlMs) {
|
|
26
|
+
break; // Map 은 넣은 순서 — 첫 살아 있는 항목부터는 다 살아 있다
|
|
27
|
+
}
|
|
28
|
+
seen.delete(seenKey);
|
|
29
|
+
}
|
|
30
|
+
if (seen.has(key)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
seen.set(key, at);
|
|
34
|
+
while (seen.size > max) {
|
|
35
|
+
seen.delete(seen.keys().next().value);
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
size() {
|
|
40
|
+
return seen.size;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function deduperOf(client) {
|
|
46
|
+
if (!client._mailNotificationDeduper) {
|
|
47
|
+
client._mailNotificationDeduper = createMailNotificationDeduper();
|
|
48
|
+
}
|
|
49
|
+
return client._mailNotificationDeduper;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function projectIdOf(client) {
|
|
53
|
+
return (client.options && client.options.projectId) || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function keyOf(projectId, connectionId, messageId) {
|
|
57
|
+
return `${projectId || ''}:${connectionId}:${messageId}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function senderOf(from) {
|
|
61
|
+
if (!from || typeof from !== 'object') {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return (typeof from.name === 'string' && from.name.trim() !== '') ? from.name : (from.email || null);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 웹소켓 {@code MAIL_CHANGED} → 새 메일마다 {@code newMail}. 읽음·보관 같은 변화(미리보기 없음)는 아무것도 내지 않는다.
|
|
69
|
+
*/
|
|
70
|
+
export function emitNewMailFromSocket(client, event) {
|
|
71
|
+
if (!event || event.eventType !== 'MAIL_CHANGED' || !Array.isArray(event.messages) || !event.connectionId) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const projectId = projectIdOf(client);
|
|
75
|
+
const deduper = deduperOf(client);
|
|
76
|
+
for (const preview of event.messages) {
|
|
77
|
+
if (!preview || !preview.messageId) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!deduper.accept(keyOf(projectId, event.connectionId, preview.messageId))) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
client.emit('newMail', {
|
|
84
|
+
projectId,
|
|
85
|
+
connectionId: event.connectionId,
|
|
86
|
+
messageId: preview.messageId,
|
|
87
|
+
threadId: preview.threadId || null,
|
|
88
|
+
sender: senderOf(preview.from),
|
|
89
|
+
from: preview.from || null,
|
|
90
|
+
subject: preview.subject || null,
|
|
91
|
+
receivedAt: preview.receivedAt || null,
|
|
92
|
+
source: 'socket'
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 포그라운드 FCM 메일 푸시({@code data.target === 'MAIL'}) → {@code newMail}(한 통) 또는 {@code newMailSummary}(묶음).
|
|
99
|
+
* 메일 푸시면 {@code true} — 호출자는 {@code pushNotification} 을 내지 않는다(같은 알림을 두 번 띄우지 않게).
|
|
100
|
+
*/
|
|
101
|
+
export function handleMailPush(client, payload) {
|
|
102
|
+
const data = (payload && payload.data) || {};
|
|
103
|
+
if (data.target !== 'MAIL') {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const projectId = projectIdOf(client);
|
|
107
|
+
if (!data.connectionId) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
if (data.kind === 'SUMMARY') {
|
|
111
|
+
const ids = typeof data.messageIds === 'string'
|
|
112
|
+
? data.messageIds.split(',').map((id) => id.trim()).filter(Boolean)
|
|
113
|
+
: [];
|
|
114
|
+
if (ids.length === 0) {
|
|
115
|
+
// 메시지 ID 없는 묶음(구버전 서버) — 거를 기준이 없어 건수 그대로 낸다.
|
|
116
|
+
const count = Number.parseInt(data.count, 10);
|
|
117
|
+
client.emit('newMailSummary', {
|
|
118
|
+
projectId,
|
|
119
|
+
connectionId: data.connectionId,
|
|
120
|
+
count: Number.isFinite(count) ? count : null,
|
|
121
|
+
messageIds: [],
|
|
122
|
+
source: 'push'
|
|
123
|
+
});
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
const deduper = deduperOf(client);
|
|
127
|
+
const unseen = ids.filter((id) => deduper.accept(keyOf(projectId, data.connectionId, id)));
|
|
128
|
+
if (unseen.length > 0) {
|
|
129
|
+
client.emit('newMailSummary', {
|
|
130
|
+
projectId,
|
|
131
|
+
connectionId: data.connectionId,
|
|
132
|
+
count: unseen.length,
|
|
133
|
+
messageIds: unseen,
|
|
134
|
+
source: 'push'
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
if (!data.messageId || !deduperOf(client).accept(keyOf(projectId, data.connectionId, data.messageId))) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
client.emit('newMail', {
|
|
143
|
+
projectId,
|
|
144
|
+
connectionId: data.connectionId,
|
|
145
|
+
messageId: data.messageId,
|
|
146
|
+
threadId: data.threadId || null,
|
|
147
|
+
sender: (payload.notification && payload.notification.title) || data.title || null,
|
|
148
|
+
from: null,
|
|
149
|
+
subject: (payload.notification && payload.notification.body) || data.body || null,
|
|
150
|
+
receivedAt: null,
|
|
151
|
+
source: 'push'
|
|
152
|
+
});
|
|
153
|
+
return true;
|
|
154
|
+
}
|
package/src/talkflow/session.js
CHANGED
|
@@ -3,6 +3,7 @@ import ConnectionManager from '../core/ConnectionManager.js';
|
|
|
3
3
|
import ChatClient from '../chat/ChatClient.js';
|
|
4
4
|
import WebRTCClient from '../webrtc/WebRTCClient.js';
|
|
5
5
|
import PushManager from '../push/PushManager.js';
|
|
6
|
+
import { handleMailPush } from './mailNotifications.js';
|
|
6
7
|
|
|
7
8
|
export function initializeSubClients(client) {
|
|
8
9
|
if (client.connectionManager) {
|
|
@@ -127,6 +128,7 @@ export function applySessionMethods(TalkFlowClient) {
|
|
|
127
128
|
if (this.chat) {
|
|
128
129
|
this.chat.unsubscribeAllRooms();
|
|
129
130
|
this.chat.unsubscribeRoomList();
|
|
131
|
+
this.chat.unsubscribeMail();
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
await this.connectionManager.disconnect();
|
|
@@ -217,6 +219,11 @@ export function applySessionMethods(TalkFlowClient) {
|
|
|
217
219
|
return;
|
|
218
220
|
}
|
|
219
221
|
|
|
222
|
+
// Gmail 메일 푸시 — 웹소켓 미리보기와 합쳐 newMail/newMailSummary 로만 낸다(pushNotification 아님).
|
|
223
|
+
if (handleMailPush(this, payload)) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
220
227
|
const activeRoomId = this.chat?.getActiveRoom();
|
|
221
228
|
|
|
222
229
|
if (activeRoomId && activeRoomId === data.roomId) {
|
|
@@ -291,6 +298,10 @@ export function applySessionMethods(TalkFlowClient) {
|
|
|
291
298
|
return PushManager.consumePendingRoom(this.options.projectId);
|
|
292
299
|
};
|
|
293
300
|
|
|
301
|
+
TalkFlowClient.prototype.consumePendingMail = async function consumePendingMail() {
|
|
302
|
+
return PushManager.consumePendingMail(this.options.projectId);
|
|
303
|
+
};
|
|
304
|
+
|
|
294
305
|
TalkFlowClient.prototype.setCurrentDeviceEnabled = function setCurrentDeviceEnabled(enabled) {
|
|
295
306
|
return this.pushManager?.setCurrentDeviceEnabled(enabled);
|
|
296
307
|
};
|