chattransfer 2.0.17 → 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.
@@ -5,7 +5,11 @@ const sqlite3 = require('sqlite3');
5
5
 
6
6
  const DEFAULT_LIMIT = 100;
7
7
  const MAX_LIMIT = 200;
8
+ const MAX_BATCH_ATTACHMENT_COUNT = 200;
9
+ const MAX_BATCH_ATTACHMENT_TOTAL_SIZE = 2 * 1024 * 1024 * 1024;
8
10
  const GROUP_SESSION_ID = 'group';
11
+ const DEFAULT_ROOM_ID = 'group_chat';
12
+ const ROOM_CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
9
13
 
10
14
  class MessageHistoryService {
11
15
  constructor({ cachePath, databasePath }) {
@@ -20,6 +24,8 @@ class MessageHistoryService {
20
24
  this.historyDir = path.dirname(this.databasePath);
21
25
  this.db = null;
22
26
  this.writeLock = Promise.resolve();
27
+ this.publicRoomCleanupTimer = null;
28
+ this.idleRetentionDays = 7;
23
29
  }
24
30
 
25
31
  init() {
@@ -46,6 +52,7 @@ class MessageHistoryService {
46
52
  }
47
53
 
48
54
  close() {
55
+ this.stopPublicRoomCleanup();
49
56
  if (!this.db) return Promise.resolve();
50
57
 
51
58
  return new Promise((resolve, reject) => {
@@ -62,11 +69,13 @@ class MessageHistoryService {
62
69
  async saveMessageInTransaction(message) {
63
70
 
64
71
  const normalized = this.normalizeMessage(message);
65
- const conversation = this.getConversation({
66
- chatType: normalized.receiver === 'group_chat' ? 'group' : 'private',
67
- requester: normalized.sender,
68
- targetUser: normalized.receiver
69
- });
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
+ });
70
79
 
71
80
  await this.run('BEGIN IMMEDIATE');
72
81
 
@@ -107,6 +116,16 @@ class MessageHistoryService {
107
116
  ]
108
117
  );
109
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
+
110
129
  await this.run('COMMIT');
111
130
  return normalized;
112
131
  } catch (error) {
@@ -115,10 +134,12 @@ class MessageHistoryService {
115
134
  }
116
135
  }
117
136
 
118
- async queryHistory({ requester, chatType, targetUser, cursor = null, limit = DEFAULT_LIMIT }) {
137
+ async queryHistory({ requester, roomId, chatType, targetUser, cursor = null, limit = DEFAULT_LIMIT }) {
119
138
  this.ensureOpened();
120
139
 
121
- const conversation = this.getConversation({ requester, chatType, targetUser });
140
+ const conversation = roomId
141
+ ? await this.getVerifiedRoomConversation(roomId, requester)
142
+ : this.getConversation({ requester, chatType, targetUser });
122
143
  const queryLimit = this.normalizeLimit(limit);
123
144
  const normalizedCursor = this.normalizeCursor(cursor);
124
145
  const direction = normalizedCursor === null ? 'DESC' : 'ASC';
@@ -147,19 +168,183 @@ class MessageHistoryService {
147
168
  };
148
169
  }
149
170
 
150
- clearHistory({ requester, chatType, targetUser }) {
171
+ async queryAttachments({ requester, roomId, chatType, targetUser }) {
151
172
  this.ensureOpened();
152
173
 
153
- return this.enqueueWrite(() => this.clearHistoryInTransaction({
174
+ const conversation = roomId
175
+ ? await this.getVerifiedRoomConversation(roomId, requester)
176
+ : this.getConversation({ requester, chatType, targetUser });
177
+ const rows = await this.all(
178
+ `SELECT seq, id, sender_id, sender_nickname, message_type, create_time, created_at,
179
+ file_path, file_name, file_size, file_mime_type
180
+ FROM messages
181
+ WHERE session_id = ? AND file_path IS NOT NULL
182
+ ORDER BY created_at ASC, seq ASC`,
183
+ [conversation.sessionId]
184
+ );
185
+
186
+ const attachments = [];
187
+ for (const row of rows) {
188
+ const physicalPath = this.resolveExistingFilePath(row.file_path);
189
+ if (!physicalPath) continue;
190
+ const stats = fs.statSync(physicalPath);
191
+ if (!stats.isFile()) continue;
192
+ const physicalFileName = path.posix.basename(row.file_path);
193
+
194
+ attachments.push({
195
+ msgId: row.id,
196
+ seq: row.seq,
197
+ sender: row.sender_id,
198
+ senderNickname: row.sender_nickname || row.sender_id,
199
+ msgType: row.message_type,
200
+ createTime: row.create_time,
201
+ createdAt: row.created_at,
202
+ fileName: row.file_name || physicalFileName,
203
+ fileSize: Number(row.file_size) || stats.size,
204
+ fileType: row.file_mime_type,
205
+ fileUrl: `/files/${encodeURIComponent(physicalFileName)}`,
206
+ filePath: row.file_path,
207
+ physicalPath,
208
+ available: true
209
+ });
210
+ }
211
+
212
+ return { attachments };
213
+ }
214
+
215
+ async getBatchAttachments({ requester, roomId, chatType, targetUser, messageIds }) {
216
+ this.ensureOpened();
217
+
218
+ if (!Array.isArray(messageIds) || messageIds.length === 0) {
219
+ throw new Error('No attachment selected');
220
+ }
221
+
222
+ const requestedIds = new Set(messageIds.filter((id) => typeof id === 'string' && id.length > 0));
223
+ if (requestedIds.size === 0) throw new Error('No attachment selected');
224
+ if (requestedIds.size > MAX_BATCH_ATTACHMENT_COUNT) {
225
+ throw new Error(`Batch download supports at most ${MAX_BATCH_ATTACHMENT_COUNT} files`);
226
+ }
227
+
228
+ const { attachments } = await this.queryAttachments({
154
229
  requester,
230
+ roomId,
155
231
  chatType,
156
232
  targetUser
157
- }));
233
+ });
234
+ const attachmentMap = new Map(attachments.map((attachment) => [attachment.msgId, attachment]));
235
+ const selectedAttachments = [];
236
+
237
+ for (const messageId of requestedIds) {
238
+ const attachment = attachmentMap.get(messageId);
239
+ if (!attachment) throw new Error(`Attachment not found: ${messageId}`);
240
+ if (!attachment.available) throw new Error(`Attachment file missing: ${attachment.fileName}`);
241
+ selectedAttachments.push(attachment);
242
+ }
243
+
244
+ const totalSize = selectedAttachments.reduce((total, attachment) => (
245
+ total + (Number(attachment.fileSize) || 0)
246
+ ), 0);
247
+ if (totalSize > MAX_BATCH_ATTACHMENT_TOTAL_SIZE) {
248
+ throw new Error('Batch download exceeds the 2GB size limit');
249
+ }
250
+
251
+ return selectedAttachments;
252
+ }
253
+
254
+ async clearHistory({ requester, roomId, chatType, targetUser }) {
255
+ this.ensureOpened();
256
+
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
+ });
324
+ }
325
+
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
+ };
335
+
336
+ run();
337
+ this.publicRoomCleanupTimer = setInterval(run, intervalMs);
338
+ this.publicRoomCleanupTimer.unref?.();
158
339
  }
159
340
 
160
- async clearHistoryInTransaction({ requester, chatType, targetUser }) {
341
+ stopPublicRoomCleanup() {
342
+ if (!this.publicRoomCleanupTimer) return;
343
+ clearInterval(this.publicRoomCleanupTimer);
344
+ this.publicRoomCleanupTimer = null;
345
+ }
161
346
 
162
- const conversation = this.getConversation({ requester, chatType, targetUser });
347
+ async clearConversationHistory(conversation) {
163
348
  const files = await this.all(
164
349
  'SELECT DISTINCT file_path FROM messages WHERE session_id = ? AND file_path IS NOT NULL',
165
350
  [conversation.sessionId]
@@ -277,6 +462,74 @@ class MessageHistoryService {
277
462
  last_error TEXT,
278
463
  created_at INTEGER NOT NULL
279
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}`);
280
533
  }
281
534
 
282
535
  getConversation({ requester, chatType, targetUser }) {
@@ -316,6 +569,463 @@ class MessageHistoryService {
316
569
  };
317
570
  }
318
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
+
319
1029
  normalizeMessage(message) {
320
1030
  if (!this.isValidUserId(message.sender)) {
321
1031
  throw new Error('Invalid message sender');
@@ -411,10 +1121,65 @@ class MessageHistoryService {
411
1121
  return physicalPath;
412
1122
  }
413
1123
 
1124
+ resolveExistingFilePath(filePath) {
1125
+ const physicalPath = this.resolveFilePath(filePath);
1126
+ if (!physicalPath) return null;
1127
+
1128
+ try {
1129
+ const realCachePath = fs.realpathSync(this.cachePath);
1130
+ const realFilesPath = path.join(realCachePath, 'files');
1131
+ const realPhysicalPath = fs.realpathSync(physicalPath);
1132
+ const relativePath = path.relative(realFilesPath, realPhysicalPath);
1133
+
1134
+ if (
1135
+ relativePath === '' ||
1136
+ relativePath.startsWith('..') ||
1137
+ path.isAbsolute(relativePath)
1138
+ ) {
1139
+ return null;
1140
+ }
1141
+
1142
+ return realPhysicalPath;
1143
+ } catch (error) {
1144
+ return null;
1145
+ }
1146
+ }
1147
+
414
1148
  isValidUserId(value) {
415
1149
  return typeof value === 'string' && value.trim().length > 0 && value.length <= 256;
416
1150
  }
417
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
+
418
1183
  normalizeLimit(limit) {
419
1184
  const value = Number(limit);
420
1185
  if (!Number.isFinite(value) || value <= 0) return DEFAULT_LIMIT;
@@ -472,5 +1237,6 @@ class MessageHistoryService {
472
1237
 
473
1238
  module.exports = {
474
1239
  MessageHistoryService,
475
- GROUP_SESSION_ID
1240
+ GROUP_SESSION_ID,
1241
+ DEFAULT_ROOM_ID
476
1242
  };