@noverachat/sdk-web 0.0.3 → 0.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.
@@ -19,6 +19,7 @@ import type {
19
19
  InviteToken,
20
20
  MessageIdStr,
21
21
  MessageOut,
22
+ RoomMemberOut,
22
23
  RoomUnread,
23
24
  SendFileOptions,
24
25
  SendParams,
@@ -34,67 +35,69 @@ import type {
34
35
  } from "../types.js";
35
36
  import { EventBus } from "../internal/event-bus.js";
36
37
  import { tempId as newTempId } from "../internal/temp-id.js";
38
+ import {
39
+ parseAnnouncementOut,
40
+ parseBulkDeleteBySenderResult,
41
+ parseBulkDeleteResult,
42
+ parseFileCreateResponse,
43
+ parseFileOut,
44
+ parseInviteTokenOut,
45
+ parseJoinRequestCreateResponse,
46
+ parseJoinRequestListResponse,
47
+ parseMessageOut,
48
+ parseRoomMemberOut,
49
+ parseRoomUnread,
50
+ } from "../generated/rest.js";
51
+ import type {
52
+ JoinRequestCreateResponse,
53
+ JoinRequestListResponse,
54
+ } from "../generated/rest.js";
37
55
  // Note: previously used `utils/debounce`, replaced with a hand-rolled
38
56
  // timer in Room so we can also expose `flushMarkRead()` — the util has
39
57
  // no flush primitive.
40
58
 
41
- // Shape coming off the wire from /api/v1/rooms/{id}/messages backend is
42
- // Pydantic v2 with snake_case AND serializes 63-bit ids as JSON strings
43
- // (see `MsgId` on the server). Kept private to this module; callers see
44
- // the camelCase MessageOut exported from types.ts with string ids.
45
- interface RawMessageOut {
46
- app_id: string;
47
- room_id: string;
48
- message_id: string;
49
- sender: { user_id: string; nickname?: string | null; profile_image_url?: string | null } | null;
50
- sender_id: string | null;
51
- message_type: MessageOut["messageType"];
52
- custom_type?: string | null;
53
- content?: string | null;
54
- data?: Record<string, unknown> | null;
55
- meta?: Record<string, unknown> | null;
56
- file_id?: string | null;
57
- file?: {
58
- id: string;
59
- kind: "image" | "video" | "file";
60
- mime: string;
61
- size: number;
62
- name?: string | null;
63
- width?: number | null;
64
- height?: number | null;
65
- duration_sec?: number | null;
66
- thumbnail_status: "pending" | "ready" | "failed" | "skipped";
67
- forward_blocked?: boolean;
68
- } | null;
69
- files?: Array<{
70
- id: string;
71
- kind: "image" | "video" | "file";
72
- mime: string;
73
- size: number;
74
- name?: string | null;
75
- width?: number | null;
76
- height?: number | null;
77
- duration_sec?: number | null;
78
- thumbnail_status: "pending" | "ready" | "failed" | "skipped";
79
- forward_blocked?: boolean;
80
- }> | null;
81
- reply_to_id?: string | null;
82
- reactions?: Array<{ key: string; user_ids: string[]; updated_at: number }>;
83
- created_at: string;
84
- created_at_ms: number;
85
- updated_at?: string | null;
86
- updated_at_ms?: number | null;
87
- deleted_at?: string | null;
88
- deleted_by_user_id?: string | null;
89
- delete_scope?: MessageOut["deleteScope"] | null;
90
- edited_count?: number;
59
+ // Inline file metadata as it rides on a message (chat_receive / REST
60
+ // history). The generated REST `MessageOut` leaves `file`/`files` free-form
61
+ // (the OpenAPI schema does), so we fold the snake_case wire shape down to
62
+ // the camelCase `FileInline` here mirrors `FileInlineWire` in wire.ts.
63
+ function toFileInline(f: {
64
+ id: string;
65
+ kind: FileKind;
66
+ mime: string;
67
+ size: number;
68
+ name?: string | null;
69
+ width?: number | null;
70
+ height?: number | null;
71
+ duration_sec?: number | null;
72
+ thumbnail_status: FileMeta["thumbnailStatus"];
73
+ forward_blocked?: boolean;
74
+ }): FileMeta {
75
+ return {
76
+ id: f.id,
77
+ kind: f.kind,
78
+ mime: f.mime,
79
+ size: f.size,
80
+ name: f.name ?? null,
81
+ width: f.width ?? null,
82
+ height: f.height ?? null,
83
+ durationSec: f.duration_sec ?? null,
84
+ thumbnailStatus: f.thumbnail_status,
85
+ forwardBlocked: Boolean(f.forward_blocked),
86
+ };
91
87
  }
92
88
 
93
- function toMessageOut(r: RawMessageOut): MessageOut {
94
- // Phase D #11 extract sender-attached `_customMeta` from `data`.
95
- // Backend stores the whole `data` blob untouched, so both self-sent
96
- // and received messages surface `customMeta` here for the caller.
97
- const dataObj = r.data ?? null;
89
+ // Enhance the GENERATED `parseMessageOut` with the two client-side
90
+ // extras it can't derive from the OpenAPI schema: parse `file`/`files`
91
+ // into the camelCase `FileInline` shape, and lift sender-attached
92
+ // `_customMeta` out of `data` (Phase D #11). Everything else — key
93
+ // renames, nested sender/reactions comes straight from the codegen.
94
+ function toMessageOut(j: unknown): MessageOut {
95
+ const base = parseMessageOut(j);
96
+ const raw = j as {
97
+ file?: Parameters<typeof toFileInline>[0] | null;
98
+ files?: Parameters<typeof toFileInline>[0][] | null;
99
+ };
100
+ const dataObj = base.data ?? null;
98
101
  const customMeta =
99
102
  dataObj && typeof dataObj === "object" && "_customMeta" in dataObj
100
103
  ? ((dataObj as Record<string, unknown>)._customMeta as
@@ -102,66 +105,12 @@ function toMessageOut(r: RawMessageOut): MessageOut {
102
105
  | null)
103
106
  : null;
104
107
  return {
105
- appId: r.app_id,
106
- roomId: r.room_id,
107
- messageId: r.message_id,
108
- sender: r.sender
109
- ? {
110
- userId: r.sender.user_id,
111
- nickname: r.sender.nickname ?? null,
112
- profileImageUrl: r.sender.profile_image_url ?? null,
113
- }
114
- : null,
115
- senderId: r.sender_id ?? null,
116
- messageType: r.message_type,
117
- customType: r.custom_type ?? null,
118
- content: r.content ?? null,
119
- data: dataObj,
120
- meta: r.meta ?? null,
108
+ ...base,
109
+ file: raw.file ? toFileInline(raw.file) : null,
110
+ files:
111
+ raw.files && raw.files.length > 0 ? raw.files.map(toFileInline) : null,
112
+ reactions: base.reactions ?? [],
121
113
  customMeta,
122
- fileId: r.file_id ?? null,
123
- file: r.file
124
- ? {
125
- id: r.file.id,
126
- kind: r.file.kind,
127
- mime: r.file.mime,
128
- size: r.file.size,
129
- name: r.file.name ?? null,
130
- width: r.file.width ?? null,
131
- height: r.file.height ?? null,
132
- durationSec: r.file.duration_sec ?? null,
133
- thumbnailStatus: r.file.thumbnail_status,
134
- forwardBlocked: Boolean(r.file.forward_blocked),
135
- }
136
- : null,
137
- files: r.files && r.files.length > 0
138
- ? r.files.map((f) => ({
139
- id: f.id,
140
- kind: f.kind,
141
- mime: f.mime,
142
- size: f.size,
143
- name: f.name ?? null,
144
- width: f.width ?? null,
145
- height: f.height ?? null,
146
- durationSec: f.duration_sec ?? null,
147
- thumbnailStatus: f.thumbnail_status,
148
- forwardBlocked: Boolean(f.forward_blocked),
149
- }))
150
- : null,
151
- replyToId: r.reply_to_id ?? null,
152
- reactions: (r.reactions ?? []).map((x) => ({
153
- key: x.key,
154
- userIds: x.user_ids,
155
- updatedAt: x.updated_at,
156
- })),
157
- createdAt: r.created_at,
158
- createdAtMs: r.created_at_ms,
159
- updatedAt: r.updated_at ?? null,
160
- updatedAtMs: r.updated_at_ms ?? null,
161
- deletedAt: r.deleted_at ?? null,
162
- deletedByUserId: r.deleted_by_user_id ?? null,
163
- deleteScope: r.delete_scope ?? null,
164
- editedCount: r.edited_count ?? 0,
165
114
  };
166
115
  }
167
116
 
@@ -446,25 +395,24 @@ export class Room {
446
395
  // Step 1 — presign. The backend assigns the file_id and the S3 key
447
396
  // and returns the URL we should PUT to. ``Content-Type`` is bound
448
397
  // into the signature, so the PUT must echo it.
449
- const presign = await this.http.request<{
450
- file_id: MessageIdStr;
451
- upload_url: string;
452
- headers: Record<string, string>;
453
- expires_at_ms: number;
454
- }>("POST", "/api/v1/files", {
455
- kind,
456
- mime: file.type || "application/octet-stream",
457
- size: file.size,
458
- original_name: name,
459
- // 파일 전달 제한 — uploader-set at presign time. When true,
460
- // other users can't forward this file to a different room.
461
- forward_blocked: Boolean(opts.forwardBlocked),
462
- });
398
+ const created = parseFileCreateResponse(
399
+ await this.http.request<unknown>("POST", "/api/v1/files", {
400
+ kind,
401
+ mime: file.type || "application/octet-stream",
402
+ size: file.size,
403
+ original_name: name,
404
+ // 파일 전달 제한 — uploader-set at presign time. When true,
405
+ // other users can't forward this file to a different room.
406
+ forward_blocked: Boolean(opts.forwardBlocked),
407
+ }),
408
+ );
463
409
  const presigned: FilePresignResponse = {
464
- fileId: presign.file_id,
465
- uploadUrl: presign.upload_url,
466
- headers: presign.headers,
467
- expiresAtMs: presign.expires_at_ms,
410
+ fileId: created.fileId,
411
+ uploadUrl: created.uploadUrl,
412
+ // Presigned S3 headers are always string-valued; the generated
413
+ // model types the JSON object loosely as Record<string, unknown>.
414
+ headers: created.headers as Record<string, string>,
415
+ expiresAtMs: created.expiresAtMs,
468
416
  };
469
417
 
470
418
  // Step 2 — S3 PUT via XHR so the caller can show a progress bar.
@@ -542,24 +490,21 @@ export class Room {
542
490
  : "file");
543
491
  const name = opts.name ?? file.name;
544
492
 
545
- const presign = await this.http.request<{
546
- file_id: MessageIdStr;
547
- upload_url: string;
548
- headers: Record<string, string>;
549
- expires_at_ms: number;
550
- }>("POST", "/api/v1/files", {
551
- kind,
552
- mime: file.type || "application/octet-stream",
553
- size: file.size,
554
- original_name: name,
555
- forward_blocked: Boolean(opts.forwardBlocked),
556
- });
493
+ const created = parseFileCreateResponse(
494
+ await this.http.request<unknown>("POST", "/api/v1/files", {
495
+ kind,
496
+ mime: file.type || "application/octet-stream",
497
+ size: file.size,
498
+ original_name: name,
499
+ forward_blocked: Boolean(opts.forwardBlocked),
500
+ }),
501
+ );
557
502
 
558
503
  await new Promise<void>((resolve, reject) => {
559
504
  const xhr = new XMLHttpRequest();
560
- xhr.open("PUT", presign.upload_url);
561
- for (const [k, v] of Object.entries(presign.headers)) {
562
- xhr.setRequestHeader(k, v);
505
+ xhr.open("PUT", created.uploadUrl);
506
+ for (const [k, v] of Object.entries(created.headers)) {
507
+ xhr.setRequestHeader(k, v as string);
563
508
  }
564
509
  if (opts.onProgress) {
565
510
  xhr.upload.addEventListener("progress", (e) => {
@@ -585,9 +530,9 @@ export class Room {
585
530
  });
586
531
 
587
532
  await this.http.request<FileMeta>(
588
- "POST", `/api/v1/files/${presign.file_id}/commit`, {},
533
+ "POST", `/api/v1/files/${created.fileId}/commit`, {},
589
534
  );
590
- return presign.file_id;
535
+ return created.fileId;
591
536
  }
592
537
 
593
538
  /**
@@ -619,18 +564,7 @@ export class Room {
619
564
  message_id: MessageIdStr;
620
565
  sender_id: string | null;
621
566
  created_at_ms: number;
622
- file: {
623
- id: MessageIdStr;
624
- kind: FileKind;
625
- mime: string;
626
- size_bytes: number;
627
- original_name: string | null;
628
- width: number | null;
629
- height: number | null;
630
- duration_sec: number | null;
631
- thumbnail_status: FileMeta["thumbnailStatus"];
632
- forward_blocked?: boolean;
633
- };
567
+ file: unknown;
634
568
  }>;
635
569
  next_cursor: MessageIdStr | null;
636
570
  has_more: boolean;
@@ -644,23 +578,29 @@ export class Room {
644
578
  },
645
579
  );
646
580
  return {
647
- items: raw.items.map((r) => ({
648
- messageId: r.message_id,
649
- senderId: r.sender_id,
650
- createdAtMs: r.created_at_ms,
651
- file: {
652
- id: r.file.id,
653
- kind: r.file.kind,
654
- mime: r.file.mime,
655
- size: r.file.size_bytes,
656
- name: r.file.original_name ?? null,
657
- width: r.file.width ?? null,
658
- height: r.file.height ?? null,
659
- durationSec: r.file.duration_sec ?? null,
660
- thumbnailStatus: r.file.thumbnail_status,
661
- forwardBlocked: Boolean(r.file.forward_blocked),
662
- },
663
- })),
581
+ items: raw.items.map((r) => {
582
+ // Per-file payload is the generated ``FileOut`` shape — parse it
583
+ // with the codegen'd converter, then fold to the leaner
584
+ // ``FileMeta`` (= FileInline) the SDK surfaces.
585
+ const f = parseFileOut(r.file);
586
+ return {
587
+ messageId: r.message_id,
588
+ senderId: r.sender_id,
589
+ createdAtMs: r.created_at_ms,
590
+ file: {
591
+ id: f.id,
592
+ kind: f.kind,
593
+ mime: f.mime,
594
+ size: f.sizeBytes,
595
+ name: f.originalName ?? null,
596
+ width: f.width ?? null,
597
+ height: f.height ?? null,
598
+ durationSec: f.durationSec ?? null,
599
+ thumbnailStatus: f.thumbnailStatus,
600
+ forwardBlocked: Boolean(f.forwardBlocked),
601
+ },
602
+ };
603
+ }),
664
604
  nextCursor: raw.next_cursor,
665
605
  hasMore: raw.has_more,
666
606
  };
@@ -706,9 +646,10 @@ export class Room {
706
646
  messageId: MessageIdStr,
707
647
  patch: { content?: string; data?: Record<string, unknown>; meta?: Record<string, unknown> },
708
648
  ): Promise<MessageOut> {
709
- return this.http.request<MessageOut>(
649
+ const raw = await this.http.request<unknown>(
710
650
  "PUT", `/api/v1/rooms/${this.roomId}/messages/${messageId}`, patch,
711
651
  );
652
+ return toMessageOut(raw);
712
653
  }
713
654
 
714
655
  async delete(messageId: MessageIdStr, scope: DeleteScope = "ALL"): Promise<void> {
@@ -733,11 +674,40 @@ export class Room {
733
674
  * Other members get a `MEMBER_LEFT` [`roomEvent`](../reference/noverachat.md#events).
734
675
  * Idempotent if already a non-member; throws 409 if the caller was KICKED
735
676
  * (ask an operator to restore first). For GROUP/SUPER_GROUP, re-entry needs
736
- * an operator add; for ONE, re-creating the 1:1 rejoins with history kept. */
737
- async leave(): Promise<void> {
677
+ * an operator add; for ONE, re-creating the 1:1 rejoins with history kept.
678
+ *
679
+ * Optional `deleteMessages` — atomically clean up chat history as part
680
+ * of leaving. Default is `"none"` (messages survive so the room's
681
+ * history keeps making sense to remaining members).
682
+ * - `"none"` — leave only.
683
+ * - `"mine"` — soft-delete every message YOU sent, then leave. Other
684
+ * members' messages are untouched. Broadcasts
685
+ * `messages_cleared_by_sender` so connected clients hide
686
+ * them locally.
687
+ * - `"all"` — wipe every message in the room, then leave. Subject to
688
+ * the app's `allow_member_bulk_delete` gate and per-room
689
+ * OPERATOR check — a plain member gets 403 back. */
690
+ async leave(options?: { deleteMessages?: "none" | "mine" | "all" }): Promise<void> {
691
+ const mode = options?.deleteMessages ?? "none";
692
+ const query = mode === "none"
693
+ ? ""
694
+ : `?delete_messages=${encodeURIComponent(mode)}`;
738
695
  await this.http.request<void>(
739
- "POST", `/api/v1/rooms/${this.roomId}/leave`,
696
+ "POST", `/api/v1/rooms/${this.roomId}/leave${query}`,
697
+ );
698
+ }
699
+
700
+ /** Delete every message YOU sent in this room. Standalone version of
701
+ * `leave({ deleteMessages: "mine" })` — useful when a user wants to
702
+ * scrub their history without actually leaving. Other members'
703
+ * messages are untouched; broadcasts `messages_cleared_by_sender`
704
+ * so connected clients hide the vanished ones locally.
705
+ * No OPERATOR gate — you own your messages. */
706
+ async deleteMyMessages(): Promise<{ cleared: number }> {
707
+ const raw = await this.http.request<unknown>(
708
+ "DELETE", `/api/v1/rooms/${this.roomId}/messages/mine`,
740
709
  );
710
+ return parseBulkDeleteBySenderResult(raw);
741
711
  }
742
712
 
743
713
  /**
@@ -769,14 +739,10 @@ export class Room {
769
739
  cleared: number;
770
740
  upToMessageId: MessageIdStr | null;
771
741
  }> {
772
- const raw = await this.http.request<{
773
- cleared: number;
774
- up_to_message_id: string | null;
775
- }>("DELETE", `/api/v1/rooms/${this.roomId}/messages`);
776
- return {
777
- cleared: raw.cleared,
778
- upToMessageId: raw.up_to_message_id,
779
- };
742
+ const raw = await this.http.request<unknown>(
743
+ "DELETE", `/api/v1/rooms/${this.roomId}/messages`,
744
+ );
745
+ return parseBulkDeleteResult(raw);
780
746
  }
781
747
 
782
748
  async react(messageId: MessageIdStr, key: string): Promise<void> {
@@ -801,7 +767,7 @@ export class Room {
801
767
  // camelCase contract here so callers (and the demo's renderMessage)
802
768
  // don't see `undefined` on `messageId` / `senderId` / etc.
803
769
  const raw = await this.http.request<{
804
- items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
770
+ items: unknown[]; next_cursor: string | null; has_more: boolean;
805
771
  }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, {
806
772
  since: String(sinceMessageId), limit,
807
773
  });
@@ -824,7 +790,7 @@ export class Room {
824
790
  hasMore: boolean;
825
791
  }> {
826
792
  const raw = await this.http.request<{
827
- items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
793
+ items: unknown[]; next_cursor: string | null; has_more: boolean;
828
794
  }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, { limit });
829
795
  return {
830
796
  items: raw.items.map(toMessageOut),
@@ -845,7 +811,7 @@ export class Room {
845
811
  hasMore: boolean;
846
812
  }> {
847
813
  const raw = await this.http.request<{
848
- items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
814
+ items: unknown[]; next_cursor: string | null; has_more: boolean;
849
815
  }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, {
850
816
  before: beforeMessageId, limit,
851
817
  });
@@ -881,7 +847,7 @@ export class Room {
881
847
  };
882
848
  if (opts?.before) params.before = opts.before;
883
849
  const raw = await this.http.request<{
884
- items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
850
+ items: unknown[]; next_cursor: string | null; has_more: boolean;
885
851
  }>("GET", `/api/v1/rooms/${this.roomId}/messages/search`, undefined, params);
886
852
  return {
887
853
  items: raw.items.map(toMessageOut),
@@ -895,40 +861,11 @@ export class Room {
895
861
  * `room.deleteAllMessages()`. Server gates by membership for non-public
896
862
  * rooms, so the caller must already be a member to read this.
897
863
  */
898
- async listMembers(): Promise<Array<{
899
- userId: string;
900
- nickname: string | null;
901
- role: "OPERATOR" | "MEMBER" | "KICKED";
902
- pushTrigger: "ALL" | "MENTION" | "OFF";
903
- lastReadMessageId: MessageIdStr | null;
904
- joinedAt: string;
905
- /** Cluster-wide presence dot. `null` when the server didn't resolve it. */
906
- isOnline: boolean | null;
907
- /** Per-room mute expiry. `null` = not muted. A far-future sentinel
908
- * (year 9999) means the operator picked "indefinite" — clients
909
- * should render that as "무기한" instead of a real timestamp. */
910
- mutedUntil: string | null;
911
- }>> {
912
- const raw = await this.http.request<Array<{
913
- user_id: string;
914
- nickname?: string | null;
915
- role: "OPERATOR" | "MEMBER" | "KICKED";
916
- push_trigger: "ALL" | "MENTION" | "OFF";
917
- last_read_message_id: string | null;
918
- joined_at: string;
919
- is_online?: boolean | null;
920
- muted_until?: string | null;
921
- }>>("GET", `/api/v1/rooms/${this.roomId}/members`);
922
- return raw.map((m) => ({
923
- userId: m.user_id,
924
- nickname: m.nickname ?? null,
925
- role: m.role,
926
- pushTrigger: m.push_trigger,
927
- lastReadMessageId: m.last_read_message_id,
928
- joinedAt: m.joined_at,
929
- isOnline: m.is_online ?? null,
930
- mutedUntil: m.muted_until ?? null,
931
- }));
864
+ async listMembers(): Promise<RoomMemberOut[]> {
865
+ const raw = await this.http.request<unknown[]>(
866
+ "GET", `/api/v1/rooms/${this.roomId}/members`,
867
+ );
868
+ return raw.map((m) => parseRoomMemberOut(m));
932
869
  }
933
870
 
934
871
  // -------- mute --------
@@ -958,18 +895,10 @@ export class Room {
958
895
  * newer than the caller's `read_watermark`, plus the room's
959
896
  * `last_message_id` so the UI can show a "•" dot regardless of count. */
960
897
  async unread(): Promise<RoomUnread> {
961
- const raw = await this.http.request<{
962
- room_id: string;
963
- unread_count: number;
964
- last_read_message_id: string | null;
965
- last_message_id: string | null;
966
- }>("GET", `/api/v1/rooms/${this.roomId}/unread`);
967
- return {
968
- roomId: raw.room_id,
969
- unreadCount: raw.unread_count,
970
- lastReadMessageId: raw.last_read_message_id,
971
- lastMessageId: raw.last_message_id,
972
- };
898
+ const raw = await this.http.request<unknown>(
899
+ "GET", `/api/v1/rooms/${this.roomId}/unread`,
900
+ );
901
+ return parseRoomUnread(raw);
973
902
  }
974
903
 
975
904
  // -------- freeze --------
@@ -1007,80 +936,32 @@ export class Room {
1007
936
  // -------- announcements --------
1008
937
 
1009
938
  async listAnnouncements(): Promise<Announcement[]> {
1010
- const raw = await this.http.request<Array<{
1011
- id: number;
1012
- room_id: string;
1013
- content: string;
1014
- created_by: string;
1015
- created_at: string;
1016
- updated_at: string;
1017
- }>>("GET", `/api/v1/rooms/${this.roomId}/announcements`);
1018
- return raw.map((a) => ({
1019
- id: a.id,
1020
- roomId: a.room_id,
1021
- content: a.content,
1022
- createdBy: a.created_by,
1023
- createdAt: a.created_at,
1024
- updatedAt: a.updated_at,
1025
- }));
939
+ const raw = await this.http.request<unknown[]>(
940
+ "GET", `/api/v1/rooms/${this.roomId}/announcements`,
941
+ );
942
+ return raw.map((a) => parseAnnouncementOut(a));
1026
943
  }
1027
944
 
1028
945
  async getCurrentAnnouncement(): Promise<Announcement | null> {
1029
- const raw = await this.http.request<{
1030
- id: number;
1031
- room_id: string;
1032
- content: string;
1033
- created_by: string;
1034
- created_at: string;
1035
- updated_at: string;
1036
- } | null>("GET", `/api/v1/rooms/${this.roomId}/announcements/current`);
946
+ const raw = await this.http.request<unknown>(
947
+ "GET", `/api/v1/rooms/${this.roomId}/announcements/current`,
948
+ );
1037
949
  if (!raw) return null;
1038
- return {
1039
- id: raw.id,
1040
- roomId: raw.room_id,
1041
- content: raw.content,
1042
- createdBy: raw.created_by,
1043
- createdAt: raw.created_at,
1044
- updatedAt: raw.updated_at,
1045
- };
950
+ return parseAnnouncementOut(raw);
1046
951
  }
1047
952
 
1048
953
  async postAnnouncement(content: string): Promise<Announcement> {
1049
- const raw = await this.http.request<{
1050
- id: number;
1051
- room_id: string;
1052
- content: string;
1053
- created_by: string;
1054
- created_at: string;
1055
- updated_at: string;
1056
- }>("POST", `/api/v1/rooms/${this.roomId}/announcements`, { content });
1057
- return {
1058
- id: raw.id,
1059
- roomId: raw.room_id,
1060
- content: raw.content,
1061
- createdBy: raw.created_by,
1062
- createdAt: raw.created_at,
1063
- updatedAt: raw.updated_at,
1064
- };
954
+ const raw = await this.http.request<unknown>(
955
+ "POST", `/api/v1/rooms/${this.roomId}/announcements`, { content },
956
+ );
957
+ return parseAnnouncementOut(raw);
1065
958
  }
1066
959
 
1067
960
  async updateAnnouncement(id: number, content: string): Promise<Announcement> {
1068
- const raw = await this.http.request<{
1069
- id: number;
1070
- room_id: string;
1071
- content: string;
1072
- created_by: string;
1073
- created_at: string;
1074
- updated_at: string;
1075
- }>("PATCH", `/api/v1/rooms/${this.roomId}/announcements/${id}`, { content });
1076
- return {
1077
- id: raw.id,
1078
- roomId: raw.room_id,
1079
- content: raw.content,
1080
- createdBy: raw.created_by,
1081
- createdAt: raw.created_at,
1082
- updatedAt: raw.updated_at,
1083
- };
961
+ const raw = await this.http.request<unknown>(
962
+ "PATCH", `/api/v1/rooms/${this.roomId}/announcements/${id}`, { content },
963
+ );
964
+ return parseAnnouncementOut(raw);
1084
965
  }
1085
966
 
1086
967
  async deleteAnnouncement(id: number): Promise<void> {
@@ -1111,51 +992,19 @@ export class Room {
1111
992
  const body: Record<string, unknown> = {};
1112
993
  if (opts && "expiresInHours" in opts) body.expires_in_hours = opts.expiresInHours;
1113
994
  if (opts && "maxUses" in opts) body.max_uses = opts.maxUses;
1114
- const raw = await this.http.request<{
1115
- token: string;
1116
- room_id: string;
1117
- created_by: string;
1118
- created_at: string;
1119
- expires_at: string | null;
1120
- max_uses: number | null;
1121
- used_count: number;
1122
- revoked_at: string | null;
1123
- }>("POST", `/api/v1/rooms/${this.roomId}/invites`, body);
1124
- return {
1125
- token: raw.token,
1126
- roomId: raw.room_id,
1127
- createdBy: raw.created_by,
1128
- createdAt: raw.created_at,
1129
- expiresAt: raw.expires_at,
1130
- maxUses: raw.max_uses,
1131
- usedCount: raw.used_count,
1132
- revokedAt: raw.revoked_at,
1133
- };
995
+ const raw = await this.http.request<unknown>(
996
+ "POST", `/api/v1/rooms/${this.roomId}/invites`, body,
997
+ );
998
+ return parseInviteTokenOut(raw);
1134
999
  }
1135
1000
 
1136
1001
  /** OPERATOR-only: list all invite tokens for this room (including
1137
1002
  * revoked / expired / exhausted), newest first. */
1138
1003
  async listInvites(): Promise<InviteToken[]> {
1139
- const raw = await this.http.request<Array<{
1140
- token: string;
1141
- room_id: string;
1142
- created_by: string;
1143
- created_at: string;
1144
- expires_at: string | null;
1145
- max_uses: number | null;
1146
- used_count: number;
1147
- revoked_at: string | null;
1148
- }>>("GET", `/api/v1/rooms/${this.roomId}/invites`);
1149
- return raw.map((r) => ({
1150
- token: r.token,
1151
- roomId: r.room_id,
1152
- createdBy: r.created_by,
1153
- createdAt: r.created_at,
1154
- expiresAt: r.expires_at,
1155
- maxUses: r.max_uses,
1156
- usedCount: r.used_count,
1157
- revokedAt: r.revoked_at,
1158
- }));
1004
+ const raw = await this.http.request<unknown[]>(
1005
+ "GET", `/api/v1/rooms/${this.roomId}/invites`,
1006
+ );
1007
+ return raw.map((r) => parseInviteTokenOut(r));
1159
1008
  }
1160
1009
 
1161
1010
  /** OPERATOR-only: revoke a token immediately. Idempotent — revoking
@@ -1167,6 +1016,87 @@ export class Room {
1167
1016
  );
1168
1017
  }
1169
1018
 
1019
+ // -------- join requests --------
1020
+
1021
+ /**
1022
+ * Ask to join this room (self). Behavior depends on the room's
1023
+ * `joinPolicy`:
1024
+ * - `OPEN` → the caller is added immediately (`status: "joined"`);
1025
+ * - `APPROVAL_REQUIRED` → the request lands in the operators' inbox
1026
+ * (`status: "pending"`) until [`approveJoinRequest`] / reject.
1027
+ * Idempotent for members (`status: "already_member"`).
1028
+ *
1029
+ * @param reason Optional note shown to operators ("하은이 반 친구 엄마예요").
1030
+ */
1031
+ async requestJoin(reason?: string): Promise<JoinRequestCreateResponse> {
1032
+ const raw = await this.http.request<unknown>(
1033
+ "POST", `/api/v1/rooms/${this.roomId}/join-request`,
1034
+ reason ? { reason } : {},
1035
+ );
1036
+ return parseJoinRequestCreateResponse(raw);
1037
+ }
1038
+
1039
+ /** Cancel the caller's own pending join request. */
1040
+ async cancelJoinRequest(): Promise<void> {
1041
+ await this.http.request<void>(
1042
+ "DELETE", `/api/v1/rooms/${this.roomId}/join-request`,
1043
+ );
1044
+ }
1045
+
1046
+ /** OPERATOR-only: list join requests for this room (the approval inbox).
1047
+ * Defaults to pending only — pass a status to see processed history. */
1048
+ async listJoinRequests(opts?: {
1049
+ status?: "PENDING" | "APPROVED" | "REJECTED" | "CANCELLED";
1050
+ limit?: number;
1051
+ offset?: number;
1052
+ }): Promise<JoinRequestListResponse> {
1053
+ const raw = await this.http.request<unknown>(
1054
+ "GET", `/api/v1/rooms/${this.roomId}/join-requests`, undefined, {
1055
+ ...(opts?.status ? { status: opts.status } : {}),
1056
+ ...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
1057
+ ...(opts?.offset !== undefined ? { offset: opts.offset } : {}),
1058
+ },
1059
+ );
1060
+ return parseJoinRequestListResponse(raw);
1061
+ }
1062
+
1063
+ /** OPERATOR-only: approve a pending join request — the requester becomes
1064
+ * a MEMBER. [reason] is echoed back to them in later list calls. */
1065
+ async approveJoinRequest(userId: string, reason?: string): Promise<void> {
1066
+ await this.http.request<void>(
1067
+ "POST",
1068
+ `/api/v1/rooms/${this.roomId}/join-requests/${encodeURIComponent(userId)}/approve`,
1069
+ reason ? { reason } : {},
1070
+ );
1071
+ }
1072
+
1073
+ /** OPERATOR-only: reject a pending join request. */
1074
+ async rejectJoinRequest(userId: string, reason?: string): Promise<void> {
1075
+ await this.http.request<void>(
1076
+ "POST",
1077
+ `/api/v1/rooms/${this.roomId}/join-requests/${encodeURIComponent(userId)}/reject`,
1078
+ reason ? { reason } : {},
1079
+ );
1080
+ }
1081
+
1082
+ // -------- hide / unhide --------
1083
+
1084
+ /** Hide this room from the caller's own room list (self, reversible —
1085
+ * the KakaoTalk "숨기기"). The room typically reappears via [unhide]
1086
+ * or on new activity, per server policy. Messages are untouched. */
1087
+ async hide(): Promise<void> {
1088
+ await this.http.request<void>(
1089
+ "POST", `/api/v1/rooms/${this.roomId}/hide`,
1090
+ );
1091
+ }
1092
+
1093
+ /** Undo [hide] — the room shows up in the caller's list again. */
1094
+ async unhide(): Promise<void> {
1095
+ await this.http.request<void>(
1096
+ "POST", `/api/v1/rooms/${this.roomId}/unhide`,
1097
+ );
1098
+ }
1099
+
1170
1100
  // -------- internal dispatch --------
1171
1101
 
1172
1102
  on<K extends keyof RoomEvents>(event: K, fn: (p: RoomEvents[K]) => void): () => void {