const puppeteer = require('puppeteer'); // *** RESTORED: createCanvas, registerFont from 'canvas' *** const { createCanvas, registerFont } = require('canvas'); const fs = require('fs'); const fsPromises = require('fs').promises; const path = require('path'); const sharp = require('sharp'); const axios = require('axios'); require('dotenv').config // const { text } = require('stream/consumers'); // <-- Removed, this was unused // --- CONFIGURATION --- const BOT_TOKEN = process.env.BOT_TOKEN; // IMPORTANT: Replace with your token // --- FONT DEFINITIONS --- const fontMap = { '/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf': 'Noto Sans', '/usr/share/fonts/truetype/noto/NotoSansSinhala-Regular.ttf': 'Noto Sans Sinhala', '/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf': 'Noto Color Emoji', '/usr/share/fonts/truetype/noto/NotoSansSymbols-Regular.ttf': 'Noto Sans Symbols', '/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf': 'Noto Sans Symbols 2', '/usr/share/fonts/truetype/noto/NotoSansMath-Regular.ttf': 'Noto Sans Math', '/usr/share/fonts/truetype/noto/NotoSansMeeteiMayek-Regular.ttf': 'Noto Sans Meetei Mayek' }; // --- FONT LOADING FOR NODE-CANVAS (Used by generateNameHtml) --- console.log('Registering fonts for node-canvas...'); Object.entries(fontMap).forEach(([fontPath, familyName]) => { try { if (fs.existsSync(fontPath)) { registerFont(fontPath, { family: familyName }); console.log(`Registered for node-canvas: ${fontPath} as "${familyName}"`); } else { console.warn(`⚠️ node-canvas: Font file not found at ${fontPath}`); } } catch (e) { console.warn(`⚠️ node-canvas: Could not register font at ${fontPath}: ${e.message}`); } }); // --- FONT LOADING FOR PUPPETEER (REMOVED) --- // Font-face rules are no longer injected. Puppeteer will use system-installed fonts. // This is the CSS font-family stack Puppeteer will now use // This is the CSS font-family stack Puppeteer will now use const FONT_STACK = "'Noto Sans', 'Noto Sans Sinhala', 'Noto Sans Meetei Mayek', 'Noto Sans Math', 'Noto Sans Symbols', 'Noto Sans Symbols 2', 'Noto Color Emoji'"; // This stack is for the dummy avatar, which also runs in Puppeteer const DUMMY_AVATAR_FONT_STACK = "'Noto Color Emoji', 'Noto Sans'"; // --- HELPER FUNCTIONS --- function getTelegramDarkThemeColor(id) { const map = new Map([[0, '#FF516A'], [1, '#FF9442'], [2, '#C66FFF'], [3, '#50D892'], [4, '#64D4F5'], [5, '#5095ED'], [6, '#FF66A6'], [7, '#FF8280'], [8, '#EDD64E'], [9, '#C66FFF']]); return map.get(id) || '#00ffff'; } // --- NEW PUPPETEER-BASED AVATAR FUNCTION --- async function createDummyAvatarBuffer(f, l, c, scale = 1) { const avatarSize = 140 * scale; // 1. Determine Initial Text (Unicode-safe) let initialText = ''; const firstChar = f ? (Array.from(f)[0] || '') : ''; const isFirstCharEmoji = /\p{Emoji}/u.test(firstChar); if (isFirstCharEmoji) { initialText = firstChar; } else { const firstInitial = firstChar; const lastInitial = l ? (Array.from(l)[0] || '') : ''; initialText = (firstInitial + lastInitial).toUpperCase().trim(); } if (!initialText) { initialText = '?'; } // 2. Determine Font Size const graphemeCount = Array.from(initialText).length; const isSingleEmoji = graphemeCount === 1 && /\p{Emoji}/u.test(initialText); let fontSize; let fontWeight = 'bold'; if (isSingleEmoji) { fontSize = 72 * scale; fontWeight = 'normal'; // Emojis look better as normal weight } else if (graphemeCount === 1) { fontSize = 64 * scale; } else { fontSize = 48 * scale; } // 3. Generate HTML for the avatar const htmlContent = `
${escapeHtml(initialText)}
`; // 4. Launch a *separate, minimal* Puppeteer instance to render it let browser; let pngBuffer; try { browser = await puppeteer.launch({ headless: true,executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox', '--disable-gpu'] }); const page = await browser.newPage(); await page.setViewport({ width: avatarSize, height: avatarSize }); await page.setContent(htmlContent, { waitUntil: 'domcontentloaded' }); // Reverted from networkidle0 const element = await page.$('#avatar'); pngBuffer = await element.screenshot({ omitBackground: true }); // Screenshot just the circle } catch (e) { console.error("❌ Error creating dummy avatar with Puppeteer:", e.message); return null; // Fallback } finally { if (browser) { await browser.close(); } } return pngBuffer; } // --- HELPER FUNCTION: Fetches and CONVERTS to PNG --- const EMOJI_STATUS_CACHE_DIR = './emoji_status'; if (!fs.existsSync(EMOJI_STATUS_CACHE_DIR)) { fs.mkdirSync(EMOJI_STATUS_CACHE_DIR); } const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); async function getEmojiStatusBuffer(emojiId) { const cachePath = `${EMOJI_STATUS_CACHE_DIR}/${emojiId}.png`; // 1. Check local cache if (fs.existsSync(cachePath)) { return fs.readFileSync(cachePath); } const PROXY_URL = 'https://telegram-proxy.resident.workers.dev'; const maxRetries = 6; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // Define common headers to prevent ENOTFOUND/EAI_AGAIN on Hugging Face const axiosConfig = { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'application/json', 'Host': 'telegram-proxy.resident.workers.dev' }, timeout: 15000 // 15s timeout for slow proxy wake-ups }; // 2. Get Metadata via Proxy const stickerApiUrl = `${PROXY_URL}/bot${BOT_TOKEN}/getCustomEmojiStickers`; const stickerResponse = await axios.post(stickerApiUrl, { custom_emoji_ids: [emojiId] }, axiosConfig ); const stickers = stickerResponse.data.result; if (!stickers || stickers.length === 0) throw new Error("Emoji not found in Telegram."); const sticker = stickers[0]; /** * VIDEO EMOJI LOGIC: * Since your emoji is 'is_video: true', we MUST use the thumbnail. * Telegram provides 'thumbnail' or 'thumb'. We prioritize these. */ let targetFileId = null; if (sticker.thumbnail && sticker.thumbnail.file_id) { targetFileId = sticker.thumbnail.file_id; } else if (sticker.thumb && sticker.thumb.file_id) { targetFileId = sticker.thumb.file_id; } else { // Fallback to main file_id if it's not a video (Sharp can't read .webm) if (sticker.is_video || sticker.is_animated) { throw new Error("No static thumbnail found for animated/video emoji."); } targetFileId = sticker.file_id; } // 3. Get File Path const fileApiUrl = `${PROXY_URL}/bot${BOT_TOKEN}/getFile`; const fileResponse = await axios.post(fileApiUrl, { file_id: targetFileId }, axiosConfig ); const filePath = fileResponse.data.result.file_path; // 4. Download Binary Data const fileUrl = `${PROXY_URL}/file/bot${BOT_TOKEN}/${filePath}`; const imageResponse = await axios.get(fileUrl, { ...axiosConfig, responseType: 'arraybuffer' }); // 5. Process with Sharp // We convert to PNG and ensure it's a standard size for the quote template const pngBuffer = await sharp(imageResponse.data) .resize(100, 100, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) .png() .toBuffer(); // 6. Save to cache and return fs.writeFileSync(cachePath, pngBuffer); console.log(`✅ Successfully cached emoji: ${emojiId}`); return pngBuffer; } catch (error) { const errorMsg = error.response ? JSON.stringify(error.response.data) : error.message; console.warn(`[Attempt ${attempt}/${maxRetries}] Emoji Fetch Error: ${errorMsg}`); if (attempt === maxRetries) { console.error(`❌ Permanent failure for Emoji ID ${emojiId}. Returning null.`); return null; } // Exponential backoff: 2s, 4s, 6s... await new Promise(resolve => setTimeout(resolve, 2000 * attempt)); } } return null; } function escapeHtml(text) { if (!text) return ''; // *** FIX: Corrected regex from /&g to /&/g *** return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } // *** RESTORED: createTextChunkImageBuffer function *** function createTextChunkImageBuffer(text, { fontSize = 20, color = '#FFFFFF' }) { const canvas = createCanvas(1, 1); const ctx = canvas.getContext('2d'); // Use the *exact* font stack from node-canvas registration ctx.font = `bold ${fontSize}px ${FONT_STACK}`; const metrics = ctx.measureText(text); const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; // *** FIX: Keep padding at 0 *** const padding = 0; // *** FIX: Ensure width and height are at least 1px *** const canvasWidth = Math.max(1, metrics.width + 2 * padding); const canvasHeight = Math.max(1, textHeight + 2 * padding); const textCanvas = createCanvas(canvasWidth, canvasHeight); const textCtx = textCanvas.getContext('2d'); textCtx.font = `bold ${fontSize}px ${FONT_STACK}`; // Use the *exact* font stack textCtx.fillStyle = color; textCtx.textBaseline = 'alphabetic'; // Draw text only if width > 0 to avoid potential issues if (metrics.width > 0) { textCtx.fillText(text, padding, metrics.actualBoundingBoxAscent + padding); } return textCanvas.toBuffer('image/png'); } // *** RESTORED: generateNameHtml function *** // *** RESTORED: generateNameHtml function *** function generateNameHtml(text, color, fontSize) {     const emojiRegex = /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/g;     // Split by emoji AND whitespace     const chunks = text.split(/(\s+|(?:\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]))/).filter(Boolean);     let html = '';     for (const chunk of chunks) {         if (chunk.match(emojiRegex)) {             // It's an emoji             html += `${escapeHtml(chunk)}`;                 // *** FIX: Check for whitespace chunk ***         } else if (chunk.match(/^\s+$/)) {             // It's whitespace. Render it as a span.             // We use 'style="white-space: pre;"' to ensure it's rendered exactly as-is.             html += `${escapeHtml(chunk)}`;         } else {             // It's a text chunk             const trimmedChunk = chunk.trim(); // We can trim, as spaces are handled separately             if (trimmedChunk) {                 const chunkImageBuffer = createTextChunkImageBuffer(trimmedChunk, { fontSize: fontSize, color: color });                 const chunkImageBase64 = `data:image/png;base64,${chunkImageBuffer.toString('base64')}`;                 html += ``;             }         }     }     return html; } function wrapTextSmartly(text, maxWidth, font) { // This function is no longer used by createImage but is kept for potential other uses // Note: It relies on node-canvas, which might be removed if not used elsewhere const { createCanvas } = require('canvas'); // Moved import here const canvas = createCanvas(1, 1); const ctx = canvas.getContext('2d'); ctx.font = font; const sanitizedText = text.replace(/\u200B/g, ' ').trim(); if (!sanitizedText) return ''; const words = sanitizedText.split(/\s+/); let line = ''; let result = ''; for (let n = 0; n < words.length; n++) { const word = words[n]; const testLine = (line ? line + ' ' : '') + word; const metrics = ctx.measureText(testLine); const testWidth = metrics.width; if ((testWidth > maxWidth && line) || metrics.width > maxWidth) { result += line.trim() + '\n'; line = word + ' '; } else { line = testLine + ' '; } } result += line.trim(); return result; } /** * Highlights links, mentions, and commands by wrapping them in tags, * and escapes all other HTML characters in the plain text segments. * Newline characters (\n) are converted to
tags. * @param {string} wrappedText Text that *has not* been pre-wrapped. CSS will handle wrapping. * @returns {string} HTML string ready for direct insertion into the DOM. */ function highlightTextPatterns(wrappedText) { // Regex: Matches links (http/www), @mentions, or /commands const patternRegex = /(https?:\/\/[^\s]+|www\.[^\s]+|@\w+|\/\w+)/g; // Split the text by the highlighted patterns. This interleaves the patterns and the plain text. const parts = wrappedText.split(patternRegex).filter(p => p !== undefined && p !== null && p !== ''); let outputHtml = ''; const highlightColor = '#6ab8ed'; // Light blue for links/mentions/commands for (const part of parts) { // *** FIX: Corrected regex from @w+ to @\w+ *** if (part.match(/^(https?:\/\/[^\s]+|www\.[^\s]+|@\w+|(\/)\w+)$/)) { // It's a highlighted match. // 1. Escape the content in case the link/mention/command itself contains '<' or '&'. const escapedContent = escapeHtml(part); // 2. Wrap it in a span with the required style. outputHtml += `${escapedContent}`; } else { // It's plain text. // 1. Escape all HTML characters. const escapedText = escapeHtml(part); // 2. Convert any *intentional* \n line breaks to
. // The browser will handle the wrapping line breaks. outputHtml += escapedText.replace(/\n/g, '
'); } } return outputHtml; } // --- MAIN PUPPETEER FUNCTION --- async function createImage(firstName, lastName, customemojiid, message, nameColorId, inputImageBuffer, replySender, replyMessage,replysendercolor) { const scale = 4; const username = `${firstName} ${lastName}`.trim().replace(/\u200B/g, ''); const nameColor = getTelegramDarkThemeColor(nameColorId); // --- UNIVERSAL FONT SIZING --- // All text elements will use this single size. const UNIVERSAL_FONT_SIZE = 26 * scale; // Set all font sizes to the universal size let messageFontSize = UNIVERSAL_FONT_SIZE; let nameImageFontSize = UNIVERSAL_FONT_SIZE; let replySenderFontSize = UNIVERSAL_FONT_SIZE; let replyMessageFontSize = UNIVERSAL_FONT_SIZE; // Emoji size is relative to the text size let nameEmojiFontSize = nameImageFontSize; // Standardized layout values based on the universal font size let nameLineHeight = 34 * scale; let nameMarginBottom = 12 * scale; let messageLineHeight = 1.4; const replyLineHeight = 1.3; const replyMarginBottom = 10 * scale; // --- END UNIVERSAL FONT SIZING --- // --- Text Highlighting & Max Width --- const DEFAULT_MESSAGE_MAX_WIDTH = 650 * scale; // Max width for the MESSAGE text to wrap const BUBBLE_MIN_WIDTH = 250 * scale; // Minimum width for the bubble const REPLY_MESSAGE_MAX_LENGTH = 50; // Max characters for the reply message // Pass the *original* message directly to the highlighter. // CSS will handle all the wrapping. const highlightedMessageHtml = highlightTextPatterns(message); // --------------------------------------------- // *** RESTORED: nameContentHtml generation *** const nameContentHtml = generateNameHtml(username, nameColor, nameImageFontSize); // *** RESTORED: replySenderHtml generation *** let replySenderHtml = ''; const replySenderColor = getTelegramDarkThemeColor(replysendercolor); // Green color if (replySender) { replySenderHtml = generateNameHtml(replySender.replace(/\u200B/g, ''), replySenderColor, replySenderFontSize); } // *** ADDED: Slicing logic for replyMessage *** let processedReplyMessage = replyMessage; if (replySender && processedReplyMessage && processedReplyMessage.length > REPLY_MESSAGE_MAX_LENGTH) { // Slice the message but don't add ellipsis, the CSS fade-out handles it processedReplyMessage = processedReplyMessage.substring(0, REPLY_MESSAGE_MAX_LENGTH); } let avatarBuffer = inputImageBuffer ? await sharp(inputImageBuffer).png().toBuffer() : await createDummyAvatarBuffer(firstName, lastName, nameColor, scale); const avatarBase64 = `data:image/png;base64,${avatarBuffer.toString('base64')}`; const emojiStatusBuffer = customemojiid ? await getEmojiStatusBuffer(customemojiid) : null; const emojiStatusBase64 = emojiStatusBuffer ? `data:image/png;base64,${emojiStatusBuffer.toString('base64')}` : null; const htmlContent = `
${nameContentHtml} ${emojiStatusBase64 ? `` : ''}
${replySender && replyMessage ? `
${replySenderHtml}
${escapeHtml(processedReplyMessage)}
` : ''}
${highlightedMessageHtml}
`; // ---PUPPETEER LAUNCH --- const AVATAR_WIDTH = 70 * scale; const AVATAR_MARGIN_RIGHT = 15 * scale; const BUBBLE_PADDING_HORIZONTAL = (25 + 25) * scale; // Left and right padding of the bubble const BUBBLE_TAIL_WIDTH = 20 * scale; // Width of the bubble tail (the '::before' element) const BODY_PADDING_HORIZONTAL = (30 + 30) * scale; // Left and right padding of the body const ESTIMATED_MAX_NAME_WIDTH = DEFAULT_MESSAGE_MAX_WIDTH * 1.5; const VIEWPORT_WIDTH = BODY_PADDING_HORIZONTAL + AVATAR_WIDTH + AVATAR_MARGIN_RIGHT + BUBBLE_TAIL_WIDTH + Math.max(ESTIMATED_MAX_NAME_WIDTH, DEFAULT_MESSAGE_MAX_WIDTH + BUBBLE_PADDING_HORIZONTAL) + (50 * scale); // Add extra buffer const VIEWPORT_HEIGHT = 1200 * scale; // Default height, will be cropped later const browser = await puppeteer.launch({ headless: true,executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox', '--disable-gpu','--no-proxy-server'] }); const page = await browser.newPage(); await page.setViewport({ width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }); // Wait until dom is loaded (reverted from networkidle0) await page.setContent(htmlContent, { waitUntil: 'domcontentloaded' }); // *** FIX: Smart function to dynamically set message max-width *** await page.evaluate((defaultMessageWidth) => { // Measure the whole name line now that it contains images/spans const nameWidth = document.querySelector('.name-line')?.scrollWidth || 0; const replySenderElement = document.querySelector('.reply-sender'); const replyWidth = replySenderElement?.scrollWidth || 0; // Content width is the wider of the name line or reply sender const contentWidth = Math.max(nameWidth, replyWidth); // Message max-width is the wider of the content width or the default const newMaxWidth = Math.max(contentWidth, defaultMessageWidth); const messageElement = document.querySelector('.message'); if (messageElement) { messageElement.style.maxWidth = newMaxWidth + 'px'; } }, DEFAULT_MESSAGE_MAX_WIDTH); // Pass only the default width const element = await page.$('#capture'); const finalPngBuffer = await element.screenshot({ omitBackground: true }); await browser.close(); // fs.writeFileSync('temp_quote.png', finalPngBuffer); // De-comment for debugging // --- DYNAMIC SIZING WITH INVISIBLE BORDER --- const stickerWidth = 2048; const scaledPngBuffer = await sharp(finalPngBuffer) .resize({ width: stickerWidth, fit: 'inside', withoutEnlargement: true }) .toBuffer(); const scaledMetadata = await sharp(scaledPngBuffer).metadata(); const bubbleWidth = scaledMetadata.width || 0; const padding = Math.floor((stickerWidth - bubbleWidth) / 2); const webpBuffer = await sharp(scaledPngBuffer) .extend({ top: 0, bottom: 0, left: padding > 0 ? padding : 0, right: padding > 0 ? padding : 0, background: { r: 0, g: 0, b: 0, alpha: 0 } }) .webp({ quality: 90 }) .toBuffer(); return webpBuffer; } module.exports = createImage;