const path = require('path') const { QuoteGenerate } = require('../utils') const { createCanvas, loadImage } = require('canvas') const sharp = require('sharp') const { parseBackgroundColor, colorLuminance, lightOrDark, hexToHsl, hslToHex } = require('../utils/quote-generate/color') const { brands: emojiBrands } = require('../utils/emoji-image') const ALLOWED_EMOJI_BRANDS = new Set(Object.keys(emojiBrands)) let cachedPatternImage = null async function getPatternImage () { if (!cachedPatternImage) { cachedPatternImage = await loadImage(path.join(__dirname, '..', 'assets', 'pattern_02.png')) } return cachedPatternImage } const imageAlpha = (image, alpha) => { const canvas = createCanvas(image.width, image.height) const canvasCtx = canvas.getContext('2d') canvasCtx.globalAlpha = alpha canvasCtx.drawImage(image, 0, 0) return canvas } function normalizeMessage (message) { if (!message.from) { message.from = { id: 0 } } if (!message.from.photo) { message.from.photo = {} } if (message.from.name !== false && !message.from.name && (message.from.first_name || message.from.last_name)) { message.from.name = [message.from.first_name, message.from.last_name] .filter(Boolean) .join(' ') } if (message.replyMessage) { if (!message.replyMessage.chatId) { message.replyMessage.chatId = message.from.id || 0 } if (!message.replyMessage.entities) { message.replyMessage.entities = [] } if (!message.replyMessage.from) { message.replyMessage.from = { name: message.replyMessage.name, photo: {} } } else if (!message.replyMessage.from.photo) { message.replyMessage.from.photo = {} } } } async function drawPatternBackground (canvas, centerColor, edgeColor, patternImage, patternAlpha) { const ctx = canvas.getContext('2d') const gradient = ctx.createRadialGradient( canvas.width / 2, canvas.height / 2, 0, canvas.width / 2, canvas.height / 2, canvas.width / 2 ) gradient.addColorStop(0, centerColor) gradient.addColorStop(1, edgeColor) ctx.fillStyle = gradient ctx.fillRect(0, 0, canvas.width, canvas.height) const pattern = ctx.createPattern(imageAlpha(patternImage, patternAlpha), 'repeat') ctx.fillStyle = pattern ctx.fillRect(0, 0, canvas.width, canvas.height) } // Wallpaper colors derived from the bubble color. The bubble must sit ON the // wallpaper, not dissolve into it: // • dark bubbles → a much darker backdrop (luminance drop + vignette); // • light bubbles → a PASTEL backdrop, like Telegram's light wallpapers: // saturate the hue and keep lightness high — darkening a near-white just // makes mud. Near-gray inputs fall back to a soft Telegram-ish blue. function wallpaperColors (colorOne) { if (lightOrDark(colorOne) === 'dark') { return { center: colorLuminance(colorOne, -0.35), edge: colorLuminance(colorOne, -0.6), patternAlpha: 0.22 } } let [h, s] = hexToHsl(colorOne) if (s < 0.08) { h = 207 // washed-out gray/white → soft blue s = 0.45 } else { s = Math.min(1, Math.max(s * 1.8, 0.35)) } return { center: hslToHex(h, s, 0.8), edge: hslToHex(h, s, 0.62), patternAlpha: 0.18 } } module.exports = async (parm) => { if (!parm) return { error: 'query_empty' } if (!Array.isArray(parm.messages) || parm.messages.length < 1) return { error: 'messages_empty' } const botToken = parm.botToken || process.env.BOT_TOKEN const quoteGenerate = new QuoteGenerate(botToken) const rawScale = parseFloat(parm.scale) || 2 const scale = Math.min(20, Math.max(1, Number.isFinite(rawScale) ? rawScale : 2)) const rawBrand = parm.emojiBrand || 'apple' const emojiBrand = ALLOWED_EMOJI_BRANDS.has(rawBrand) ? rawBrand : 'apple' const background = parseBackgroundColor(parm.backgroundColor) // Normalize all messages first (sync, no I/O) const validMessages = parm.messages.filter(Boolean) for (const message of validMessages) { normalizeMessage(message) } // Same-sender runs render with grouped corners (small radii between // neighbours), like consecutive messages in Telegram. The avatar (and with // it the bubble tail) belongs to the LAST message of a group only — the // reserved left column keeps the other bubbles aligned. for (let i = 0; i < validMessages.length; i++) { const prevSame = i > 0 && validMessages[i - 1].chatId === validMessages[i].chatId const nextSame = i < validMessages.length - 1 && validMessages[i + 1].chatId === validMessages[i].chatId validMessages[i].groupPos = prevSame && nextSame ? 'middle' : prevSame ? 'last' : nextSame ? 'first' : 'single' if (nextSame) validMessages[i].avatar = false } // Generate quotes with concurrency limit to avoid Telegram API rate limits const CONCURRENCY = 3 const quoteImages = new Array(validMessages.length).fill(null) let running = 0 let nextIndex = 0 await new Promise((resolve) => { function runNext () { while (running < CONCURRENCY && nextIndex < validMessages.length) { const index = nextIndex++ running++ quoteGenerate.generate( background.colorOne, background.colorTwo, validMessages[index], parm.width, parm.height, scale, emojiBrand ).then((canvas) => { if (canvas) quoteImages[index] = canvas else console.warn('Failed to generate quote for message, skipping') }).catch((error) => { console.error('Error generating quote for message:', error.message) }).finally(() => { running-- if (nextIndex >= validMessages.length && running === 0) resolve() else runNext() }) } if (validMessages.length === 0) resolve() } runNext() }) // Filter nulls (failed messages) while preserving order, keeping each // image paired with its source message (for grouped-margin decisions). const pairs = validMessages .map((message, i) => ({ message, image: quoteImages[i] })) .filter((p) => p.image) const filteredImages = pairs.map((p) => p.image) if (filteredImages.length === 0) { return { error: 'empty_messages' } } let canvasQuote if (filteredImages.length > 1) { let width = 0 let height = 0 for (let index = 0; index < filteredImages.length; index++) { if (filteredImages[index].width > width) width = filteredImages[index].width height += filteredImages[index].height } // Tighter spacing inside a same-sender group, roomier between groups. const margins = [] let totalMargin = 0 for (let index = 0; index < pairs.length - 1; index++) { const grouped = pairs[index].message.chatId === pairs[index + 1].message.chatId const m = (grouped ? 2 : 6) * scale margins.push(m) totalMargin += m } const canvas = createCanvas(width, height + totalMargin) const canvasCtx = canvas.getContext('2d') let imageY = 0 for (let index = 0; index < filteredImages.length; index++) { canvasCtx.drawImage(filteredImages[index], 0, imageY) imageY += filteredImages[index].height + (margins[index] || 0) } canvasQuote = canvas } else { canvasQuote = filteredImages[0] } let quoteImage let { type, format, ext } = parm if (!type && ext) type = 'png' if (type !== 'image' && type !== 'stories' && canvasQuote.height > 1024 * 2) type = 'png' if (type === 'quote') { const downPadding = 75 const maxWidth = 512 const maxHeight = 512 const imageQuoteSharp = sharp(canvasQuote.toBuffer()) if (canvasQuote.height > canvasQuote.width) imageQuoteSharp.resize({ height: maxHeight }) else imageQuoteSharp.resize({ width: maxWidth }) const canvasImage = await loadImage(await imageQuoteSharp.toBuffer()) const canvasPadding = createCanvas(canvasImage.width, canvasImage.height + downPadding) const canvasPaddingCtx = canvasPadding.getContext('2d') canvasPaddingCtx.drawImage(canvasImage, 0, 0) const imageSharp = sharp(canvasPadding.toBuffer()) if (canvasPadding.height >= canvasPadding.width) imageSharp.resize({ height: maxHeight }) else imageSharp.resize({ width: maxWidth }) if (format === 'png') quoteImage = await imageSharp.png().toBuffer() else quoteImage = await imageSharp.webp({ lossless: true, force: true }).toBuffer() } else if (type === 'image') { const heightPadding = 75 * scale const widthPadding = 95 * scale // Draw canvas-to-canvas directly — no need for toBuffer() -> loadImage() round-trip const canvasPic = createCanvas(canvasQuote.width + widthPadding, canvasQuote.height + heightPadding) const canvasPicCtx = canvasPic.getContext('2d') const patternImage = await getPatternImage() const wp = wallpaperColors(background.colorOne) await drawPatternBackground(canvasPic, wp.center, wp.edge, patternImage, wp.patternAlpha) canvasPicCtx.shadowOffsetX = 8 canvasPicCtx.shadowOffsetY = 8 canvasPicCtx.shadowBlur = 13 canvasPicCtx.shadowColor = 'rgba(0, 0, 0, 0.5)' canvasPicCtx.drawImage(canvasQuote, widthPadding / 2, heightPadding / 2) canvasPicCtx.shadowOffsetX = 0 canvasPicCtx.shadowOffsetY = 0 canvasPicCtx.shadowBlur = 0 canvasPicCtx.shadowColor = 'rgba(0, 0, 0, 0)' canvasPicCtx.fillStyle = 'rgba(0, 0, 0, 0.3)' canvasPicCtx.font = `${8 * scale}px Noto Sans` canvasPicCtx.textAlign = 'right' canvasPicCtx.fillText('@QuotLyBot', canvasPic.width - 25, canvasPic.height - 25) quoteImage = await sharp(canvasPic.toBuffer()).png({ lossless: true, force: true }).toBuffer() } else if (type === 'stories') { const canvasPic = createCanvas(720, 1280) const canvasPicCtx = canvasPic.getContext('2d') const patternImage = await getPatternImage() const storyWp = wallpaperColors(background.colorOne) await drawPatternBackground(canvasPic, storyWp.center, storyWp.edge, patternImage, storyWp.patternAlpha) canvasPicCtx.shadowOffsetX = 8 canvasPicCtx.shadowOffsetY = 8 canvasPicCtx.shadowBlur = 13 canvasPicCtx.shadowColor = 'rgba(0, 0, 0, 0.5)' const minPadding = 110 const maxW = canvasPic.width - minPadding * 2 const maxH = canvasPic.height - minPadding * 2 // Use canvas dimensions directly to decide if resize is needed — avoid toBuffer() -> loadImage() let drawSource = canvasQuote if (canvasQuote.width > maxW || canvasQuote.height > maxH) { const resizedBuffer = await sharp(canvasQuote.toBuffer()).resize({ width: maxW, height: maxH, fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).toBuffer() drawSource = await loadImage(resizedBuffer) } const imageX = (canvasPic.width - drawSource.width) / 2 const imageY = (canvasPic.height - drawSource.height) / 2 canvasPicCtx.drawImage(drawSource, imageX, imageY) canvasPicCtx.shadowOffsetX = 0 canvasPicCtx.shadowOffsetY = 0 canvasPicCtx.shadowBlur = 0 canvasPicCtx.fillStyle = 'rgba(0, 0, 0, 0.4)' canvasPicCtx.font = `${16 * scale}px Noto Sans` canvasPicCtx.textAlign = 'center' canvasPicCtx.translate(70, canvasPic.height / 2) canvasPicCtx.rotate(-Math.PI / 2) canvasPicCtx.fillText('@QuotLyBot', 0, 0) quoteImage = await sharp(canvasPic.toBuffer()).png({ lossless: true, force: true }).toBuffer() } else { quoteImage = canvasQuote.toBuffer() } // Use sharp metadata only when we went through sharp pipeline, otherwise use canvas dimensions let width, height if (type === 'quote' || type === 'image' || type === 'stories') { const imageMetadata = await sharp(quoteImage).metadata() width = imageMetadata.width height = imageMetadata.height } else { width = canvasQuote.width height = canvasQuote.height } let image if (ext) image = quoteImage else image = quoteImage.toString('base64') return { image, type, width, height, ext } }