@noverachat/sdk-web 0.0.3
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/LICENSE +21 -0
- package/README.ko.md +86 -0
- package/README.md +86 -0
- package/dist/index.cjs +1737 -0
- package/dist/index.d.cts +1151 -0
- package/dist/index.d.ts +1151 -0
- package/dist/index.js +1707 -0
- package/package.json +43 -0
- package/src/client.ts +733 -0
- package/src/errors.ts +59 -0
- package/src/features/room.ts +1292 -0
- package/src/generated/README.ko.md +15 -0
- package/src/generated/README.md +15 -0
- package/src/generated/wire.ts +224 -0
- package/src/index.ts +27 -0
- package/src/internal/event-bus.ts +42 -0
- package/src/internal/temp-id.ts +10 -0
- package/src/transport/http.ts +81 -0
- package/src/transport/ws.ts +200 -0
- package/src/types.ts +375 -0
- package/src/utils/debounce.ts +14 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
type WsInbound = WsConnected | WsAck | WsChatReceive | WsMessageUpdated | WsMessageDeleted | WsMessagesCleared | WsReactionAdded | WsReactionRemoved | WsSyncTick | WsTypingBroadcast | WsRoomEvent | WsAnnouncementEvent | WsPresence | WsPong | WsError | WsCloseNotice;
|
|
2
|
+
type MessageType = "TEXT" | "FILE" | "EMOTICON" | "ADMIN" | "SYSTEM";
|
|
3
|
+
type FileKind = "image" | "video" | "file";
|
|
4
|
+
type FileThumbnailStatus = "pending" | "ready" | "failed" | "skipped";
|
|
5
|
+
type DeleteScope = "MY" | "ALL";
|
|
6
|
+
type WsOutbound = WsChatSend | WsReadWatermark | WsTypingSend | WsPing;
|
|
7
|
+
interface WsConnected {
|
|
8
|
+
type: "connected";
|
|
9
|
+
session_id: string;
|
|
10
|
+
server_ts: number;
|
|
11
|
+
protocol_version: string;
|
|
12
|
+
gap_fill_url: string;
|
|
13
|
+
}
|
|
14
|
+
interface WsAck {
|
|
15
|
+
type: "ack";
|
|
16
|
+
temp_id: string | null;
|
|
17
|
+
message_id: string;
|
|
18
|
+
room_id: string;
|
|
19
|
+
server_ts: number;
|
|
20
|
+
}
|
|
21
|
+
interface WsChatReceive {
|
|
22
|
+
type: "chat_receive";
|
|
23
|
+
room_id: string;
|
|
24
|
+
/**
|
|
25
|
+
* Snowflake id as string
|
|
26
|
+
*/
|
|
27
|
+
message_id: string;
|
|
28
|
+
sender_id: string | null;
|
|
29
|
+
message_type: MessageType;
|
|
30
|
+
custom_type?: string | null;
|
|
31
|
+
content?: string | null;
|
|
32
|
+
data?: {
|
|
33
|
+
[k: string]: unknown;
|
|
34
|
+
} | null;
|
|
35
|
+
meta?: {
|
|
36
|
+
[k: string]: unknown;
|
|
37
|
+
} | null;
|
|
38
|
+
file_id?: string | null;
|
|
39
|
+
file?: FileInlineWire | null;
|
|
40
|
+
files?: FileInlineWire[] | null;
|
|
41
|
+
reply_to_id?: string | null;
|
|
42
|
+
/**
|
|
43
|
+
* unix ms
|
|
44
|
+
*/
|
|
45
|
+
created_at: number;
|
|
46
|
+
/**
|
|
47
|
+
* unix ms
|
|
48
|
+
*/
|
|
49
|
+
server_ts: number;
|
|
50
|
+
/**
|
|
51
|
+
* server-echoed on self-echo only
|
|
52
|
+
*/
|
|
53
|
+
temp_id?: string | null;
|
|
54
|
+
}
|
|
55
|
+
interface FileInlineWire {
|
|
56
|
+
id: string;
|
|
57
|
+
kind: FileKind;
|
|
58
|
+
mime: string;
|
|
59
|
+
size: number;
|
|
60
|
+
name?: string | null;
|
|
61
|
+
width?: number | null;
|
|
62
|
+
height?: number | null;
|
|
63
|
+
duration_sec?: number | null;
|
|
64
|
+
thumbnail_status: FileThumbnailStatus;
|
|
65
|
+
forward_blocked?: boolean;
|
|
66
|
+
}
|
|
67
|
+
interface WsMessageUpdated {
|
|
68
|
+
type: "message_updated";
|
|
69
|
+
room_id: string;
|
|
70
|
+
message_id: string;
|
|
71
|
+
content?: string | null;
|
|
72
|
+
data?: {
|
|
73
|
+
[k: string]: unknown;
|
|
74
|
+
} | null;
|
|
75
|
+
meta?: {
|
|
76
|
+
[k: string]: unknown;
|
|
77
|
+
} | null;
|
|
78
|
+
updated_at: number;
|
|
79
|
+
}
|
|
80
|
+
interface WsMessageDeleted {
|
|
81
|
+
type: "message_deleted";
|
|
82
|
+
room_id: string;
|
|
83
|
+
message_id: string;
|
|
84
|
+
scope: DeleteScope;
|
|
85
|
+
deleted_by_user_id?: string | null;
|
|
86
|
+
deleted_at: number;
|
|
87
|
+
}
|
|
88
|
+
interface WsMessagesCleared {
|
|
89
|
+
type: "messages_cleared";
|
|
90
|
+
room_id: string;
|
|
91
|
+
up_to_message_id: string | null;
|
|
92
|
+
cleared_by_user_id: string | null;
|
|
93
|
+
cleared_count: number;
|
|
94
|
+
cleared_at: number;
|
|
95
|
+
}
|
|
96
|
+
interface WsReactionAdded {
|
|
97
|
+
type: "reaction_added";
|
|
98
|
+
room_id: string;
|
|
99
|
+
message_id: string;
|
|
100
|
+
reaction_key: string;
|
|
101
|
+
user_id: string;
|
|
102
|
+
updated_at: number;
|
|
103
|
+
}
|
|
104
|
+
interface WsReactionRemoved {
|
|
105
|
+
type: "reaction_removed";
|
|
106
|
+
room_id: string;
|
|
107
|
+
message_id: string;
|
|
108
|
+
reaction_key: string;
|
|
109
|
+
user_id: string;
|
|
110
|
+
updated_at: number;
|
|
111
|
+
}
|
|
112
|
+
interface WsSyncTick {
|
|
113
|
+
type: "sync_tick";
|
|
114
|
+
room_id: string;
|
|
115
|
+
read_states: {
|
|
116
|
+
[k: string]: string;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
interface WsTypingBroadcast {
|
|
120
|
+
type: "typing";
|
|
121
|
+
room_id: string;
|
|
122
|
+
user_id: string;
|
|
123
|
+
is_typing: boolean;
|
|
124
|
+
}
|
|
125
|
+
interface WsRoomEvent {
|
|
126
|
+
type: "room_event";
|
|
127
|
+
event: string;
|
|
128
|
+
room_id: string;
|
|
129
|
+
target_user_id: string;
|
|
130
|
+
by_user_id: string | null;
|
|
131
|
+
server_ts: number;
|
|
132
|
+
}
|
|
133
|
+
interface WsAnnouncementEvent {
|
|
134
|
+
type: "announcement_event";
|
|
135
|
+
event: "CREATED" | "UPDATED" | "DELETED";
|
|
136
|
+
room_id: string;
|
|
137
|
+
announcement: AnnouncementWire | null;
|
|
138
|
+
announcement_id: number;
|
|
139
|
+
by: string;
|
|
140
|
+
at: number;
|
|
141
|
+
}
|
|
142
|
+
interface AnnouncementWire {
|
|
143
|
+
id: number;
|
|
144
|
+
room_id: string;
|
|
145
|
+
content: string;
|
|
146
|
+
created_by: string;
|
|
147
|
+
created_at: string;
|
|
148
|
+
updated_at: string;
|
|
149
|
+
}
|
|
150
|
+
interface WsPresence {
|
|
151
|
+
type: "presence";
|
|
152
|
+
user_id: string;
|
|
153
|
+
online: boolean;
|
|
154
|
+
at: number;
|
|
155
|
+
}
|
|
156
|
+
interface WsPong {
|
|
157
|
+
type: "pong";
|
|
158
|
+
server_ts: number;
|
|
159
|
+
client_ts?: number | null;
|
|
160
|
+
}
|
|
161
|
+
interface WsError {
|
|
162
|
+
type: "error";
|
|
163
|
+
code: string;
|
|
164
|
+
message: string;
|
|
165
|
+
details?: {
|
|
166
|
+
[k: string]: unknown;
|
|
167
|
+
} | null;
|
|
168
|
+
}
|
|
169
|
+
interface WsCloseNotice {
|
|
170
|
+
type: "close_notice";
|
|
171
|
+
reason: string;
|
|
172
|
+
retry_after_ms?: number;
|
|
173
|
+
}
|
|
174
|
+
interface WsChatSend {
|
|
175
|
+
type: "chat_send";
|
|
176
|
+
room_id: string;
|
|
177
|
+
content?: string | null;
|
|
178
|
+
message_type: "TEXT" | "FILE" | "EMOTICON";
|
|
179
|
+
custom_type?: string | null;
|
|
180
|
+
data?: {
|
|
181
|
+
[k: string]: unknown;
|
|
182
|
+
} | null;
|
|
183
|
+
meta?: {
|
|
184
|
+
[k: string]: unknown;
|
|
185
|
+
} | null;
|
|
186
|
+
file_id?: string | null;
|
|
187
|
+
reply_to_id?: string | null;
|
|
188
|
+
forward_of_message_id?: string | null;
|
|
189
|
+
temp_id?: string | null;
|
|
190
|
+
client_ts: number;
|
|
191
|
+
}
|
|
192
|
+
interface WsReadWatermark {
|
|
193
|
+
type: "read_watermark";
|
|
194
|
+
room_id: string;
|
|
195
|
+
last_read_message_id: string;
|
|
196
|
+
}
|
|
197
|
+
interface WsTypingSend {
|
|
198
|
+
type: "typing";
|
|
199
|
+
room_id: string;
|
|
200
|
+
is_typing: boolean;
|
|
201
|
+
}
|
|
202
|
+
interface WsPing {
|
|
203
|
+
type: "ping";
|
|
204
|
+
client_ts: number;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Shared wire types. Must stay in lock-step with:
|
|
209
|
+
* noverachat-backend/src/noverachat/ws/protocol.py
|
|
210
|
+
* noverachat-backend/src/noverachat/modules/message/schemas.py
|
|
211
|
+
*
|
|
212
|
+
* NOTE on `message_id` / `reply_to_id` / `last_*_message_id`: server-side
|
|
213
|
+
* these are 63-bit Snowflake integers. On the wire they are JSON STRINGS
|
|
214
|
+
* because JS `Number` can only represent integers up to 2^53 exactly.
|
|
215
|
+
* Stringifying preserves precision; the SDK never converts them back to
|
|
216
|
+
* Number. Compare with `BigInt(a) > BigInt(b)`.
|
|
217
|
+
*/
|
|
218
|
+
/** 63-bit Snowflake serialized as a JSON string. Treat as opaque. */
|
|
219
|
+
type MessageIdStr = string;
|
|
220
|
+
|
|
221
|
+
interface SenderMini {
|
|
222
|
+
userId: string;
|
|
223
|
+
nickname?: string | null;
|
|
224
|
+
profileImageUrl?: string | null;
|
|
225
|
+
}
|
|
226
|
+
interface ReactionEntry {
|
|
227
|
+
key: string;
|
|
228
|
+
userIds: string[];
|
|
229
|
+
updatedAt: number;
|
|
230
|
+
}
|
|
231
|
+
interface MessageOut {
|
|
232
|
+
appId: string;
|
|
233
|
+
roomId: string;
|
|
234
|
+
messageId: MessageIdStr;
|
|
235
|
+
sender?: SenderMini | null;
|
|
236
|
+
senderId?: string | null;
|
|
237
|
+
messageType: MessageType;
|
|
238
|
+
customType?: string | null;
|
|
239
|
+
content?: string | null;
|
|
240
|
+
data?: Record<string, unknown> | null;
|
|
241
|
+
meta?: Record<string, unknown> | null;
|
|
242
|
+
/** Custom metadata attached by the sender (extracted from `data._customMeta`).
|
|
243
|
+
* Null when the sender didn't attach anything. */
|
|
244
|
+
customMeta?: Record<string, unknown> | null;
|
|
245
|
+
/** Snowflake bigint (string) of the attached file, when this is a file
|
|
246
|
+
* message. Use ``chat.getFileUrl(fileId)`` to obtain a short-lived
|
|
247
|
+
* signed download URL, or ``chat.getFileMeta(fileId)`` for metadata
|
|
248
|
+
* without the download. Null for text-only messages. */
|
|
249
|
+
fileId?: MessageIdStr | null;
|
|
250
|
+
/** Inline file metadata embedded by the server on chat_receive — lets
|
|
251
|
+
* the UI render a placeholder (filename, size, dimensions, thumbnail
|
|
252
|
+
* status) without an extra round-trip. May be null even when `fileId`
|
|
253
|
+
* is set (e.g. for messages loaded via REST history before the inline
|
|
254
|
+
* metadata feature shipped); call ``getFileMeta()`` in that case. */
|
|
255
|
+
file?: FileInline | null;
|
|
256
|
+
/** Multi-file bundle — set when the message references multiple files
|
|
257
|
+
* via ``data.file_ids`` (kind = "file_group"). Each entry has the
|
|
258
|
+
* same shape as ``file``. Null for single-file / text-only messages.
|
|
259
|
+
* UIs should check ``files?.length > 0`` first and only fall through
|
|
260
|
+
* to the ``file`` singleton when the bundle field is absent. */
|
|
261
|
+
files?: FileInline[] | null;
|
|
262
|
+
replyToId?: MessageIdStr | null;
|
|
263
|
+
reactions: ReactionEntry[];
|
|
264
|
+
createdAt: string;
|
|
265
|
+
createdAtMs: number;
|
|
266
|
+
updatedAt?: string | null;
|
|
267
|
+
updatedAtMs?: number | null;
|
|
268
|
+
deletedAt?: string | null;
|
|
269
|
+
deletedByUserId?: string | null;
|
|
270
|
+
deleteScope?: DeleteScope | null;
|
|
271
|
+
editedCount: number;
|
|
272
|
+
}
|
|
273
|
+
interface RoomUnread {
|
|
274
|
+
roomId: string;
|
|
275
|
+
unreadCount: number;
|
|
276
|
+
lastReadMessageId: MessageIdStr | null;
|
|
277
|
+
lastMessageId: MessageIdStr | null;
|
|
278
|
+
}
|
|
279
|
+
interface Announcement {
|
|
280
|
+
id: number;
|
|
281
|
+
roomId: string;
|
|
282
|
+
content: string;
|
|
283
|
+
createdBy: string;
|
|
284
|
+
createdAt: string;
|
|
285
|
+
updatedAt: string;
|
|
286
|
+
}
|
|
287
|
+
/** One-way user block — KakaoTalk-style.
|
|
288
|
+
*
|
|
289
|
+
* A row in the caller's block list. The blocked user is NOT told they're
|
|
290
|
+
* blocked; they keep sending messages that just don't reach the blocker.
|
|
291
|
+
* See ``chat.blockUser`` / ``chat.unblockUser`` / ``chat.listBlocks``.
|
|
292
|
+
*/
|
|
293
|
+
interface Block {
|
|
294
|
+
blockedUserId: string;
|
|
295
|
+
reason: string | null;
|
|
296
|
+
/** ISO-8601 UTC. */
|
|
297
|
+
createdAt: string;
|
|
298
|
+
}
|
|
299
|
+
/** 방 초대 링크 (카톡 오픈채팅 URL 스타일). Minted by an OPERATOR via
|
|
300
|
+
* ``room.createInvite()``; consumed by any user via ``chat.acceptInvite()``. */
|
|
301
|
+
interface InviteToken {
|
|
302
|
+
/** URL-safe short id (~22 chars). Embed in the shareable URL as-is. */
|
|
303
|
+
token: string;
|
|
304
|
+
roomId: string;
|
|
305
|
+
createdBy: string;
|
|
306
|
+
/** ISO-8601 UTC. */
|
|
307
|
+
createdAt: string;
|
|
308
|
+
/** ISO-8601 UTC. `null` = never expires. */
|
|
309
|
+
expiresAt: string | null;
|
|
310
|
+
/** `null` = unlimited uses. */
|
|
311
|
+
maxUses: number | null;
|
|
312
|
+
/** Successful `accept` calls so far. */
|
|
313
|
+
usedCount: number;
|
|
314
|
+
/** ISO-8601 UTC. `null` = still active (assuming not expired / exhausted). */
|
|
315
|
+
revokedAt: string | null;
|
|
316
|
+
}
|
|
317
|
+
/** Result of `chat.acceptInvite(token)`. */
|
|
318
|
+
interface AcceptInviteResult {
|
|
319
|
+
roomId: string;
|
|
320
|
+
roomName: string | null;
|
|
321
|
+
/** True when the caller was already an active member — the call is a
|
|
322
|
+
* no-op and does NOT bump the invite's `used_count`. Useful for
|
|
323
|
+
* deep-link flows that may be triggered on re-open. */
|
|
324
|
+
wasAlreadyMember: boolean;
|
|
325
|
+
}
|
|
326
|
+
/** One member shown in a channel-list avatar mosaic. */
|
|
327
|
+
interface MemberPreview {
|
|
328
|
+
userId: string;
|
|
329
|
+
nickname: string | null;
|
|
330
|
+
profileImageUrl: string | null;
|
|
331
|
+
/** Cluster-wide presence dot. `null` = not resolved (endpoint didn't
|
|
332
|
+
* include it). Consumers rendering a green/gray dot should treat
|
|
333
|
+
* `null` and `false` differently only if they care about the
|
|
334
|
+
* "unknown" state. */
|
|
335
|
+
isOnline: boolean | null;
|
|
336
|
+
}
|
|
337
|
+
interface UnreadRoomItem {
|
|
338
|
+
roomId: string;
|
|
339
|
+
name: string | null;
|
|
340
|
+
/** "ONE" | "GROUP" | "SUPER_GROUP" — lets the UI pick a 1-up vs mosaic avatar. */
|
|
341
|
+
roomType: string | null;
|
|
342
|
+
unreadCount: number;
|
|
343
|
+
lastMessageId: MessageIdStr | null;
|
|
344
|
+
/** One-line preview shown under the room name in the channel list.
|
|
345
|
+
* TEXT/SYSTEM → first 200 chars, EMOTICON → "[이모티콘]", images/videos/
|
|
346
|
+
* files → "[사진]" / "[동영상]" / "[파일] name". Null when the room has
|
|
347
|
+
* no messages yet (or was just cleared). */
|
|
348
|
+
lastMessagePreview: string | null;
|
|
349
|
+
lastReadMessageId: MessageIdStr | null;
|
|
350
|
+
/** Total active (non-KICKED) members incl. the caller — the count badge. */
|
|
351
|
+
memberCount: number;
|
|
352
|
+
/** Up to 4 OTHER members (caller excluded) for the avatar mosaic. */
|
|
353
|
+
members: MemberPreview[];
|
|
354
|
+
}
|
|
355
|
+
interface UnreadSummary {
|
|
356
|
+
total: number;
|
|
357
|
+
rooms: UnreadRoomItem[];
|
|
358
|
+
}
|
|
359
|
+
interface AppPolicy {
|
|
360
|
+
appId: string;
|
|
361
|
+
/** When true, any non-KICKED room member can call
|
|
362
|
+
* `room.deleteAllMessages()`. When false, only operators (or super-admin)
|
|
363
|
+
* can. UIs should gate the destructive button on this. */
|
|
364
|
+
allowMemberBulkDelete: boolean;
|
|
365
|
+
/** When false, server does NOT broadcast `sync_tick` frames — hide
|
|
366
|
+
* read-receipt badges client-side. Per-user unread counts via REST
|
|
367
|
+
* still work (DB tracking is independent of the broadcast). */
|
|
368
|
+
readReceiptsEnabled: boolean;
|
|
369
|
+
/** When false, the server silently drops inbound `typing` frames and
|
|
370
|
+
* no peer ever sees "alice is typing…". SDK callers should also
|
|
371
|
+
* short-circuit `room.setTyping()` based on this flag so a disabled
|
|
372
|
+
* app doesn't pay the WS frame cost per keystroke. */
|
|
373
|
+
typingIndicatorsEnabled: boolean;
|
|
374
|
+
/** When true, any non-KICKED room member can hard-delete another
|
|
375
|
+
* member's message (scope=ALL). Default false — sender-or-operator
|
|
376
|
+
* only. UIs should gate the "Delete for everyone" button on other
|
|
377
|
+
* users' messages by this flag. */
|
|
378
|
+
deleteAnyMessageEnabled: boolean;
|
|
379
|
+
}
|
|
380
|
+
/** Push providers we can deliver to. Matches the backend `TokenType`. */
|
|
381
|
+
type DeviceTokenType = "FCM" | "APNS" | "HUAWEI";
|
|
382
|
+
/** Coarse device platform — used only for display / analytics on the
|
|
383
|
+
* server. Matches the backend `DeviceOS`. */
|
|
384
|
+
type DeviceOS = "iOS" | "Android" | "Web";
|
|
385
|
+
interface RegisterDeviceParams {
|
|
386
|
+
/** Client-stable installation id. NOT the push token — tokens rotate,
|
|
387
|
+
* this should not. Re-registering with the same deviceId just
|
|
388
|
+
* refreshes the stored token. */
|
|
389
|
+
deviceId: string;
|
|
390
|
+
/** The FCM / APNS / HMS push token. */
|
|
391
|
+
token: string;
|
|
392
|
+
tokenType: DeviceTokenType;
|
|
393
|
+
os?: DeviceOS;
|
|
394
|
+
appVersion?: string;
|
|
395
|
+
}
|
|
396
|
+
interface DeviceInfo {
|
|
397
|
+
deviceId: string;
|
|
398
|
+
tokenType: DeviceTokenType;
|
|
399
|
+
/** Server returns only a short prefix of the token — it's a credential
|
|
400
|
+
* and the caller already holds the full value. */
|
|
401
|
+
tokenPreview: string;
|
|
402
|
+
os: DeviceOS | null;
|
|
403
|
+
appVersion: string | null;
|
|
404
|
+
pushEnabled: boolean;
|
|
405
|
+
lastActiveAt: string | null;
|
|
406
|
+
createdAt: string;
|
|
407
|
+
}
|
|
408
|
+
interface SendParams {
|
|
409
|
+
content?: string;
|
|
410
|
+
messageType?: "TEXT" | "FILE" | "EMOTICON";
|
|
411
|
+
customType?: string;
|
|
412
|
+
data?: Record<string, unknown>;
|
|
413
|
+
meta?: Record<string, unknown>;
|
|
414
|
+
/** Attach an already-committed file (returned by ``room.sendFile()``'s
|
|
415
|
+
* internal upload flow, or by a prior ``presignFileUpload()`` +
|
|
416
|
+
* ``commitFileUpload()`` if you're orchestrating the upload manually).
|
|
417
|
+
* The message_type will be auto-set to ``FILE`` when this is supplied. */
|
|
418
|
+
fileId?: MessageIdStr;
|
|
419
|
+
/** Multi-file bundle — attach N already-committed files as ONE
|
|
420
|
+
* message. The SDK sets ``message_type = "FILE"``, ``file_id = null``,
|
|
421
|
+
* and writes the ids into ``data.file_ids`` (with
|
|
422
|
+
* ``data.kind = "file_group"``). Use ``room.uploadFileOnly()`` per
|
|
423
|
+
* file to collect the ids first. Mutually exclusive with ``fileId`` —
|
|
424
|
+
* when both are set, ``fileIds`` wins. */
|
|
425
|
+
fileIds?: MessageIdStr[];
|
|
426
|
+
/** Apply the "no-forward" flag to the whole bundle (mirrors the
|
|
427
|
+
* per-file flag set at upload time). Written to
|
|
428
|
+
* ``data.forward_blocked`` for the receiving client to gate its
|
|
429
|
+
* forward-toolbar button on. */
|
|
430
|
+
forwardBlocked?: boolean;
|
|
431
|
+
replyToId?: MessageIdStr;
|
|
432
|
+
/** Set when this send is forwarding an existing message. The server
|
|
433
|
+
* uses it to enforce the source file's ``forwardBlocked`` flag (Phase
|
|
434
|
+
* C #7): if the source's file is no-forward AND the destination room
|
|
435
|
+
* differs from the source room, the send is rejected with
|
|
436
|
+
* ``NC-FILE-014``. Pass the ORIGINAL message's ``messageId``. */
|
|
437
|
+
forwardOfMessageId?: MessageIdStr;
|
|
438
|
+
/** Arbitrary custom metadata attached to the message. Stored under
|
|
439
|
+
* `data._customMeta`. Surfaces on receiving clients as
|
|
440
|
+
* `MessageOut.customMeta`. */
|
|
441
|
+
customMeta?: Record<string, unknown>;
|
|
442
|
+
}
|
|
443
|
+
/** Compact metadata embedded in chat_receive frames. The full ``FileMeta``
|
|
444
|
+
* shape is returned by ``chat.getFileMeta(fileId)`` and matches the
|
|
445
|
+
* ``GET /files/{id}/meta`` response — currently identical to this
|
|
446
|
+
* inline form. */
|
|
447
|
+
interface FileInline {
|
|
448
|
+
id: MessageIdStr;
|
|
449
|
+
kind: FileKind;
|
|
450
|
+
mime: string;
|
|
451
|
+
/** Bytes. */
|
|
452
|
+
size: number;
|
|
453
|
+
/** Original filename as the uploader sent it — may be null when not
|
|
454
|
+
* known (some clients omit it). */
|
|
455
|
+
name?: string | null;
|
|
456
|
+
/** Pixel dimensions — populated for image / video after the thumbnail
|
|
457
|
+
* worker probes the file. May be null until the worker finishes. */
|
|
458
|
+
width?: number | null;
|
|
459
|
+
height?: number | null;
|
|
460
|
+
/** Seconds — video only. */
|
|
461
|
+
durationSec?: number | null;
|
|
462
|
+
thumbnailStatus: FileThumbnailStatus;
|
|
463
|
+
/** Uploader-set "no forward" flag. When true, another user cannot
|
|
464
|
+
* forward a message carrying this file to a different room — the
|
|
465
|
+
* server rejects such sends with NC-FILE-014. UIs should surface a
|
|
466
|
+
* 🚫 badge and hide the forward action for these files. Downloads
|
|
467
|
+
* and same-room views stay allowed. */
|
|
468
|
+
forwardBlocked: boolean;
|
|
469
|
+
}
|
|
470
|
+
/** Same shape as ``FileInline`` for now — kept distinct so future
|
|
471
|
+
* meta-only fields (e.g. uploader info, dedupe hash) can land here
|
|
472
|
+
* without forcing them onto every WS frame. */
|
|
473
|
+
type FileMeta = FileInline;
|
|
474
|
+
/** Per-call options for ``room.sendFile()``. */
|
|
475
|
+
interface SendFileOptions {
|
|
476
|
+
/** Optional progress callback fired during the S3 PUT. ``loaded`` and
|
|
477
|
+
* ``total`` are bytes; ``ratio`` is in [0, 1]. Called from an XHR
|
|
478
|
+
* ``upload.onprogress`` listener — at minimum the final ``ratio=1``
|
|
479
|
+
* fires when the PUT completes. */
|
|
480
|
+
onProgress?: (p: {
|
|
481
|
+
loaded: number;
|
|
482
|
+
total: number;
|
|
483
|
+
ratio: number;
|
|
484
|
+
}) => void;
|
|
485
|
+
/** Override the declared kind. Default: derived from the File's MIME
|
|
486
|
+
* type (``image/*`` → image, ``video/*`` → video, else file). Useful
|
|
487
|
+
* if you need to force a particular UI rendering. */
|
|
488
|
+
kind?: FileKind;
|
|
489
|
+
/** Override the declared filename. Default: ``file.name``. */
|
|
490
|
+
name?: string;
|
|
491
|
+
/** 파일 전달 제한 — when true, the uploader (this call) marks the
|
|
492
|
+
* file as "no-forward". Other users won't be able to forward the
|
|
493
|
+
* resulting message to a different room; the server rejects such
|
|
494
|
+
* sends with ``NC-FILE-014``. Downloads and same-room views stay
|
|
495
|
+
* allowed. Default false. */
|
|
496
|
+
forwardBlocked?: boolean;
|
|
497
|
+
/** Arbitrary custom metadata attached to the resulting file message.
|
|
498
|
+
* Stored under `data._customMeta`. Surfaces on receiving clients as
|
|
499
|
+
* `MessageOut.customMeta`. */
|
|
500
|
+
customMeta?: Record<string, unknown>;
|
|
501
|
+
}
|
|
502
|
+
interface ClientOptions {
|
|
503
|
+
appId: string;
|
|
504
|
+
endpoint: string;
|
|
505
|
+
wsEndpoint?: string;
|
|
506
|
+
tokenProvider: () => Promise<string>;
|
|
507
|
+
autoReconnect?: boolean;
|
|
508
|
+
pingIntervalMs?: number;
|
|
509
|
+
readWatermarkDebounceMs?: number;
|
|
510
|
+
maxReconnectDelayMs?: number;
|
|
511
|
+
/**
|
|
512
|
+
* Seed the per-room "highest-seen message_id" map at construction time.
|
|
513
|
+
* Use this to feed back values your app persisted to local storage in a
|
|
514
|
+
* previous session, so a cold page load can still drive `?since=...` on
|
|
515
|
+
* the WS handshake and surface unread messages via `chat.backfill()`.
|
|
516
|
+
*
|
|
517
|
+
* Shape: { [roomId]: lastSeenMessageId }
|
|
518
|
+
*/
|
|
519
|
+
initialLastMessageIds?: Record<string, MessageIdStr>;
|
|
520
|
+
fetch?: typeof fetch;
|
|
521
|
+
WebSocketImpl?: typeof WebSocket;
|
|
522
|
+
logger?: (lvl: "debug" | "info" | "warn" | "error", msg: string, ctx?: unknown) => void;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
interface HttpOptions {
|
|
526
|
+
baseUrl: string;
|
|
527
|
+
appId: string;
|
|
528
|
+
tokenProvider: () => Promise<string>;
|
|
529
|
+
fetch?: typeof fetch;
|
|
530
|
+
logger?: (lvl: "debug" | "info" | "warn" | "error", msg: string, ctx?: unknown) => void;
|
|
531
|
+
}
|
|
532
|
+
declare class HttpClient {
|
|
533
|
+
private opts;
|
|
534
|
+
constructor(opts: HttpOptions);
|
|
535
|
+
request<T>(method: string, path: string, body?: unknown, query?: Record<string, string | number | undefined>): Promise<T>;
|
|
536
|
+
private buildUrl;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Reconnecting WebSocket — state machine per docs/ws_protocol.md §6.
|
|
541
|
+
*
|
|
542
|
+
* IDLE → CONNECTING → OPEN → RECONNECTING → CONNECTING …
|
|
543
|
+
*
|
|
544
|
+
* - Exponential backoff with jitter (500ms → 30s cap)
|
|
545
|
+
* - Refuses to reconnect on 1008/4001/4002 (policy / app-inactive / user-banned)
|
|
546
|
+
* - Emits typed events via callbacks
|
|
547
|
+
*/
|
|
548
|
+
|
|
549
|
+
type WsState = "idle" | "connecting" | "open" | "reconnecting" | "closed";
|
|
550
|
+
interface ReconnectingWsOptions {
|
|
551
|
+
urlBuilder: () => Promise<string>;
|
|
552
|
+
onMessage: (msg: WsInbound) => void;
|
|
553
|
+
onStateChange?: (s: WsState) => void;
|
|
554
|
+
onOpen?: () => void;
|
|
555
|
+
pingIntervalMs: number;
|
|
556
|
+
maxReconnectDelayMs: number;
|
|
557
|
+
autoReconnect: boolean;
|
|
558
|
+
WebSocketImpl?: typeof WebSocket;
|
|
559
|
+
logger?: (lvl: "debug" | "info" | "warn" | "error", msg: string, ctx?: unknown) => void;
|
|
560
|
+
}
|
|
561
|
+
declare class ReconnectingWs {
|
|
562
|
+
private opts;
|
|
563
|
+
private ws;
|
|
564
|
+
private state;
|
|
565
|
+
private attempt;
|
|
566
|
+
private pingTimer;
|
|
567
|
+
private explicitClose;
|
|
568
|
+
private outboundQueue;
|
|
569
|
+
private static readonly OUTBOUND_QUEUE_CAP;
|
|
570
|
+
constructor(opts: ReconnectingWsOptions);
|
|
571
|
+
get currentState(): WsState;
|
|
572
|
+
connect(): Promise<void>;
|
|
573
|
+
close(): void;
|
|
574
|
+
send(msg: WsOutbound): boolean;
|
|
575
|
+
private openOnce;
|
|
576
|
+
private scheduleReconnect;
|
|
577
|
+
private startPings;
|
|
578
|
+
private stopPings;
|
|
579
|
+
private setState;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
interface RoomEvents {
|
|
583
|
+
message: WsChatReceive;
|
|
584
|
+
messageUpdated: WsMessageUpdated;
|
|
585
|
+
messageDeleted: WsMessageDeleted;
|
|
586
|
+
/** Operator (or super-admin) wiped the room. Hide every locally-rendered
|
|
587
|
+
* message with `message_id <= up_to_message_id`. */
|
|
588
|
+
messagesCleared: WsMessagesCleared;
|
|
589
|
+
reactionAdded: WsReactionAdded;
|
|
590
|
+
reactionRemoved: WsReactionRemoved;
|
|
591
|
+
syncTick: WsSyncTick;
|
|
592
|
+
typing: WsTypingBroadcast;
|
|
593
|
+
ack: {
|
|
594
|
+
tempId: string;
|
|
595
|
+
messageId: MessageIdStr;
|
|
596
|
+
};
|
|
597
|
+
announcement: WsAnnouncementEvent;
|
|
598
|
+
}
|
|
599
|
+
declare class Room {
|
|
600
|
+
readonly roomId: string;
|
|
601
|
+
private http;
|
|
602
|
+
private ws;
|
|
603
|
+
private bus;
|
|
604
|
+
private pendingAcks;
|
|
605
|
+
private static readonly MAX_RATE_LIMIT_RETRIES;
|
|
606
|
+
private static readonly RATE_LIMIT_BACKOFF_MS;
|
|
607
|
+
private lastReadSent;
|
|
608
|
+
private pendingReadMid;
|
|
609
|
+
private readDebounceTimer;
|
|
610
|
+
private readDebounceMs;
|
|
611
|
+
/**
|
|
612
|
+
* Every temp_id we've sent this session. The server echoes the same
|
|
613
|
+
* temp_id back on `chat_receive` for the sender's own sessions, so the
|
|
614
|
+
* client can dedup against its optimistic bubble regardless of whether
|
|
615
|
+
* the `ack` or the `chat_receive` arrives first. Unbounded growth is
|
|
616
|
+
* fine in practice (small strings, cleared on `chat.disconnect()`).
|
|
617
|
+
*/
|
|
618
|
+
private mySentTempIds;
|
|
619
|
+
constructor(roomId: string, http: HttpClient, ws: ReconnectingWs, readDebounceMs: number);
|
|
620
|
+
private _sendPendingMarkRead;
|
|
621
|
+
send(params: string | SendParams): Promise<{
|
|
622
|
+
tempId: string;
|
|
623
|
+
messageId: Promise<MessageIdStr>;
|
|
624
|
+
}>;
|
|
625
|
+
/**
|
|
626
|
+
* Send an admin-uploaded emoticon as a STICKER-like message.
|
|
627
|
+
*
|
|
628
|
+
* Thin wrapper over ``send()``: stamps ``messageType: "EMOTICON"`` and
|
|
629
|
+
* fills ``data`` with the pack / item identifiers + a stable URL so the
|
|
630
|
+
* receiving client can render the bubble without a separate fetch.
|
|
631
|
+
*
|
|
632
|
+
* Pass either an ``EmoticonItem``-shaped object (matching the response
|
|
633
|
+
* of ``GET /v1/emoticon-packs``) or the raw ids + url. ``caption`` is
|
|
634
|
+
* optional — KakaoTalk-style sticker bubbles render fine without one.
|
|
635
|
+
*/
|
|
636
|
+
sendEmoticon(item: {
|
|
637
|
+
packId: string;
|
|
638
|
+
itemId: string;
|
|
639
|
+
fileId?: string;
|
|
640
|
+
url: string;
|
|
641
|
+
}, caption?: string): Promise<{
|
|
642
|
+
tempId: string;
|
|
643
|
+
messageId: Promise<MessageIdStr>;
|
|
644
|
+
}>;
|
|
645
|
+
/**
|
|
646
|
+
* Send a file as a message. Wraps the three-step file pipeline:
|
|
647
|
+
*
|
|
648
|
+
* 1. ``POST /files`` to presign an S3 PUT
|
|
649
|
+
* 2. ``PUT`` the bytes direct to S3 (XHR — so we can report
|
|
650
|
+
* progress; ``fetch`` has no upload progress event)
|
|
651
|
+
* 3. ``POST /files/{id}/commit`` to flip the row to ``committed``
|
|
652
|
+
* and enqueue the thumbnail worker
|
|
653
|
+
* 4. ``room.send({ fileId, messageType: "FILE" })`` to broadcast
|
|
654
|
+
* the message to the room
|
|
655
|
+
*
|
|
656
|
+
* Returns the same ``{ tempId, messageId }`` shape as ``send()`` —
|
|
657
|
+
* callers reconcile the optimistic bubble against the eventual ack
|
|
658
|
+
* the same way text messages do.
|
|
659
|
+
*
|
|
660
|
+
* On any error before the final ``send()`` (upload aborted, S3
|
|
661
|
+
* rejected, commit failed) the row in ``files`` stays at
|
|
662
|
+
* ``upload_status=pending`` and the background cleanup worker
|
|
663
|
+
* reclaims it within ``FILE_PENDING_UPLOAD_TTL_SEC`` (24h default)
|
|
664
|
+
* — no client-side cleanup needed.
|
|
665
|
+
*/
|
|
666
|
+
sendFile(file: File, opts?: SendFileOptions): Promise<{
|
|
667
|
+
tempId: string;
|
|
668
|
+
messageId: Promise<MessageIdStr>;
|
|
669
|
+
/** Snowflake id of the just-committed file row. Useful when the
|
|
670
|
+
* caller renders an optimistic bubble with a placeholder and
|
|
671
|
+
* wants to swap in the real thumbnail / download URL as soon as
|
|
672
|
+
* the upload finishes (without waiting for the chat_receive
|
|
673
|
+
* echo, which the SDK suppresses for self-sent messages). */
|
|
674
|
+
fileId: MessageIdStr;
|
|
675
|
+
}>;
|
|
676
|
+
/**
|
|
677
|
+
* Upload a file (presign → S3 PUT → commit) WITHOUT emitting a chat
|
|
678
|
+
* message. Returns the committed ``file_id`` so callers can gather
|
|
679
|
+
* several ids and bundle them into ONE ``room.send({ fileIds: [...] })``
|
|
680
|
+
* for the multi-file "file_group" message pattern.
|
|
681
|
+
*
|
|
682
|
+
* Same 3-step pipeline as ``sendFile()`` minus step 4. Errors before
|
|
683
|
+
* commit leave the file at ``upload_status=pending`` — the cleanup
|
|
684
|
+
* worker reclaims it within ``FILE_PENDING_UPLOAD_TTL_SEC``.
|
|
685
|
+
*/
|
|
686
|
+
uploadFileOnly(file: File, opts?: SendFileOptions): Promise<MessageIdStr>;
|
|
687
|
+
/**
|
|
688
|
+
* List files shared in this room — newest-first, paginated.
|
|
689
|
+
*
|
|
690
|
+
* One entry per non-deleted message that carries a ``file_id`` —
|
|
691
|
+
* forwarded files (same file_id, multiple messages) appear once
|
|
692
|
+
* per occurrence. The history-visibility floor is applied so files
|
|
693
|
+
* attached to messages older than the caller's ``last_cleared_at``
|
|
694
|
+
* (or the post-rejoin cutoff for grouprooms) are filtered out.
|
|
695
|
+
*
|
|
696
|
+
* Use ``before`` with the smallest ``messageId`` of the previous
|
|
697
|
+
* page to paginate.
|
|
698
|
+
*/
|
|
699
|
+
listFiles(opts?: {
|
|
700
|
+
limit?: number;
|
|
701
|
+
before?: MessageIdStr;
|
|
702
|
+
}): Promise<{
|
|
703
|
+
items: Array<{
|
|
704
|
+
messageId: MessageIdStr;
|
|
705
|
+
senderId: string | null;
|
|
706
|
+
createdAtMs: number;
|
|
707
|
+
file: FileMeta;
|
|
708
|
+
}>;
|
|
709
|
+
nextCursor: MessageIdStr | null;
|
|
710
|
+
hasMore: boolean;
|
|
711
|
+
}>;
|
|
712
|
+
markRead(messageId: MessageIdStr): void;
|
|
713
|
+
/**
|
|
714
|
+
* Bypass the read_watermark debounce and send the pending watermark
|
|
715
|
+
* immediately. Use on critical UX boundaries (user leaves room,
|
|
716
|
+
* client disconnects, page unload) so the server's per-user unread
|
|
717
|
+
* count is correct by the time any other device / admin / push
|
|
718
|
+
* service queries it. No-op when nothing is pending.
|
|
719
|
+
*/
|
|
720
|
+
flushMarkRead(): void;
|
|
721
|
+
setTyping(isTyping: boolean): void;
|
|
722
|
+
edit(messageId: MessageIdStr, patch: {
|
|
723
|
+
content?: string;
|
|
724
|
+
data?: Record<string, unknown>;
|
|
725
|
+
meta?: Record<string, unknown>;
|
|
726
|
+
}): Promise<MessageOut>;
|
|
727
|
+
delete(messageId: MessageIdStr, scope?: DeleteScope): Promise<void>;
|
|
728
|
+
/** Per-user "Clear chat". Hides every existing message in this room
|
|
729
|
+
* from the calling user (via `room_members.last_cleared_at = now()`).
|
|
730
|
+
* Other members are unaffected — this is local history hiding, not
|
|
731
|
+
* a destructive bulk delete. Irreversible from the caller's side. */
|
|
732
|
+
clearHistory(): Promise<void>;
|
|
733
|
+
/** Leave this room (self). Deletes the caller's membership row — the room
|
|
734
|
+
* drops out of `unreadSummary()` and they stop receiving its messages.
|
|
735
|
+
* Other members get a `MEMBER_LEFT` [`roomEvent`](../reference/noverachat.md#events).
|
|
736
|
+
* Idempotent if already a non-member; throws 409 if the caller was KICKED
|
|
737
|
+
* (ask an operator to restore first). For GROUP/SUPER_GROUP, re-entry needs
|
|
738
|
+
* an operator add; for ONE, re-creating the 1:1 rejoins with history kept. */
|
|
739
|
+
leave(): Promise<void>;
|
|
740
|
+
/**
|
|
741
|
+
* Set the caller's per-room push preference — like long-pressing a
|
|
742
|
+
* chat and tapping "notifications off".
|
|
743
|
+
* "ALL" — push for every message (default)
|
|
744
|
+
* "MENTION_ONLY" — push only when the message @-mentions the caller
|
|
745
|
+
* "OFF" — mute this room's push entirely
|
|
746
|
+
* Scoped to the calling user; throws if they're not a room member.
|
|
747
|
+
*/
|
|
748
|
+
setPushTrigger(trigger: "ALL" | "MENTION_ONLY" | "OFF"): Promise<void>;
|
|
749
|
+
/**
|
|
750
|
+
* **DESTRUCTIVE, operator-only.** Soft-deletes every currently-undeleted
|
|
751
|
+
* message in the room — visible to all members instantly via a
|
|
752
|
+
* `messagesCleared` event (subscribe via `room.on("messagesCleared", ...)`).
|
|
753
|
+
* Server enforces the operator check; throws 403 for regular members.
|
|
754
|
+
* Returns the cleared count plus the cutoff `message_id` so callers can
|
|
755
|
+
* hide rendered messages up to (and including) that id.
|
|
756
|
+
*/
|
|
757
|
+
deleteAllMessages(): Promise<{
|
|
758
|
+
cleared: number;
|
|
759
|
+
upToMessageId: MessageIdStr | null;
|
|
760
|
+
}>;
|
|
761
|
+
react(messageId: MessageIdStr, key: string): Promise<void>;
|
|
762
|
+
unreact(messageId: MessageIdStr, key: string): Promise<void>;
|
|
763
|
+
listSince(sinceMessageId: MessageIdStr | number, limit?: number): Promise<{
|
|
764
|
+
items: MessageOut[];
|
|
765
|
+
nextCursor: MessageIdStr | null;
|
|
766
|
+
hasMore: boolean;
|
|
767
|
+
}>;
|
|
768
|
+
/**
|
|
769
|
+
* Load the latest `limit` messages of the room — what to render when the
|
|
770
|
+
* user just opens the chat. Returns ASC (oldest → newest within the
|
|
771
|
+
* page) so callers don't need to reverse. Use `room.listBefore(cursor)`
|
|
772
|
+
* to paginate further back.
|
|
773
|
+
*/
|
|
774
|
+
listRecent(limit?: number): Promise<{
|
|
775
|
+
items: MessageOut[];
|
|
776
|
+
nextCursor: MessageIdStr | null;
|
|
777
|
+
hasMore: boolean;
|
|
778
|
+
}>;
|
|
779
|
+
/**
|
|
780
|
+
* Scrollback. Fetches the page of `limit` messages immediately preceding
|
|
781
|
+
* `beforeMessageId` (exclusive), in ASC order. The returned
|
|
782
|
+
* `nextCursor` is the oldest id in this page — feed it back to keep
|
|
783
|
+
* scrolling up.
|
|
784
|
+
*/
|
|
785
|
+
listBefore(beforeMessageId: MessageIdStr, limit?: number): Promise<{
|
|
786
|
+
items: MessageOut[];
|
|
787
|
+
nextCursor: MessageIdStr | null;
|
|
788
|
+
hasMore: boolean;
|
|
789
|
+
}>;
|
|
790
|
+
/** Phase D #2 — 방 내 메시지 검색.
|
|
791
|
+
*
|
|
792
|
+
* ``content`` 필드에 대한 대소문자 무시 부분 문자열 매칭 (ILIKE). 삭제된
|
|
793
|
+
* 메시지는 제외. 결과는 최신순 (message_id DESC) 으로 반환되고, ``before``
|
|
794
|
+
* 커서로 뒤로 페이지네이션. 스티커/파일/이모티콘 등 ``content`` 가 없는
|
|
795
|
+
* 메시지는 검색 대상 X.
|
|
796
|
+
*
|
|
797
|
+
* @param q 검색어 (필수, 1~200자, whitespace-only 는 서버가 빈 결과 반환)
|
|
798
|
+
* @param opts.before 이전 페이지 마지막 message_id (다음 페이지 커서)
|
|
799
|
+
* @param opts.limit 페이지 크기 (기본 30, 최대 100)
|
|
800
|
+
*/
|
|
801
|
+
search(q: string, opts?: {
|
|
802
|
+
before?: MessageIdStr;
|
|
803
|
+
limit?: number;
|
|
804
|
+
}): Promise<{
|
|
805
|
+
items: MessageOut[];
|
|
806
|
+
nextCursor: MessageIdStr | null;
|
|
807
|
+
hasMore: boolean;
|
|
808
|
+
}>;
|
|
809
|
+
/** List this room's members (with each one's role). Handy for UIs that
|
|
810
|
+
* want to conditionally enable destructive operator-only actions like
|
|
811
|
+
* `room.deleteAllMessages()`. Server gates by membership for non-public
|
|
812
|
+
* rooms, so the caller must already be a member to read this.
|
|
813
|
+
*/
|
|
814
|
+
listMembers(): Promise<Array<{
|
|
815
|
+
userId: string;
|
|
816
|
+
nickname: string | null;
|
|
817
|
+
role: "OPERATOR" | "MEMBER" | "KICKED";
|
|
818
|
+
pushTrigger: "ALL" | "MENTION" | "OFF";
|
|
819
|
+
lastReadMessageId: MessageIdStr | null;
|
|
820
|
+
joinedAt: string;
|
|
821
|
+
/** Cluster-wide presence dot. `null` when the server didn't resolve it. */
|
|
822
|
+
isOnline: boolean | null;
|
|
823
|
+
/** Per-room mute expiry. `null` = not muted. A far-future sentinel
|
|
824
|
+
* (year 9999) means the operator picked "indefinite" — clients
|
|
825
|
+
* should render that as "무기한" instead of a real timestamp. */
|
|
826
|
+
mutedUntil: string | null;
|
|
827
|
+
}>>;
|
|
828
|
+
/** OPERATOR-only: mute a member so they can't send messages in this
|
|
829
|
+
* room. `durationMinutes=null` → indefinite (until an explicit unmute).
|
|
830
|
+
* Emits a `MEMBER_MUTED` [`roomEvent`](../reference/noverachat.md#events)
|
|
831
|
+
* + a SYSTEM chat message. Non-operator callers get 403. Server also
|
|
832
|
+
* refuses self-mute and muting another OPERATOR. */
|
|
833
|
+
muteMember(userId: string, durationMinutes: number | null): Promise<void>;
|
|
834
|
+
/** OPERATOR-only: clear a member's mute. Idempotent — no-op + no
|
|
835
|
+
* broadcast when the target wasn't muted. Emits a `MEMBER_UNMUTED`
|
|
836
|
+
* roomEvent + SYSTEM chat message otherwise. */
|
|
837
|
+
unmuteMember(userId: string): Promise<void>;
|
|
838
|
+
/** Server-side unread state for this room: exact COUNT(*) of messages
|
|
839
|
+
* newer than the caller's `read_watermark`, plus the room's
|
|
840
|
+
* `last_message_id` so the UI can show a "•" dot regardless of count. */
|
|
841
|
+
unread(): Promise<RoomUnread>;
|
|
842
|
+
/** OPERATOR-only moderation: freeze this room. Non-admin messages are
|
|
843
|
+
* blocked server-side with NC-ROOM-FROZEN while frozen. All members'
|
|
844
|
+
* clients receive a `roomEvent` with `event: "ROOM_FROZEN"` in real
|
|
845
|
+
* time, plus a SYSTEM chat message "방이 얼려졌습니다."
|
|
846
|
+
* Idempotent — no-op if already frozen. Non-operator callers get 403. */
|
|
847
|
+
freeze(): Promise<void>;
|
|
848
|
+
/** Mirror of `freeze()` — reopens the room to chat_send. Emits a
|
|
849
|
+
* `ROOM_UNFROZEN` roomEvent + a "방이 녹여졌습니다." SYSTEM message. */
|
|
850
|
+
unfreeze(): Promise<void>;
|
|
851
|
+
/** OPERATOR-only: toggle room visibility. `true` → 공개방 (open chat,
|
|
852
|
+
* anyone can join). `false` → 비공개방 (invite-only, join requires
|
|
853
|
+
* approval). Auto-inserts a SYSTEM chat message and broadcasts a
|
|
854
|
+
* `ROOM_VISIBILITY_CHANGED` roomEvent so peers update in real time. */
|
|
855
|
+
setPublic(isPublic: boolean): Promise<void>;
|
|
856
|
+
listAnnouncements(): Promise<Announcement[]>;
|
|
857
|
+
getCurrentAnnouncement(): Promise<Announcement | null>;
|
|
858
|
+
postAnnouncement(content: string): Promise<Announcement>;
|
|
859
|
+
updateAnnouncement(id: number, content: string): Promise<Announcement>;
|
|
860
|
+
deleteAnnouncement(id: number): Promise<void>;
|
|
861
|
+
/**
|
|
862
|
+
* OPERATOR-only: mint a new invite link for this room. Default:
|
|
863
|
+
* expires in 7 days, unlimited uses. Pass `expiresInHours: null`
|
|
864
|
+
* for a link that never expires, or `maxUses: 1` for a one-shot.
|
|
865
|
+
*
|
|
866
|
+
* Returns the raw `InviteToken` — build the shareable URL yourself
|
|
867
|
+
* (e.g. `${location.origin}/#invite/${token.token}`) so you control
|
|
868
|
+
* the format your app uses to consume it.
|
|
869
|
+
*/
|
|
870
|
+
createInvite(opts?: {
|
|
871
|
+
/** Hours until the link stops working. Default 168 (7 days). Pass
|
|
872
|
+
* `null` for no expiry. */
|
|
873
|
+
expiresInHours?: number | null;
|
|
874
|
+
/** Max successful `acceptInvite` calls before the link
|
|
875
|
+
* auto-exhausts. Default null (unlimited). */
|
|
876
|
+
maxUses?: number | null;
|
|
877
|
+
}): Promise<InviteToken>;
|
|
878
|
+
/** OPERATOR-only: list all invite tokens for this room (including
|
|
879
|
+
* revoked / expired / exhausted), newest first. */
|
|
880
|
+
listInvites(): Promise<InviteToken[]>;
|
|
881
|
+
/** OPERATOR-only: revoke a token immediately. Idempotent — revoking
|
|
882
|
+
* an already-revoked token returns 204 as well. */
|
|
883
|
+
revokeInvite(token: string): Promise<void>;
|
|
884
|
+
on<K extends keyof RoomEvents>(event: K, fn: (p: RoomEvents[K]) => void): () => void;
|
|
885
|
+
off<K extends keyof RoomEvents>(event: K, fn: (p: RoomEvents[K]) => void): void;
|
|
886
|
+
_dispatchAnnouncement(msg: WsAnnouncementEvent): void;
|
|
887
|
+
_dispatchAck(tempId: string | null, messageId: MessageIdStr): void;
|
|
888
|
+
/** True iff this room has a still-pending send with this temp_id. */
|
|
889
|
+
_hasPendingTempId(tempId: string): boolean;
|
|
890
|
+
/** Reject the pending send for tempId with the given error. Used by the
|
|
891
|
+
* client when a server `error` frame echoes back our temp_id so we can
|
|
892
|
+
* fail the matching outbound promise immediately instead of waiting
|
|
893
|
+
* for the 15s ack timeout. */
|
|
894
|
+
_rejectPending(tempId: string, err: Error): void;
|
|
895
|
+
/**
|
|
896
|
+
* Try to re-send a pending frame after a transient server reject
|
|
897
|
+
* (currently only NC-RATE-001 — the tenant's per-second token bucket
|
|
898
|
+
* spilled). Reuses the same temp_id so:
|
|
899
|
+
* 1. ack matching on success still works,
|
|
900
|
+
* 2. self-echo dedup still recognizes the frame as ours,
|
|
901
|
+
* 3. *if* the server's first attempt secretly succeeded (we got an
|
|
902
|
+
* error frame but the message was already persisted) the second
|
|
903
|
+
* attempt's `chat_receive` self-echo collapses cleanly.
|
|
904
|
+
*
|
|
905
|
+
* Returns true if a retry was scheduled; false if we've already used
|
|
906
|
+
* up the retry budget and the caller should fall back to rejecting
|
|
907
|
+
* the promise.
|
|
908
|
+
*/
|
|
909
|
+
_retryPending(tempId: string): boolean;
|
|
910
|
+
/** True if this room originated a `chat_send` with the given temp_id
|
|
911
|
+
* during the current session. Used by the client dispatcher to detect
|
|
912
|
+
* self-echo chat_receive frames. */
|
|
913
|
+
_isMySentTempId(tempId: string): boolean;
|
|
914
|
+
/** Forget a self-sent temp_id once both the ack and the echo are reconciled. */
|
|
915
|
+
_forgetMySentTempId(tempId: string): void;
|
|
916
|
+
_dispatchInbound(msg: Extract<WsInbound, {
|
|
917
|
+
room_id?: string;
|
|
918
|
+
} | {
|
|
919
|
+
type: string;
|
|
920
|
+
}>): void;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* NoveraChat — top-level client facade.
|
|
925
|
+
*
|
|
926
|
+
* const chat = new NoveraChat({ appId, endpoint, tokenProvider });
|
|
927
|
+
* await chat.connect();
|
|
928
|
+
* const room = chat.room("room_123");
|
|
929
|
+
* room.on("message", ...);
|
|
930
|
+
* await room.send("hi");
|
|
931
|
+
* chat.disconnect();
|
|
932
|
+
*/
|
|
933
|
+
|
|
934
|
+
/** Client-level events (not tied to a single Room facade). `roomEvent` fires
|
|
935
|
+
* for membership / room-lifecycle changes on ANY room — including ones the
|
|
936
|
+
* caller hasn't opened yet (e.g. being added to a brand-new room), which a
|
|
937
|
+
* per-room `room.on(...)` listener could never catch. `presence` fires
|
|
938
|
+
* when any user this session shares a room with transitions online or
|
|
939
|
+
* offline (first / last live session only — multi-tab open/close stays
|
|
940
|
+
* silent). */
|
|
941
|
+
interface ClientEvents {
|
|
942
|
+
roomEvent: WsRoomEvent;
|
|
943
|
+
presence: WsPresence;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
declare class NoveraChat {
|
|
947
|
+
private opts;
|
|
948
|
+
private http;
|
|
949
|
+
private ws;
|
|
950
|
+
private rooms;
|
|
951
|
+
private bus;
|
|
952
|
+
private readDebounceMs;
|
|
953
|
+
private logger;
|
|
954
|
+
private connectedOnce;
|
|
955
|
+
private lastMessageIdByRoom;
|
|
956
|
+
/** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
|
|
957
|
+
* Used to drop the N-1 shared-room copies of a single online/offline
|
|
958
|
+
* transition — the server fans out one publish per room, and we don't
|
|
959
|
+
* want the caller's listener firing N times for one UX event. */
|
|
960
|
+
private lastPresenceKey;
|
|
961
|
+
constructor(opts: ClientOptions);
|
|
962
|
+
connect(): Promise<void>;
|
|
963
|
+
disconnect(): void;
|
|
964
|
+
room(roomId: string): Room;
|
|
965
|
+
/** Subscribe to client-level events. Currently `roomEvent` — membership /
|
|
966
|
+
* room-lifecycle changes for ANY room (added, kicked, restored, frozen,
|
|
967
|
+
* …), including rooms the caller hasn't opened. Returns an unsubscribe fn.
|
|
968
|
+
* Use this to refresh a channel list the instant you're added to a new
|
|
969
|
+
* room, rather than waiting for a poll. */
|
|
970
|
+
on<K extends keyof ClientEvents>(event: K, fn: (p: ClientEvents[K]) => void): () => void;
|
|
971
|
+
off<K extends keyof ClientEvents>(event: K, fn: (p: ClientEvents[K]) => void): void;
|
|
972
|
+
/**
|
|
973
|
+
* Manually fetch all messages newer than the highest known `message_id`
|
|
974
|
+
* for each room and surface them as regular `room.on("message", ...)`
|
|
975
|
+
* events. Useful at cold start (after `chat.connect()`) to pull the
|
|
976
|
+
* "unread since last session" tail — the WS reconnect path calls this
|
|
977
|
+
* automatically, but the first connect does not.
|
|
978
|
+
*
|
|
979
|
+
* No-op for rooms with no seeded id.
|
|
980
|
+
*/
|
|
981
|
+
backfill(): Promise<void>;
|
|
982
|
+
/** Highest `message_id` the client has seen for a given room, or null. */
|
|
983
|
+
lastSeenMessageId(roomId: string): MessageIdStr | null;
|
|
984
|
+
/**
|
|
985
|
+
* Monotonically advance the per-room "highest-seen message_id" anchor.
|
|
986
|
+
* Used by callers that learn a newer watermark from out-of-band sources —
|
|
987
|
+
* typically the server's own `last_read_message_id` (via the unread
|
|
988
|
+
* endpoint) so the next `backfill()` doesn't re-pull messages the user
|
|
989
|
+
* already marked read on another device. Never moves the value backward;
|
|
990
|
+
* a smaller `messageId` is silently ignored. Accepts either the string
|
|
991
|
+
* form (preferred) or a number (legacy / convenience).
|
|
992
|
+
*/
|
|
993
|
+
setLastSeen(roomId: string, messageId: MessageIdStr | number): void;
|
|
994
|
+
/** Server-side aggregate unread state: total count + per-room breakdown
|
|
995
|
+
* for the authenticated user. Single REST call; cheap enough to poll
|
|
996
|
+
* every few seconds for a badge, or just refresh on focus + after
|
|
997
|
+
* receiving new messages. */
|
|
998
|
+
/** Client-facing feature flags for the caller's app. Cheap (one row
|
|
999
|
+
* read on the server) — fetch once on connect to decide which
|
|
1000
|
+
* destructive buttons to expose. */
|
|
1001
|
+
appPolicy(): Promise<AppPolicy>;
|
|
1002
|
+
/**
|
|
1003
|
+
* Register (or refresh) this device's push token. Call after login and
|
|
1004
|
+
* whenever the platform hands you a rotated FCM/APNS token. Idempotent
|
|
1005
|
+
* on `deviceId` — the server upserts, so re-registering is cheap and
|
|
1006
|
+
* safe. Returns the stored device record (token shown only as a prefix).
|
|
1007
|
+
*/
|
|
1008
|
+
registerDevice(params: RegisterDeviceParams): Promise<DeviceInfo>;
|
|
1009
|
+
/** List the devices registered for the authenticated user. */
|
|
1010
|
+
listDevices(): Promise<DeviceInfo[]>;
|
|
1011
|
+
/**
|
|
1012
|
+
* Turn push delivery on/off for one device without unregistering it —
|
|
1013
|
+
* the token stays stored, the push pipeline just skips it. Use for an
|
|
1014
|
+
* in-app "notifications" toggle.
|
|
1015
|
+
*/
|
|
1016
|
+
setDevicePushEnabled(deviceId: string, enabled: boolean): Promise<void>;
|
|
1017
|
+
/** Unregister a device — call on logout so the user stops receiving
|
|
1018
|
+
* push on a device they're no longer signed into. */
|
|
1019
|
+
unregisterDevice(deviceId: string): Promise<void>;
|
|
1020
|
+
/**
|
|
1021
|
+
* Read the caller's GLOBAL push preference. When `pushEnabled` is
|
|
1022
|
+
* false the push worker skips this user entirely — every per-room
|
|
1023
|
+
* `push_trigger` setting is ignored. Fail-open: an unknown user
|
|
1024
|
+
* comes back as `pushEnabled: true`.
|
|
1025
|
+
*
|
|
1026
|
+
* Two levels of push control:
|
|
1027
|
+
* 1. **Per-room** — `room.setPushTrigger("ALL" | "MENTION_ONLY" | "OFF")`.
|
|
1028
|
+
* 2. **Global (this)** — overrides all rooms when off.
|
|
1029
|
+
*/
|
|
1030
|
+
getPushPreferences(): Promise<{
|
|
1031
|
+
pushEnabled: boolean;
|
|
1032
|
+
}>;
|
|
1033
|
+
/**
|
|
1034
|
+
* Flip the caller's global push kill switch. Pass `false` to mute
|
|
1035
|
+
* every push notification for this user across every room; pass
|
|
1036
|
+
* `true` to restore normal delivery. Idempotent.
|
|
1037
|
+
*/
|
|
1038
|
+
setPushEnabled(enabled: boolean): Promise<{
|
|
1039
|
+
pushEnabled: boolean;
|
|
1040
|
+
}>;
|
|
1041
|
+
/**
|
|
1042
|
+
* Block a user (one-way). Idempotent — repeated calls return the
|
|
1043
|
+
* existing row without changing `createdAt` or `reason`.
|
|
1044
|
+
*
|
|
1045
|
+
* @param userId The user to block. `chat.blockUser(myUserId)` throws
|
|
1046
|
+
* a validation error server-side.
|
|
1047
|
+
* @param reason Optional local note ≤500 chars.
|
|
1048
|
+
*/
|
|
1049
|
+
blockUser(userId: string, reason?: string): Promise<Block>;
|
|
1050
|
+
/** Unblock a user. Idempotent — resolves either way. */
|
|
1051
|
+
unblockUser(userId: string): Promise<void>;
|
|
1052
|
+
/** List every user the caller has blocked, newest first. */
|
|
1053
|
+
listBlocks(): Promise<Block[]>;
|
|
1054
|
+
private _toDeviceInfo;
|
|
1055
|
+
/**
|
|
1056
|
+
* Authorization-gated URL for downloading a file attachment.
|
|
1057
|
+
*
|
|
1058
|
+
* The backend runs the request through the authorization gate (caller
|
|
1059
|
+
* must be a non-KICKED member of some room with a non-deleted,
|
|
1060
|
+
* post-cutoff message referencing this file) and then 302-redirects
|
|
1061
|
+
* to a short-lived signed S3 URL. ``<img src=...>`` / ``<video
|
|
1062
|
+
* src=...>`` / ``<a href=...>`` consumers transparently follow the
|
|
1063
|
+
* redirect.
|
|
1064
|
+
*
|
|
1065
|
+
* Why the optional ``token`` argument? Browsers don't let
|
|
1066
|
+
* ``<img>``/``<video>`` tags carry custom Authorization headers —
|
|
1067
|
+
* so URLs destined for those elements have to embed credentials in
|
|
1068
|
+
* the URL itself. The backend's ``require_user`` dep accepts
|
|
1069
|
+
* ``?token=...&app_id=...`` query params as a fallback for exactly
|
|
1070
|
+
* this case (it still prefers headers when present). Pass the same
|
|
1071
|
+
* JWT the SDK was constructed with; ``app_id`` is filled in
|
|
1072
|
+
* automatically from the client options. Omit the token when you're
|
|
1073
|
+
* going to ``fetch()`` the URL yourself with the Authorization
|
|
1074
|
+
* header set (e.g. server-side preview).
|
|
1075
|
+
*
|
|
1076
|
+
* Note: each call returns a fresh URL but the URL embeds a JWT — its
|
|
1077
|
+
* lifetime is the JWT's TTL (~24h by default), not the few-seconds
|
|
1078
|
+
* window of the S3 signed URL the backend redirects to. Don't log it.
|
|
1079
|
+
*/
|
|
1080
|
+
fileUrl(fileId: MessageIdStr, token?: string): string;
|
|
1081
|
+
/**
|
|
1082
|
+
* Thumbnail URL for an image / video attachment. Returns 404 when
|
|
1083
|
+
* the thumbnail isn't ready yet (poll ``getFileMeta`` then re-render
|
|
1084
|
+
* once ``thumbnailStatus === "ready"``). Same token-in-query rule
|
|
1085
|
+
* as ``fileUrl()``.
|
|
1086
|
+
*/
|
|
1087
|
+
fileThumbnailUrl(fileId: MessageIdStr, token?: string): string;
|
|
1088
|
+
/**
|
|
1089
|
+
* Fetch a file's metadata without committing to a download. Useful
|
|
1090
|
+
* when ``Message.file`` (the inline copy) is missing — e.g. for
|
|
1091
|
+
* messages loaded from REST history that predate the inline-meta
|
|
1092
|
+
* shipping date.
|
|
1093
|
+
*/
|
|
1094
|
+
getFileMeta(fileId: MessageIdStr): Promise<FileMeta>;
|
|
1095
|
+
unreadSummary(): Promise<UnreadSummary>;
|
|
1096
|
+
/**
|
|
1097
|
+
* Accept an invite link — call this in flows where you receive a
|
|
1098
|
+
* shareable URL (e.g. after parsing a deep link like
|
|
1099
|
+
* `#invite/<token>`). Consumes one use of the token and joins the
|
|
1100
|
+
* caller to the target room; peers already in the room receive a
|
|
1101
|
+
* `MEMBER_JOINED` roomEvent and a SYSTEM chat message.
|
|
1102
|
+
*
|
|
1103
|
+
* Idempotent: if the caller is already an active member the call
|
|
1104
|
+
* succeeds with `wasAlreadyMember: true` and does NOT decrement the
|
|
1105
|
+
* token's remaining uses — safe to call again on every app open.
|
|
1106
|
+
*
|
|
1107
|
+
* Throws (via HttpClient's `NoveraChatError`) when the token is
|
|
1108
|
+
* invalid, expired, revoked, exhausted, or when the caller has been
|
|
1109
|
+
* kicked from the target room.
|
|
1110
|
+
*/
|
|
1111
|
+
acceptInvite(token: string): Promise<AcceptInviteResult>;
|
|
1112
|
+
private dispatch;
|
|
1113
|
+
private highestKnownMessageId;
|
|
1114
|
+
private backfillAllRooms;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Error taxonomy mirrors noverachat-backend/src/noverachat/core/errors.py.
|
|
1119
|
+
* Keep the list in-sync with the server.
|
|
1120
|
+
*/
|
|
1121
|
+
declare const ErrorCode: {
|
|
1122
|
+
readonly INTERNAL: "NC-GEN-001";
|
|
1123
|
+
readonly NOT_FOUND: "NC-GEN-002";
|
|
1124
|
+
readonly VALIDATION: "NC-GEN-003";
|
|
1125
|
+
readonly FORBIDDEN: "NC-GEN-004";
|
|
1126
|
+
readonly UNAUTHORIZED: "NC-AUTH-001";
|
|
1127
|
+
readonly TOKEN_EXPIRED: "NC-AUTH-002";
|
|
1128
|
+
readonly TOKEN_INVALID: "NC-AUTH-003";
|
|
1129
|
+
readonly TOKEN_REVOKED: "NC-AUTH-004";
|
|
1130
|
+
readonly APP_INACTIVE: "NC-APP-001";
|
|
1131
|
+
readonly APP_NOT_FOUND: "NC-APP-002";
|
|
1132
|
+
readonly USER_BANNED: "NC-USER-001";
|
|
1133
|
+
readonly USER_NOT_FOUND: "NC-USER-002";
|
|
1134
|
+
readonly ROOM_NOT_FOUND: "NC-ROOM-001";
|
|
1135
|
+
readonly ROOM_FROZEN: "NC-ROOM-002";
|
|
1136
|
+
readonly NOT_A_MEMBER: "NC-ROOM-003";
|
|
1137
|
+
readonly MESSAGE_TOO_LARGE: "NC-MSG-001";
|
|
1138
|
+
readonly MESSAGE_NOT_FOUND: "NC-MSG-002";
|
|
1139
|
+
readonly MESSAGE_EDIT_FORBID: "NC-MSG-003";
|
|
1140
|
+
readonly RATE_LIMITED: "NC-RATE-001";
|
|
1141
|
+
};
|
|
1142
|
+
type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
1143
|
+
declare class NoveraChatError extends Error {
|
|
1144
|
+
readonly code: string;
|
|
1145
|
+
readonly status: number;
|
|
1146
|
+
readonly details?: Record<string, unknown>;
|
|
1147
|
+
constructor(message: string, code?: string, status?: number, details?: Record<string, unknown>);
|
|
1148
|
+
static fromResponse(status: number, body: unknown): NoveraChatError;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
export { type AcceptInviteResult, type Announcement, type AppPolicy, type Block, type ClientOptions, type DeleteScope, ErrorCode, type ErrorCodeValue, type InviteToken, type MessageOut, type MessageType, NoveraChat, NoveraChatError, type ReactionEntry, Room, type RoomEvents, type RoomUnread, type SendParams, type SenderMini, type UnreadRoomItem, type UnreadSummary, type WsAnnouncementEvent, type WsInbound, type WsOutbound };
|