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.
- package/frontend/dist/assets/{browser-CiFpIcIA.js → browser-_rMkfdfG.js} +1 -1
- package/frontend/dist/assets/{index-PrmIL31V.js → index-BTJ_Fb6p.js} +42 -42
- package/frontend/dist/assets/{index-DDcqIagG.css → index-IvWY8A4p.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/cli-options.js +9 -1
- package/server/index.js +15 -1
- package/server/room-manager.js +117 -25
- package/server/routes/qr.js +10 -2
- package/server/services/batch-download-service.js +15 -1
- package/server/services/message-history-service.js +684 -20
- package/server/socket/index.js +536 -59
- package/server/utils/network.js +11 -1
- package/server/utils/runtime-options.js +30 -0
|
@@ -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 =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 =
|
|
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 =
|
|
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({
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|