Blame Raw
Cohee · e3f41666 · · 234 lines (10.5 KB)
1 contributor
1import fs from 'node:fs';
2import { promises as fsPromises } from 'node:fs';
3import path from 'node:path';
4
5import express from 'express';
6import sanitize from 'sanitize-filename';
7import { sync as writeFileAtomicSync, default as writeFileAtomic } from 'write-file-atomic';
8
9import { color, tryParse } from '../util.js';
10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
11
12export 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 */
18function 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 */
34export 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
113router.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
156router.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
190router.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
203router.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});