import { MatrixClient } from 'matrix-js-sdk'; import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; import { AccountDataEvent } from '../../types/matrix/accountData'; import { ImageUsage, PackContent, PackImages } from '../plugins/custom-emoji/types'; import { getAccountData } from './room'; import { tgsToGif, webmToGif } from './animatedStickerConverter'; /** Telegram Bot API base URL */ const TELEGRAM_API = 'https://api.telegram.org/bot'; /** Telegram file download base URL */ const TELEGRAM_FILE_API = 'https://api.telegram.org/file/bot'; /** * Telegram sticker info from Bot API */ interface TelegramSticker { file_id: string; file_unique_id: string; type: string; width: number; height: number; is_animated: boolean; is_video: boolean; emoji?: string; set_name?: string; thumbnail?: { file_id: string; width: number; height: number; }; } /** * Telegram sticker set info from Bot API */ interface TelegramStickerSet { name: string; title: string; sticker_type: string; contains_masks: boolean; stickers: TelegramSticker[]; thumbnail?: { file_id: string; width: number; height: number; }; } /** * Telegram API response wrapper */ interface TelegramResponse { ok: boolean; result?: T; description?: string; } /** * Progress callback for import operation */ export type ImportProgressCallback = (current: number, total: number, status: string) => void; /** * Extract pack name from a Telegram sticker URL * Supports: t.me/addstickers/PackName, https://t.me/addstickers/PackName * @param url - The Telegram sticker pack URL * @returns The pack name, or null if invalid */ export function extractPackName(url: string): string | null { const trimmed = url.trim(); // Direct pack name (no URL) if (/^[a-zA-Z0-9_]+$/.test(trimmed)) { return trimmed; } // URL format: t.me/addstickers/PackName or https://t.me/addstickers/PackName const match = trimmed.match(/(?:https?:\/\/)?t\.me\/addstickers\/([a-zA-Z0-9_]+)/i); return match ? match[1] : null; } /** * Fetch sticker set info from Telegram Bot API * @param botToken - Telegram Bot API token * @param setName - Name of the sticker set * @returns Sticker set info */ export async function fetchStickerSet( botToken: string, setName: string ): Promise { const response = await fetch( `${TELEGRAM_API}${botToken}/getStickerSet?name=${encodeURIComponent(setName)}` ); if (!response.ok) { throw new Error(`Failed to fetch sticker set: ${response.statusText}`); } const data: TelegramResponse = await response.json(); if (!data.ok || !data.result) { throw new Error(data.description || 'Failed to fetch sticker set'); } return data.result; } /** * Get file path for a Telegram file * @param botToken - Telegram Bot API token * @param fileId - Telegram file ID * @returns File path for download */ async function getFilePath(botToken: string, fileId: string): Promise { const response = await fetch(`${TELEGRAM_API}${botToken}/getFile?file_id=${fileId}`); if (!response.ok) { throw new Error(`Failed to get file path: ${response.statusText}`); } const data: TelegramResponse<{ file_path: string }> = await response.json(); if (!data.ok || !data.result) { throw new Error(data.description || 'Failed to get file path'); } return data.result.file_path; } /** * Download a file from Telegram * Uses Tauri HTTP plugin to bypass CORS restrictions * @param botToken - Telegram Bot API token * @param filePath - File path from getFile * @returns File blob */ async function downloadFile(botToken: string, filePath: string): Promise { const response = await tauriFetch(`${TELEGRAM_FILE_API}${botToken}/${filePath}`); if (!response.ok) { throw new Error(`Failed to download file: ${response.statusText}`); } return response.blob(); } /** * Upload a blob to Matrix and get the mxc:// URL * @param mx - Matrix client * @param blob - File blob to upload * @param filename - Filename for the upload * @returns mxc:// URL */ async function uploadToMatrix( mx: MatrixClient, blob: Blob, filename: string ): Promise { const response = await mx.uploadContent(blob, { name: filename, type: blob.type || 'image/webp', }); return response.content_uri; } /** * Determine MIME type from file path * @param filePath - Telegram file path * @returns MIME type string */ function getMimeType(filePath: string): string { if (filePath.endsWith('.webp')) return 'image/webp'; if (filePath.endsWith('.webm')) return 'video/webm'; if (filePath.endsWith('.tgs')) return 'application/x-tgsticker'; if (filePath.endsWith('.png')) return 'image/png'; if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) return 'image/jpeg'; return 'image/webp'; } /** * Import a Telegram sticker pack to Matrix * @param mx - Matrix client * @param botToken - Telegram Bot API token * @param packUrl - Telegram sticker pack URL or name * @param onProgress - Progress callback * @returns The created pack content */ export async function importTelegramStickerPack( mx: MatrixClient, botToken: string, packUrl: string, onProgress?: ImportProgressCallback ): Promise<{ packId: string; content: PackContent }> { // Extract pack name from URL const packName = extractPackName(packUrl); if (!packName) { throw new Error('Invalid Telegram sticker pack URL'); } onProgress?.(0, 1, 'Fetching sticker pack info...'); // Fetch sticker set from Telegram const stickerSet = await fetchStickerSet(botToken, packName); const total = stickerSet.stickers.length; const images: PackImages = {}; // Download and upload each sticker sequentially // (Using sequential await is intentional to avoid overwhelming APIs) for (let i = 0; i < stickerSet.stickers.length; i += 1) { const sticker = stickerSet.stickers[i]; const isAnimated = sticker.is_animated; const isVideo = sticker.is_video; let stickerType = 'static'; if (isAnimated) stickerType = 'animated'; else if (isVideo) stickerType = 'video'; onProgress?.(i + 1, total, `Importing ${stickerType} sticker ${i + 1}/${total}...`); try { // Get file path from Telegram // eslint-disable-next-line no-await-in-loop const filePath = await getFilePath(botToken, sticker.file_id); // Download the sticker // eslint-disable-next-line no-await-in-loop let blob = await downloadFile(botToken, filePath); // Generate a shortcode from emoji or index const shortcode = sticker.emoji ? `${packName}_${sticker.emoji.codePointAt(0)?.toString(16) || i}` : `${packName}_${i}`; let finalMimeType = getMimeType(filePath); let extension = 'webp'; // Convert animated stickers to GIF if (isAnimated && filePath.endsWith('.tgs')) { onProgress?.(i + 1, total, `Converting animated sticker ${i + 1}/${total} to GIF...`); // eslint-disable-next-line no-await-in-loop blob = await tgsToGif(blob, sticker.width, sticker.height); finalMimeType = 'image/gif'; extension = 'gif'; } else if (isVideo && filePath.endsWith('.webm')) { onProgress?.(i + 1, total, `Converting video sticker ${i + 1}/${total} to GIF...`); // eslint-disable-next-line no-await-in-loop blob = await webmToGif(blob, sticker.width, sticker.height); finalMimeType = 'image/gif'; extension = 'gif'; } // Upload to Matrix // eslint-disable-next-line no-await-in-loop const mxcUrl = await uploadToMatrix( mx, blob, `${shortcode}.${extension}` ); // Add to images images[shortcode] = { url: mxcUrl, body: sticker.emoji || shortcode, usage: [ImageUsage.Emoticon, ImageUsage.Sticker], info: { w: sticker.width, h: sticker.height, mimetype: finalMimeType, size: blob.size, }, }; } catch (err) { // Log error and continue with other stickers // eslint-disable-next-line no-console console.error(`Failed to import sticker ${i + 1}:`, err); } } if (Object.keys(images).length === 0) { throw new Error('Failed to import any stickers from the pack'); } // eslint-disable-next-line no-console console.log(`Successfully imported ${Object.keys(images).length} stickers`); // Create pack content const packContent: PackContent = { pack: { display_name: stickerSet.title, usage: [ImageUsage.Emoticon, ImageUsage.Sticker], }, images, }; // Generate a unique pack ID const packId = `telegram_${packName}`; // Get existing user emotes or create new const existingEvent = getAccountData(mx, AccountDataEvent.PoniesUserEmotes); const existingEmotes = existingEvent?.getContent() as PackContent | undefined; // Merge with existing pack if any const mergedContent: PackContent = { pack: existingEmotes?.pack || { usage: [ImageUsage.Emoticon, ImageUsage.Sticker] }, images: { ...existingEmotes?.images, ...images, }, }; // Save to user account data // eslint-disable-next-line no-console console.log('Saving merged pack to account data:', AccountDataEvent.PoniesUserEmotes); // eslint-disable-next-line no-console console.log('Merged content:', JSON.stringify(mergedContent, null, 2)); // eslint-disable-next-line @typescript-eslint/no-explicit-any await mx.setAccountData(AccountDataEvent.PoniesUserEmotes as any, mergedContent as any); // eslint-disable-next-line no-console console.log('Pack saved successfully'); // Force the account data event to be emitted for UI refresh const updatedEvent = getAccountData(mx, AccountDataEvent.PoniesUserEmotes); // eslint-disable-next-line no-console console.log('Updated account data after save:', updatedEvent?.getContent()); onProgress?.(total, total, 'Import complete!'); return { packId, content: packContent }; }