@vibexnpm/talkx 5.1.0 → 5.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibexnpm/talkx",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "type": "module",
5
5
  "description": "TalkFlow SDK - Chat & WebRTC unified client",
6
6
  "main": "dist/talkflow-sdk.umd.js",
@@ -1017,6 +1017,222 @@ class ChatClient extends EventEmitter {
1017
1017
  return this.apiClient.post(`/api/v1/rooms/group/${roomId}/unpin`);
1018
1018
  }
1019
1019
 
1020
+ // ── 투표 ────────────────────────────────────────────────────────────────
1021
+ //
1022
+ // 투표 서버는 클러스터 내부에만 있어 클라이언트가 직접 부를 수 없다. 모든 호출은 채팅 서버를 거치고,
1023
+ // 신원(userId/projectId)은 채팅 서버가 인증 결과로 채워 넣는다.
1024
+ //
1025
+ // 🔴 실패는 서버가 투표 서버의 code 를 <b>그대로</b> 내려준다 — {@code error.response.code} 로 분기한다
1026
+ // (VOTE_ALREADY_CLOSED / VOTE_NOT_EDITABLE / VOTE_MODIFY_FORBIDDEN / CONFLICT ...).
1027
+ // 409 는 세 종류이고 대응이 다르다: ALREADY_CLOSED·NOT_EDITABLE 은 재시도해도 같고,
1028
+ // CONFLICT 만 <b>재조회 후</b> 재전송하면 성공한다.
1029
+ //
1030
+ // 🔴 결과 마스킹은 <b>호출자마다</b> 계산된다. 목록에는 집계가 없고, 단건 조회/접수 응답에만 있다.
1031
+ // 같은 투표라도 다른 사용자가 보면 가려지므로 응답을 공유 캐시에 넣으면 안 된다.
1032
+
1033
+ /**
1034
+ * 투표 생성.
1035
+ *
1036
+ * <p>🔴 <b>{@code idempotencyKey} 는 필수다.</b> SDK 가 대신 만들지 않는다 — 만들면 그 값이 호출자에게
1037
+ * 남지 않아, 응답만 유실된 타임아웃에서 <b>복구가 불가능</b>하다(다음 호출이 새 키를 만들어 투표가
1038
+ * 두 개 생긴다). 호출자가 키를 소유하고, 같은 시도의 재시도에는 <b>같은 키</b>를 넘긴다 —
1039
+ * 그러면 서버가 기존 투표를 돌려준다(응답도 201, {@code voteId} 도 같다).</p>
1040
+ *
1041
+ * @param {string} roomId - 채팅방 ID
1042
+ * @param {Object} vote - 투표 폼
1043
+ * @param {string} vote.question - 질문 (최대 500자)
1044
+ * @param {'SINGLE'|'MULTIPLE'} vote.type
1045
+ * @param {number} vote.maxSelections - 양수. {@code SINGLE} 이면 1
1046
+ * @param {'TEXT'|'TEXT_WITH_IMAGE'} vote.optionType - {@code 'IMAGE'} 라는 값은 없다
1047
+ * @param {number} vote.closeAt - 마감 시각 (epoch millis). 과거·현재 불가 · 최대 30일 뒤
1048
+ * @param {Array<{id: string, content: string, order: number, imageUrl?: string}>} vote.options - 2~10개.
1049
+ * {@code imageUrl} 은 {@link #uploadVoteImage} 가 돌려준 값을 <b>가공 없이</b> 넣는다
1050
+ * @param {Object} options
1051
+ * @param {string} options.idempotencyKey - <b>필수.</b> 재시도에는 반드시 같은 값을 넘긴다
1052
+ * @returns {Promise<Object>} 생성된 투표 (집계 포함)
1053
+ * @throws {Error} {@code idempotencyKey} 가 없으면 서버를 호출하지 않고 즉시 실패한다
1054
+ * @example
1055
+ * const key = crypto.randomUUID();
1056
+ * const vote = await chat.createVote(roomId, {
1057
+ * question: '점심 뭐 먹지', type: 'SINGLE', optionType: 'TEXT', maxSelections: 1,
1058
+ * closeAt: Date.now() + 86_400_000,
1059
+ * options: [{ id: 'opt-1', content: '삼겹살', order: 0 }, { id: 'opt-2', content: '국밥', order: 1 }]
1060
+ * }, { idempotencyKey: key });
1061
+ */
1062
+ async createVote(roomId, vote, options = {}) {
1063
+ if (!roomId) {
1064
+ throw new Error('roomId is required');
1065
+ }
1066
+ const { idempotencyKey } = options;
1067
+ if (!idempotencyKey) {
1068
+ // SDK 가 만들어 주면 호출자는 그 값을 볼 수 없다 — 타임아웃으로 응답만 유실됐을 때
1069
+ // 같은 키로 재시도할 방법이 사라지고, 다음 호출이 투표를 하나 더 만든다.
1070
+ throw new Error(
1071
+ 'createVote: options.idempotencyKey is required — '
1072
+ + 'generate it once per vote and reuse the same value on retry '
1073
+ + '(재시도에 같은 키를 넘겨야 투표가 중복 생성되지 않는다).'
1074
+ );
1075
+ }
1076
+ const response = await this.apiClient.post(
1077
+ `/api/v1/rooms/${roomId}/votes`,
1078
+ vote,
1079
+ undefined,
1080
+ { 'X-Idempotency-Key': idempotencyKey }
1081
+ );
1082
+ return this._unwrapSuccessResponse(response);
1083
+ }
1084
+
1085
+ /**
1086
+ * 방의 투표 목록 (최신순).
1087
+ *
1088
+ * <p>목록 항목에는 선택지별 집계도 내 선택도 없다 — 참여자 수({@code responseCount})만 있다.
1089
+ * 결과를 보려면 {@link #getVote} 를 쓴다.</p>
1090
+ *
1091
+ * @param {string} roomId - 채팅방 ID
1092
+ * @param {Object} [options]
1093
+ * @param {string} [options.cursor] - 이전 응답의 {@code nextCursor} 를 <b>그대로</b>. 불투명 문자열이라 파싱하지 않는다
1094
+ * @param {number} [options.limit=20] - 최대 100. 초과분은 서버가 조용히 100 으로 보정한다(에러 아님)
1095
+ * @returns {Promise<{items: Array<Object>, nextCursor: ?string}>} {@code items} 가 비면 끝
1096
+ */
1097
+ async getVotes(roomId, options = {}) {
1098
+ const { cursor, limit } = options;
1099
+ const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/votes`, { cursor, limit });
1100
+ return this._unwrapSuccessResponse(response);
1101
+ }
1102
+
1103
+ /**
1104
+ * 투표 단건 + 집계.
1105
+ *
1106
+ * <p>{@code revealed: false} 일 때의 {@code count}/{@code ratio} 0 은 「참여자가 없다」가 아니라
1107
+ * <b>서버가 가린 값</b>이다. 결과 공개 조건은 「내가 응답했다」 또는 「마감됐다」. 비공개 상태에서도
1108
+ * {@code myOptionIds} 는 채워진다(재투표 화면용).</p>
1109
+ *
1110
+ * <p>{@code ratio} 의 분모는 응답자 수이지 총 선택 수가 아니다 — 복수 선택에서 <b>합이 100을 넘는 것이
1111
+ * 정상</b>이고 화면이 「복수 응답」임을 표기해야 한다.</p>
1112
+ *
1113
+ * <p>마감 여부는 {@code status} 만으로 정해지지 않는다. 서버 자동 마감이 미구현이라
1114
+ * {@code status: 'OPEN'} 인데 {@code closeAt} 이 지난 투표가 있고 그건 마감된 것으로 동작한다 —
1115
+ * <b>{@code status} 와 {@code closeAt} 을 함께</b> 봐야 한다.</p>
1116
+ *
1117
+ * @param {string} roomId - 채팅방 ID
1118
+ * @param {string} voteId - 투표 ID
1119
+ * @returns {Promise<Object>}
1120
+ */
1121
+ async getVote(roomId, voteId) {
1122
+ const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/votes/${voteId}`);
1123
+ return this._unwrapSuccessResponse(response);
1124
+ }
1125
+
1126
+ /**
1127
+ * 투표 수정 — <b>부분 수정이 아니다.</b> 등록 폼 전체를 보낸다({@link #createVote} 와 같은 타입).
1128
+ *
1129
+ * <p>{@code maxSelections}↔{@code type}, {@code optionType}↔{@code imageUrl} 이 서로 묶여 있어
1130
+ * 한쪽만 바꾸면 판독 불가능한 투표가 만들어지기 때문이다.</p>
1131
+ *
1132
+ * <p>조건 둘: <b>개설자만</b> · <b>참여자가 0명일 때만</b>. 표가 들어오면
1133
+ * {@code VOTE_NOT_EDITABLE}(409), 개설자가 아니면 {@code VOTE_MODIFY_FORBIDDEN}(403).
1134
+ * 선택지 {@code id} 를 이전 값과 맞출 필요는 없다 — 참여자가 0명이라 어긋날 응답이 없다.</p>
1135
+ *
1136
+ * @param {string} roomId - 채팅방 ID
1137
+ * @param {string} voteId - 투표 ID
1138
+ * @param {Object} vote - 투표 폼 (생성과 동일)
1139
+ * @returns {Promise<Object>}
1140
+ */
1141
+ async updateVote(roomId, voteId, vote) {
1142
+ const response = await this.apiClient.patch(`/api/v1/rooms/${roomId}/votes/${voteId}`, vote);
1143
+ return this._unwrapSuccessResponse(response);
1144
+ }
1145
+
1146
+ /**
1147
+ * 투표 삭제 — 개설자면 된다. 마감 여부도 참여자 유무도 보지 않는다.
1148
+ *
1149
+ * <p>채팅에 이미 게시된 투표 카드는 <b>그대로 남는다</b>(서버가 발행된 메시지를 고칠 수 없다).
1150
+ * 원본 카드를 누르면 404 가 오고, 이 404 를 「취소된 투표」로 표시하는 것은 화면 몫이다.
1151
+ * 삭제 사실은 별도의 취소 카드 메시지로도 방에 발행된다.</p>
1152
+ *
1153
+ * @param {string} roomId - 채팅방 ID
1154
+ * @param {string} voteId - 투표 ID
1155
+ * @returns {Promise<void>} 본문 없음 (204)
1156
+ */
1157
+ async deleteVote(roomId, voteId) {
1158
+ await this.apiClient.delete(`/api/v1/rooms/${roomId}/votes/${voteId}`);
1159
+ }
1160
+
1161
+ /**
1162
+ * 수동 마감 (개설자만).
1163
+ *
1164
+ * <p>본문이 없으므로 마감 후 결과를 보려면 {@link #getVote} 를 다시 호출한다.
1165
+ * 이미 마감이면 {@code VOTE_ALREADY_CLOSED}(409), 개설자가 아니면 {@code VOTE_CLOSE_FORBIDDEN}(403)
1166
+ * — 수정의 403 과 code 가 다르다.</p>
1167
+ *
1168
+ * @param {string} roomId - 채팅방 ID
1169
+ * @param {string} voteId - 투표 ID
1170
+ * @returns {Promise<void>} 본문 없음 (204)
1171
+ */
1172
+ async closeVote(roomId, voteId) {
1173
+ await this.apiClient.patch(`/api/v1/rooms/${roomId}/votes/${voteId}/close`);
1174
+ }
1175
+
1176
+ /**
1177
+ * 투표 접수 · 재투표 — <b>같은 메서드</b>다.
1178
+ *
1179
+ * <p>재투표는 새 자원을 만들지 않고 기존 응답을 덮어쓴다. 클라이언트가 최초/재투표를 구분해 보낼 필요가
1180
+ * 없고, 같은 값을 여러 번 보내도 결과가 같다. 응답에 <b>갱신된 집계가 함께</b> 오므로 별도 폴링이 필요 없다.</p>
1181
+ *
1182
+ * <p>빈 배열은 「투표 취소」로 해석되지 않는다 — 투표를 취소하는 경로는 없다(400).</p>
1183
+ *
1184
+ * <p>여러 규칙을 동시에 위반해도 서버는 <b>먼저 걸린 하나만</b> 응답한다. 하나를 고쳐 재시도하면
1185
+ * 다음 위반이 드러날 수 있어, 모든 오류를 한 번에 수집하는 화면은 성립하지 않는다.</p>
1186
+ *
1187
+ * @param {string} roomId - 채팅방 ID
1188
+ * @param {string} voteId - 투표 ID
1189
+ * @param {string[]} optionIds - 고른 선택지. 빈 배열 불가
1190
+ * @returns {Promise<Object>} 갱신된 투표 (집계 포함)
1191
+ */
1192
+ async submitVote(roomId, voteId, optionIds) {
1193
+ const response = await this.apiClient.put(
1194
+ `/api/v1/rooms/${roomId}/votes/${voteId}/responses`,
1195
+ { optionIds }
1196
+ );
1197
+ return this._unwrapSuccessResponse(response);
1198
+ }
1199
+
1200
+ /**
1201
+ * 선택지 이미지 업로드.
1202
+ *
1203
+ * <p>돌려받은 {@code imageUrl} 을 <b>가공 없이 그대로</b> 생성·수정 본문의 {@code options[].imageUrl} 에
1204
+ * 넣는다 — 서버가 문자열로 비교하고, 이 API 가 발급한 URL 만 통과한다. <b>채팅 첨부 URL 은 쓸 수 없다</b>
1205
+ * (같은 버킷이라도 거절된다).</p>
1206
+ *
1207
+ * <p>형식은 매직넘버로 판정한다(PNG · JPEG · GIF · WEBP) — 신고된 Content-Type 과 확장자는 쓰이지 않으므로
1208
+ * {@code .png} 로 이름 붙인 텍스트 파일은 거절된다. 10MB 를 넘으면 {@code VOTE_IMAGE_TOO_LARGE}.</p>
1209
+ *
1210
+ * <p>{@code optionType: 'TEXT_WITH_IMAGE'} 는 「이미지를 쓸 수 있다」는 상한이지 「모두 이미지다」가 아니다 —
1211
+ * 이미지가 있는 선택지와 없는 선택지를 섞어도 되고 하나도 없어도 된다. 다만 {@code imageUrl} 이
1212
+ * <b>빈 문자열</b>이면 거절된다(없으면 키를 아예 빼거나 {@code null}).</p>
1213
+ *
1214
+ * @param {string} roomId - 채팅방 ID
1215
+ * @param {File|Blob} file - 이미지 파일
1216
+ * @param {Object} [options]
1217
+ * @param {Function} [options.onProgress] - {@code ({loaded, total, percent}) => void}
1218
+ * @param {AbortSignal} [options.signal] - 취소용
1219
+ * @returns {Promise<{imageUrl: string}>}
1220
+ */
1221
+ async uploadVoteImage(roomId, file, options = {}) {
1222
+ if (!roomId) {
1223
+ throw new Error('roomId is required');
1224
+ }
1225
+ if (!file) {
1226
+ throw new Error('file is required');
1227
+ }
1228
+ const response = await this.apiClient.upload(
1229
+ `/api/v1/rooms/${roomId}/votes/images`,
1230
+ file,
1231
+ { onProgress: options.onProgress, signal: options.signal }
1232
+ );
1233
+ return this._unwrapSuccessResponse(response);
1234
+ }
1235
+
1020
1236
  /**
1021
1237
  * 이모지 리액션 토글.
1022
1238
  * <p>한 사용자는 메시지당 하나의 이모지만 보유.
@@ -65,7 +65,16 @@ const CHAT_DELEGATE_METHODS = [
65
65
  'deactivateRoomPmPrompt',
66
66
  'getRoomPmPromptVersions',
67
67
  'activateRoomPmPromptVersion',
68
- 'previewRoomPmPrompt'
68
+ 'previewRoomPmPrompt',
69
+ // 투표 — 투표 서버는 클러스터 내부에만 있어 클라이언트가 직접 부를 수 없다. 전부 채팅 서버 경유.
70
+ 'createVote',
71
+ 'getVotes',
72
+ 'getVote',
73
+ 'updateVote',
74
+ 'deleteVote',
75
+ 'closeVote',
76
+ 'submitVote',
77
+ 'uploadVoteImage'
69
78
  ];
70
79
 
71
80
  const WEBRTC_DELEGATE_METHODS = [
@@ -62,10 +62,12 @@ class ApiClient {
62
62
  * @param {Object} [options] - 옵션
63
63
  * @param {Object} [options.body] - 요청 본문
64
64
  * @param {Object} [options.params] - URL 파라미터
65
+ * @param {Object} [options.headers] - 이 요청에만 얹는 추가 헤더. 기본 헤더(Authorization 등)와 병합된다.
66
+ * 같은 이름이면 이 값이 이긴다 — 인증 헤더를 덮어쓰지 않도록 호출자가 주의한다.
65
67
  * @returns {Promise<Object>}
66
68
  */
