feat: add Telegram sticker import functionality
- 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.
This commit is contained in:
@@ -3,6 +3,7 @@ import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { GlobalPacks } from './GlobalPacks';
|
||||
import { UserPack } from './UserPack';
|
||||
import { TelegramImport } from './TelegramImport';
|
||||
import { ImagePack } from '../../../plugins/custom-emoji';
|
||||
import { ImagePackView } from '../../../components/image-pack-view';
|
||||
|
||||
@@ -42,6 +43,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
||||
<Box direction="Column" gap="700">
|
||||
<UserPack onViewPack={setImagePack} />
|
||||
<GlobalPacks onViewPack={setImagePack} />
|
||||
<TelegramImport onViewPack={setImagePack} />
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
|
||||
167
src/app/features/settings/emojis-stickers/TelegramImport.tsx
Normal file
167
src/app/features/settings/emojis-stickers/TelegramImport.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Button,
|
||||
Input,
|
||||
Spinner,
|
||||
color,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import {
|
||||
extractPackName,
|
||||
importTelegramStickerPack,
|
||||
} from '../../../utils/telegramStickerImport';
|
||||
import { getUserImagePack, ImagePack } from '../../../plugins/custom-emoji';
|
||||
|
||||
const TELEGRAM_BOT_TOKEN_KEY = 'cinny_telegram_bot_token';
|
||||
|
||||
type TelegramImportProps = {
|
||||
onViewPack?: (pack: ImagePack) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component for importing Telegram sticker packs
|
||||
*/
|
||||
export function TelegramImport({ onViewPack }: TelegramImportProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [botToken, setBotToken] = useState(() =>
|
||||
localStorage.getItem(TELEGRAM_BOT_TOKEN_KEY) || ''
|
||||
);
|
||||
const [packUrl, setPackUrl] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [progress, setProgress] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [success, setSuccess] = useState<string>('');
|
||||
|
||||
const handleBotTokenChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const token = e.target.value;
|
||||
setBotToken(token);
|
||||
localStorage.setItem(TELEGRAM_BOT_TOKEN_KEY, token);
|
||||
}, []);
|
||||
|
||||
const handlePackUrlChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPackUrl(e.target.value);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
}, []);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!botToken.trim()) {
|
||||
setError('Please enter your Telegram Bot token');
|
||||
return;
|
||||
}
|
||||
|
||||
const packName = extractPackName(packUrl);
|
||||
if (!packName) {
|
||||
setError('Invalid Telegram sticker pack URL. Use format: t.me/addstickers/PackName');
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setProgress('Starting import...');
|
||||
|
||||
try {
|
||||
const result = await importTelegramStickerPack(
|
||||
mx,
|
||||
botToken.trim(),
|
||||
packUrl.trim(),
|
||||
(current, total, status) => {
|
||||
setProgress(status);
|
||||
}
|
||||
);
|
||||
|
||||
const count = Object.keys(result.content.images || {}).length;
|
||||
setSuccess(`Successfully imported ${count} stickers!`);
|
||||
setPackUrl('');
|
||||
|
||||
// Show the user's pack with the newly imported stickers
|
||||
const userPack = getUserImagePack(mx);
|
||||
if (userPack && onViewPack) {
|
||||
onViewPack(userPack);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to import sticker pack');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
setProgress('');
|
||||
}
|
||||
}, [mx, botToken, packUrl, onViewPack]);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Import from Telegram</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Telegram Bot Token"
|
||||
description="Create a bot via @BotFather on Telegram to get a token. Your token is stored locally."
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={botToken}
|
||||
onChange={handleBotTokenChange}
|
||||
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
style={{ width: '100%', marginTop: toRem(8) }}
|
||||
disabled={importing}
|
||||
/>
|
||||
</SettingTile>
|
||||
|
||||
<SettingTile
|
||||
title="Sticker Pack URL"
|
||||
description="Paste a Telegram sticker pack link (e.g., t.me/addstickers/PackName)"
|
||||
>
|
||||
<Input
|
||||
value={packUrl}
|
||||
onChange={handlePackUrlChange}
|
||||
placeholder="https://t.me/addstickers/YourPackName"
|
||||
style={{ width: '100%', marginTop: toRem(8) }}
|
||||
disabled={importing}
|
||||
/>
|
||||
</SettingTile>
|
||||
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Text size="T200" style={{ color: color.Success.Main }}>
|
||||
{success}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{importing && progress && (
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Spinner size="200" variant="Secondary" />
|
||||
<Text size="T200" priority="300">{progress}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box justifyContent="End">
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={handleImport}
|
||||
disabled={importing || !packUrl.trim()}
|
||||
>
|
||||
<Text size="B300">{importing ? 'Importing...' : 'Import Pack'}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
344
src/app/utils/animatedStickerConverter.ts
Normal file
344
src/app/utils/animatedStickerConverter.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import lottie from 'lottie-web/build/player/lottie_light';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import pako from 'pako';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import GIF from 'gif.js';
|
||||
|
||||
/** Animation item type from lottie-web */
|
||||
type AnimationItem = ReturnType<typeof lottie.loadAnimation>;
|
||||
|
||||
/** Default GIF frame rate */
|
||||
const GIF_FPS = 15;
|
||||
|
||||
/** Default GIF quality (1-20, lower is better but slower) */
|
||||
const GIF_QUALITY = 10;
|
||||
|
||||
/** Maximum dimension for GIF output */
|
||||
const MAX_GIF_SIZE = 256;
|
||||
|
||||
/** Transparency key color (magenta) - this color becomes transparent in the GIF */
|
||||
const TRANSPARENT_COLOR = 0xFF00FF;
|
||||
|
||||
/**
|
||||
* Decompress a TGS file (gzipped Lottie JSON)
|
||||
* @param tgsData - The compressed TGS data
|
||||
* @returns The Lottie animation JSON object
|
||||
*/
|
||||
function decompressTgs(tgsData: ArrayBuffer): object {
|
||||
const decompressed = pako.inflate(new Uint8Array(tgsData), { to: 'string' });
|
||||
return JSON.parse(decompressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Lottie animation to GIF
|
||||
* @param animationData - The Lottie JSON animation data
|
||||
* @param width - Output width
|
||||
* @param height - Output height
|
||||
* @returns Promise resolving to GIF blob
|
||||
*/
|
||||
async function renderLottieToGif(
|
||||
animationData: object,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create a hidden container for rendering
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
width: ${width}px;
|
||||
height: ${height}px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Create canvas for capturing frames
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
document.body.removeChild(container);
|
||||
reject(new Error('Failed to get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize Lottie animation
|
||||
let animation: AnimationItem;
|
||||
try {
|
||||
animation = lottie.loadAnimation({
|
||||
container,
|
||||
renderer: 'svg',
|
||||
loop: false,
|
||||
autoplay: false,
|
||||
animationData,
|
||||
});
|
||||
} catch (error) {
|
||||
document.body.removeChild(container);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const workerScript = (GIF as any).getWorkerScript?.() || '/gif.worker.js';
|
||||
|
||||
// Initialize GIF encoder with transparency support
|
||||
// Use magenta as the transparent color key
|
||||
const gif = new GIF({
|
||||
workers: 2,
|
||||
quality: GIF_QUALITY,
|
||||
width,
|
||||
height,
|
||||
workerScript,
|
||||
transparent: TRANSPARENT_COLOR,
|
||||
});
|
||||
|
||||
const { totalFrames } = animation;
|
||||
const duration = animation.getDuration(false) || 3; // Default 3 seconds if duration unknown
|
||||
const frameDelay = 1000 / GIF_FPS;
|
||||
const framesToCapture = Math.ceil(duration * GIF_FPS);
|
||||
|
||||
let capturedFrames = 0;
|
||||
|
||||
/**
|
||||
* Finalize and render the GIF
|
||||
*/
|
||||
const finishGif = () => {
|
||||
gif.on('finished', (gifBlob: Blob) => {
|
||||
animation.destroy();
|
||||
document.body.removeChild(container);
|
||||
resolve(gifBlob);
|
||||
});
|
||||
|
||||
gif.on('error', (error: Error) => {
|
||||
animation.destroy();
|
||||
document.body.removeChild(container);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
gif.render();
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture a single frame from the animation
|
||||
*/
|
||||
const captureFrame = (frameIndex: number) => {
|
||||
// Calculate the animation frame to seek to
|
||||
const progress = frameIndex / framesToCapture;
|
||||
const animFrame = Math.floor(progress * totalFrames);
|
||||
|
||||
animation.goToAndStop(animFrame, true);
|
||||
|
||||
// Get the SVG content and render to canvas
|
||||
const svgElement = container.querySelector('svg');
|
||||
if (svgElement) {
|
||||
const svgData = new XMLSerializer().serializeToString(svgElement);
|
||||
const img = new Image();
|
||||
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
|
||||
img.onload = () => {
|
||||
// Fill with transparent key color first
|
||||
ctx.fillStyle = '#FF00FF';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
gif.addFrame(ctx, { copy: true, delay: frameDelay, dispose: 2 });
|
||||
capturedFrames += 1;
|
||||
|
||||
if (capturedFrames < framesToCapture) {
|
||||
captureFrame(capturedFrames);
|
||||
} else {
|
||||
finishGif();
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
capturedFrames += 1;
|
||||
if (capturedFrames < framesToCapture) {
|
||||
captureFrame(capturedFrames);
|
||||
} else {
|
||||
finishGif();
|
||||
}
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
} else {
|
||||
capturedFrames += 1;
|
||||
if (capturedFrames < framesToCapture) {
|
||||
captureFrame(capturedFrames);
|
||||
} else {
|
||||
finishGif();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start capturing frames
|
||||
captureFrame(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a TGS (Telegram animated sticker) to GIF
|
||||
* @param tgsBlob - The TGS file blob
|
||||
* @param targetWidth - Desired output width (will maintain aspect ratio)
|
||||
* @param targetHeight - Desired output height (will maintain aspect ratio)
|
||||
* @returns Promise resolving to GIF blob
|
||||
*/
|
||||
export async function tgsToGif(
|
||||
tgsBlob: Blob,
|
||||
targetWidth = 256,
|
||||
targetHeight = 256
|
||||
): Promise<Blob> {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('tgsToGif: Starting conversion, blob size:', tgsBlob.size);
|
||||
const arrayBuffer = await tgsBlob.arrayBuffer();
|
||||
const animationData = decompressTgs(arrayBuffer);
|
||||
|
||||
// Calculate output dimensions (maintain aspect ratio, cap at MAX_GIF_SIZE)
|
||||
const width = Math.min(targetWidth, MAX_GIF_SIZE);
|
||||
const height = Math.min(targetHeight, MAX_GIF_SIZE);
|
||||
|
||||
return renderLottieToGif(animationData, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a WEBM video sticker to GIF
|
||||
* @param webmBlob - The WEBM file blob
|
||||
* @param targetWidth - Desired output width
|
||||
* @param targetHeight - Desired output height
|
||||
* @returns Promise resolving to GIF blob
|
||||
*/
|
||||
export async function webmToGif(
|
||||
webmBlob: Blob,
|
||||
targetWidth = 256,
|
||||
targetHeight = 256
|
||||
): Promise<Blob> {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('webmToGif: Starting conversion, blob size:', webmBlob.size);
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const width = Math.min(targetWidth, MAX_GIF_SIZE);
|
||||
const height = Math.min(targetHeight, MAX_GIF_SIZE);
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const workerScript = (GIF as any).getWorkerScript?.() || '/gif.worker.js';
|
||||
|
||||
// Initialize GIF encoder with transparency support
|
||||
// Use magenta as the transparent color key
|
||||
const gif = new GIF({
|
||||
workers: 2,
|
||||
quality: GIF_QUALITY,
|
||||
width,
|
||||
height,
|
||||
workerScript,
|
||||
transparent: TRANSPARENT_COLOR,
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(webmBlob);
|
||||
video.src = url;
|
||||
video.load();
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('webmToGif: Video metadata loaded, duration:', video.duration);
|
||||
const { duration } = video;
|
||||
const frameDelay = 1000 / GIF_FPS;
|
||||
const totalFrames = Math.ceil(duration * GIF_FPS);
|
||||
let currentFrame = 0;
|
||||
|
||||
/**
|
||||
* Capture frames from video
|
||||
*/
|
||||
const captureNextFrame = () => {
|
||||
if (currentFrame >= totalFrames) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('webmToGif: All frames captured, rendering GIF');
|
||||
URL.revokeObjectURL(url);
|
||||
gif.on('finished', (blob: Blob) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('webmToGif: GIF rendered, size:', blob.size);
|
||||
resolve(blob);
|
||||
});
|
||||
gif.on('error', (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('webmToGif: GIF render error:', err);
|
||||
reject(err);
|
||||
});
|
||||
gif.render();
|
||||
return;
|
||||
}
|
||||
|
||||
const time = (currentFrame / totalFrames) * duration;
|
||||
video.currentTime = time;
|
||||
};
|
||||
|
||||
video.onseeked = () => {
|
||||
// Fill with transparent key color first
|
||||
ctx.fillStyle = '#FF00FF';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
gif.addFrame(ctx, { copy: true, delay: frameDelay, dispose: 2 });
|
||||
currentFrame += 1;
|
||||
captureNextFrame();
|
||||
};
|
||||
|
||||
captureNextFrame();
|
||||
};
|
||||
|
||||
video.onerror = (e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('webmToGif: Video load error:', e, video.error);
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error(`Failed to load video: ${video.error?.message || 'unknown error'}`));
|
||||
};
|
||||
|
||||
// Add timeout in case video never loads
|
||||
setTimeout(() => {
|
||||
if (!video.readyState) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('webmToGif: Video load timeout');
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Video load timeout'));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a TGS (animated Telegram sticker)
|
||||
* @param mimeType - The file MIME type
|
||||
* @param filePath - The file path
|
||||
* @returns True if TGS
|
||||
*/
|
||||
export function isTgsFile(mimeType: string, filePath: string): boolean {
|
||||
return mimeType === 'application/x-tgsticker' || filePath.endsWith('.tgs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a WEBM video sticker
|
||||
* @param mimeType - The file MIME type
|
||||
* @param filePath - The file path
|
||||
* @returns True if WEBM
|
||||
*/
|
||||
export function isWebmFile(mimeType: string, filePath: string): boolean {
|
||||
return mimeType === 'video/webm' || filePath.endsWith('.webm');
|
||||
}
|
||||
332
src/app/utils/telegramStickerImport.ts
Normal file
332
src/app/utils/telegramStickerImport.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
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 };
|
||||
}
|
||||
36
src/types/gif.js.d.ts
vendored
Normal file
36
src/types/gif.js.d.ts
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
declare module 'gif.js' {
|
||||
interface GIFOptions {
|
||||
workers?: number;
|
||||
quality?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
workerScript?: string;
|
||||
repeat?: number;
|
||||
background?: string;
|
||||
transparent?: string | number | null;
|
||||
dither?: boolean | string;
|
||||
}
|
||||
|
||||
interface AddFrameOptions {
|
||||
delay?: number;
|
||||
copy?: boolean;
|
||||
dispose?: number;
|
||||
}
|
||||
|
||||
class GIF {
|
||||
constructor(options?: GIFOptions);
|
||||
addFrame(
|
||||
element: HTMLCanvasElement | CanvasRenderingContext2D | ImageData,
|
||||
options?: AddFrameOptions
|
||||
): void;
|
||||
on(event: 'finished', callback: (blob: Blob) => void): void;
|
||||
on(event: 'error', callback: (error: Error) => void): void;
|
||||
on(event: 'progress', callback: (progress: number) => void): void;
|
||||
on(event: 'start' | 'abort', callback: () => void): void;
|
||||
render(): void;
|
||||
abort(): void;
|
||||
static getWorkerScript?(): string;
|
||||
}
|
||||
|
||||
export default GIF;
|
||||
}
|
||||
Reference in New Issue
Block a user