| 1 | import fs from 'node:fs'; |
| 2 | import { promises as fsPromises } from 'node:fs'; |
| 3 | import path from 'node:path'; |
| 4 | |
| 5 | import express from 'express'; |
| 6 | import sanitize from 'sanitize-filename'; |
| 7 | import { sync as writeFileAtomicSync, default as writeFileAtomic } from 'write-file-atomic'; |
| 8 | |
| 9 | import { color, tryParse } from '../util.js'; |
| 10 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
| 11 | |
| 12 | export const router = express.Router(); |
| 13 | |
| 14 | /** |
| 15 | * Warns if group data contains deprecated metadata keys and removes them. |
| 16 | * @param {object} groupData Group data object |
| 17 | */ |
| 18 | function warnOnGroupMetadata(groupData) { |
| 19 | if (typeof groupData !== 'object' || groupData === null) { |
| 20 | return; |
| 21 | } |
| 22 | ['chat_metadata', 'past_metadata'].forEach(key => { |
| 23 | if (Object.hasOwn(groupData, key)) { |
| 24 | console.warn(color.yellow(`Group JSON data for "${groupData.id}" contains deprecated key "${key}".`)); |
| 25 | delete groupData[key]; |
| 26 | } |
| 27 | }); |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * Migrates group metadata to include chat metadata for each group chat instead of the group itself. |
| 32 | * @param {import('../users.js').UserDirectoryList[]} userDirectories Listing of all users' directories |
| 33 | */ |
| 34 | export async function migrateGroupChatsMetadataFormat(userDirectories) { |
| 35 | for (const userDirs of userDirectories) { |
| 36 | try { |
| 37 | let anyDataMigrated = false; |
| 38 | const backupPath = path.join(userDirs.backups, '_group_metadata_update'); |
| 39 | const groupFiles = await fsPromises.readdir(userDirs.groups, { withFileTypes: true }); |
| 40 | const groupChatFiles = await fsPromises.readdir(userDirs.groupChats, { withFileTypes: true }); |
| 41 | for (const groupFile of groupFiles) { |
| 42 | try { |
| 43 | const isJsonFile = groupFile.isFile() && path.extname(groupFile.name) === '.json'; |
| 44 | if (!isJsonFile) { |
| 45 | continue; |
| 46 | } |
| 47 | const groupFilePath = path.join(userDirs.groups, groupFile.name); |
| 48 | const groupDataRaw = await fsPromises.readFile(groupFilePath, 'utf8'); |
| 49 | const groupData = tryParse(groupDataRaw) || {}; |
| 50 | const needsMigration = ['chat_metadata', 'past_metadata'].some(key => Object.hasOwn(groupData, key)); |
| 51 | if (!needsMigration) { |
| 52 | continue; |
| 53 | } |
| 54 | if (!fs.existsSync(backupPath)) { |
| 55 | await fsPromises.mkdir(backupPath, { recursive: true }); |
| 56 | } |
| 57 | await fsPromises.copyFile(groupFilePath, path.join(backupPath, groupFile.name)); |
| 58 | const allMetadata = { |
| 59 | ...(groupData.past_metadata || {}), |
| 60 | [groupData.chat_id]: (groupData.chat_metadata || {}), |
| 61 | }; |
| 62 | if (!Array.isArray(groupData.chats)) { |
| 63 | console.warn(color.yellow(`Group ${groupFile.name} has no chats array, skipping migration.`)); |
| 64 | continue; |
| 65 | } |
| 66 | for (const chatId of groupData.chats) { |
| 67 | try { |
| 68 | const chatFileName = sanitize(`${chatId}.jsonl`); |
| 69 | const chatFileDirent = groupChatFiles.find(f => f.isFile() && f.name === chatFileName); |
| 70 | if (!chatFileDirent) { |
| 71 | console.warn(color.yellow(`Group chat file ${chatId} not found, skipping migration.`)); |
| 72 | continue; |
| 73 | } |
| 74 | const chatFilePath = path.join(userDirs.groupChats, chatFileName); |
| 75 | const chatMetadata = allMetadata[chatId] || {}; |
| 76 | const chatDataRaw = await fsPromises.readFile(chatFilePath, 'utf8'); |
| 77 | const chatData = chatDataRaw.split('\n').filter(line => line.trim()).map(line => tryParse(line)).filter(Boolean); |
| 78 | const alreadyHasMetadata = chatData.length > 0 && Object.hasOwn(chatData[0], 'chat_metadata'); |
| 79 | if (alreadyHasMetadata) { |
| 80 | console.log(color.yellow(`Group chat ${chatId} already has chat metadata, skipping update.`)); |
| 81 | continue; |
| 82 | } |
| 83 | await fsPromises.copyFile(chatFilePath, path.join(backupPath, chatFileName)); |
| 84 | const chatHeader = { chat_metadata: chatMetadata, user_name: 'unused', character_name: 'unused' }; |
| 85 | const newChatData = [chatHeader, ...chatData]; |
| 86 | const newChatDataRaw = newChatData.map(entry => JSON.stringify(entry)).join('\n'); |
| 87 | await writeFileAtomic(chatFilePath, newChatDataRaw, 'utf8'); |
| 88 | console.log(`Updated group chat data format for ${chatId}`); |
| 89 | anyDataMigrated = true; |
| 90 | } catch (chatError) { |
| 91 | console.error(color.red(`Could not update existing chat data for ${chatId}`), chatError); |
| 92 | } |
| 93 | } |
| 94 | delete groupData.chat_metadata; |
| 95 | delete groupData.past_metadata; |
| 96 | await writeFileAtomic(groupFilePath, JSON.stringify(groupData, null, 4), 'utf8'); |
| 97 | console.log(`Migrated group chats metadata for group: ${groupData.id}`); |
| 98 | anyDataMigrated = true; |
| 99 | } catch (groupError) { |
| 100 | console.error(color.red(`Could not process group file ${groupFile.name}`), groupError); |
| 101 | } |
| 102 | } |
| 103 | if (anyDataMigrated) { |
| 104 | console.log(color.green(`Completed migration of group chats metadata for user at ${userDirs.root}`)); |
| 105 | console.log(color.cyan(`Backups of modified files are located at ${backupPath}`)); |
| 106 | } |
| 107 | } catch (directoryError) { |
| 108 | console.error(color.red(`Error migrating group chats metadata for user at ${userDirs.root}`), directoryError); |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | router.post('/all', (request, response) => { |
| 114 | const groups = []; |
| 115 | |
| 116 | if (!fs.existsSync(request.user.directories.groups)) { |
| 117 | fs.mkdirSync(request.user.directories.groups); |
| 118 | } |
| 119 | |
| 120 | const files = fs.readdirSync(request.user.directories.groups).filter(x => path.extname(x) === '.json'); |
| 121 | const chats = fs.readdirSync(request.user.directories.groupChats).filter(x => path.extname(x) === '.jsonl'); |
| 122 | |
| 123 | files.forEach(function (file) { |
| 124 | try { |
| 125 | const filePath = path.join(request.user.directories.groups, file); |
| 126 | const fileContents = fs.readFileSync(filePath, 'utf8'); |
| 127 | const group = JSON.parse(fileContents); |
| 128 | const groupStat = fs.statSync(filePath); |
| 129 | group.date_added = groupStat.birthtimeMs; |
| 130 | group.create_date = new Date(groupStat.birthtimeMs).toISOString(); |
| 131 | |
| 132 | let chat_size = 0; |
| 133 | let date_last_chat = 0; |
| 134 | |
| 135 | if (Array.isArray(group.chats) && Array.isArray(chats)) { |
| 136 | for (const chat of chats) { |
| 137 | if (group.chats.includes(path.parse(chat).name)) { |
| 138 | const chatStat = fs.statSync(path.join(request.user.directories.groupChats, chat)); |
| 139 | chat_size += chatStat.size; |
| 140 | date_last_chat = Math.max(date_last_chat, chatStat.mtimeMs); |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | group.date_last_chat = date_last_chat; |
| 146 | group.chat_size = chat_size; |
| 147 | groups.push(group); |
| 148 | } catch (error) { |
| 149 | console.error(error); |
| 150 | } |
| 151 | }); |
| 152 | |
| 153 | return response.send(groups); |
| 154 | }); |
| 155 | |
| 156 | router.post('/create', (request, response) => { |
| 157 | if (!request.body) { |
| 158 | return response.sendStatus(400); |
| 159 | } |
| 160 | |
| 161 | warnOnGroupMetadata(request.body); |
| 162 | const id = String(Date.now()); |
| 163 | const groupMetadata = { |
| 164 | id: id, |
| 165 | name: request.body.name ?? 'New Group', |
| 166 | members: request.body.members ?? [], |
| 167 | avatar_url: request.body.avatar_url, |
| 168 | allow_self_responses: !!request.body.allow_self_responses, |
| 169 | activation_strategy: request.body.activation_strategy ?? 0, |
| 170 | generation_mode: request.body.generation_mode ?? 0, |
| 171 | disabled_members: request.body.disabled_members ?? [], |
| 172 | fav: request.body.fav, |
| 173 | chat_id: request.body.chat_id ?? id, |
| 174 | chats: request.body.chats ?? [id], |
| 175 | auto_mode_delay: request.body.auto_mode_delay ?? 5, |
| 176 | generation_mode_join_prefix: request.body.generation_mode_join_prefix ?? '', |
| 177 | generation_mode_join_suffix: request.body.generation_mode_join_suffix ?? '', |
| 178 | }; |
| 179 | const pathToFile = path.join(request.user.directories.groups, sanitize(`${id}.json`)); |
| 180 | const fileData = JSON.stringify(groupMetadata, null, 4); |
| 181 | |
| 182 | if (!fs.existsSync(request.user.directories.groups)) { |
| 183 | fs.mkdirSync(request.user.directories.groups); |
| 184 | } |
| 185 | |
| 186 | writeFileAtomicSync(pathToFile, fileData); |
| 187 | return response.send(groupMetadata); |
| 188 | }); |
| 189 | |
| 190 | router.post('/edit', getFileNameValidationFunction('id'), (request, response) => { |
| 191 | if (!request.body || !request.body.id) { |
| 192 | return response.sendStatus(400); |
| 193 | } |
| 194 | warnOnGroupMetadata(request.body); |
| 195 | const id = request.body.id; |
| 196 | const pathToFile = path.join(request.user.directories.groups, sanitize(`${id}.json`)); |
| 197 | const fileData = JSON.stringify(request.body, null, 4); |
| 198 | |
| 199 | writeFileAtomicSync(pathToFile, fileData); |
| 200 | return response.send({ ok: true }); |
| 201 | }); |
| 202 | |
| 203 | router.post('/delete', getFileNameValidationFunction('id'), async (request, response) => { |
| 204 | if (!request.body || !request.body.id) { |
| 205 | return response.sendStatus(400); |
| 206 | } |
| 207 | |
| 208 | const id = request.body.id; |
| 209 | const pathToGroup = path.join(request.user.directories.groups, sanitize(`${id}.json`)); |
| 210 | |
| 211 | try { |
| 212 | // Delete group chats |
| 213 | const group = JSON.parse(fs.readFileSync(pathToGroup, 'utf8')); |
| 214 | |
| 215 | if (group && Array.isArray(group.chats)) { |
| 216 | for (const chat of group.chats) { |
| 217 | console.info('Deleting group chat', chat); |
| 218 | const pathToFile = path.join(request.user.directories.groupChats, sanitize(`${chat}.jsonl`)); |
| 219 | |
| 220 | if (fs.existsSync(pathToFile)) { |
| 221 | fs.unlinkSync(pathToFile); |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | } catch (error) { |
| 226 | console.error('Could not delete group chats. Clean them up manually.', error); |
| 227 | } |
| 228 | |
| 229 | if (fs.existsSync(pathToGroup)) { |
| 230 | fs.unlinkSync(pathToGroup); |
| 231 | } |
| 232 | |
| 233 | return response.send({ ok: true }); |
| 234 | }); |