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