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 @@
1380 "char_import_7": "للمضيفين المسموح بهم)",1380 "char_import_7": "للمضيفين المسموح بهم)",
1381 "char_import_8": "شخصية RisuRealm (رابط مباشر)",1381 "char_import_8": "شخصية RisuRealm (رابط مباشر)",
1382 "char_import_9": "شخصية Soulkyn (رابط مباشر)",1382 "char_import_9": "شخصية Soulkyn (رابط مباشر)",
1383 "char_import_10": "شخصية Perchance (رابط مباشر أو UUID + .gz)",
1383 "Supports importing multiple characters.": "يدعم استيراد أحرف متعددة.",1384 "Supports importing multiple characters.": "يدعم استيراد أحرف متعددة.",
1384 "Write each URL or ID into a new line.": "اكتب كل عنوان URL أو معرف في سطر جديد.",1385 "Write each URL or ID into a new line.": "اكتب كل عنوان URL أو معرف في سطر جديد.",
1385 "Export for character": "تصدير للشخصية",1386 "Export for character": "تصدير للشخصية",
public/locales/de-de.json+1 -0
@@ -1380,6 +1380,7 @@
1380 "char_import_7": "für erlaubte Hosts)",1380 "char_import_7": "für erlaubte Hosts)",
1381 "char_import_8": "RisuRealm-Charakter (Direktlink)",1381 "char_import_8": "RisuRealm-Charakter (Direktlink)",
1382 "char_import_9": "Soulkyn-Charakter (Direktlink)",1382 "char_import_9": "Soulkyn-Charakter (Direktlink)",
1383 "char_import_10": "Perchance-Charakter (Direktlink oder UUID + .gz)",
1383 "Supports importing multiple characters.": "Unterstützt den Import mehrerer Zeichen.",1384 "Supports importing multiple characters.": "Unterstützt den Import mehrerer Zeichen.",
1384 "Write each URL or ID into a new line.": "Schreiben Sie jede URL oder ID in eine neue Zeile.",1385 "Write each URL or ID into a new line.": "Schreiben Sie jede URL oder ID in eine neue Zeile.",
1385 "Export for character": "Export für Zeichen",1386 "Export for character": "Export für Zeichen",
public/locales/es-es.json+1 -0
@@ -1380,6 +1380,7 @@
1380 "char_import_7": "para hosts permitidos)",1380 "char_import_7": "para hosts permitidos)",
1381 "char_import_8": "Personaje RisuRealm (Enlace directo)",1381 "char_import_8": "Personaje RisuRealm (Enlace directo)",
1382 "char_import_9": "Personaje Soulkyn (Enlace directo)",1382 "char_import_9": "Personaje Soulkyn (Enlace directo)",
1383 "char_import_10": "Personaje Perchance (enlace directo o UUID + .gz)",
1383 "Supports importing multiple characters.": "Admite la importación de múltiples caracteres.",1384 "Supports importing multiple characters.": "Admite la importación de múltiples caracteres.",
1384 "Write each URL or ID into a new line.": "Escriba cada URL o ID en una nueva línea.",1385 "Write each URL or ID into a new line.": "Escriba cada URL o ID en una nueva línea.",
1385 "Export for character": "Exportar para personaje",1386 "Export for character": "Exportar para personaje",
public/locales/fr-fr.json+1 -0
@@ -1302,6 +1302,7 @@
1302 "char_import_7": "pour les hôtes autorisés)",1302 "char_import_7": "pour les hôtes autorisés)",
1303 "char_import_8": "Personnage de RisuRealm (lien direct)",1303 "char_import_8": "Personnage de RisuRealm (lien direct)",
1304 "char_import_9": "Personnage de Soulkyn (lien direct)",1304 "char_import_9": "Personnage de Soulkyn (lien direct)",
1305 "char_import_10": "Personnage de Perchance (lien direct ou UUID + .gz)",
1305 "Supports importing multiple characters.": "Prend en charge l'importation de plusieurs caractères.",1306 "Supports importing multiple characters.": "Prend en charge l'importation de plusieurs caractères.",
1306 "Write each URL or ID into a new line.": "Écrivez chaque URL ou identifiant dans une nouvelle ligne.",1307 "Write each URL or ID into a new line.": "Écrivez chaque URL ou identifiant dans une nouvelle ligne.",
1307 "Export for character": "Exportation pour le personnage",1308 "Export for character": "Exportation pour le personnage",
public/locales/is-is.json+1 -0
@@ -1380,6 +1380,7 @@
1380 "char_import_7": "fyrir leyfilega gestgjafa)",1380 "char_import_7": "fyrir leyfilega gestgjafa)",
1381 "char_import_8": "RisuRealm karakter (beinn hlekkur)",1381 "char_import_8": "RisuRealm karakter (beinn hlekkur)",
1382 "char_import_9": "Soulkyn karakter (beinn hlekkur)",1382 "char_import_9": "Soulkyn karakter (beinn hlekkur)",
1383 "char_import_10": "Perchance karakter (beinn hlekkur eða UUID + .gz)",
1383 "Supports importing multiple characters.": "Styður innflutning á mörgum stöfum.",1384 "Supports importing multiple characters.": "Styður innflutning á mörgum stöfum.",
1384 "Write each URL or ID into a new line.": "Skrifaðu hverja vefslóð eða auðkenni í nýja línu.",1385 "Write each URL or ID into a new line.": "Skrifaðu hverja vefslóð eða auðkenni í nýja línu.",
1385 "Export for character": "Flytja út fyrir persónu",1386 "Export for character": "Flytja út fyrir persónu",
public/locales/it-it.json+1 -0
@@ -1380,6 +1380,7 @@
1380 "char_import_7": "per gli host consentiti)",1380 "char_import_7": "per gli host consentiti)",
1381 "char_import_8": "Personaggio RisuRealm (collegamento diretto)",1381 "char_import_8": "Personaggio RisuRealm (collegamento diretto)",
1382 "char_import_9": "Personaggio Soulkyn (collegamento diretto)",1382 "char_import_9": "Personaggio Soulkyn (collegamento diretto)",
1383 "char_import_10": "Perchance Personaggio (collegamento diretto o UUID + .gz)",
1383 "Supports importing multiple characters.": "Supporta l'importazione di più caratteri.",1384 "Supports importing multiple characters.": "Supporta l'importazione di più caratteri.",
1384 "Write each URL or ID into a new line.": "Scrivi ogni URL o ID in una nuova riga.",1385 "Write each URL or ID into a new line.": "Scrivi ogni URL o ID in una nuova riga.",
1385 "Export for character": "Esporta per carattere",1386 "Export for character": "Esporta per carattere",
public/locales/ja-jp.json+1 -0
@@ -1382,6 +1382,7 @@
1382 "char_import_7": "許可されたホストの場合)",1382 "char_import_7": "許可されたホストの場合)",
1383 "char_import_8": "RisuRealm キャラクター (直接リンク)",1383 "char_import_8": "RisuRealm キャラクター (直接リンク)",
1384 "char_import_9": "Soulkyn キャラクター (直接リンク)",1384 "char_import_9": "Soulkyn キャラクター (直接リンク)",
1385 "char_import_10": "Perchance キャラクター (直接リンクまたは UUID + .gz)",
1385 "Supports importing multiple characters.": "複数のキャラクターのインポートをサポートします。",1386 "Supports importing multiple characters.": "複数のキャラクターのインポートをサポートします。",
1386 "Write each URL or ID into a new line.": "各 URL または ID を新しい行に入力します。",1387 "Write each URL or ID into a new line.": "各 URL または ID を新しい行に入力します。",
1387 "Export for character": "キャラクターのエクスポート",1388 "Export for character": "キャラクターのエクスポート",
public/locales/ko-kr.json+1 -0
@@ -1399,6 +1399,7 @@
1399 "char_import_7": "허용된 호스트의 경우)",1399 "char_import_7": "허용된 호스트의 경우)",
1400 "char_import_8": "RisuRealm 캐릭터 (직접링크)",1400 "char_import_8": "RisuRealm 캐릭터 (직접링크)",
1401 "char_import_9": "Soulkyn 캐릭터 (직접링크)",1401 "char_import_9": "Soulkyn 캐릭터 (직접링크)",
1402 "char_import_10": "Perchance 캐릭터 (직접 링크 또는 UUID + .gz)",
1402 "Supports importing multiple characters.": "여러 문자 가져오기를 지원합니다.",1403 "Supports importing multiple characters.": "여러 문자 가져오기를 지원합니다.",
1403 "Write each URL or ID into a new line.": "각 URL 또는 ID를 새 줄에 작성합니다.",1404 "Write each URL or ID into a new line.": "각 URL 또는 ID를 새 줄에 작성합니다.",
1404 "Export for character": "캐릭터 내보내기",1405 "Export for character": "캐릭터 내보내기",
public/locales/nl-nl.json+1 -0
@@ -1380,6 +1380,7 @@
1380 "char_import_7": "voor toegestane hosts)",1380 "char_import_7": "voor toegestane hosts)",
1381 "char_import_8": "RisuRealm-personage (directe link)",1381 "char_import_8": "RisuRealm-personage (directe link)",
1382 "char_import_9": "Soulkyn-personage (directe link)",1382 "char_import_9": "Soulkyn-personage (directe link)",
1383 "char_import_10": "Perchance-personage (directe link of UUID + .gz)",
1383 "Supports importing multiple characters.": "Ondersteunt het importeren van meerdere tekens.",1384 "Supports importing multiple characters.": "Ondersteunt het importeren van meerdere tekens.",
1384 "Write each URL or ID into a new line.": "Schrijf elke URL of ID op een nieuwe regel.",1385 "Write each URL or ID into a new line.": "Schrijf elke URL of ID op een nieuwe regel.",
1385 "Export for character": "Exporteren voor karakter",1386 "Export for character": "Exporteren voor karakter",
public/locales/pt-pt.json+1 -0
@@ -1380,6 +1380,7 @@
1380 "char_import_7": "para hosts permitidos)",1380 "char_import_7": "para hosts permitidos)",
1381 "char_import_8": "Personagem RisuRealm (link direto)",1381 "char_import_8": "Personagem RisuRealm (link direto)",
1382 "char_import_9": "Personagem Soulkyn (link direto)",1382 "char_import_9": "Personagem Soulkyn (link direto)",
1383 "char_import_10": "Personagem Perchance (Link Direto ou UUID + .gz)",
1383 "Supports importing multiple characters.": "Suporta importação de vários caracteres.",1384 "Supports importing multiple characters.": "Suporta importação de vários caracteres.",
1384 "Write each URL or ID into a new line.": "Escreva cada URL ou ID em uma nova linha.",1385 "Write each URL or ID into a new line.": "Escreva cada URL ou ID em uma nova linha.",
1385 "Export for character": "Exportar para personagem",1386 "Export for character": "Exportar para personagem",
public/locales/ru-ru.json+1 -0
@@ -966,6 +966,7 @@
966 "char_import_6": "Прямая ссылка на PNG-файл (список разрешённых хостов находится в",966 "char_import_6": "Прямая ссылка на PNG-файл (список разрешённых хостов находится в",
967 "char_import_7": ")",967 "char_import_7": ")",
968 "char_import_9": "Персонаж с Soulkyn (прямая ссылка)",968 "char_import_9": "Персонаж с Soulkyn (прямая ссылка)",
969 "char_import_10": "Персонаж с Perchance (прямая ссылка или UUID + .gz)",
969 "Grammar String": "Грамматика",970 "Grammar String": "Грамматика",
970 "GBNF or EBNF, depends on the backend in use. If you're using this you should know which.": "GBNF или EBNF, зависит от бэкенда. Если вы это используете, то, скорее всего, сами знаете, какой именно.",971 "GBNF or EBNF, depends on the backend in use. If you're using this you should know which.": "GBNF или EBNF, зависит от бэкенда. Если вы это используете, то, скорее всего, сами знаете, какой именно.",
971 "Account": "Аккаунт",972 "Account": "Аккаунт",
public/locales/uk-ua.json+1 -0
@@ -1380,6 +1380,7 @@
1380 "char_import_7": "для дозволених хостів)",1380 "char_import_7": "для дозволених хостів)",
1381 "char_import_8": "Персонаж RisuRealm (пряме посилання)",1381 "char_import_8": "Персонаж RisuRealm (пряме посилання)",
1382 "char_import_9": "Персонаж Soulkyn (пряме посилання)",1382 "char_import_9": "Персонаж Soulkyn (пряме посилання)",
1383 "char_import_10": "Персонаж Perchance (пряме посилання або UUID + .gz)",
1383 "Supports importing multiple characters.": "Підтримується імпорт кількох символів.",1384 "Supports importing multiple characters.": "Підтримується імпорт кількох символів.",
1384 "Write each URL or ID into a new line.": "Напишіть кожну URL-адресу або ідентифікатор у новому рядку.",1385 "Write each URL or ID into a new line.": "Напишіть кожну URL-адресу або ідентифікатор у новому рядку.",
1385 "Export for character": "Експорт для персонажа",1386 "Export for character": "Експорт для персонажа",
public/locales/vi-vn.json+1 -0
@@ -1380,6 +1380,7 @@
1380 "char_import_7": "đối với các máy chủ được phép)",1380 "char_import_7": "đối với các máy chủ được phép)",
1381 "char_import_8": "RisuRealm (URL trực tiếp)",1381 "char_import_8": "RisuRealm (URL trực tiếp)",
1382 "char_import_9": "Soulkyn (URL trực tiếp)",1382 "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)",
1383 "Supports importing multiple characters.": "Hỗ trợ nhập nhiều ký tự.",1384 "Supports importing multiple characters.": "Hỗ trợ nhập nhiều ký tự.",
1384 "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.",1385 "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.",
1385 "Export for character": "Xuất cho nhân vật",1386 "Export for character": "Xuất cho nhân vật",
public/locales/zh-cn.json+1 -0
@@ -1894,6 +1894,7 @@
1894 "char_import_7": ")",1894 "char_import_7": ")",
1895 "char_import_8": "RisuRealm 角色(直链)",1895 "char_import_8": "RisuRealm 角色(直链)",
1896 "char_import_9": "Soulkyn 角色(直链)",1896 "char_import_9": "Soulkyn 角色(直链)",
1897 "char_import_10": "Perchance 角色(直链或UUID + .gz)",
1897 "Supports importing multiple characters.": "支持导入多个角色。",1898 "Supports importing multiple characters.": "支持导入多个角色。",
1898 "Write each URL or ID into a new line.": "将每个 URL 或 ID 写入新行。",1899 "Write each URL or ID into a new line.": "将每个 URL 或 ID 写入新行。",
1899 "Enter the Git URL of the extension to install": "输入扩展程序的 Git URL 以安装",1900 "Enter the Git URL of the extension to install": "输入扩展程序的 Git URL 以安装",
public/locales/zh-tw.json+1 -0
@@ -1385,6 +1385,7 @@
1385 "char_import_7": "對於允許的主機)",1385 "char_import_7": "對於允許的主機)",
1386 "char_import_8": "RisuRealm 角色(直接連結)",1386 "char_import_8": "RisuRealm 角色(直接連結)",
1387 "char_import_9": "Soulkyn 角色(直接連結)",1387 "char_import_9": "Soulkyn 角色(直接連結)",
1388 "char_import_10": "Perchance 角色(直接連結或 ID + .gz)",
1388 "Supports importing multiple characters.": "支援匯入多個字元。",1389 "Supports importing multiple characters.": "支援匯入多個字元。",
1389 "Write each URL or ID into a new line.": "將每個 URL 或 ID 寫入新行。",1390 "Write each URL or ID into a new line.": "將每個 URL 或 ID 寫入新行。",
1390 "Export for character": "匯出字元",1391 "Export for character": "匯出字元",
public/script.js+6 -0
@@ -1836,6 +1836,12 @@ function getCharacterSource(chId = this_chid) {
1836 return `https://realm.risuai.net/character/${realmId}`;1836 return `https://realm.risuai.net/character/${realmId}`;
1837 }1837 }
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
1839 return '';1845 return '';
1840}1846}
18411847
public/scripts/templates/importCharacters.html+1 -0
@@ -11,6 +11,7 @@
11 <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>11 <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>
12 <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>12 <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>
13 <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>13 <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>
14 </ul>15 </ul>
15</div>16</div>
16<small>17<small>
src/endpoints/content-manager.js+208 -2
@@ -1,15 +1,17 @@
1import fs from 'node:fs';1import fs from 'node:fs';
2import path from 'node:path';2import path from 'node:path';
3import zlib from 'node:zlib';
3import { Buffer } from 'node:buffer';4import { Buffer } from 'node:buffer';
45
5import express from 'express';6import express from 'express';
6import fetch from 'node-fetch';7import fetch from 'node-fetch';
7import sanitize from 'sanitize-filename';8import sanitize from 'sanitize-filename';
8import { sync as writeFileAtomicSync } from 'write-file-atomic';9import { sync as writeFileAtomicSync } from 'write-file-atomic';
910
10import { getConfigValue, color, setPermissionsSync } from '../util.js';11import { getConfigValue, color, setPermissionsSync, isValidUrl } from '../util.js';
11import { write } from '../character-card-parser.js';12import { write } from '../character-card-parser.js';
12import { serverDirectory } from '../server-directory.js';13import { serverDirectory } from '../server-directory.js';
14import { Jimp, JimpMime } from '../jimp.js';
13import { DEFAULT_AVATAR_PATH } from '../constants.js';15import { DEFAULT_AVATAR_PATH } from '../constants.js';
1416
15const contentDirectory = path.join(serverDirectory, 'default/content');17const contentDirectory = path.join(serverDirectory, 'default/content');
@@ -818,6 +820,193 @@ async function downloadSoulkynCharacter(slug) {
818 return null;820 return null;
819}821}
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 */
827function 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 */
844function 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 */
855async 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 */
929async 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 */
956async 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
821/**1010/**
822* @param {String} url1011* @param {String} url
823* @returns {String | null } UUID of the character1012* @returns {String | null } UUID of the character
@@ -874,6 +1063,7 @@ router.post('/importURL', async (request, response) => {
874 const isAICharacterCardsContent = host.includes('aicharactercards.com');1063 const isAICharacterCardsContent = host.includes('aicharactercards.com');
875 const isRisu = host.includes('realm.risuai.net');1064 const isRisu = host.includes('realm.risuai.net');
876 const isSoulkyn = host.includes('soulkyn.com');1065 const isSoulkyn = host.includes('soulkyn.com');
1066 const isPerchance = host.includes('perchance.org');
877 const isGeneric = isHostWhitelisted(host);1067 const isGeneric = isHostWhitelisted(host);
8781068
879 if (isPygmalionContent) {1069 if (isPygmalionContent) {
@@ -929,6 +1119,13 @@ router.post('/importURL', async (request, response) => {
929 }1119 }
930 type = 'character';1120 type = 'character';
931 result = await downloadSoulkynCharacter(soulkynSlug);1121 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);
932 } else if (isGeneric) {1129 } else if (isGeneric) {
933 console.info('Downloading from generic url:', url);1130 console.info('Downloading from generic url:', url);
934 type = 'character';1131 type = 'character';
@@ -964,6 +1161,7 @@ router.post('/importUUID', async (request, response) => {
964 const isJannny = uuid.includes('_character');1161 const isJannny = uuid.includes('_character');
965 const isPygmalion = (!isJannny && uuid.length == 36);1162 const isPygmalion = (!isJannny && uuid.length == 36);
966 const isAICC = uuid.startsWith('AICC/');1163 const isAICC = uuid.startsWith('AICC/');
1164 const isPerchance = isPerchanceUUID(uuid);
967 const uuidType = uuid.includes('lorebook') ? 'lorebook' : 'character';1165 const uuidType = uuid.includes('lorebook') ? 'lorebook' : 'character';
9681166
969 if (isPygmalion) {1167 if (isPygmalion) {
@@ -976,6 +1174,10 @@ router.post('/importUUID', async (request, response) => {
976 const [, author, card] = uuid.split('/');1174 const [, author, card] = uuid.split('/');
977 console.info('Downloading AICC character:', `${author}/${card}`);1175 console.info('Downloading AICC character:', `${author}/${card}`);
978 result = await downloadAICCCharacter(`${author}/${card}`);1176 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);
979 } else {1181 } else {
980 if (uuidType === 'character') {1182 if (uuidType === 'character') {
981 console.info('Downloading chub character:', uuid);1183 console.info('Downloading chub character:', uuid);
@@ -990,6 +1192,10 @@ router.post('/importUUID', async (request, response) => {
990 }1192 }
991 }1193 }
9921194
1195 if (!result) {
1196 throw new Error('Failed to download content');
1197 }
1198
993 if (result.fileType) response.set('Content-Type', result.fileType);1199 if (result.fileType) response.set('Content-Type', result.fileType);
994 response.set('Content-Disposition', `attachment; filename="${result.fileName}"`);1200 response.set('Content-Disposition', `attachment; filename="${result.fileName}"`);
995 response.set('X-Custom-Content-Type', uuidType);1201 response.set('X-Custom-Content-Type', uuidType);