';
+ return;
}
+
+ // ── Tabbed picker with sticker packs ──
+ picker.classList.add('has-tabs');
+
+ // Tab bar
+ const tabBar = document.createElement('div');
+ tabBar.className = 'emoji-picker-tabs';
+
+ // Emoji grid area
+ const gridArea = document.createElement('div');
+ gridArea.className = 'emoji-picker-grid';
+
+ let activeTabId = null;
+
+ const showTab = (packId) => {
+ activeTabId = packId;
+ tabBar.querySelectorAll('.ep-tab').forEach(t => t.classList.toggle('active', t.dataset.packId === String(packId ?? 'null')));
+
+ gridArea.innerHTML = '';
+ const pack = packs.find(p => String(p.id ?? null) === String(packId ?? null)) || packs[0];
+ if (!pack) return;
+
+ pack.emojis.forEach(({ name, url }) => {
+ const el = makeEmojiEl(url, name);
+ el.onclick = (ev) => {
+ ev.stopPropagation();
+ const pos = textarea.selectionStart ?? textarea.value.length;
+ const val = textarea.value;
+ textarea.value = val.slice(0, pos) + `:${name}:` + val.slice(pos);
+ textarea.focus();
+ const newPos = pos + name.length + 2;
+ textarea.setSelectionRange(newPos, newPos);
+ };
+ gridArea.appendChild(el);
+ });
+ };
+
+ // Build tab buttons
+ packs.forEach((pack, i) => {
+ const tab = document.createElement('button');
+ tab.className = 'ep-tab';
+ tab.dataset.packId = String(pack.id ?? null);
+ tab.title = pack.name || 'Emojis';
+ tab.type = 'button';
+ // Tab icon — always use a static img (thumb_url from import, or first non-video emoji)
+ if (pack.emojis && pack.emojis.length > 0) {
+ const iconUrl = pack.thumb_url
+ || (pack.emojis.find(e => !e.url.endsWith('.webm')) || pack.emojis[0]).url;
+ const icon = document.createElement('img');
+ icon.src = iconUrl;
+ icon.alt = pack.name;
+ icon.loading = 'lazy';
+ tab.appendChild(icon);
+ } else {
+ tab.textContent = '☺';
+ }
+ tab.addEventListener('mousedown', e => e.preventDefault());
+ tab.addEventListener('click', () => showTab(pack.id ?? null));
+ tabBar.appendChild(tab);
+ });
+
+ picker.appendChild(tabBar);
+ picker.appendChild(gridArea);
+
+ // Show first pack by default
+ showTab(packs[0]?.id ?? null);
};
trigger.addEventListener('click', (e) => {
diff --git a/src/inc/routes/emojis.mjs b/src/inc/routes/emojis.mjs
index 81ddf94..0797dd6 100644
--- a/src/inc/routes/emojis.mjs
+++ b/src/inc/routes/emojis.mjs
@@ -23,15 +23,170 @@ export default (router, tpl) => {
});
});
- // List all emojis (Public)
+ // List all emojis — returns both a flat list and a grouped-by-pack structure
router.get('/api/v2/emojis', async (req, res) => {
try {
- const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id DESC`;
+ // Try the full pack-aware query first; fall back if pack_id column doesn't exist yet
+ let emojis;
+ let hasPacks = true;
+ try {
+ emojis = await db`
+ SELECT e.id, e.name, e.url, e.pack_id,
+ p.name as pack_name, p.tg_name, p.tg_title, p.thumb_url as pack_thumb_url
+ FROM custom_emojis e
+ LEFT JOIN sticker_packs p ON p.id = e.pack_id
+ ORDER BY e.pack_id NULLS FIRST, e.id ASC
+ `;
+ } catch (colErr) {
+ // column pack_id or thumb_url may not exist — try without thumb_url, then flat
+ try {
+ emojis = await db`
+ SELECT e.id, e.name, e.url, e.pack_id,
+ p.name as pack_name, p.tg_name, p.tg_title, NULL as pack_thumb_url
+ FROM custom_emojis e
+ LEFT JOIN sticker_packs p ON p.id = e.pack_id
+ ORDER BY e.pack_id NULLS FIRST, e.id ASC
+ `;
+ } catch (_) {
+ // pack_id column missing entirely — serve flat list
+ console.warn('[EMOJIS] pack_id column missing, serving flat list');
+ hasPacks = false;
+ emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id ASC`;
+ }
+ }
+
+ // Flat list (backwards compat)
+ const flat = emojis.map(e => ({ id: e.id, name: e.name, url: e.url, pack_id: e.pack_id ?? null }));
+
+ if (!hasPacks) {
+ return res.reply({
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ success: true, emojis: flat, packs: [] })
+ });
+ }
+
+ // Grouped by pack
+ const packsMap = new Map();
+ // "No pack" group for standalone emojis
+ packsMap.set(null, { id: null, name: 'Custom Emojis', tg_name: null, emojis: [] });
+
+ for (const e of emojis) {
+ if (e.pack_id && !packsMap.has(e.pack_id)) {
+ packsMap.set(e.pack_id, {
+ id: e.pack_id,
+ name: e.pack_name || e.tg_title || `Pack #${e.pack_id}`,
+ tg_name: e.tg_name,
+ thumb_url: e.pack_thumb_url || null,
+ emojis: []
+ });
+ }
+ const packKey = e.pack_id ?? null;
+ packsMap.get(packKey).emojis.push({ id: e.id, name: e.name, url: e.url });
+ }
+
+ // Filter out empty groups
+ const packs = [...packsMap.values()].filter(p => p.emojis.length > 0);
+
return res.reply({
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ success: true, emojis })
+ body: JSON.stringify({ success: true, emojis: flat, packs })
});
} catch (e) {
+
+ console.error(e);
+ return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
+ }
+ });
+
+ // List sticker packs (Admin)
+ router.get('/api/v2/admin/sticker-packs', async (req, res) => {
+ if (!req.session || !req.session.admin) {
+ return res.reply({ code: 403, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, message: "Forbidden" }) });
+ }
+ try {
+ let packs;
+ try {
+ packs = await db`
+ SELECT sp.id, sp.name, sp.tg_name, sp.tg_title, sp.thumb_url, sp.sticker_count, sp.created_at,
+ count(e.id)::int as actual_count
+ FROM sticker_packs sp
+ LEFT JOIN custom_emojis e ON e.pack_id = sp.id
+ GROUP BY sp.id
+ ORDER BY sp.created_at DESC
+ `;
+ } catch (_) {
+ // thumb_url column may not exist yet — fall back without it
+ packs = await db`
+ SELECT sp.id, sp.name, sp.tg_name, sp.tg_title, NULL as thumb_url, sp.sticker_count, sp.created_at,
+ count(e.id)::int as actual_count
+ FROM sticker_packs sp
+ LEFT JOIN custom_emojis e ON e.pack_id = sp.id
+ GROUP BY sp.id
+ ORDER BY sp.created_at DESC
+ `;
+ }
+ return res.reply({
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ success: true, packs })
+ });
+ } catch (e) {
+ console.error(e);
+ return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
+ }
+ });
+
+
+ // Delete a sticker pack and all its emojis (Admin)
+ router.delete(/\/api\/v2\/admin\/sticker-packs\/(?\d+)/, async (req, res) => {
+ if (!req.session || !req.session.admin) {
+ return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
+ }
+ const csrfToken = req.headers['x-csrf-token'];
+ if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
+ return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
+ }
+ const id = req.params.id;
+ try {
+ // Fetch emoji file URLs before deletion for filesystem cleanup
+ const emojiFiles = await db`SELECT url FROM custom_emojis WHERE pack_id = ${id}`;
+ await db`DELETE FROM custom_emojis WHERE pack_id = ${id}`;
+ await db`DELETE FROM sticker_packs WHERE id = ${id}`;
+
+ // Clean up local files
+ for (const e of emojiFiles) {
+ if (e.url && e.url.startsWith('/s/emojis/')) {
+ const filename = path.basename(e.url);
+ await fs.unlink(path.join(cfg.paths.emojis, filename)).catch(() => {});
+ }
+ }
+
+ await db`NOTIFY emojis_updated, '{}'`;
+ return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
+ } catch (e) {
+ console.error(e);
+ return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
+ }
+ });
+
+ // Rename a sticker pack (Admin)
+ router.post(/\/api\/v2\/admin\/sticker-packs\/(?\d+)\/rename/, async (req, res) => {
+ if (!req.session || !req.session.admin) {
+ return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
+ }
+ const csrfToken = req.headers['x-csrf-token'];
+ if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
+ return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
+ }
+ const id = req.params.id;
+ const newName = (req.body?.name || req.post?.name || '').trim();
+ if (!newName) {
+ return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'name is required' }) });
+ }
+ try {
+ await db`UPDATE sticker_packs SET name = ${newName} WHERE id = ${id}`;
+ await db`NOTIFY emojis_updated, '{}'`;
+ return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
+ } catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
@@ -132,5 +287,7 @@ export default (router, tpl) => {
}
});
+ // Edit emoji (Admin only) — handled by bypass middleware in index.mjs
+
return router;
};
diff --git a/src/index.mjs b/src/index.mjs
index 30fab78..684f997 100644
--- a/src/index.mjs
+++ b/src/index.mjs
@@ -13,6 +13,7 @@ import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs";
import { handleRethumbUpload } from "./rethumb_handler.mjs";
import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs";
import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs";
+import { handleImportTelegramPack } from "./sticker_pack_handler.mjs";
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs";
@@ -784,7 +785,7 @@ process.on('uncaughtException', err => {
// because the session middleware will have completed by the time router callbacks execute.
app.use(async (req, res) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
- if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload'].includes(req.url.pathname)) return;
+ if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import'].includes(req.url.pathname)) return;
// DM attachment upload validates CSRF internally
if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return;
// Hall manager routes are handled by bypass middleware with their own session auth
@@ -874,6 +875,14 @@ process.on('uncaughtException', err => {
}
});
+ // Bypass middleware for Telegram sticker pack import
+ app.use(async (req, res) => {
+ if (req.method === 'POST' && req.url.pathname === '/api/v2/admin/sticker-packs/import') {
+ await handleImportTelegramPack(req, res);
+ req.url.pathname = '/handled_sticker_pack_import_bypass';
+ }
+ });
+
// Bypass middleware for hall image uploads (multipart — needs raw body)
app.use(async (req, res) => {
if (cfg.websrv.halls_enabled === false) return;
diff --git a/src/sticker_pack_handler.mjs b/src/sticker_pack_handler.mjs
new file mode 100644
index 0000000..375eb87
--- /dev/null
+++ b/src/sticker_pack_handler.mjs
@@ -0,0 +1,279 @@
+import { promises as fs } from "fs";
+import db from "./inc/sql.mjs";
+import lib from "./inc/lib.mjs";
+import cfg from "./inc/config.mjs";
+import { collectBody } from "./inc/multipart.mjs";
+import path from "path";
+import { fileURLToPath } from "url";
+import { execFile as _execFile } from "child_process";
+import { promisify } from "util";
+import crypto from "crypto";
+import fetch from "flumm-fetch"; // used for JSON API calls only
+import https from "https";
+import http from "http";
+
+const execFile = promisify(_execFile);
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const MAGICK_POLICY_PATH = path.resolve(__dirname, '../config/magick-policy');
+const magickEnv = { ...process.env, MAGICK_CONFIGURE_PATH: MAGICK_POLICY_PATH };
+
+const sendJson = (res, data, code = 200) => {
+ res.writeHead(code, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(data));
+};
+
+const requireAdmin = async (req, res) => {
+ let user = [];
+ if (req.cookies && req.cookies.session) {
+ user = await db`
+ select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token
+ from "user_sessions"
+ left join "user" on "user".id = "user_sessions".user_id
+ where "user_sessions".session = ${lib.sha256(req.cookies.session)}
+ limit 1
+ `;
+ }
+ if (user.length === 0 || !user[0].admin) {
+ sendJson(res, { success: false, message: 'Unauthorized' }, 403);
+ return null;
+ }
+ const csrfToken = req.headers['x-csrf-token'];
+ if (user[0].csrf_token && (!csrfToken || csrfToken !== user[0].csrf_token)) {
+ sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403);
+ return null;
+ }
+ return user[0];
+};
+
+/**
+ * Download a file from a URL and return it as a raw Buffer.
+ * Uses Node's built-in https/http directly — flumm-fetch's .buffer() method
+ * calls setEncoding('utf8') which corrupts binary image data.
+ */
+const downloadBuffer = (url) => {
+ return new Promise((resolve, reject) => {
+ const mod = url.startsWith('https://') ? https : http;
+ mod.get(url, (res) => {
+ if (res.statusCode >= 400) {
+ res.resume(); // drain the socket
+ return reject(new Error('HTTP ' + res.statusCode + ' fetching ' + url));
+ }
+ const chunks = [];
+ res.on('data', (chunk) => chunks.push(chunk));
+ res.on('end', () => resolve(Buffer.concat(chunks)));
+ res.on('error', reject);
+ }).on('error', reject);
+ });
+};
+
+/**
+ * Convert a buffer to WebP and write to the emojis dir.
+ * Returns the relative URL path.
+ */
+const saveAsWebP = async (buffer, originalExt) => {
+ const randSuffix = crypto.randomBytes(24).toString('hex');
+ const webpFilename = randSuffix + '.webp';
+ const webpPath = path.join(cfg.paths.emojis, webpFilename);
+
+ if (originalExt === 'webp') {
+ await fs.writeFile(webpPath, buffer);
+ } else {
+ const tmpFilename = randSuffix + '_tmp.' + originalExt;
+ const tmpPath = path.join(cfg.paths.emojis, tmpFilename);
+ await fs.writeFile(tmpPath, buffer);
+ try {
+ await execFile('magick', [tmpPath, '-coalesce', '-quality', '80', webpPath], { env: magickEnv });
+ } finally {
+ await fs.unlink(tmpPath).catch(() => {});
+ }
+ }
+
+ const stat = await fs.stat(webpPath);
+ if (!stat || stat.size === 0) throw new Error('WebP conversion produced empty file');
+
+ return '/s/emojis/' + webpFilename;
+};
+
+/**
+ * Save a raw buffer directly to the emojis dir with the given extension (no re-encoding).
+ * Used for video stickers (.webm) which browsers can play natively.
+ */
+const saveRaw = async (buffer, ext) => {
+ const randSuffix = crypto.randomBytes(24).toString('hex');
+ const filename = randSuffix + '.' + ext;
+ const filePath = path.join(cfg.paths.emojis, filename);
+ await fs.writeFile(filePath, buffer);
+ const stat = await fs.stat(filePath);
+ if (!stat || stat.size === 0) throw new Error('Saved file is empty');
+ return '/s/emojis/' + filename;
+};
+
+/**
+ * Import a Telegram sticker pack given its name (short name from t.me/addstickers/).
+ * Creates a sticker_pack record and downloads all stickers as custom_emojis.
+ */
+export const handleImportTelegramPack = async (req, res) => {
+ const user = await requireAdmin(req, res);
+ if (!user) return;
+
+ let body = {};
+ try {
+ const raw = await collectBody(req);
+ body = JSON.parse(raw.toString());
+ } catch (e) {
+ return sendJson(res, { success: false, message: 'Invalid JSON body' }, 400);
+ }
+
+ const { pack_name, display_name } = body;
+ if (!pack_name || typeof pack_name !== 'string') {
+ return sendJson(res, { success: false, message: 'pack_name is required' }, 400);
+ }
+
+ // Get Telegram bot token
+ const tgClient = cfg.clients.find(c => c.type === 'tg');
+ if (!tgClient || !tgClient.token) {
+ return sendJson(res, { success: false, message: 'Telegram bot not configured' }, 500);
+ }
+ const botToken = tgClient.token;
+
+ const tgName = pack_name.trim().replace(/^https?:\/\/t\.me\/addstickers\//i, '');
+ if (!tgName) {
+ return sendJson(res, { success: false, message: 'Could not extract pack name from URL' }, 400);
+ }
+
+ console.log(`[STICKER PACK] Importing Telegram pack: ${tgName}`);
+
+ // Check if pack already imported
+ const existing = await db`SELECT id FROM sticker_packs WHERE tg_name = ${tgName} LIMIT 1`;
+ if (existing.length > 0) {
+ return sendJson(res, { success: false, message: `Pack "${tgName}" was already imported (ID ${existing[0].id})` }, 409);
+ }
+
+ // Call Telegram API
+ let stickerSet;
+ try {
+ const apiRes = await fetch(`https://api.telegram.org/bot${botToken}/getStickerSet?name=${encodeURIComponent(tgName)}`);
+ const apiData = await apiRes.json();
+ if (!apiData.ok) {
+ return sendJson(res, { success: false, message: `Telegram API error: ${apiData.description || 'unknown'}` }, 400);
+ }
+ stickerSet = apiData.result;
+ } catch (e) {
+ console.error('[STICKER PACK] Telegram API fetch failed:', e);
+ return sendJson(res, { success: false, message: `Failed to contact Telegram API: ${e.message}` }, 500);
+ }
+
+ const packTitle = display_name?.trim() || stickerSet.title || tgName;
+ const stickers = stickerSet.stickers || [];
+
+ console.log(`[STICKER PACK] Found ${stickers.length} stickers in "${stickerSet.title}"`);
+
+ // Create pack record
+ const [newPack] = await db`
+ INSERT INTO sticker_packs (name, tg_name, tg_title, sticker_count)
+ VALUES (${packTitle}, ${tgName}, ${stickerSet.title}, ${stickers.length})
+ RETURNING id, name, tg_name, tg_title
+ `;
+
+ // Generate a static WebP thumbnail for the pack from the first sticker's Telegram thumbnail.
+ // Telegram always provides a static webp thumbnail for every sticker regardless of type.
+ let packThumbUrl = null;
+ const firstThumb = stickers[0]?.thumbnail;
+ if (firstThumb && firstThumb.file_id) {
+ try {
+ const tRes = await fetch('https://api.telegram.org/bot' + botToken + '/getFile?file_id=' + encodeURIComponent(firstThumb.file_id));
+ const tData = await tRes.json();
+ if (tData.ok && tData.result && tData.result.file_path) {
+ const tBuf = await downloadBuffer('https://api.telegram.org/file/bot' + botToken + '/' + tData.result.file_path);
+ const tExt = (tData.result.file_path.match(/\.([a-z0-9]+)$/i) || ['', 'webp'])[1].toLowerCase();
+ packThumbUrl = await saveAsWebP(tBuf, tExt);
+ await db`UPDATE sticker_packs SET thumb_url = ${packThumbUrl} WHERE id = ${newPack.id}`;
+ console.log('[STICKER PACK] Pack thumbnail saved: ' + packThumbUrl);
+ }
+ } catch (e) {
+ console.warn('[STICKER PACK] Could not save pack thumbnail:', e.message);
+ }
+ }
+ let imported = 0;
+ let failed = 0;
+ const errors = [];
+
+ for (let i = 0; i < stickers.length; i++) {
+ const sticker = stickers[i];
+ try {
+ // Skip only animated TGS stickers (Lottie format — cannot be displayed as-is)
+ const isAnimated = sticker.is_animated || false;
+ const isVideo = sticker.is_video || false;
+ if (isAnimated) {
+ console.log('[STICKER PACK] Skipping animated (TGS) sticker ' + (i + 1));
+ continue;
+ }
+
+ const fileId = sticker.file_id;
+
+ // Resolve the download URL via getFile
+ const fileRes = await fetch('https://api.telegram.org/bot' + botToken + '/getFile?file_id=' + encodeURIComponent(fileId));
+ const fileData = await fileRes.json();
+ if (!fileData.ok || !fileData.result || !fileData.result.file_path) {
+ throw new Error('getFile failed: ' + (fileData.description || 'no file_path'));
+ }
+
+ const filePath = fileData.result.file_path;
+ const extMatch = filePath.match(/\.([a-z0-9]+)$/i);
+ const ext = extMatch ? extMatch[1].toLowerCase() : 'webp';
+
+ const downloadUrl = 'https://api.telegram.org/file/bot' + botToken + '/' + filePath;
+ const buffer = await downloadBuffer(downloadUrl);
+
+ // Generate emoji name from pack name + index
+ const safeName = tgName.replace(/[^a-z0-9_]/gi, '_').toLowerCase();
+ const emojiName = safeName + '_' + String(i + 1).padStart(3, '0');
+
+ // Check for name conflict
+ const nameConflict = await db`SELECT id FROM custom_emojis WHERE name = ${emojiName} LIMIT 1`;
+ const finalName = nameConflict.length > 0 ? emojiName + '_' + crypto.randomBytes(3).toString('hex') : emojiName;
+
+ // Save: video stickers (.webm) stored as-is; static stickers converted to WebP
+ let savedUrl;
+ if (isVideo) {
+ savedUrl = await saveRaw(buffer, ext); // keeps original webm, no re-encoding
+ console.log('[STICKER PACK] Saved video sticker ' + (i + 1) + ' as ' + ext);
+ } else {
+ savedUrl = await saveAsWebP(buffer, ext);
+ }
+
+ await db`
+ INSERT INTO custom_emojis (name, url, pack_id)
+ VALUES (${finalName}, ${savedUrl}, ${newPack.id})
+ `;
+
+ imported++;
+ console.log('[STICKER PACK] Imported sticker ' + (i + 1) + '/' + stickers.length + ': ' + finalName + ' (' + (isVideo ? 'video' : 'static') + ')');
+
+ // Small delay to avoid Telegram rate limits
+ if (i < stickers.length - 1) await new Promise(function(resolve) { setTimeout(resolve, 50); });
+
+ } catch (e) {
+ console.error('[STICKER PACK] Failed sticker ' + (i + 1) + ':', e.message);
+ errors.push('Sticker ' + (i + 1) + ': ' + e.message);
+ failed++;
+ }
+ }
+
+ // Update pack with actual imported count
+ await db`UPDATE sticker_packs SET sticker_count = ${imported} WHERE id = ${newPack.id}`;
+
+ // Notify clients that emojis changed
+ await db`NOTIFY emojis_updated, '{}'`;
+
+ console.log(`[STICKER PACK] Done. Imported: ${imported}, Failed: ${failed}`);
+
+ return sendJson(res, {
+ success: true,
+ pack: { ...newPack, sticker_count: imported },
+ imported,
+ failed,
+ errors
+ });
+};
diff --git a/views/admin/emojis.html b/views/admin/emojis.html
index f709a29..863b2ef 100644
--- a/views/admin/emojis.html
+++ b/views/admin/emojis.html
@@ -2,54 +2,85 @@
-
Custom Emojis
+
Custom Emojis & Sticker Packs
+
+
+
+
+ Import Telegram Sticker Pack
+
+
+ Enter a Telegram sticker pack URL (e.g. https://t.me/addstickers/PackName) or just the short name.
+ All stickers will be downloaded and added as a new sticker pack.
+