Feature/Perchance AI Character Chat import (#4211) * Adding ZLib and Jimp imports. * Adding checks to see if UUID or URL are from Perchance. * Adding conversion for Perchance cards and avatar. * Adding label and example for Perchance character import. * Adding localization of Perchance import option. * Lint dangling comma fix. * Simplifying one liner arrow function. * Checking .gz at the end of Perchance url. * Refactoring. * Handling Base64 avatars. * Fixing issue with UUID and refactoring. * Adding char name to Perchance UUID example. * Undoing unwanted variable name change of avatarBuffer to defaultAvatarBuffer * Adding null check. * Minor adjustments: renaming variable and organizing imports. * Simple refactoring and reducing level of console messages. * Add character source for perchance * Add null check * Use slug for source --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

34118bbdc28a96efe7b53ef6a8252e46634ea110

Marcela Petra Ferraz de Novaes <novaes.marcela@outlook.com>

Signed
18 files changed, +230 -2Ignore whitespace
public/locales/ar-sa.json+1 -0
@@ -1380,6 +1380,7 @@
13801380 "char_import_7": "للمضيفين المسموح بهم)",
13811381 "char_import_8": "شخصية RisuRealm (رابط مباشر)",
13821382 "char_import_9": "شخصية Soulkyn (رابط مباشر)",
1383+ "char_import_10": "شخصية Perchance (رابط مباشر أو UUID + .gz)",
13831384 "Supports importing multiple characters.": "يدعم استيراد أحرف متعددة.",
13841385 "Write each URL or ID into a new line.": "اكتب كل عنوان URL أو معرف في سطر جديد.",
13851386 "Export for character": "تصدير للشخصية",
public/locales/de-de.json+1 -0
@@ -1380,6 +1380,7 @@
13801380 "char_import_7": "für erlaubte Hosts)",
13811381 "char_import_8": "RisuRealm-Charakter (Direktlink)",
13821382 "char_import_9": "Soulkyn-Charakter (Direktlink)",
1383+ "char_import_10": "Perchance-Charakter (Direktlink oder UUID + .gz)",
13831384 "Supports importing multiple characters.": "Unterstützt den Import mehrerer Zeichen.",
13841385 "Write each URL or ID into a new line.": "Schreiben Sie jede URL oder ID in eine neue Zeile.",
13851386 "Export for character": "Export für Zeichen",
public/locales/es-es.json+1 -0
@@ -1380,6 +1380,7 @@
13801380 "char_import_7": "para hosts permitidos)",
13811381 "char_import_8": "Personaje RisuRealm (Enlace directo)",
13821382 "char_import_9": "Personaje Soulkyn (Enlace directo)",
1383+ "char_import_10": "Personaje Perchance (enlace directo o UUID + .gz)",
13831384 "Supports importing multiple characters.": "Admite la importación de múltiples caracteres.",
13841385 "Write each URL or ID into a new line.": "Escriba cada URL o ID en una nueva línea.",
13851386 "Export for character": "Exportar para personaje",
public/locales/fr-fr.json+1 -0
@@ -1302,6 +1302,7 @@
13021302 "char_import_7": "pour les hôtes autorisés)",
13031303 "char_import_8": "Personnage de RisuRealm (lien direct)",
13041304 "char_import_9": "Personnage de Soulkyn (lien direct)",
1305+ "char_import_10": "Personnage de Perchance (lien direct ou UUID + .gz)",
13051306 "Supports importing multiple characters.": "Prend en charge l'importation de plusieurs caractères.",
13061307 "Write each URL or ID into a new line.": "Écrivez chaque URL ou identifiant dans une nouvelle ligne.",
13071308 "Export for character": "Exportation pour le personnage",
public/locales/is-is.json+1 -0
@@ -1380,6 +1380,7 @@
13801380 "char_import_7": "fyrir leyfilega gestgjafa)",
13811381 "char_import_8": "RisuRealm karakter (beinn hlekkur)",
13821382 "char_import_9": "Soulkyn karakter (beinn hlekkur)",
1383+ "char_import_10": "Perchance karakter (beinn hlekkur eða UUID + .gz)",
13831384 "Supports importing multiple characters.": "Styður innflutning á mörgum stöfum.",
13841385 "Write each URL or ID into a new line.": "Skrifaðu hverja vefslóð eða auðkenni í nýja línu.",
13851386 "Export for character": "Flytja út fyrir persónu",
public/locales/it-it.json+1 -0
@@ -1380,6 +1380,7 @@
13801380 "char_import_7": "per gli host consentiti)",
13811381 "char_import_8": "Personaggio RisuRealm (collegamento diretto)",
13821382 "char_import_9": "Personaggio Soulkyn (collegamento diretto)",
1383+ "char_import_10": "Perchance Personaggio (collegamento diretto o UUID + .gz)",
13831384 "Supports importing multiple characters.": "Supporta l'importazione di più caratteri.",
13841385 "Write each URL or ID into a new line.": "Scrivi ogni URL o ID in una nuova riga.",
13851386 "Export for character": "Esporta per carattere",
public/locales/ja-jp.json+1 -0
@@ -1382,6 +1382,7 @@
13821382 "char_import_7": "許可されたホストの場合)",
13831383 "char_import_8": "RisuRealm キャラクター (直接リンク)",
13841384 "char_import_9": "Soulkyn キャラクター (直接リンク)",
1385+ "char_import_10": "Perchance キャラクター (直接リンクまたは UUID + .gz)",
13851386 "Supports importing multiple characters.": "複数のキャラクターのインポートをサポートします。",
13861387 "Write each URL or ID into a new line.": "各 URL または ID を新しい行に入力します。",
13871388 "Export for character": "キャラクターのエクスポート",
public/locales/ko-kr.json+1 -0
@@ -1399,6 +1399,7 @@
13991399 "char_import_7": "허용된 호스트의 경우)",
14001400 "char_import_8": "RisuRealm 캐릭터 (직접링크)",
14011401 "char_import_9": "Soulkyn 캐릭터 (직접링크)",
1402+ "char_import_10": "Perchance 캐릭터 (직접 링크 또는 UUID + .gz)",
14021403 "Supports importing multiple characters.": "여러 문자 가져오기를 지원합니다.",
14031404 "Write each URL or ID into a new line.": "각 URL 또는 ID를 새 줄에 작성합니다.",
14041405 "Export for character": "캐릭터 내보내기",
public/locales/nl-nl.json+1 -0
@@ -1380,6 +1380,7 @@
13801380 "char_import_7": "voor toegestane hosts)",
13811381 "char_import_8": "RisuRealm-personage (directe link)",
13821382 "char_import_9": "Soulkyn-personage (directe link)",
1383+ "char_import_10": "Perchance-personage (directe link of UUID + .gz)",
13831384 "Supports importing multiple characters.": "Ondersteunt het importeren van meerdere tekens.",
13841385 "Write each URL or ID into a new line.": "Schrijf elke URL of ID op een nieuwe regel.",
13851386 "Export for character": "Exporteren voor karakter",
public/locales/pt-pt.json+1 -0
@@ -1380,6 +1380,7 @@
13801380 "char_import_7": "para hosts permitidos)",
13811381 "char_import_8": "Personagem RisuRealm (link direto)",
13821382 "char_import_9": "Personagem Soulkyn (link direto)",
1383+ "char_import_10": "Personagem Perchance (Link Direto ou UUID + .gz)",
13831384 "Supports importing multiple characters.": "Suporta importação de vários caracteres.",
13841385 "Write each URL or ID into a new line.": "Escreva cada URL ou ID em uma nova linha.",
13851386 "Export for character": "Exportar para personagem",
public/locales/ru-ru.json+1 -0
@@ -966,6 +966,7 @@
966966 "char_import_6": "Прямая ссылка на PNG-файл (список разрешённых хостов находится в",
967967 "char_import_7": ")",
968968 "char_import_9": "Персонаж с Soulkyn (прямая ссылка)",
969+ "char_import_10": "Персонаж с Perchance (прямая ссылка или UUID + .gz)",
969970 "Grammar String": "Грамматика",
970971 "GBNF or EBNF, depends on the backend in use. If you're using this you should know which.": "GBNF или EBNF, зависит от бэкенда. Если вы это используете, то, скорее всего, сами знаете, какой именно.",
971972 "Account": "Аккаунт",
public/locales/uk-ua.json+1 -0
@@ -1380,6 +1380,7 @@
13801380 "char_import_7": "для дозволених хостів)",
13811381 "char_import_8": "Персонаж RisuRealm (пряме посилання)",
13821382 "char_import_9": "Персонаж Soulkyn (пряме посилання)",
1383+ "char_import_10": "Персонаж Perchance (пряме посилання або UUID + .gz)",
13831384 "Supports importing multiple characters.": "Підтримується імпорт кількох символів.",
13841385 "Write each URL or ID into a new line.": "Напишіть кожну URL-адресу або ідентифікатор у новому рядку.",
13851386 "Export for character": "Експорт для персонажа",
public/locales/vi-vn.json+1 -0
@@ -1380,6 +1380,7 @@
13801380 "char_import_7": "đối với các máy chủ được phép)",
13811381 "char_import_8": "RisuRealm (URL trực tiếp)",
13821382 "char_import_9": "Soulkyn (URL trực tiếp)",
1383+ "char_import_10": "Perchance (Nhập URL trực tiếp hoặc UUID + .gz)",
13831384 "Supports importing multiple characters.": "Hỗ trợ nhập nhiều ký tự.",
13841385 "Write each URL or ID into a new line.": "Viết mỗi URL hoặc ID vào một dòng mới.",
13851386 "Export for character": "Xuất cho nhân vật",
public/locales/zh-cn.json+1 -0
@@ -1894,6 +1894,7 @@
18941894 "char_import_7": ")",
18951895 "char_import_8": "RisuRealm 角色(直链)",
18961896 "char_import_9": "Soulkyn 角色(直链)",
1897+ "char_import_10": "Perchance 角色(直链或UUID + .gz)",
18971898 "Supports importing multiple characters.": "支持导入多个角色。",
18981899 "Write each URL or ID into a new line.": "将每个 URL 或 ID 写入新行。",
18991900 "Enter the Git URL of the extension to install": "输入扩展程序的 Git URL 以安装",
public/locales/zh-tw.json+1 -0
@@ -1385,6 +1385,7 @@
13851385 "char_import_7": "對於允許的主機)",
13861386 "char_import_8": "RisuRealm 角色(直接連結)",
13871387 "char_import_9": "Soulkyn 角色(直接連結)",
1388+ "char_import_10": "Perchance 角色(直接連結或 ID + .gz)",
13881389 "Supports importing multiple characters.": "支援匯入多個字元。",
13891390 "Write each URL or ID into a new line.": "將每個 URL 或 ID 寫入新行。",
13901391 "Export for character": "匯出字元",
public/script.js+6 -0
@@ -1836,6 +1836,12 @@ function getCharacterSource(chId = this_chid) {
18361836 return `https://realm.risuai.net/character/${realmId}`;
18371837 }
18381838
1839+ const perchanceSlug = characters[chId]?.data?.extensions?.perchance_data?.slug;
1840+
1841+ if (perchanceSlug) {
1842+ return `https://perchance.org/ai-character-chat?data=${perchanceSlug}`;
1843+ }
1844+
18391845 return '';
18401846}
18411847
public/scripts/templates/importCharacters.html+1 -0
@@ -11,6 +11,7 @@
1111 <li><span data-i18n="char_import_6">Direct PNG Link (refer to</span> <code>config.yaml</code><span data-i18n="char_import_7"> for allowed hosts)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://files.catbox.moe/notarealfile.png</tt></li>
1212 <li><span data-i18n="char_import_8">RisuRealm Character (Direct Link)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://realm.risuai.net/character/3ca54c71-6efe-46a2-b9d0-4f62df23d712</tt></li>
1313 <li><span data-i18n="char_import_9">Soulkyn Character (Direct Link)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://soulkyn.com/l/en-US/@haruka-509al</tt></li>
14+ <li><span data-i18n="char_import_10">Perchance Character (Direct Link or UUID + .gz)</span><br><span data-i18n="char_import_example">Example:</span> <tt>https://perchance.org/ai-character-chat?data=Loreena~cb3a1b531477378db7cad0148ba62d71.gz</tt><br><span data-i18n="char_import_example">Example:</span> <tt>Loreena~cb3a1b531477378db7cad0148ba62d71.gz</tt></li>
1415 </ul>
1516</div>
1617<small>
src/endpoints/content-manager.js+208 -2
@@ -1,15 +1,17 @@
11import fs from 'node:fs';
22import path from 'node:path';
3+import zlib from 'node:zlib';
34import { Buffer } from 'node:buffer';
45
56import express from 'express';
67import fetch from 'node-fetch';
78import sanitize from 'sanitize-filename';
89import { sync as writeFileAtomicSync } from 'write-file-atomic';
910
1011import { getConfigValue, color, setPermissionsSync, isValidUrl } from '../util.js';
1112import { write } from '../character-card-parser.js';
1213import { serverDirectory } from '../server-directory.js';
14+import { Jimp, JimpMime } from '../jimp.js';
1315import { DEFAULT_AVATAR_PATH } from '../constants.js';
1416
1517const contentDirectory = path.join(serverDirectory, 'default/content');
@@ -818,6 +820,193 @@ async function downloadSoulkynCharacter(slug) {
818820 return null;
819821}
820822
823+/** * Check if the given string is a valid Perchance UUID.
824+ * @param {string} uuid UUID string to check
825+ * @returns {boolean} True if the UUID is valid, false otherwise
826+ */
827+function isPerchanceUUID(uuid) {
828+ if (!uuid) {
829+ return false;
830+ }
831+
832+ //example: Personality_Advisor~6903e991c90fd1dba52c036d917e99c6.gz
833+ //charactername~uuid.gz
834+
835+ const uuidRegex = /^\w+~[a-f0-9]{32}\.gz$/;
836+ return uuidRegex.test(uuid);
837+}
838+
839+/**
840+ * Parse Perchance URL to extract the character slug.
841+ * @param {string} url Perchance character URL
842+ * @returns {string} Slug of the character
843+ */
844+function parsePerchanceSlug(url) {
845+ // Example: https://perchance.org/ai-character-chat?data=Personality_Advisor~6903e991c90fd1dba52c036d917e99c6.gz
846+ // or: Personality_Advisor~6903e991c90fd1dba52c036d917e99c6.gz
847+ return url?.split('~')[1] || '';
848+}
849+
850+/**
851+ * Download Perchance character card
852+ * @param {string} slug Slug of the character
853+ * @returns {Promise<{buffer: Buffer, fileName: string, fileType: string} | null>}
854+ */
855+async function downloadPerchanceCharacter(slug) {
856+ // example of slug
857+ // 6903e991c90fd1dba52c036d917e99c6.gz
858+ const perchanceBaseURL = 'https://user.uploads.dev/file';
859+
860+ try {
861+ const charURL = `${perchanceBaseURL}/${slug}`;
862+ console.log('Downloading Perchance character from URL:', charURL);
863+ const result = await fetch(charURL, {
864+ headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
865+ });
866+
867+ //decompress gzipped content
868+ if (result.ok) {
869+ const perchanceChar = await extractPerchanceCharacterFromGz(result);
870+
871+ const avatarUrl = perchanceChar.avatar?.url;
872+
873+ //check if avatarURL is a base64 of any image type
874+ const isAvatarBase64 = avatarUrl && avatarUrl.startsWith('data:image/');
875+
876+ const charData = {
877+ name: perchanceChar.name || 'Unnamed Perchance Character',
878+ first_mes: '',
879+ tags: [],
880+ description: perchanceChar.roleInstruction || '',
881+ creator: perchanceChar.metaTitle || '',
882+ creator_notes: perchanceChar.metaDescription || '',
883+ alternate_greetings: [],
884+ character_version: '',
885+ mes_example: '',
886+ post_history_instructions: '',
887+ system_prompt: '',
888+ scenario: '',
889+ personality: perchanceChar.reminderMessage || '',
890+ extensions: {
891+ perchance_data: {
892+ slug: slug,
893+ char_url: charURL,
894+ uuid: perchanceChar.uuid || null,
895+ avatar_url: isAvatarBase64 ? null : (avatarUrl || null),
896+ folder_path: perchanceChar.folderPath || null,
897+ folder_name: perchanceChar.folderName || null,
898+ custom_data: perchanceChar.customData || {},
899+ },
900+ },
901+ };
902+
903+ const avatarBuffer = await fetchPerchanceAvatar(avatarUrl, isAvatarBase64);
904+
905+ // Character card
906+ const buffer = write(avatarBuffer, JSON.stringify({
907+ 'spec': 'chara_card_v2',
908+ 'spec_version': '2.0',
909+ 'data': charData,
910+ }));
911+
912+ const fileName = `${charData.name}.png`;
913+ const fileType = 'image/png';
914+
915+ return { buffer, fileName, fileType };
916+ }
917+ } catch (error) {
918+ console.error('Error downloading character:', error);
919+ throw error;
920+ }
921+ return null;
922+}
923+
924+/** * Extracts Perchance character data from a gzipped response.
925+ * @param {import('node-fetch').Response} result Fetch response containing gzipped character data
926+ * @returns {Promise<Object>} Parsed Perchance character data
927+ * @throws {Error} If the character data is invalid or missing required fields
928+ */
929+async function extractPerchanceCharacterFromGz(result) {
930+ const compressedBuffer = Buffer.from(await result.arrayBuffer());
931+ const decompressedBuffer = zlib.gunzipSync(compressedBuffer);
932+
933+ // inside the gz file, there is a file of the same name without extensions, but it is a json file
934+
935+ if (!decompressedBuffer || decompressedBuffer.length === 0) {
936+ console.error('Perchance character data is empty or invalid');
937+ throw new Error('Failed to download character: Invalid Perchance character data');
938+ }
939+
940+ // Parse the decompressed JSON
941+ const perchanceCharData = JSON.parse(decompressedBuffer.toString());
942+
943+ if (!perchanceCharData?.addCharacter) {
944+ console.error('Perchance character data is missing addCharacter field', perchanceCharData);
945+ throw new Error('Failed to download character: Invalid Perchance character data');
946+ }
947+
948+ return perchanceCharData.addCharacter;
949+}
950+
951+/** * Fetches the avatar from Perchance URL or uses a default avatar if not available.
952+ * @param {string} avatarUrl URL of the avatar
953+ * @param {boolean} isAvatarBase64 Flag indicating if the avatar URL is a base64 string
954+ * @returns {Promise<Buffer>} Buffer containing the avatar image
955+ */
956+async function fetchPerchanceAvatar(avatarUrl, isAvatarBase64) {
957+ const defaultAvatarPath = path.join(serverDirectory, DEFAULT_AVATAR_PATH);
958+ const defaultAvatarBuffer = fs.readFileSync(defaultAvatarPath);
959+
960+ if (!avatarUrl || (!isAvatarBase64 && !isValidUrl(avatarUrl))) {
961+ console.warn('Perchance character does not have an avatar, it is not base64, or it is an invalid url, using default avatar');
962+ return defaultAvatarBuffer;
963+ }
964+
965+ if (isAvatarBase64) {
966+ // check if avatarUrl is a png
967+ const isPng = avatarUrl.startsWith('data:image/png;base64,');
968+ const base64 = avatarUrl.split(',')[1];
969+ const buffer = Buffer.from(base64, 'base64');
970+
971+ if (isPng) {
972+ return buffer;
973+ } else {
974+ // use jimp to convert the base64 to PNG if it's not PNG
975+ console.trace('Perchance character avatar is not PNG, converting to PNG...');
976+ return await Jimp.read(buffer).then(image => image.getBuffer(JimpMime.png));
977+ }
978+ }
979+
980+ // Fetch avatar from URL
981+ console.log('Fetching Perchance avatar from URL:', avatarUrl);
982+ const avatarResponse = await fetch(avatarUrl, { headers: { 'User-Agent': USER_AGENT } });
983+
984+ if (avatarResponse.ok) {
985+ const avatarContentType = avatarResponse.headers.get('content-type');
986+ const avatarBuffer = Buffer.from(await avatarResponse.arrayBuffer());
987+
988+ if (avatarContentType === 'image/png') {
989+ return avatarBuffer;
990+ } else {
991+ console.trace(`Perchance character avatar is not PNG: ${avatarContentType}. Converting to PNG...`);
992+
993+ // use jimp to convert the image to PNG if it's not PNG
994+ return await Jimp.read(avatarBuffer)
995+ .then(image => image.getBuffer(JimpMime.png));
996+ }
997+ }
998+
999+ console.error('Failed to fetch Perchance avatar:', avatarResponse.statusText);
1000+ const isPerchanceOrgFileUploader = avatarUrl.includes('https://user-uploads.perchance.org');
1001+
1002+ if (isPerchanceOrgFileUploader) {
1003+ console.warn('Files from https://user-uploads.perchance.org are sometimes blocked by CloudFlare, try reuploading it in https://perchance.org/upload to get the new link from https://user-uploads.dev instead.');
1004+ }
1005+
1006+ console.warn('You can also download the avatar manually and assign it to the character:', avatarUrl);
1007+ return defaultAvatarBuffer;
1008+}
1009+
8211010/**
8221011* @param {String} url
8231012* @returns {String | null } UUID of the character
@@ -874,6 +1063,7 @@ router.post('/importURL', async (request, response) => {
8741063 const isAICharacterCardsContent = host.includes('aicharactercards.com');
8751064 const isRisu = host.includes('realm.risuai.net');
8761065 const isSoulkyn = host.includes('soulkyn.com');
1066+ const isPerchance = host.includes('perchance.org');
8771067 const isGeneric = isHostWhitelisted(host);
8781068
8791069 if (isPygmalionContent) {
@@ -929,6 +1119,13 @@ router.post('/importURL', async (request, response) => {
9291119 }
9301120 type = 'character';
9311121 result = await downloadSoulkynCharacter(soulkynSlug);
1122+ } else if (isPerchance) {
1123+ const perchanceSlug = parsePerchanceSlug(url);
1124+ if (!perchanceSlug) {
1125+ return response.sendStatus(404);
1126+ }
1127+ type = 'character';
1128+ result = await downloadPerchanceCharacter(perchanceSlug);
9321129 } else if (isGeneric) {
9331130 console.info('Downloading from generic url:', url);
9341131 type = 'character';
@@ -964,6 +1161,7 @@ router.post('/importUUID', async (request, response) => {
9641161 const isJannny = uuid.includes('_character');
9651162 const isPygmalion = (!isJannny && uuid.length == 36);
9661163 const isAICC = uuid.startsWith('AICC/');
1164+ const isPerchance = isPerchanceUUID(uuid);
9671165 const uuidType = uuid.includes('lorebook') ? 'lorebook' : 'character';
9681166
9691167 if (isPygmalion) {
@@ -976,6 +1174,10 @@ router.post('/importUUID', async (request, response) => {
9761174 const [, author, card] = uuid.split('/');
9771175 console.info('Downloading AICC character:', `${author}/${card}`);
9781176 result = await downloadAICCCharacter(`${author}/${card}`);
1177+ } else if (isPerchance) {
1178+ console.info('Downloading Perchance character:', uuid);
1179+ const parsedUuid = parsePerchanceSlug(uuid);
1180+ result = await downloadPerchanceCharacter(parsedUuid);
9791181 } else {
9801182 if (uuidType === 'character') {
9811183 console.info('Downloading chub character:', uuid);
@@ -990,6 +1192,10 @@ router.post('/importUUID', async (request, response) => {
9901192 }
9911193 }
9921194
1195+ if (!result) {
1196+ throw new Error('Failed to download content');
1197+ }
1198+
9931199 if (result.fileType) response.set('Content-Type', result.fileType);
9941200 response.set('Content-Disposition', `attachment; filename="${result.fileName}"`);
9951201 response.set('X-Custom-Content-Type', uuidType);