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.
- package/frontend/dist/assets/{browser-BzA-fxZ1.js → browser-_rMkfdfG.js} +1 -1
- package/frontend/dist/assets/{index-B2dOrakb.js → index-BTJ_Fb6p.js} +43 -43
- package/frontend/dist/assets/index-IvWY8A4p.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +4 -3
- package/server/cli-options.js +9 -1
- package/server/index.js +20 -4
- package/server/room-manager.js +117 -25
- package/server/routes/batch-download.js +31 -0
- package/server/routes/index.js +14 -2
- package/server/routes/qr.js +10 -2
- package/server/services/batch-download-service.js +175 -0
- package/server/services/message-history-service.js +779 -13
- package/server/socket/index.js +592 -51
- package/server/utils/network.js +11 -1
- package/server/utils/runtime-options.js +30 -0
- package/frontend/dist/assets/index-DYJPnabS.css +0 -1
|
@@ -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 =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
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
|
-
|
|
171
|
+
async queryAttachments({ requester, roomId, chatType, targetUser }) {
|
|
151
172
|
this.ensureOpened();
|
|
152
173
|
|
|
153
|
-
|
|
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
|
-
|
|
341
|
+
stopPublicRoomCleanup() {
|
|
342
|
+
if (!this.publicRoomCleanupTimer) return;
|
|
343
|
+
clearInterval(this.publicRoomCleanupTimer);
|
|
344
|
+
this.publicRoomCleanupTimer = null;
|
|
345
|
+
}
|
|
161
346
|
|
|
162
|
-
|
|
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
|
};
|