chattransfer 2.0.18 → 3.0.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.
@@ -8,6 +8,8 @@ const MAX_LIMIT = 200;
8
8
  const MAX_BATCH_ATTACHMENT_COUNT = 200;
9
9
  const MAX_BATCH_ATTACHMENT_TOTAL_SIZE = 2 * 1024 * 1024 * 1024;
10
10
  const GROUP_SESSION_ID = 'group';
11
+ const DEFAULT_ROOM_ID = 'group_chat';
12
+ const ROOM_CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
11
13
 
12
14
  class MessageHistoryService {
13
15
  constructor({ cachePath, databasePath }) {
@@ -22,6 +24,8 @@ class MessageHistoryService {
22
24
  this.historyDir = path.dirname(this.databasePath);
23
25
  this.db = null;
24
26
  this.writeLock = Promise.resolve();
27
+ this.publicRoomCleanupTimer = null;
28
+ this.idleRetentionDays = 7;
25
29
  }
26
30
 
27
31
  init() {
@@ -48,6 +52,7 @@ class MessageHistoryService {
48
52
  }
49
53
 
50
54
  close() {
55
+ this.stopPublicRoomCleanup();
51
56
  if (!this.db) return Promise.resolve();
52
57
 
53
58
  return new Promise((resolve, reject) => {
@@ -64,11 +69,13 @@ class MessageHistoryService {
64
69
  async saveMessageInTransaction(message) {
65
70
 
66
71
  const normalized = this.normalizeMessage(message);
67
- const conversation = this.getConversation({
68
- chatType: normalized.receiver === 'group_chat' ? 'group' : 'private',
69
- requester: normalized.sender,
70
- targetUser: normalized.receiver
71
- });
72
+ const conversation = normalized.roomId
73
+ ? this.getRoomConversation(normalized.roomId)
74
+ : this.getConversation({
75
+ chatType: normalized.receiver === 'group_chat' ? 'group' : 'private',
76
+ requester: normalized.sender,
77
+ targetUser: normalized.receiver
78
+ });
72
79
 
73
80
  await this.run('BEGIN IMMEDIATE');
74
81
 
@@ -109,6 +116,16 @@ class MessageHistoryService {
109
116
  ]
110
117
  );
111
118
 
119
+ if (normalized.roomId && normalized.roomId !== DEFAULT_ROOM_ID) {
120
+ const activityAt = Date.now();
121
+ await this.run(
122
+ `UPDATE rooms
123
+ SET last_active_at = ?, updated_at = ?
124
+ WHERE id = ? AND status = 'active'`,
125
+ [activityAt, activityAt, normalized.roomId]
126
+ );
127
+ }
128
+
112
129
  await this.run('COMMIT');
113
130
  return normalized;
114
131
  } catch (error) {
@@ -117,10 +134,12 @@ class MessageHistoryService {
117
134
  }
118
135
  }
119
136
 
120
- async queryHistory({ requester, chatType, targetUser, cursor = null, limit = DEFAULT_LIMIT }) {
137
+ async queryHistory({ requester, roomId, chatType, targetUser, cursor = null, limit = DEFAULT_LIMIT }) {
121
138
  this.ensureOpened();
122
139
 
123
- const conversation = this.getConversation({ requester, chatType, targetUser });
140
+ const conversation = roomId
141
+ ? await this.getVerifiedRoomConversation(roomId, requester)
142
+ : this.getConversation({ requester, chatType, targetUser });
124
143
  const queryLimit = this.normalizeLimit(limit);
125
144
  const normalizedCursor = this.normalizeCursor(cursor);
126
145
  const direction = normalizedCursor === null ? 'DESC' : 'ASC';
@@ -149,10 +168,12 @@ class MessageHistoryService {
149
168
  };
150
169
  }
151
170
 
152
- async queryAttachments({ requester, chatType, targetUser }) {
171
+ async queryAttachments({ requester, roomId, chatType, targetUser }) {
153
172
  this.ensureOpened();
154
173
 
155
- const conversation = this.getConversation({ requester, chatType, targetUser });
174
+ const conversation = roomId
175
+ ? await this.getVerifiedRoomConversation(roomId, requester)
176
+ : this.getConversation({ requester, chatType, targetUser });
156
177
  const rows = await this.all(
157
178
  `SELECT seq, id, sender_id, sender_nickname, message_type, create_time, created_at,
158
179
  file_path, file_name, file_size, file_mime_type
@@ -191,7 +212,7 @@ class MessageHistoryService {
191
212
  return { attachments };
192
213
  }
193
214
 
194
- async getBatchAttachments({ requester, chatType, targetUser, messageIds }) {
215
+ async getBatchAttachments({ requester, roomId, chatType, targetUser, messageIds }) {
195
216
  this.ensureOpened();
196
217
 
197
218
  if (!Array.isArray(messageIds) || messageIds.length === 0) {
@@ -204,7 +225,12 @@ class MessageHistoryService {
204
225
  throw new Error(`Batch download supports at most ${MAX_BATCH_ATTACHMENT_COUNT} files`);
205
226
  }
206
227
 
207
- const { attachments } = await this.queryAttachments({ requester, chatType, targetUser });
228
+ const { attachments } = await this.queryAttachments({
229
+ requester,
230
+ roomId,
231
+ chatType,
232
+ targetUser
233
+ });
208
234
  const attachmentMap = new Map(attachments.map((attachment) => [attachment.msgId, attachment]));
209
235
  const selectedAttachments = [];
210
236
 
@@ -225,19 +251,100 @@ class MessageHistoryService {
225
251
  return selectedAttachments;
226
252
  }
227
253
 
228
- clearHistory({ requester, chatType, targetUser }) {
254
+ async clearHistory({ requester, roomId, chatType, targetUser }) {
229
255
  this.ensureOpened();
230
256
 
231
- return this.enqueueWrite(() => this.clearHistoryInTransaction({
232
- requester,
233
- chatType,
234
- targetUser
235
- }));
257
+ let conversation;
258
+ if (roomId) {
259
+ if (roomId === DEFAULT_ROOM_ID) {
260
+ throw new Error('Public Lobby history requires administrator');
261
+ }
262
+ await this.requireRoomOwner(roomId, requester);
263
+ conversation = this.getRoomConversation(roomId);
264
+ } else {
265
+ conversation = this.getConversation({ requester, chatType, targetUser });
266
+ if (conversation.chatType === 'group') {
267
+ throw new Error('Public Lobby history requires administrator');
268
+ }
269
+ }
270
+
271
+ return this.enqueueWrite(() => this.clearConversationHistory(conversation));
272
+ }
273
+
274
+ async clearPublicRoomHistory() {
275
+ this.ensureOpened();
276
+ const conversation = this.getRoomConversation(DEFAULT_ROOM_ID);
277
+ return this.enqueueWrite(() => this.clearConversationHistory(conversation));
278
+ }
279
+
280
+ async cleanupExpiredPublicRoomMessages({ retentionDays = 7, now = Date.now() } = {}) {
281
+ this.ensureOpened();
282
+ const days = Number(retentionDays);
283
+ if (!Number.isFinite(days) || days <= 0) {
284
+ return { deletedFiles: 0, skippedFiles: 0, failedFiles: 0 };
285
+ }
286
+
287
+ const cutoff = now - (days * 24 * 60 * 60 * 1000);
288
+
289
+ return this.enqueueWrite(async () => {
290
+ const files = await this.all(
291
+ `SELECT DISTINCT file_path
292
+ FROM messages
293
+ WHERE session_id = ? AND created_at < ? AND file_path IS NOT NULL`,
294
+ [GROUP_SESSION_ID, cutoff]
295
+ );
296
+
297
+ await this.run('BEGIN IMMEDIATE');
298
+
299
+ try {
300
+ for (const file of files) {
301
+ await this.run(
302
+ 'INSERT OR IGNORE INTO file_cleanup_queue(file_path, created_at) VALUES (?, ?)',
303
+ [file.file_path, Date.now()]
304
+ );
305
+ }
306
+
307
+ await this.run(
308
+ 'DELETE FROM messages WHERE session_id = ? AND created_at < ?',
309
+ [GROUP_SESSION_ID, cutoff]
310
+ );
311
+ await this.run('COMMIT');
312
+ } catch (error) {
313
+ await this.run('ROLLBACK').catch(() => {});
314
+ throw error;
315
+ }
316
+
317
+ const cleanup = await this.processCleanupQueue();
318
+ return {
319
+ deletedFiles: cleanup.deletedFiles,
320
+ skippedFiles: cleanup.skippedFiles,
321
+ failedFiles: cleanup.failedFiles
322
+ };
323
+ });
236
324
  }
237
325
 
238
- async clearHistoryInTransaction({ requester, chatType, targetUser }) {
326
+ startPublicRoomCleanup({ retentionDays = 7, intervalMs = 6 * 60 * 60 * 1000 } = {}) {
327
+ this.stopPublicRoomCleanup();
328
+ if (!Number.isFinite(Number(retentionDays)) || Number(retentionDays) <= 0) return;
329
+
330
+ const run = () => {
331
+ this.cleanupExpiredPublicRoomMessages({ retentionDays }).catch((error) => {
332
+ console.error('[History] public room cleanup failed:', error.message);
333
+ });
334
+ };
239
335
 
240
- const conversation = this.getConversation({ requester, chatType, targetUser });
336
+ run();
337
+ this.publicRoomCleanupTimer = setInterval(run, intervalMs);
338
+ this.publicRoomCleanupTimer.unref?.();
339
+ }
340
+
341
+ stopPublicRoomCleanup() {
342
+ if (!this.publicRoomCleanupTimer) return;
343
+ clearInterval(this.publicRoomCleanupTimer);
344
+ this.publicRoomCleanupTimer = null;
345
+ }
346
+
347
+ async clearConversationHistory(conversation) {
241
348
  const files = await this.all(
242
349
  'SELECT DISTINCT file_path FROM messages WHERE session_id = ? AND file_path IS NOT NULL',
243
350
  [conversation.sessionId]
@@ -355,6 +462,74 @@ class MessageHistoryService {
355
462
  last_error TEXT,
356
463
  created_at INTEGER NOT NULL
357
464
  )`);
465
+ await this.run(`CREATE TABLE IF NOT EXISTS rooms (
466
+ id TEXT PRIMARY KEY,
467
+ name TEXT NOT NULL,
468
+ code TEXT UNIQUE,
469
+ created_by TEXT,
470
+ owner_id TEXT,
471
+ status TEXT NOT NULL DEFAULT 'active',
472
+ created_at INTEGER NOT NULL,
473
+ updated_at INTEGER NOT NULL,
474
+ deleted_at INTEGER,
475
+ last_active_at INTEGER,
476
+ deleted_reason TEXT
477
+ )`);
478
+ await this.run(`CREATE TABLE IF NOT EXISTS room_participants (
479
+ room_id TEXT NOT NULL,
480
+ user_id TEXT NOT NULL,
481
+ role TEXT NOT NULL DEFAULT 'member',
482
+ nickname TEXT,
483
+ joined_at INTEGER NOT NULL,
484
+ last_seen_at INTEGER,
485
+ PRIMARY KEY (room_id, user_id),
486
+ FOREIGN KEY (room_id) REFERENCES rooms(id)
487
+ )`);
488
+ await this.run(`CREATE INDEX IF NOT EXISTS idx_room_participants_user
489
+ ON room_participants(user_id, joined_at)`);
490
+ await this.ensureColumn('rooms', 'owner_id', 'TEXT');
491
+ await this.ensureColumn('rooms', 'deleted_at', 'INTEGER');
492
+ await this.ensureColumn('rooms', 'last_active_at', 'INTEGER');
493
+ await this.ensureColumn('rooms', 'deleted_reason', 'TEXT');
494
+ await this.ensureColumn('room_participants', 'role', "TEXT NOT NULL DEFAULT 'member'");
495
+ await this.run(
496
+ `UPDATE rooms
497
+ SET owner_id = created_by
498
+ WHERE id <> ? AND owner_id IS NULL AND created_by IS NOT NULL`,
499
+ [DEFAULT_ROOM_ID]
500
+ );
501
+ await this.run(
502
+ `UPDATE rooms
503
+ SET last_active_at = COALESCE(last_active_at, updated_at, created_at)
504
+ WHERE last_active_at IS NULL`
505
+ );
506
+ await this.run(
507
+ `UPDATE room_participants
508
+ SET role = 'owner'
509
+ WHERE EXISTS (
510
+ SELECT 1 FROM rooms
511
+ WHERE rooms.id = room_participants.room_id
512
+ AND rooms.owner_id = room_participants.user_id
513
+ )`
514
+ );
515
+ await this.run(
516
+ `INSERT OR IGNORE INTO rooms(
517
+ id, name, code, created_by, owner_id, status, created_at, updated_at
518
+ ) VALUES (?, ?, NULL, NULL, NULL, 'active', ?, ?)`,
519
+ [DEFAULT_ROOM_ID, 'Public Lobby', Date.now(), Date.now()]
520
+ );
521
+ await this.run(
522
+ `UPDATE rooms
523
+ SET name = 'Public Lobby'
524
+ WHERE id = ? AND name IN ('Default Room', 'Public Transit Station')`,
525
+ [DEFAULT_ROOM_ID]
526
+ );
527
+ }
528
+
529
+ async ensureColumn(table, column, definition) {
530
+ const columns = await this.all(`PRAGMA table_info(${table})`);
531
+ if (columns.some((item) => item.name === column)) return;
532
+ await this.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
358
533
  }
359
534
 
360
535
  getConversation({ requester, chatType, targetUser }) {
@@ -394,6 +569,463 @@ class MessageHistoryService {
394
569
  };
395
570
  }
396
571
 
572
+ getRoomConversation(roomId) {
573
+ if (!this.isValidRoomId(roomId)) {
574
+ throw new Error('Invalid room ID');
575
+ }
576
+
577
+ return {
578
+ sessionId: roomId === DEFAULT_ROOM_ID ? GROUP_SESSION_ID : `room:${roomId}`,
579
+ chatType: 'room',
580
+ roomId,
581
+ participants: []
582
+ };
583
+ }
584
+
585
+ async getVerifiedRoomConversation(roomId, requester) {
586
+ const conversation = this.getRoomConversation(roomId);
587
+ const room = await this.getRoom(roomId);
588
+ if (!room) throw new Error('Room not found');
589
+
590
+ if (roomId !== DEFAULT_ROOM_ID) {
591
+ const participant = await this.get(
592
+ 'SELECT user_id FROM room_participants WHERE room_id = ? AND user_id = ?',
593
+ [roomId, requester]
594
+ );
595
+ if (!participant) throw new Error('Room access denied');
596
+ }
597
+
598
+ return conversation;
599
+ }
600
+
601
+ async createRoom({ name, createdBy, nickname = '' }) {
602
+ this.ensureOpened();
603
+
604
+ const roomName = String(name || '').trim();
605
+ if (!roomName || roomName.length > 60) {
606
+ throw new Error('Invalid room name');
607
+ }
608
+
609
+ const now = Date.now();
610
+ const room = {
611
+ id: crypto.randomUUID(),
612
+ name: roomName,
613
+ code: null,
614
+ createdBy: createdBy || null,
615
+ ownerId: createdBy || null,
616
+ status: 'active',
617
+ createdAt: now,
618
+ updatedAt: now,
619
+ lastActiveAt: now
620
+ };
621
+
622
+ return this.enqueueWrite(async () => {
623
+ for (let attempt = 0; attempt < 10; attempt += 1) {
624
+ room.code = this.generateRoomCode();
625
+
626
+ try {
627
+ await this.run(
628
+ `INSERT INTO rooms(
629
+ id, name, code, created_by, owner_id, status,
630
+ created_at, updated_at, last_active_at
631
+ )
632
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
633
+ [
634
+ room.id,
635
+ room.name,
636
+ room.code,
637
+ room.createdBy,
638
+ room.ownerId,
639
+ room.status,
640
+ room.createdAt,
641
+ room.updatedAt,
642
+ room.lastActiveAt
643
+ ]
644
+ );
645
+ if (room.createdBy) {
646
+ await this.run(
647
+ `INSERT INTO room_participants(
648
+ room_id, user_id, role, nickname, joined_at, last_seen_at
649
+ ) VALUES (?, ?, 'owner', ?, ?, ?)
650
+ ON CONFLICT(room_id, user_id) DO UPDATE SET
651
+ role = 'owner',
652
+ nickname = COALESCE(room_participants.nickname, excluded.nickname),
653
+ last_seen_at = excluded.last_seen_at`,
654
+ [room.id, room.createdBy, nickname || null, now, now]
655
+ );
656
+ }
657
+ return { ...this.mapRoom(room), role: room.createdBy ? 'owner' : 'member' };
658
+ } catch (error) {
659
+ if (!String(error.message || '').includes('UNIQUE constraint failed: rooms.code')) {
660
+ throw error;
661
+ }
662
+ }
663
+ }
664
+
665
+ throw new Error('Unable to generate a unique room code');
666
+ });
667
+ }
668
+
669
+ async getRoom(roomId) {
670
+ this.ensureOpened();
671
+ if (!this.isValidRoomId(roomId)) return null;
672
+
673
+ const row = await this.get(
674
+ 'SELECT * FROM rooms WHERE id = ? AND status = ?',
675
+ [roomId, 'active']
676
+ );
677
+ return row ? this.mapRoom(row) : null;
678
+ }
679
+
680
+ async getRoomByCode(code) {
681
+ this.ensureOpened();
682
+ const normalizedCode = this.normalizeRoomCode(code);
683
+ if (!normalizedCode) return null;
684
+
685
+ const row = await this.get(
686
+ 'SELECT * FROM rooms WHERE code = ? AND status = ?',
687
+ [normalizedCode, 'active']
688
+ );
689
+ return row ? this.mapRoom(row) : null;
690
+ }
691
+
692
+ async joinRoom({ roomId, code, userId, nickname = '' }) {
693
+ this.ensureOpened();
694
+
695
+ if (!this.isValidUserId(userId)) throw new Error('Invalid user');
696
+ const room = await this.getRoom(roomId);
697
+ if (!room) throw new Error('Room not found');
698
+
699
+ const participant = await this.get(
700
+ 'SELECT user_id, role FROM room_participants WHERE room_id = ? AND user_id = ?',
701
+ [room.id, userId]
702
+ );
703
+ const normalizedCode = this.normalizeRoomCode(code);
704
+
705
+ if (room.id !== DEFAULT_ROOM_ID && !participant) {
706
+ if (!normalizedCode) throw new Error('Room code required');
707
+ if (normalizedCode !== room.code) throw new Error('Invalid room code');
708
+ }
709
+
710
+ const now = Date.now();
711
+ await this.enqueueWrite(() => this.run(
712
+ `INSERT INTO room_participants(room_id, user_id, role, nickname, joined_at, last_seen_at)
713
+ VALUES (?, ?, 'member', ?, ?, ?)
714
+ ON CONFLICT(room_id, user_id) DO UPDATE SET
715
+ nickname = excluded.nickname,
716
+ last_seen_at = excluded.last_seen_at`,
717
+ [room.id, userId, nickname || null, now, now]
718
+ ));
719
+
720
+ if (room.id !== DEFAULT_ROOM_ID) {
721
+ await this.enqueueWrite(() => this.run(
722
+ 'UPDATE rooms SET last_active_at = ?, updated_at = ? WHERE id = ?',
723
+ [now, now, room.id]
724
+ ));
725
+ }
726
+
727
+ return {
728
+ ...room,
729
+ role: participant?.role || 'member'
730
+ };
731
+ }
732
+
733
+ async listRoomsForUser(userId) {
734
+ this.ensureOpened();
735
+ if (!this.isValidUserId(userId)) throw new Error('Invalid user');
736
+
737
+ const rows = await this.all(
738
+ `SELECT r.*, p.role AS participant_role, p.joined_at, p.last_seen_at
739
+ FROM rooms r
740
+ INNER JOIN room_participants p ON p.room_id = r.id
741
+ WHERE p.user_id = ? AND r.status = 'active'
742
+ ORDER BY p.last_seen_at DESC, p.joined_at DESC`,
743
+ [userId]
744
+ );
745
+
746
+ return Promise.all(rows.map(async (row) => {
747
+ const room = this.mapRoom(row);
748
+ const session = this.getRoomConversation(room.id);
749
+ const lastMessage = await this.get(
750
+ `SELECT content, message_type, create_time, created_at
751
+ FROM messages
752
+ WHERE session_id = ?
753
+ ORDER BY seq DESC
754
+ LIMIT 1`,
755
+ [session.sessionId]
756
+ );
757
+
758
+ return {
759
+ ...room,
760
+ role: row.participant_role || 'member',
761
+ joinedAt: row.joined_at,
762
+ lastSeenAt: row.last_seen_at,
763
+ expiresAt: room.id !== DEFAULT_ROOM_ID && this.idleRetentionDays > 0
764
+ ? room.lastActiveAt + (this.idleRetentionDays * 24 * 60 * 60 * 1000)
765
+ : null,
766
+ lastMessage: lastMessage ? {
767
+ content: lastMessage.content || '',
768
+ msgType: lastMessage.message_type,
769
+ createTime: lastMessage.create_time,
770
+ createdAt: lastMessage.created_at
771
+ } : null
772
+ };
773
+ }));
774
+ }
775
+
776
+ configureIdleRetention(days = 7) {
777
+ const value = Number(days);
778
+ this.idleRetentionDays = Number.isFinite(value) && value >= 0 ? value : 7;
779
+ }
780
+
781
+ async listInactiveRooms({ retentionDays = this.idleRetentionDays, now = Date.now() } = {}) {
782
+ this.ensureOpened();
783
+ const days = Number(retentionDays);
784
+ if (!Number.isFinite(days) || days <= 0) return [];
785
+
786
+ const cutoff = now - (days * 24 * 60 * 60 * 1000);
787
+ const rows = await this.all(
788
+ `SELECT *
789
+ FROM rooms
790
+ WHERE status = 'active'
791
+ AND id <> ?
792
+ AND COALESCE(last_active_at, updated_at, created_at) < ?
793
+ ORDER BY COALESCE(last_active_at, updated_at, created_at) ASC`,
794
+ [DEFAULT_ROOM_ID, cutoff]
795
+ );
796
+
797
+ return rows.map((row) => this.mapRoom(row));
798
+ }
799
+
800
+ async deleteRoomBySystem({ roomId, reason = 'inactive', cutoff = null }) {
801
+ this.ensureOpened();
802
+ if (roomId === DEFAULT_ROOM_ID) return null;
803
+
804
+ return this.enqueueWrite(async () => {
805
+ const room = await this.getRoom(roomId);
806
+ if (!room) return null;
807
+ if (cutoff !== null && Number(room.lastActiveAt) >= Number(cutoff)) {
808
+ return null;
809
+ }
810
+
811
+ await this.run('BEGIN IMMEDIATE');
812
+
813
+ try {
814
+ const dissolved = await this.dissolveRoomContentsInTransaction(room, reason);
815
+ await this.run('COMMIT');
816
+ const cleanup = await this.processCleanupQueue();
817
+ return {
818
+ roomId,
819
+ roomName: room.name,
820
+ sessionId: dissolved.sessionId,
821
+ deletedReason: reason,
822
+ ...cleanup
823
+ };
824
+ } catch (error) {
825
+ await this.run('ROLLBACK').catch(() => {});
826
+ throw error;
827
+ }
828
+ });
829
+ }
830
+
831
+ async requireRoomOwner(roomId, requester) {
832
+ if (!this.isValidUserId(requester)) throw new Error('Invalid user');
833
+ if (roomId === DEFAULT_ROOM_ID) {
834
+ throw new Error('Public Lobby is managed by the server administrator');
835
+ }
836
+
837
+ const room = await this.getRoom(roomId);
838
+ if (!room) throw new Error('Room not found');
839
+
840
+ const participant = await this.get(
841
+ 'SELECT role FROM room_participants WHERE room_id = ? AND user_id = ?',
842
+ [roomId, requester]
843
+ );
844
+ if (!participant) throw new Error('Room access denied');
845
+ if (participant.role !== 'owner' || room.ownerId !== requester) {
846
+ throw new Error('Room owner permission required');
847
+ }
848
+
849
+ return { ...room, role: 'owner' };
850
+ }
851
+
852
+ async updateRoomName({ roomId, name, requester }) {
853
+ this.ensureOpened();
854
+
855
+ const roomName = String(name || '').trim();
856
+ if (!roomName || roomName.length > 60) throw new Error('Invalid room name');
857
+ const room = await this.requireRoomOwner(roomId, requester);
858
+
859
+ const updatedAt = Date.now();
860
+ await this.enqueueWrite(() => this.run(
861
+ 'UPDATE rooms SET name = ?, updated_at = ?, last_active_at = ? WHERE id = ?',
862
+ [roomName, updatedAt, updatedAt, roomId]
863
+ ));
864
+
865
+ return { ...room, name: roomName, updatedAt };
866
+ }
867
+
868
+ async regenerateRoomCode({ roomId, requester }) {
869
+ this.ensureOpened();
870
+
871
+ const room = await this.requireRoomOwner(roomId, requester);
872
+
873
+ const code = this.generateRoomCode();
874
+ const updatedAt = Date.now();
875
+ await this.enqueueWrite(() => this.run(
876
+ 'UPDATE rooms SET code = ?, updated_at = ?, last_active_at = ? WHERE id = ?',
877
+ [code, updatedAt, updatedAt, roomId]
878
+ ));
879
+
880
+ return { ...room, code, updatedAt };
881
+ }
882
+
883
+ async leaveRoom({ roomId, userId }) {
884
+ this.ensureOpened();
885
+ if (!this.isValidUserId(userId)) throw new Error('Invalid user');
886
+ if (roomId === DEFAULT_ROOM_ID) {
887
+ throw new Error('Public Lobby cannot be left');
888
+ }
889
+
890
+ return this.enqueueWrite(async () => {
891
+ const room = await this.getRoom(roomId);
892
+ if (!room) throw new Error('Room not found');
893
+
894
+ const participant = await this.get(
895
+ 'SELECT role FROM room_participants WHERE room_id = ? AND user_id = ?',
896
+ [roomId, userId]
897
+ );
898
+ if (!participant) throw new Error('Room access denied');
899
+
900
+ await this.run('BEGIN IMMEDIATE');
901
+
902
+ try {
903
+ await this.run(
904
+ 'DELETE FROM room_participants WHERE room_id = ? AND user_id = ?',
905
+ [roomId, userId]
906
+ );
907
+
908
+ const remaining = await this.all(
909
+ `SELECT user_id, role FROM room_participants
910
+ WHERE room_id = ?
911
+ ORDER BY joined_at ASC, user_id ASC`,
912
+ [roomId]
913
+ );
914
+
915
+ if (remaining.length === 0) {
916
+ const dissolved = await this.dissolveRoomContentsInTransaction(room, 'last_member_left');
917
+ await this.run('COMMIT');
918
+ const cleanup = await this.processCleanupQueue();
919
+ return {
920
+ roomId,
921
+ sessionId: dissolved.sessionId,
922
+ dissolved: true,
923
+ newOwnerId: null,
924
+ ...cleanup
925
+ };
926
+ }
927
+
928
+ let newOwnerId = room.ownerId;
929
+ let ownerChanged = false;
930
+ if (participant.role === 'owner' || room.ownerId === userId) {
931
+ newOwnerId = remaining[0].user_id;
932
+ ownerChanged = true;
933
+ await this.run(
934
+ "UPDATE room_participants SET role = 'member' WHERE room_id = ?",
935
+ [roomId]
936
+ );
937
+ await this.run(
938
+ "UPDATE room_participants SET role = 'owner' WHERE room_id = ? AND user_id = ?",
939
+ [roomId, newOwnerId]
940
+ );
941
+ await this.run(
942
+ `UPDATE rooms
943
+ SET owner_id = ?, updated_at = ?, last_active_at = ?
944
+ WHERE id = ?`,
945
+ [newOwnerId, Date.now(), Date.now(), roomId]
946
+ );
947
+ }
948
+
949
+ if (!ownerChanged) {
950
+ const activityAt = Date.now();
951
+ await this.run(
952
+ 'UPDATE rooms SET last_active_at = ?, updated_at = ? WHERE id = ?',
953
+ [activityAt, activityAt, roomId]
954
+ );
955
+ }
956
+
957
+ await this.run('COMMIT');
958
+ return {
959
+ roomId,
960
+ sessionId: this.getRoomConversation(roomId).sessionId,
961
+ dissolved: false,
962
+ newOwnerId,
963
+ ownerChanged
964
+ };
965
+ } catch (error) {
966
+ await this.run('ROLLBACK').catch(() => {});
967
+ throw error;
968
+ }
969
+ });
970
+ }
971
+
972
+ async dissolveRoom({ roomId, requester }) {
973
+ this.ensureOpened();
974
+ const room = await this.requireRoomOwner(roomId, requester);
975
+
976
+ return this.enqueueWrite(async () => {
977
+ await this.run('BEGIN IMMEDIATE');
978
+
979
+ try {
980
+ const dissolved = await this.dissolveRoomContentsInTransaction(room, 'manual');
981
+ await this.run('COMMIT');
982
+ const cleanup = await this.processCleanupQueue();
983
+ return {
984
+ roomId,
985
+ sessionId: dissolved.sessionId,
986
+ ...cleanup
987
+ };
988
+ } catch (error) {
989
+ await this.run('ROLLBACK').catch(() => {});
990
+ throw error;
991
+ }
992
+ });
993
+ }
994
+
995
+ async dissolveRoomContentsInTransaction(room, reason = 'manual') {
996
+ const conversation = this.getRoomConversation(room.id);
997
+ const files = await this.all(
998
+ 'SELECT DISTINCT file_path FROM messages WHERE session_id = ? AND file_path IS NOT NULL',
999
+ [conversation.sessionId]
1000
+ );
1001
+ const now = Date.now();
1002
+
1003
+ for (const file of files) {
1004
+ await this.run(
1005
+ 'INSERT OR IGNORE INTO file_cleanup_queue(file_path, created_at) VALUES (?, ?)',
1006
+ [file.file_path, now]
1007
+ );
1008
+ }
1009
+
1010
+ await this.run(
1011
+ 'DELETE FROM messages WHERE session_id = ?',
1012
+ [conversation.sessionId]
1013
+ );
1014
+ await this.run(
1015
+ 'DELETE FROM room_participants WHERE room_id = ?',
1016
+ [room.id]
1017
+ );
1018
+ await this.run(
1019
+ `UPDATE rooms
1020
+ SET status = 'deleted', deleted_at = ?, updated_at = ?, code = NULL,
1021
+ deleted_reason = ?
1022
+ WHERE id = ?`,
1023
+ [now, now, reason, room.id]
1024
+ );
1025
+
1026
+ return { sessionId: conversation.sessionId };
1027
+ }
1028
+
397
1029
  normalizeMessage(message) {
398
1030
  if (!this.isValidUserId(message.sender)) {
399
1031
  throw new Error('Invalid message sender');
@@ -517,6 +1149,37 @@ class MessageHistoryService {
517
1149
  return typeof value === 'string' && value.trim().length > 0 && value.length <= 256;
518
1150
  }
519
1151
 
1152
+ isValidRoomId(value) {
1153
+ return value === DEFAULT_ROOM_ID || /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
1154
+ }
1155
+
1156
+ generateRoomCode() {
1157
+ return Array.from(
1158
+ { length: 8 },
1159
+ () => ROOM_CODE_ALPHABET[crypto.randomInt(ROOM_CODE_ALPHABET.length)]
1160
+ ).join('');
1161
+ }
1162
+
1163
+ normalizeRoomCode(value) {
1164
+ return String(value || '').toUpperCase().replace(/[^0-9A-Z]/g, '');
1165
+ }
1166
+
1167
+ mapRoom(row) {
1168
+ return {
1169
+ id: row.id,
1170
+ name: row.name,
1171
+ code: row.code,
1172
+ createdBy: row.created_by,
1173
+ ownerId: row.owner_id || null,
1174
+ status: row.status,
1175
+ createdAt: row.created_at,
1176
+ updatedAt: row.updated_at,
1177
+ deletedAt: row.deleted_at || null,
1178
+ deletedReason: row.deleted_reason || null,
1179
+ lastActiveAt: row.last_active_at || row.updated_at || row.created_at || null
1180
+ };
1181
+ }
1182
+
520
1183
  normalizeLimit(limit) {
521
1184
  const value = Number(limit);
522
1185
  if (!Number.isFinite(value) || value <= 0) return DEFAULT_LIMIT;
@@ -574,5 +1237,6 @@ class MessageHistoryService {
574
1237
 
575
1238
  module.exports = {
576
1239
  MessageHistoryService,
577
- GROUP_SESSION_ID
1240
+ GROUP_SESSION_ID,
1241
+ DEFAULT_ROOM_ID
578
1242
  };