|
|
import fs from 'node:fs'; |
|
|
import { promises as fsPromises } from 'node:fs'; |
|
|
import path from 'node:path'; |
|
|
|
|
|
import express from 'express'; |
|
|
import sanitize from 'sanitize-filename'; |
|
|
import { sync as writeFileAtomicSync, default as writeFileAtomic } from 'write-file-atomic'; |
|
|
|
|
|
import { color, tryParse } from '../util.js'; |
|
|
import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
|
|
|
|
|
export const router = express.Router(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function warnOnGroupMetadata(groupData) { |
|
|
if (typeof groupData !== 'object' || groupData === null) { |
|
|
return; |
|
|
} |
|
|
['chat_metadata', 'past_metadata'].forEach(key => { |
|
|
if (Object.hasOwn(groupData, key)) { |
|
|
console.warn(color.yellow(`Group JSON data for "${groupData.id}" contains deprecated key "${key}".`)); |
|
|
delete groupData[key]; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function migrateGroupChatsMetadataFormat(userDirectories) { |
|
|
for (const userDirs of userDirectories) { |
|
|
try { |
|
|
let anyDataMigrated = false; |
|
|
const backupPath = path.join(userDirs.backups, '_group_metadata_update'); |
|
|
const groupFiles = await fsPromises.readdir(userDirs.groups, { withFileTypes: true }); |
|
|
const groupChatFiles = await fsPromises.readdir(userDirs.groupChats, { withFileTypes: true }); |
|
|
for (const groupFile of groupFiles) { |
|
|
try { |
|
|
const isJsonFile = groupFile.isFile() && path.extname(groupFile.name) === '.json'; |
|
|
if (!isJsonFile) { |
|
|
continue; |
|
|
} |
|
|
const groupFilePath = path.join(userDirs.groups, groupFile.name); |
|
|
const groupDataRaw = await fsPromises.readFile(groupFilePath, 'utf8'); |
|
|
const groupData = tryParse(groupDataRaw) || {}; |
|
|
const needsMigration = ['chat_metadata', 'past_metadata'].some(key => Object.hasOwn(groupData, key)); |
|
|
if (!needsMigration) { |
|
|
continue; |
|
|
} |
|
|
if (!fs.existsSync(backupPath)){ |
|
|
await fsPromises.mkdir(backupPath, { recursive: true }); |
|
|
} |
|
|
await fsPromises.copyFile(groupFilePath, path.join(backupPath, groupFile.name)); |
|
|
const allMetadata = { |
|
|
...(groupData.past_metadata || {}), |
|
|
[groupData.chat_id]: (groupData.chat_metadata || {}), |
|
|
}; |
|
|
if (!Array.isArray(groupData.chats)) { |
|
|
console.warn(color.yellow(`Group ${groupFile.name} has no chats array, skipping migration.`)); |
|
|
continue; |
|
|
} |
|
|
for (const chatId of groupData.chats) { |
|
|
try { |
|
|
const chatFileName = sanitize(`${chatId}.jsonl`); |
|
|
const chatFileDirent = groupChatFiles.find(f => f.isFile() && f.name === chatFileName); |
|
|
if (!chatFileDirent) { |
|
|
console.warn(color.yellow(`Group chat file ${chatId} not found, skipping migration.`)); |
|
|
continue; |
|
|
} |
|
|
const chatFilePath = path.join(userDirs.groupChats, chatFileName); |
|
|
const chatMetadata = allMetadata[chatId] || {}; |
|
|
const chatDataRaw = await fsPromises.readFile(chatFilePath, 'utf8'); |
|
|
const chatData = chatDataRaw.split('\n').filter(line => line.trim()).map(line => tryParse(line)).filter(Boolean); |
|
|
const alreadyHasMetadata = chatData.length > 0 && Object.hasOwn(chatData[0], 'chat_metadata'); |
|
|
if (alreadyHasMetadata) { |
|
|
console.log(color.yellow(`Group chat ${chatId} already has chat metadata, skipping update.`)); |
|
|
continue; |
|
|
} |
|
|
await fsPromises.copyFile(chatFilePath, path.join(backupPath, chatFileName)); |
|
|
const chatHeader = { chat_metadata: chatMetadata, user_name: 'unused', character_name: 'unused' }; |
|
|
const newChatData = [chatHeader, ...chatData]; |
|
|
const newChatDataRaw = newChatData.map(entry => JSON.stringify(entry)).join('\n'); |
|
|
await writeFileAtomic(chatFilePath, newChatDataRaw, 'utf8'); |
|
|
console.log(`Updated group chat data format for ${chatId}`); |
|
|
anyDataMigrated = true; |
|
|
} catch (chatError) { |
|
|
console.error(color.red(`Could not update existing chat data for ${chatId}`), chatError); |
|
|
} |
|
|
} |
|
|
delete groupData.chat_metadata; |
|
|
delete groupData.past_metadata; |
|
|
await writeFileAtomic(groupFilePath, JSON.stringify(groupData, null, 4), 'utf8'); |
|
|
console.log(`Migrated group chats metadata for group: ${groupData.id}`); |
|
|
anyDataMigrated = true; |
|
|
} catch (groupError) { |
|
|
console.error(color.red(`Could not process group file ${groupFile.name}`), groupError); |
|
|
} |
|
|
} |
|
|
if (anyDataMigrated) { |
|
|
console.log(color.green(`Completed migration of group chats metadata for user at ${userDirs.root}`)); |
|
|
console.log(color.cyan(`Backups of modified files are located at ${backupPath}`)); |
|
|
} |
|
|
} catch (directoryError) { |
|
|
console.error(color.red(`Error migrating group chats metadata for user at ${userDirs.root}`), directoryError); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
router.post('/all', (request, response) => { |
|
|
const groups = []; |
|
|
|
|
|
if (!fs.existsSync(request.user.directories.groups)) { |
|
|
fs.mkdirSync(request.user.directories.groups); |
|
|
} |
|
|
|
|
|
const files = fs.readdirSync(request.user.directories.groups).filter(x => path.extname(x) === '.json'); |
|
|
const chats = fs.readdirSync(request.user.directories.groupChats).filter(x => path.extname(x) === '.jsonl'); |
|
|
|
|
|
files.forEach(function (file) { |
|
|
try { |
|
|
const filePath = path.join(request.user.directories.groups, file); |
|
|
const fileContents = fs.readFileSync(filePath, 'utf8'); |
|
|
const group = JSON.parse(fileContents); |
|
|
const groupStat = fs.statSync(filePath); |
|
|
group['date_added'] = groupStat.birthtimeMs; |
|
|
group['create_date'] = new Date(groupStat.birthtimeMs).toISOString(); |
|
|
|
|
|
let chat_size = 0; |
|
|
let date_last_chat = 0; |
|
|
|
|
|
if (Array.isArray(group.chats) && Array.isArray(chats)) { |
|
|
for (const chat of chats) { |
|
|
if (group.chats.includes(path.parse(chat).name)) { |
|
|
const chatStat = fs.statSync(path.join(request.user.directories.groupChats, chat)); |
|
|
chat_size += chatStat.size; |
|
|
date_last_chat = Math.max(date_last_chat, chatStat.mtimeMs); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
group['date_last_chat'] = date_last_chat; |
|
|
group['chat_size'] = chat_size; |
|
|
groups.push(group); |
|
|
} |
|
|
catch (error) { |
|
|
console.error(error); |
|
|
} |
|
|
}); |
|
|
|
|
|
return response.send(groups); |
|
|
}); |
|
|
|
|
|
router.post('/create', (request, response) => { |
|
|
if (!request.body) { |
|
|
return response.sendStatus(400); |
|
|
} |
|
|
|
|
|
warnOnGroupMetadata(request.body); |
|
|
const id = String(Date.now()); |
|
|
const groupMetadata = { |
|
|
id: id, |
|
|
name: request.body.name ?? 'New Group', |
|
|
members: request.body.members ?? [], |
|
|
avatar_url: request.body.avatar_url, |
|
|
allow_self_responses: !!request.body.allow_self_responses, |
|
|
activation_strategy: request.body.activation_strategy ?? 0, |
|
|
generation_mode: request.body.generation_mode ?? 0, |
|
|
disabled_members: request.body.disabled_members ?? [], |
|
|
fav: request.body.fav, |
|
|
chat_id: request.body.chat_id ?? id, |
|
|
chats: request.body.chats ?? [id], |
|
|
auto_mode_delay: request.body.auto_mode_delay ?? 5, |
|
|
generation_mode_join_prefix: request.body.generation_mode_join_prefix ?? '', |
|
|
generation_mode_join_suffix: request.body.generation_mode_join_suffix ?? '', |
|
|
}; |
|
|
const pathToFile = path.join(request.user.directories.groups, sanitize(`${id}.json`)); |
|
|
const fileData = JSON.stringify(groupMetadata, null, 4); |
|
|
|
|
|
if (!fs.existsSync(request.user.directories.groups)) { |
|
|
fs.mkdirSync(request.user.directories.groups); |
|
|
} |
|
|
|
|
|
writeFileAtomicSync(pathToFile, fileData); |
|
|
return response.send(groupMetadata); |
|
|
}); |
|
|
|
|
|
router.post('/edit', getFileNameValidationFunction('id'), (request, response) => { |
|
|
if (!request.body || !request.body.id) { |
|
|
return response.sendStatus(400); |
|
|
} |
|
|
warnOnGroupMetadata(request.body); |
|
|
const id = request.body.id; |
|
|
const pathToFile = path.join(request.user.directories.groups, sanitize(`${id}.json`)); |
|
|
const fileData = JSON.stringify(request.body, null, 4); |
|
|
|
|
|
writeFileAtomicSync(pathToFile, fileData); |
|
|
return response.send({ ok: true }); |
|
|
}); |
|
|
|
|
|
router.post('/delete', getFileNameValidationFunction('id'), async (request, response) => { |
|
|
if (!request.body || !request.body.id) { |
|
|
return response.sendStatus(400); |
|
|
} |
|
|
|
|
|
const id = request.body.id; |
|
|
const pathToGroup = path.join(request.user.directories.groups, sanitize(`${id}.json`)); |
|
|
|
|
|
try { |
|
|
|
|
|
const group = JSON.parse(fs.readFileSync(pathToGroup, 'utf8')); |
|
|
|
|
|
if (group && Array.isArray(group.chats)) { |
|
|
for (const chat of group.chats) { |
|
|
console.info('Deleting group chat', chat); |
|
|
const pathToFile = path.join(request.user.directories.groupChats, sanitize(`${chat}.jsonl`)); |
|
|
|
|
|
if (fs.existsSync(pathToFile)) { |
|
|
fs.unlinkSync(pathToFile); |
|
|
} |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Could not delete group chats. Clean them up manually.', error); |
|
|
} |
|
|
|
|
|
if (fs.existsSync(pathToGroup)) { |
|
|
fs.unlinkSync(pathToGroup); |
|
|
} |
|
|
|
|
|
return response.send({ ok: true }); |
|
|
}); |
|
|
|