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 | "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": "تصدير للشخصية", |
| @@ -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", |
| @@ -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", |
| @@ -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", |
| @@ -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", |
| @@ -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", |
| @@ -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": "キャラクターのエクスポート", |
| @@ -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": "캐릭터 내보내기", |
| @@ -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", |
| @@ -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", |
| @@ -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": "Аккаунт", |
| @@ -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": "Експорт для персонажа", |
| @@ -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", |
| @@ -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 以安装", |
| @@ -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": "匯出字元", |
| @@ -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 | } |
| 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 | return ''; | 1845 | return ''; |
| 1840 | } | 1846 | } |
| 1841 | 1847 | ||
| @@ -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> |
| @@ -1,5 +1,6 @@ | |||
| 1 | import fs from 'node:fs'; | 1 | import fs from 'node:fs'; |
| 2 | import path from 'node:path'; | 2 | import path from 'node:path'; |
| 3 | import zlib from 'node:zlib'; | ||
| 3 | import { Buffer } from 'node:buffer'; | 4 | import { Buffer } from 'node:buffer'; |
| 4 | 5 | ||
| 5 | import express from 'express'; | 6 | import express from 'express'; |
| @@ -7,9 +8,10 @@ import fetch from 'node-fetch'; | |||
| 7 | import sanitize from 'sanitize-filename'; | 8 | import sanitize from 'sanitize-filename'; |
| 8 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; | 9 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 9 | 10 | ||
| 10 | import { getConfigValue, color, setPermissionsSync } from '../util.js'; | 11 | import { getConfigValue, color, setPermissionsSync, isValidUrl } from '../util.js'; |
| 11 | import { write } from '../character-card-parser.js'; | 12 | import { write } from '../character-card-parser.js'; |
| 12 | import { serverDirectory } from '../server-directory.js'; | 13 | import { serverDirectory } from '../server-directory.js'; |
| 14 | import { Jimp, JimpMime } from '../jimp.js'; | ||
| 13 | import { DEFAULT_AVATAR_PATH } from '../constants.js'; | 15 | import { DEFAULT_AVATAR_PATH } from '../constants.js'; |
| 14 | 16 | ||
| 15 | const contentDirectory = path.join(serverDirectory, 'default/content'); | 17 | const contentDirectory = path.join(serverDirectory, 'default/content'); |
| @@ -818,6 +820,193 @@ async function downloadSoulkynCharacter(slug) { | |||
| 818 | return null; | 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 | * @param {String} url | 1011 | * @param {String} url |
| 823 | * @returns {String | null } UUID of the character | 1012 | * @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); |
| 878 | 1068 | ||
| 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'; |
| 968 | 1166 | ||
| 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 | } |
| 992 | 1194 | ||
| 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); |