67
69
  async request(method, path, options = {}) {
68
- const { body, params } = options;
70
+ const { body, params, headers } = options;
69
71
 
70
72
  // URL 빌드
71
73
  let url = `${this.baseUrl}${path}`;
@@ -92,7 +94,7 @@ class ApiClient {
92
94
 
93
95
  response = await fetch(url, {
94
96
  method,
95
- headers: this._getHeaders(),
97
+ headers: headers ? { ...this._getHeaders(), ...headers } : this._getHeaders(),
96
98
  body: body ? JSON.stringify(body) : undefined,
97
99
  signal: controller.signal
98
100
  });
@@ -152,10 +154,12 @@ class ApiClient {
152
154
  * @param {string} path - API 경로
153
155
  * @param {Object} [body] - 요청 본문
154
156
  * @param {Object} [params] - URL 파라미터
157
+ * @param {Object} [headers] - 이 요청에만 얹는 추가 헤더 (예: {@code X-Idempotency-Key}).
158
+ * 기존 호출자는 인자를 넘기지 않으므로 동작이 바뀌지 않는다.
155
159
  * @returns {Promise<Object>}
156
160
  */
157
- post(path, body, params) {
158
- return this.request('POST', path, { body, params });
161
+ post(path, body, params, headers) {
162
+ return this.request('POST', path, { body, params, headers });
159
163
  }
160
164
 
161
165
  /**
package/types/index.d.ts CHANGED
@@ -753,6 +753,177 @@ export interface ChatMessage {
753
753
  decisionRequest?: DecisionRequest | null;
754
754
  /** 사용자의 선택. 어느 카드인지는 {@code replyTo} 가 특정한다. */
755
755
  decisionResponse?: DecisionResponse | null;
756
+ /**
757
+ * 투표 카드 — 투표가 열리거나 마감·취소됐음을 알리는 메시지의 payload.
758
+ *
759
+ * <p><b>모르면 그냥 텍스트로 그려라 — 그게 정상 동작이다.</b> {@code content} 에 같은 내용이
760
+ * 사람이 읽는 형태로 들어 있다. 반대로 카드를 그린다면 {@code content} 를 다시 그리지 마라.</p>
761
+ *
762
+ * <p>카드에는 집계가 없다. 결과는 {@code voteId} 로 {@link ChatClient.getVote} 를 호출해 받는다 —
763
+ * 마스킹이 호출자마다 다르게 계산되기 때문이다.</p>
764
+ */
765
+ voteCard?: VoteCard | null;
766
+ }
767
+
768
+ /**
769
+ * 투표 카드 payload — 이벤트 하나당 메시지 하나다(개설·마감·취소).
770
+ *
771
+ * <p>이미 발행된 카드는 고쳐지지 않는다. 투표가 삭제돼도 개설 카드는 그대로 남고 조회가 404 가 되며,
772
+ * 「취소됨」 표시는 화면 몫이다(별도의 취소 카드도 함께 발행된다).</p>
773
+ */
774
+ export interface VoteCard {
775
+ voteId: string;
776
+ /**
777
+ * {@code 'OPENED'} | {@code 'CLOSED'} | {@code 'CANCELED'}.
778
+ *
779
+ * <p>🔴 <b>모르는 값이 올 수 있다</b> — 서버가 나중에 종류를 늘려도 구버전이 깨지지 않도록 열린 문자열이다.
780
+ * switch 의 default 는 텍스트 폴백으로 둔다.</p>
781
+ */
782
+ kind: 'OPENED' | 'CLOSED' | 'CANCELED' | (string & {});
783
+ /**
784
+ * 발행 시점의 질문 <b>스냅샷</b>. 참여자가 0명인 동안 투표가 수정될 수 있으므로 최신값이 필요하면
785
+ * {@link ChatClient.getVote} 로 조회한다. 마감·취소 카드에서는 비어 있을 수 있다.
786
+ */
787
+ question?: string | null;
788
+ /** 마감 예정 시각 (epoch millis, UTC). 마감·취소 카드에서는 null 일 수 있다. */
789
+ closeAt?: number | null;
790
+ }
791
+
792
+ // ── 투표 (createVote / getVotes / getVote / submitVote ...) ──
793
+
794
+ /** 투표 등록 폼 — 생성과 수정이 같은 타입이다({@code PATCH} 지만 부분 수정이 아니다). */
795
+ export interface VoteForm {
796
+ /** 최대 500자. 공백만이면 거절. */
797
+ question: string;
798
+ type: 'SINGLE' | 'MULTIPLE';
799
+ /** 양수. {@code SINGLE} 이면 1. */
800
+ maxSelections: number;
801
+ /** {@code 'IMAGE'} 라는 값은 없다. */
802
+ optionType: 'TEXT' | 'TEXT_WITH_IMAGE';
803
+ /** 마감 시각 (epoch millis). 과거·현재 불가 · 최대 30일 뒤. 무기한 투표는 없다. */
804
+ closeAt: number;
805
+ /** 2~10개. */
806
+ options: VoteFormOption[];
807
+ }
808
+
809
+ export interface VoteFormOption {
810
+ /** {@code .} {@code $} NUL 사용 불가 · 최대 100자 · 중복 불가 (집계 필드명으로 쓰인다). */
811
+ id: string;
812
+ /** 최대 200자. */
813
+ content: string;
814
+ /** 표시 순서. */
815
+ order: number;
816
+ /**
817
+ * {@link ChatClient.uploadVoteImage} 가 돌려준 값을 <b>가공 없이</b> 넣는다 — 서버가 문자열로 비교한다.
818
+ * 채팅 첨부 URL 은 쓸 수 없다. {@code optionType: 'TEXT'} 면 넣으면 안 되고, 빈 문자열도 거절된다.
819
+ */
820
+ imageUrl?: string | null;
821
+ }
822
+
823
+ /** 투표 단건 + 집계 — 생성 · 수정 · 단건조회 · 접수 응답. */
824
+ export interface Vote {
825
+ voteId: string;
826
+ roomId: string;
827
+ question: string;
828
+ type: 'SINGLE' | 'MULTIPLE';
829
+ optionType: 'TEXT' | 'TEXT_WITH_IMAGE';
830
+ maxSelections: number;
831
+ /**
832
+ * {@code 'OPEN'} | {@code 'CLOSED'}.
833
+ *
834
+ * <p>🔴 <b>이 값만으로 마감을 판정하면 안 된다</b> — 서버 자동 마감이 미구현이라 {@code closeAt} 이
835
+ * 지났는데도 {@code 'OPEN'} 인 투표가 있고, 그건 마감된 것으로 동작한다. 둘을 함께 본다.</p>
836
+ */
837
+ status: 'OPEN' | 'CLOSED' | (string & {});
838
+ /** epoch millis. */
839
+ closeAt: number;
840
+ /** 마감 전이면 null. epoch millis. */
841
+ closedAt?: number | null;
842
+ creatorId: string;
843
+ createdAt: number;
844
+ updatedAt: number;
845
+ /**
846
+ * 결과 공개 여부. 공개 조건은 「내가 응답했다」 또는 「마감됐다」.
847
+ *
848
+ * <p>🔴 {@code false} 일 때의 {@code count}/{@code ratio} 0 은 「참여자가 없다」가 아니라
849
+ * <b>서버가 가린 값</b>이다. 「아직 0표」로 표시하면 거짓말이 된다.</p>
850
+ */
851
+ revealed: boolean;
852
+ /** 응답자 수. <b>득표 합이 아니다</b> — 복수 선택에서 값이 다르다. */
853
+ totalResponses: number;
854
+ /** 호출자가 고른 선택지. 없으면 빈 배열. 비공개 상태에서도 채워진다(재투표 화면용). */
855
+ myOptionIds: string[];
856
+ options: VoteOptionResult[];
857
+ }
858
+
859
+ export interface VoteOptionResult {
860
+ optionId: string;
861
+ content: string;
862
+ order: number;
863
+ /** {@code optionType} 이 {@code 'TEXT'} 면 항상 null. */
864
+ imageUrl?: string | null;
865
+ /** 득표 수. 비공개면 0(가려진 값). */
866
+ count: number;
867
+ /**
868
+ * 응답자 대비 %, 소수 첫째 자리.
869
+ *
870
+ * <p>분모가 <b>응답자 수</b>이지 총 선택 수가 아니다 — 복수 선택 투표에서는 합이 100을 넘는 것이
871
+ * 정상이고, 화면이 「복수 응답」임을 표기해야 한다.</p>
872
+ */
873
+ ratio: number;
874
+ }
875
+
876
+ /** {@link ChatClient.getVotes} 옵션. */
877
+ export interface GetVotesOptions {
878
+ /** 이전 응답의 {@code nextCursor} 를 <b>그대로</b>. 불투명 문자열이라 파싱하지 않는다. */
879
+ cursor?: string;
880
+ /** 기본 20, 최대 100. 초과분은 서버가 조용히 100 으로 보정한다(에러 아님). */
881
+ limit?: number;
882
+ }
883
+
884
+ /** {@link ChatClient.getVotes} 반환. */
885
+ export interface VoteList {
886
+ /** 최신순. 비면 끝이다. */
887
+ items: VoteListItem[];
888
+ /** null 이면 끝. 마지막 페이지가 정확히 {@code limit} 개면 채워진 채 오고, 그 커서로 부르면 빈 목록이 온다. */
889
+ nextCursor?: string | null;
890
+ }
891
+
892
+ /** 목록 항목 — {@code optionType} · {@code options} · {@code myOptionIds} · {@code revealed} 가 <b>없다</b>. */
893
+ export interface VoteListItem {
894
+ voteId: string;
895
+ question: string;
896
+ type: 'SINGLE' | 'MULTIPLE';
897
+ status: 'OPEN' | 'CLOSED' | (string & {});
898
+ closeAt: number;
899
+ creatorId: string;
900
+ createdAt: number;
901
+ /** 참여자 수. 선택지별 집계는 목록에 없다 — 마스킹이 사용자마다 다르기 때문이다. */
902
+ responseCount: number;
903
+ }
904
+
905
+ /** {@link ChatClient.createVote} 옵션. */
906
+ export interface CreateVoteOptions {
907
+ /**
908
+ * 🔴 <b>필수.</b> SDK 가 대신 만들지 않는다 — 만들면 그 값이 호출자에게 남지 않아 응답만 유실된
909
+ * 타임아웃에서 복구가 불가능하다(다음 호출이 새 키를 만들어 투표가 두 개 생긴다).
910
+ *
911
+ * <p>한 투표당 한 번 만들어 보관하고, 같은 시도의 재시도에는 <b>같은 값</b>을 넘긴다 —
912
+ * 그러면 서버가 기존 투표를 돌려준다(201, 같은 voteId).</p>
913
+ */
914
+ idempotencyKey: string;
915
+ }
916
+
917
+ /** {@link ChatClient.uploadVoteImage} 옵션. */
918
+ export interface UploadVoteImageOptions {
919
+ onProgress?: (progress: { loaded: number; total: number; percent: number }) => void;
920
+ signal?: AbortSignal;
921
+ }
922
+
923
+ /** {@link ChatClient.uploadVoteImage} 반환. */
924
+ export interface VoteImage {
925
+ /** 생성·수정 본문의 {@code options[].imageUrl} 에 <b>가공 없이</b> 넣는다. */
926
+ imageUrl: string;
756
927
  }
757
928
 
758
929
  /**
@@ -1721,6 +1892,24 @@ export class ChatClient implements SdkEventEmitter<ChatClientEvents> {
1721
1892
  refreshSummary(roomId: string, options?: RefreshSummaryOptions): Promise<RefreshSummaryResult>;
1722
1893
  /** Rolling 누적 요약 조회 — rollup + segment 페이지. 저장된 요약의 단일 진실 소스. */
1723
1894
  getSummary(roomId: string, options?: GetSummaryOptions): Promise<RoomSummaryView>;
1895
+
1896
+ // ── 투표 ──
1897
+ /** 투표 생성. {@code idempotencyKey} 필수 — 재시도에 같은 값을 넘겨야 투표가 두 개 안 생긴다. */
1898
+ createVote(roomId: string, vote: VoteForm, options: CreateVoteOptions): Promise<Vote>;
1899
+ /** 방의 투표 목록(최신순). 집계는 없고 참여자 수만 있다. */
1900
+ getVotes(roomId: string, options?: GetVotesOptions): Promise<VoteList>;
1901
+ /** 투표 단건 + 집계. 마스킹이 호출자마다 다르므로 공유 캐시에 넣지 않는다. */
1902
+ getVote(roomId: string, voteId: string): Promise<Vote>;
1903
+ /** 투표 수정 — 폼 전체를 보낸다. 개설자 + 참여자 0명일 때만. */
1904
+ updateVote(roomId: string, voteId: string, vote: VoteForm): Promise<Vote>;
1905
+ /** 투표 삭제(204). 이미 발행된 카드는 남고 조회가 404 가 된다. */
1906
+ deleteVote(roomId: string, voteId: string): Promise<void>;
1907
+ /** 수동 마감(204). 결과를 보려면 {@link getVote} 를 다시 호출한다. */
1908
+ closeVote(roomId: string, voteId: string): Promise<void>;
1909
+ /** 투표 접수 · 재투표 — 같은 메서드. 응답에 갱신된 집계가 함께 온다. */
1910
+ submitVote(roomId: string, voteId: string, optionIds: string[]): Promise<Vote>;
1911
+ /** 선택지 이미지 업로드. 반환 {@code imageUrl} 을 가공 없이 폼에 넣는다. */
1912
+ uploadVoteImage(roomId: string, file: File | Blob, options?: UploadVoteImageOptions): Promise<VoteImage>;
1724
1913
  /** 방 PM 프롬프트 레이어 조회 — PM_BACKSTAGE 방 전용, 참여자 전체. 미등록 시 404. */
1725
1914
  getRoomPmPrompt(roomId: string): Promise<PmPromptLayerResponse>;
1726
1915
  /** 방 PM 프롬프트 레이어 등록/수정 (upsert, 새 버전 자동 기록) — 방 주인(OWNER) 전용. content 1~2,000자. */
@@ -2089,6 +2278,24 @@ export default class TalkFlowClient implements SdkEventEmitter<TalkFlowClientEve
2089
2278
  refreshSummary(roomId: string, options?: RefreshSummaryOptions): Promise<RefreshSummaryResult>;
2090
2279
  /** Rolling 누적 요약 조회 — rollup + segment 페이지. 저장된 요약의 단일 진실 소스. */
2091
2280
  getSummary(roomId: string, options?: GetSummaryOptions): Promise<RoomSummaryView>;
2281
+
2282
+ // ── 투표 ──
2283
+ /** 투표 생성. {@code idempotencyKey} 필수 — 재시도에 같은 값을 넘겨야 투표가 두 개 안 생긴다. */
2284
+ createVote(roomId: string, vote: VoteForm, options: CreateVoteOptions): Promise<Vote>;
2285
+ /** 방의 투표 목록(최신순). 집계는 없고 참여자 수만 있다. */
2286
+ getVotes(roomId: string, options?: GetVotesOptions): Promise<VoteList>;
2287
+ /** 투표 단건 + 집계. 마스킹이 호출자마다 다르므로 공유 캐시에 넣지 않는다. */
2288
+ getVote(roomId: string, voteId: string): Promise<Vote>;
2289
+ /** 투표 수정 — 폼 전체를 보낸다. 개설자 + 참여자 0명일 때만. */
2290
+ updateVote(roomId: string, voteId: string, vote: VoteForm): Promise<Vote>;
2291
+ /** 투표 삭제(204). 이미 발행된 카드는 남고 조회가 404 가 된다. */
2292
+ deleteVote(roomId: string, voteId: string): Promise<void>;
2293
+ /** 수동 마감(204). 결과를 보려면 {@link getVote} 를 다시 호출한다. */
2294
+ closeVote(roomId: string, voteId: string): Promise<void>;
2295
+ /** 투표 접수 · 재투표 — 같은 메서드. 응답에 갱신된 집계가 함께 온다. */
2296
+ submitVote(roomId: string, voteId: string, optionIds: string[]): Promise<Vote>;
2297
+ /** 선택지 이미지 업로드. 반환 {@code imageUrl} 을 가공 없이 폼에 넣는다. */
2298
+ uploadVoteImage(roomId: string, file: File | Blob, options?: UploadVoteImageOptions): Promise<VoteImage>;
2092
2299
  /** 방 PM 프롬프트 레이어 조회 — PM_BACKSTAGE 방 전용, 참여자 전체. 미등록 시 404. */
2093
2300
  getRoomPmPrompt(roomId: string): Promise<PmPromptLayerResponse>;
2094
2301
  /** 방 PM 프롬프트 레이어 등록/수정 (upsert, 새 버전 자동 기록) — 방 주인(OWNER) 전용. content 1~2,000자. */