- Implemented TelegramImport component for importing sticker packs from Telegram using a bot token. - Added utility functions for animated sticker conversion (TGS and WEBM to GIF). - Created telegramStickerImport module to handle fetching and processing of Telegram sticker sets. - Introduced GIF.js type definitions for TypeScript support.
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
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<T> {
|
|
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<TelegramStickerSet> {
|
|
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<TelegramStickerSet> = 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<string> {
|
|
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<Blob> {
|
|
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<string> {
|
|
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 };
|
|
}
|