diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/Dockerfile b/Dockerfile index 87595e8..c35c53c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,9 @@ -FROM nikolaik/python-nodejs:python3.8-nodejs12 AS builder +FROM mcr.microsoft.com/playwright:v1.35.0-jammy -ENV NODE_WORKDIR /app -WORKDIR $NODE_WORKDIR +WORKDIR /app +COPY . /app -ADD . $NODE_WORKDIR +RUN apt-get update && apt-get install -y build-essential libvips libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev && rm -rf /var/lib/apt/lists/* -RUN apt-get update && apt-get install -y build-essential gcc wget git libvips && rm -rf /var/lib/apt/lists/* - - -RUN npm install canvas@2.6.1 && npm install # TODO: canvas crashes if installed via npm install from package.json \ No newline at end of file +RUN npm install && npx playwright install +ENTRYPOINT [ "npm", "start" ] diff --git a/app.js b/app.js index 1910928..f0a5ced 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,10 @@ +const path = require('path') const logger = require('koa-logger') const responseTime = require('koa-response-time') const bodyParser = require('koa-bodyparser') const ratelimit = require('koa-ratelimit') +const serve = require('koa-static') +const mount = require('koa-mount') const Router = require('koa-router') const Koa = require('koa') @@ -10,12 +13,14 @@ const app = new Koa() app.use(logger()) app.use(responseTime()) app.use(bodyParser()) +app.use(mount('/assets', serve(path.resolve(__dirname, 'assets'), { maxAge: 1000 * 3600 * 4, immutable: true }))) +app.use(mount('/cache', serve(path.resolve(__dirname, 'cache'), { maxAge: 1000 * 3600 * 4, immutable: true }))) -const ratelimitВb = new Map() +const ratelimitDb = new Map() app.use(ratelimit({ driver: 'memory', - db: ratelimitВb, + db: ratelimitDb, duration: 1000 * 55, errorMessage: { ok: false, diff --git a/assets/pattern_02_alpha.png b/assets/pattern_02_alpha.png new file mode 100644 index 0000000..f726684 Binary files /dev/null and b/assets/pattern_02_alpha.png differ diff --git a/assets/reset.min.css b/assets/reset.min.css new file mode 100644 index 0000000..a4b442c --- /dev/null +++ b/assets/reset.min.css @@ -0,0 +1 @@ +/* v1.1 | https://github.com/arebaka/reset.css | License: MIT*/*,:before,:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-ms-box-sizing:border-box;-o-box-sizing:border-box;box-sizing:border-box;margin:0;padding:0;font:100% inherit;vertical-align:baseline;background-repeat:no-repeat;border:none;text-shadow:none;box-shadow:none}:hover,:focus,:active{outline:none}::-moz-selection,::selection{text-shadow:none}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section{display:block}[hidden]{display:none}:root{-webkit-tab-size:4;-moz-tab-size:4;-ms-tab-size:4;-o-tab-size:4;-webkit-tap-highlight-color:transparent;-moz-tap-highlight-color:transparent;-ms-tap-highlight-color:transparent;-o-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;-ms-text-size-adjust:100%;-o-text-size-adjust:100%;tab-size:4;line-height:1.5;text-size-adjust:100%;overflow-wrap:break-word;cursor:default}body{scroll-behavior:smooth;min-height:100vh;color:#000;background-color:#fff}ol,ul{list-style:none}ol{list-style-type:decimal}ul{list-style-type:disc}nav ol,nav ul{list-style-type:none}li{display:list-item}menu{list-style-type:disc}blockquote,q{quotes:none}cite{quotes:none;font-style:italic}blockquote:before,blockquote:after,q:before,q:after,cite:before,cite:after{content:"";content:none}table{text-indent:0;border-collapse:collapse;border-spacing:0;border-color:currentColor}thead{display:table-header-group;vertical-align:middle;border-color:inherit}tbody{display:table-row-group;vertical-align:middle;border-color:inherit}tfoot{display:table-footer-group;vertical-align:middle;border-color:inherit}col{display:table-column}colgroup{display:table-column-group}tr{display:table-row;vertical-align:inherit;border-color:inherit}th{display:table-cell;vertical-align:inherit;font-weight:700;text-align:center}td{display:table-cell;vertical-align:inherit}label,legend{font:inherit;white-space:normal;color:inherit;cursor:default}legend{display:table}input,textarea,select,button{display:inline-block;max-width:100%;height:auto;font:inherit;line-height:normal;letter-spacing:inherit;color:inherit;background-color:transparent;border:1px solid;border-radius:0}select,button{text-transform:none}optgroup{font:inherit;line-height:normal;text-transform:none}button,input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;-moz-appearance:button;-ms-appearance:button;-o-appearance:button;overflow:visible;cursor:pointer;appearance:button}button:disabled,input:disabled{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}button:-moz-focusring,input:-moz-focusring{outline:none}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;-ms-box-sizing:content-box;-o-box-sizing:content-box;-webkit-appearance:none;-moz-appearance:none;-moz-appearance:none;-o-appearance:none;box-sizing:content-box;appearance:none;outline:none}::-webkit-search-cancel-button,::-webkit-search-decoration,::-webkit-search-results-button,::-webkit-search-results-decoration{-webkit-appearance:none;-moz-appearance:none;-moz-appearance:none;-o-appearance:none;appearance:none}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}textarea{overflow:auto;resize:vertical}::-webkit-file-upload-button{-webkit-appearance:button;-moz-appearance:button;-moz-appearance:button;-o-appearance:button;font:inherit;appearance:button}img,audio,video,canvas,svg,iframe{max-width:100%;height:auto;vertical-align:middle;border:none}img,audio,video,canvas,svg{-ms-interpolation-mode:bicubic;display:inline-block}iframe{display:block}address{font-style:italic}abbr[title]{text-decoration:underline dotted;border-bottom:0}caption{display:table-caption;text-align:center}fieldset{border:none}summary{display:list-item}template{display:none}a{text-decoration:underline;color:blue;background-color:transparent;cursor:pointer}b,strong{font-weight:700}dfn,em,i,var{font-style:italic}code,kbd,samp,tt{font:1em monospace}pre{overflow:auto;display:block;font:1em monospace;white-space:pre}del,s,strike{text-decoration:line-through}ins{text-decoration:underline}mark{color:#000;background-color:#ff0}sup{font-size:smaller;vertical-align:super}sub{font-size:smaller;vertical-align:sub}big{font-size:larger}small{font-size:smaller}center{text-align:center}h1,h2,h3,h4,h5,h6{font-weight:700;text-align:center}h1{font-size:2em;line-height:3.34em}h2{font-size:1.5em;line-height:3.16em}h3{font-size:1.17em;line-height:3.17em}h4{font-size:1em;line-height:3.66em}h5{font-size:.83em;line-height:4.17em}h6{font-size:.67em;line-height:5.33em}hr{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;-ms-box-sizing:content-box;-o-box-sizing:content-box;box-sizing:content-box;display:block;margin:.5em auto;height:0;border:1px inset}@media (prefers-reduced-motion: reduce){*,:before,:after{animation-delay:-1ms!important;animation-duration:1ms!important;animation-iteration-count:1!important;background-attachment:initial!important;scroll-behavior:auto!important;transition-delay:0!important;transition-duration:0!important}} diff --git a/docker-compose.yml b/docker-compose.yml index 73b6d28..e992be2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,7 @@ version: '3' services: api: - build: - context: . + build: . env_file: .env restart: always logging: @@ -13,11 +12,10 @@ services: max-file: "3" networks: - quotly - command: node index.js ports: - - 127.0.0.1:4888:4888 + - ${PORT}:${PORT} networks: quotly: - external: true \ No newline at end of file + external: true diff --git a/methods/generate.js b/methods/generate.js index bd27063..6626254 100644 --- a/methods/generate.js +++ b/methods/generate.js @@ -1,238 +1,227 @@ -const { - QuoteGenerate -} = require('../utils') -const { createCanvas, loadImage } = require('canvas') +const path = require('path') +const fs = require('fs') +const { createCanvas } = require('canvas') const sharp = require('sharp') +const runes = require('runes') +const axios = require('axios') +const lottie = require('lottie-node') +const zlib = require('zlib') -const normalizeColor = (color) => { - const canvas = createCanvas(0, 0) - const canvasCtx = canvas.getContext('2d') - - canvasCtx.fillStyle = color - color = canvasCtx.fillStyle +const { + telegram, render, compile, getAvatarURL, formatHTML, getBackground, colorLuminance, lightOrDark +} = require('../utils') - return color -} +const getEmojiStatusURL = async (emojiId) => { + const customEmojiStickers = await telegram.callApi('getCustomEmojiStickers', { + custom_emoji_ids: [emojiId] + }).catch(console.error) -const colorLuminance = (hex, lum) => { - hex = String(hex).replace(/[^0-9a-f]/gi, '') - if (hex.length < 6) { - hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2] + if (!Array.isArray(customEmojiStickers) || !customEmojiStickers.length) { + return null } - lum = lum || 0 - - // convert to decimal and change luminosity - let rgb = '#' - let c - let i - for (i = 0; i < 3; i++) { - c = parseInt(hex.substr(i * 2, 2), 16) - c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16) - rgb += ('00' + c).substr(c.length) - } - - return rgb -} - -const imageAlpha = (image, alpha) => { - const canvas = createCanvas(image.width, image.height) - - const canvasCtx = canvas.getContext('2d') - - canvasCtx.globalAlpha = alpha - canvasCtx.drawImage(image, 0, 0) + const fileId = customEmojiStickers[0].thumb.file_id + const fileURL = await telegram.getFileLink(fileId).catch(console.error) - return canvas + return fileURL || null } -module.exports = async (parm) => { - // console.log(JSON.stringify(parm, null, 2)) - if (!parm) return { error: 'query_empty' } - if (!parm.messages || parm.messages.length < 1) return { error: 'messages_empty' } - - let botToken = parm.botToken || process.env.BOT_TOKEN - - const quoteGenerate = new QuoteGenerate(botToken) - - const quoteImages = [] - - let backgroundColor = parm.backgroundColor || '//#292232' - let backgroundColorOne - let backgroundColorTwo - - const backgroundColorSplit = backgroundColor.split('/') - - if (backgroundColorSplit && backgroundColorSplit.length > 1 && backgroundColorSplit[0] !== '') { - backgroundColorOne = normalizeColor(backgroundColorSplit[0]) - backgroundColorTwo = normalizeColor(backgroundColorSplit[1]) - } else if (backgroundColor.startsWith('//')) { - backgroundColor = normalizeColor(backgroundColor.replace('//', '')) - backgroundColorOne = colorLuminance(backgroundColor, 0.35) - backgroundColorTwo = colorLuminance(backgroundColor, -0.15) - } else { - backgroundColor = normalizeColor(backgroundColor) - backgroundColorOne = backgroundColor - backgroundColorTwo = backgroundColor - } - - for (const key in parm.messages) { - const message = parm.messages[key] - - if (message) { - const canvasQuote = await quoteGenerate.generate( - backgroundColorOne, - backgroundColorTwo, - message, - parm.width, - parm.height, - parseFloat(parm.scale), - parm.emojiBrand - ) - - quoteImages.push(canvasQuote) +const buildUser = async (user, theme, options={ getAvatar: false }) => { + const index = user.id ? Math.abs(user.id) % 7 : 1 + const color = userColors[theme][index] + const emojiStatus = user.emoji_status ? await getEmojiStatusURL(user.emoji_status) : null + + let photo = null + if (options.getAvatar) { + photo = user.photo || null + if (!photo || !photo.url) { + const photoURL = await getAvatarURL(user) + photo = photoURL ? { url: photoURL } : photo } } - if (quoteImages.length === 0) { - return { - error: 'empty_messages' - } - } + let name, initials - let canvasQuote - - if (quoteImages.length > 1) { - let width = 0 - let height = 0 - - for (let index = 0; index < quoteImages.length; index++) { - if (quoteImages[index].width > width) width = quoteImages[index].width - height += quoteImages[index].height + if (user.first_name && user.last_name) { + name = user.first_name + ' ' + user.last_name + initials = runes(user.first_name)[0] + runes(user.last_name)[0] + } + else { + name = user.name || user.first_name || user.title + + if (typeof name == 'string') { + const nameWords = name.split(' ') + initials = runes(nameWords[0])[0] + if (nameWords.length > 1) { + initials += runes(nameWords.pop()[0])[0] + } } - - const quoteMargin = 5 * parm.scale - - const canvas = createCanvas(width, height + (quoteMargin * quoteImages.length)) - const canvasCtx = canvas.getContext('2d') - - let imageY = 0 - - for (let index = 0; index < quoteImages.length; index++) { - canvasCtx.drawImage(quoteImages[index], 0, imageY) - imageY += quoteImages[index].height + quoteMargin + else { + name = '' + initials = '' } - canvasQuote = canvas - } else { - canvasQuote = quoteImages[0] } - let quoteImage - - let { type, format, ext } = parm - - if (!type && ext) type = 'png' - if (type !== 'image' && canvasQuote.height > 1024 * 2) type = 'png' - - if (type === 'quote') { - const downPadding = 75 - const maxWidth = 512 - const maxHeight = 512 - - const imageQuoteSharp = sharp(canvasQuote.toBuffer()) + return { name, initials, color, photo, emojiStatus } +} - if (canvasQuote.height > canvasQuote.width) imageQuoteSharp.resize({ height: maxHeight }) - else imageQuoteSharp.resize({ width: maxWidth }) +const buildReplyMessage = async (message, theme) => { + if (!Object.keys(message).length) { + return null + } - const canvasImage = await loadImage(await imageQuoteSharp.toBuffer()) + // kostyl + const from = message.from || { + id: message.chatId, + name: message.name, + emoji_status: null, + photo: null + } - const canvasPadding = createCanvas(canvasImage.width, canvasImage.height + downPadding) - const canvasPaddingCtx = canvasPadding.getContext('2d') + return { + from: await buildUser(from, theme), + text: message.text + } +} - canvasPaddingCtx.drawImage(canvasImage, 0, 0) +const buildMedia = async (media, type) => { + let url = media.url + if (!url) { + const mediaInfo = Array.isArray(media) ? media.pop() : media + url = await telegram.getFileLink(mediaInfo).catch(console.error) + } - const imageSharp = sharp(canvasPadding.toBuffer()) + if (!type) { + type = url.endsWith('.webp') || url.endsWith('.tgs') ? 'sticker' : 'image' + } - if (canvasPadding.height >= canvasPadding.width) imageSharp.resize({ height: maxHeight }) - else imageSharp.resize({ width: maxWidth }) + if (url.endsWith('.tgs')) { + const tgsCompressed = await axios + .get(url, { responseType: 'arraybuffer' }) + .then(res => Buffer.from(res.data)) + .catch(console.error) + const tgs = await new Promise((resolve, reject) => zlib.gunzip(tgsCompressed, (error, result) => { + if (error) { + return reject(error) + } + resolve(JSON.parse(result.toString())) + })) + + const canvas = createCanvas(512, 512) + const animation = lottie(tgs, canvas) + const middleFrame = Math.floor(animation.getDuration(true) / 2) + + animation.goToAndStop(middleFrame, true) + const filename = url.split('/').pop() + fs.writeFileSync(path.resolve(__dirname, `../cache/${filename}.png`), canvas.toBuffer()) + + url = `http://localhost:${process.env.PORT}/cache/${filename}.png` + } - 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 * parm.scale - const widthPadding = 95 * parm.scale + return { url, type } +} - const canvasImage = await loadImage(canvasQuote.toBuffer()) +const buildMessage = async (message, theme) => { + const from = await buildUser(message.from, theme, { getAvatar: message.avatar || false }) + const replyMessage = message.replyMessage ? await buildReplyMessage(message.replyMessage, theme) : null + const media = message.media ? await buildMedia(message.media, message.mediaType) : null - const canvasPic = createCanvas(canvasImage.width + widthPadding, canvasImage.height + heightPadding) - const canvasPicCtx = canvasPic.getContext('2d') + let text = message.text ?? '' + if (Array.isArray(message.entities)) { + text = await formatHTML(text, message.entities) + } + text = text.replace(/\n/g, '
') - // radial gradient background (top left) - const gradient = canvasPicCtx.createRadialGradient( - canvasPic.width / 2, - canvasPic.height / 2, - 0, - canvasPic.width / 2, - canvasPic.height / 2, - canvasPic.width / 2 - ) + const type = media ? media.type : 'regular' - const patternColorOne = colorLuminance(backgroundColorTwo, 0.15) - const patternColorTwo = colorLuminance(backgroundColorOne, 0.15) + return { + type, from, replyMessage, text, media, + showAvatar: message.avatar + } +} - gradient.addColorStop(0, patternColorOne) - gradient.addColorStop(1, patternColorTwo) +const userColors = { + light: ['#FC5C51', '#FA790F', '#895DD5', '#0FB297', '#0FC9D6', '#3CA5EC', '#D54FAF'], + dark: ['#FF8E86', '#FFA357', '#B18FFF', '#4DD6BF', '#45E8D1', '#7AC9FF', '#FF7FD5'] +} +const bgImageURL = `http://localhost:${process.env.PORT}/assets/pattern_02_alpha.png` +const MAX_SCALE = 20 +const DEFAULT_BG_COLOR = '//#292232' +const MAX_QUOTE_WIDTH = 512 +const MAX_QUOTE_HEIGHT = 512 - canvasPicCtx.fillStyle = gradient - canvasPicCtx.fillRect(0, 0, canvasPic.width, canvasPic.height) +// kostyl +const htmlWrapper = fs.readFileSync(path.resolve(__dirname, '../pages/html.html'), { encoding: 'utf-8' }) - const canvasPatternImage = await loadImage('./assets/pattern_02.png') - // const canvasPatternImage = await loadImage('./assets/pattern_ny.png'); +module.exports = async (parm) => { + if (!parm || typeof parm != 'object') { + return { error: 'query_empty' } + } - const pattern = canvasPicCtx.createPattern(imageAlpha(canvasPatternImage, 0.3), 'repeat') + let type = parm.type || 'png' + const format = parm.format || '' + const ext = parm.ext || false + const scale = parm.scale ? Math.min(parseFloat(parm.scale), MAX_SCALE) : 2 - canvasPicCtx.fillStyle = pattern - canvasPicCtx.fillRect(0, 0, canvasPic.width, canvasPic.height) + const { + backgroundColor, backgroundColorOne, backgroundColorTwo + } = getBackground(parm.backgroundColor || DEFAULT_BG_COLOR) + const theme = lightOrDark(backgroundColorOne) - // Add shadow effect to the canvas image - canvasPicCtx.shadowOffsetX = 8 - canvasPicCtx.shadowOffsetY = 8 - canvasPicCtx.shadowBlur = 13 - canvasPicCtx.shadowColor = 'rgba(0, 0, 0, 0.5)' + const messages = await Promise.all(parm.messages + .filter(message => message) + .map(message => buildMessage(message, theme)) + ) - // Draw the image to the canvas with padding centered - canvasPicCtx.drawImage(canvasImage, widthPadding / 2, heightPadding / 2) + if (!messages?.length) { + return { error: 'messages_empty' } + } - canvasPicCtx.shadowOffsetX = 0 - canvasPicCtx.shadowOffsetY = 0 - canvasPicCtx.shadowBlur = 0 - canvasPicCtx.shadowColor = 'rgba(0, 0, 0, 0)' + const content = compile(type, { + scale, + width: parm.width, + height: parm.height, + theme, + background: { + image: { url: bgImageURL }, + color1: colorLuminance(backgroundColorOne, 0.15), + color2: colorLuminance(backgroundColorTwo, 0.15) + }, + messages + }) + + // kostyl + if (type == 'html') { + const result = htmlWrapper + .replace('{{> content}}', content) + .replace('{{> CSSresetURL}}', `http://localhost:${process.env.PORT}/assets/reset.min.css`) + return { + image: ext ? content : Buffer.from(result).toString('base64'), + width: parm.width, + height: parm.height, + type, ext + } + } - // write text button right - canvasPicCtx.fillStyle = `rgba(0, 0, 0, 0.3)` - canvasPicCtx.font = `${8 * parm.scale}px Noto Sans` - canvasPicCtx.textAlign = 'right' - canvasPicCtx.fillText('@QuotLyBot', canvasPic.width - 25, canvasPic.height - 25) + let image = await render(type, content) + const imageSharp = await sharp(image) + const { width, height } = await imageSharp.metadata() - quoteImage = await sharp(canvasPic.toBuffer()).png({ lossless: true, force: true }).toBuffer() - } else { - quoteImage = canvasQuote.toBuffer() + // if height is more than 2 width, return png instead quote + if (type == 'quote' && height > width * 2) { + type = 'png' } - const imageMetadata = await sharp(quoteImage).metadata() - - const width = imageMetadata.width - const height = imageMetadata.height + if (type == 'quote') { + imageSharp.resize(height > width ? { height: MAX_QUOTE_HEIGHT } : { width: MAX_QUOTE_WIDTH }) - let image - if (ext) image = quoteImage - else image = quoteImage.toString('base64') + image = format == 'png' ? + await imageSharp.png().toBuffer() : + await imageSharp.webp({ lossless: true, force: true }).toBuffer() + } return { - image, - type, - width, - height, - ext + image: ext ? image : image.toString('base64'), + type, width, height, ext } } diff --git a/package-lock.json b/package-lock.json index fb0b5cf..7163e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,41 +1,48 @@ { "name": "quote-api", - "version": "0.13.2", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "quote-api", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "dependencies": { "canvas": "2.11.0", "dotenv": "^7.0.0", "emoji-db": "^14.0.1", + "handlebars": "^4.7.7", "jimp": "^0.16.1", "jsdom": "^16.5.3", "koa": "^2.11.0", "koa-bodyparser": "^4.2.1", "koa-logger": "^3.2.1", + "koa-mount": "^4.0.0", "koa-ratelimit": "^4.2.1", "koa-response-time": "^2.1.0", "koa-router": "^7.4.0", + "koa-static": "^5.0.0", "lottie-node": "^2.0.0", "lottie-web": "^5.7.8", "lru-cache": "^5.1.1", "object-sizeof": "^1.6.0", + "playwright": "^1.34.3", "runes": "^0.4.3", "sharp": "^0.31.3", "smartcrop-sharp": "^2.0.7", "telegraf": "^3.38.0" }, "devDependencies": { + "async": "^3.2.4", + "axios": "^1.4.0", "eslint": "^8.6.0", "eslint-config-standard": "^12.0.0", "eslint-plugin-import": "^2.17.3", "eslint-plugin-node": "^9.1.0", "eslint-plugin-promise": "^4.1.1", - "eslint-plugin-standard": "^4.0.0" + "eslint-plugin-standard": "^4.0.0", + "lorem-ipsum": "^2.0.8" } }, "node_modules/@babel/runtime": { @@ -734,11 +741,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", + "dev": true + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, + "node_modules/axios": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -980,6 +1018,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1075,9 +1122,9 @@ } }, "node_modules/debug": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", - "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dependencies": { "ms": "2.1.2" }, @@ -1225,7 +1272,8 @@ "node_modules/emoji-db": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/emoji-db/-/emoji-db-14.0.1.tgz", - "integrity": "sha512-MFiCUr4DsehfCRPAS845AJR4RsekQkh4bUpfag7de3H7FlDHAEbL8Oq03bPzhl4W6rJgrGFhRkcll101ZpQjjg==" + "integrity": "sha512-MFiCUr4DsehfCRPAS845AJR4RsekQkh4bUpfag7de3H7FlDHAEbL8Oq03bPzhl4W6rJgrGFhRkcll101ZpQjjg==", + "dev": true }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -1959,6 +2007,26 @@ "integrity": "sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", @@ -2129,6 +2197,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -2422,9 +2510,9 @@ } }, "node_modules/is-core-module": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.0.tgz", - "integrity": "sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz", + "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==", "dev": true, "dependencies": { "has": "^1.0.3" @@ -2802,6 +2890,18 @@ "node": ">= 7.6.0" } }, + "node_modules/koa-mount": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/koa-mount/-/koa-mount-4.0.0.tgz", + "integrity": "sha512-rm71jaA/P+6HeCpoRhmCv8KVBIi0tfGuO/dMKicbQnQW/YJntJ6MnnspkodoA4QstMVEZArsCphmd0bJEtoMjQ==", + "dependencies": { + "debug": "^4.0.1", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 7.6.0" + } + }, "node_modules/koa-ratelimit": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/koa-ratelimit/-/koa-ratelimit-4.3.0.tgz", @@ -2853,6 +2953,39 @@ "any-promise": "^1.1.0" } }, + "node_modules/koa-send": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", + "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==", + "dependencies": { + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "resolve-path": "^1.4.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/koa-static": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/koa-static/-/koa-static-5.0.0.tgz", + "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", + "dependencies": { + "debug": "^3.1.0", + "koa-send": "^5.0.0" + }, + "engines": { + "node": ">= 7.6.0" + } + }, + "node_modules/koa-static/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/levn": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", @@ -2909,6 +3042,22 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lorem-ipsum": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/lorem-ipsum/-/lorem-ipsum-2.0.8.tgz", + "integrity": "sha512-5RIwHuCb979RASgCJH0VKERn9cQo/+NcAi2BMe9ddj+gp7hujl6BI+qdOG4nVsLDpwWEJwTVYXNKP6BGgbcoGA==", + "dev": true, + "dependencies": { + "commander": "^9.3.0" + }, + "bin": { + "lorem-ipsum": "dist/bin/lorem-ipsum.bin.js" + }, + "engines": { + "node": ">= 8.x", + "npm": ">= 5.x" + } + }, "node_modules/lottie-node": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lottie-node/-/lottie-node-2.0.0.tgz", @@ -3121,6 +3270,11 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, "node_modules/node-abi": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.34.0.tgz", @@ -3466,6 +3620,32 @@ "pixelmatch": "bin/pixelmatch" } }, + "node_modules/playwright": { + "version": "1.34.3", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.34.3.tgz", + "integrity": "sha512-UOOVE4ZbGfGkP1KVqWTdXOmm8Pw2pBhfbmlqKMkpiRCQjL5W+J+xRQXpgutFr0iM4pWl8g0GyyASMsqjQfFohw==", + "hasInstallScript": true, + "dependencies": { + "playwright-core": "1.34.3" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/playwright-core": { + "version": "1.34.3", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.34.3.tgz", + "integrity": "sha512-2pWd6G7OHKemc5x1r1rp8aQcpvDh7goMBZlJv6Co5vCNLVcQJdhxRL09SGaY6HcyHH9aT4tiynZabMofVasBYw==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/pngjs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", @@ -3581,6 +3761,12 @@ "node": ">=0.4.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "node_modules/psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -3686,12 +3872,12 @@ } }, "node_modules/resolve": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.21.0.tgz", - "integrity": "sha512-3wCbTpk5WJlyE4mSOtDLhqQmGFi0/TD9VPwmiolnk8U0wRgMEktqCXd3vy5buTO3tljvalNvKrjHEfrd2WpEKA==", + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", + "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", "dev": true, "dependencies": { - "is-core-module": "^2.8.0", + "is-core-module": "^2.11.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -3711,6 +3897,50 @@ "node": ">=4" } }, + "node_modules/resolve-path": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz", + "integrity": "sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==", + "dependencies": { + "http-errors": "~1.6.2", + "path-is-absolute": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-path/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + }, + "node_modules/resolve-path/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -3996,7 +4226,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -4320,6 +4549,18 @@ "resolved": "https://registry.npmjs.org/typegram/-/typegram-3.7.0.tgz", "integrity": "sha512-IafMO+GRi5H8CtWSNihuD56Bjpmj/ISbg6G8jdTkNxldrym+FOPlo/fxtaPs/LyWnS0l1Bm18MUDwOikZSKmJw==" }, + "node_modules/uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unbox-primitive": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", @@ -4492,6 +4733,11 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index ae3afb5..a5c0900 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,15 @@ { "name": "quote-api", - "version": "0.13.17", + "version": "0.14.3", "description": "", "main": "index.js", "scripts": { "start": "node index.js", "lint": "node_modules/.bin/eslint --ext js .", - "lint:fix": "node_modules/.bin/eslint --fix --ext js ." + "lint:fix": "node_modules/.bin/eslint --fix --ext js .", + "test": "node test/runtime.js", + "test:stress": "node test/stress.js 1000", + "test:async": "node test/async.js 1000 50" }, "repository": { "type": "git", @@ -22,29 +25,36 @@ "canvas": "2.11.0", "dotenv": "^7.0.0", "emoji-db": "^14.0.1", + "handlebars": "^4.7.7", "jimp": "^0.16.1", "jsdom": "^16.5.3", "koa": "^2.11.0", "koa-bodyparser": "^4.2.1", "koa-logger": "^3.2.1", + "koa-mount": "^4.0.0", "koa-ratelimit": "^4.2.1", "koa-response-time": "^2.1.0", "koa-router": "^7.4.0", + "koa-static": "^5.0.0", "lottie-node": "^2.0.0", "lottie-web": "^5.7.8", "lru-cache": "^5.1.1", "object-sizeof": "^1.6.0", + "playwright": "^1.34.3", "runes": "^0.4.3", "sharp": "^0.31.3", "smartcrop-sharp": "^2.0.7", "telegraf": "^3.38.0" }, "devDependencies": { + "async": "^3.2.4", + "axios": "^1.4.0", "eslint": "^8.6.0", "eslint-config-standard": "^12.0.0", "eslint-plugin-import": "^2.17.3", "eslint-plugin-node": "^9.1.0", "eslint-plugin-promise": "^4.1.1", - "eslint-plugin-standard": "^4.0.0" + "eslint-plugin-standard": "^4.0.0", + "lorem-ipsum": "^2.0.8" } } diff --git a/pages/html.html b/pages/html.html new file mode 100644 index 0000000..c9bf582 --- /dev/null +++ b/pages/html.html @@ -0,0 +1,146 @@ + + + + + + + + + + {{> content}} + + diff --git a/pages/image.html b/pages/image.html new file mode 100644 index 0000000..882ceaa --- /dev/null +++ b/pages/image.html @@ -0,0 +1,144 @@ + + + + + + + + + + + diff --git a/pages/png.html b/pages/png.html new file mode 100644 index 0000000..9f09372 --- /dev/null +++ b/pages/png.html @@ -0,0 +1,145 @@ + + + + + + + + + + + diff --git a/pages/quote.html b/pages/quote.html new file mode 100644 index 0000000..45b69c0 --- /dev/null +++ b/pages/quote.html @@ -0,0 +1,139 @@ + + + + + + + + + + + diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..0916753 --- /dev/null +++ b/test/.gitignore @@ -0,0 +1,3 @@ +*.png +*.webp +*.html diff --git a/test/async.js b/test/async.js new file mode 100644 index 0000000..d971d22 --- /dev/null +++ b/test/async.js @@ -0,0 +1,122 @@ +const async = require('async') +const axios = require('axios') +const path = require('path') +const fs = require('fs') +const LoremIpsum = require('lorem-ipsum').LoremIpsum + +require('dotenv').config({ path: './.env' }) +require('../app') + +const lorem = new LoremIpsum({ + sentencesPerParagraph: { + max: 4, + min: 1 + }, + wordsPerSentence: { + max: 8, + min: 2 + } +}) + +const testTemplates = [ + { + method: 'generate', + params: { + type: 'quote', + format: 'webp' + }, + filename: (index) => `quote/${index}.webp` + }, { + method: 'generate', + params: { + type: 'image', + format: 'png' + }, + filename: (index) => `image/${index}.png` + }, { + method: 'generate', + params: { + type: 'html', + format: 'html' + }, + filename: (index) => `html/${index}.html` + } +] + +const nQuotes = parseInt(process.argv[2]) +const nCallsLimit = parseInt(process.argv[3]) + +const buildMessage = (index, hasReply) => { + const showAvatar = index === 0 || Math.random() < 0.3 + const fromId = Math.floor(Math.random() * 100) + 1 + const fromName = lorem.generateWords(Math.floor(Math.random() * 2) + 1) + const photo = { url: 'https://telegra.ph/file/59952c903fdfb10b752b3.jpg' } + const paragraphs = Array.from( + { length: Math.floor(Math.random(3)) + 1 }, + (_, k) => lorem.generateParagraphs(1) + ) + const text = paragraphs.join('\n\n') + + const media = { + url: `https://via.placeholder.com/${ + Math.floor(Math.random() * 1900) + 100 + }x${ + Math.floor(Math.random() * 1900) + 100 + }` + } + + const replyMessage = hasReply ? buildMessage(index, false) : null + + return { + entities: [], + avatar: showAvatar, + from: { + id: fromId, + name: fromName, + photo: Math.random() < 0.5 ? photo : null + }, + text, + replyMessage, + media: Math.random() < 0.3 ? media : null, + mediaType: Math.random() < 0.5 ? 'sticker' : 'image' + } +} + +const queue = async.queue(async ({ json, template, i }, cb) => { + console.time(`${i}-${template.params.type}`) + await axios.post( + `http://localhost:${process.env.PORT}/${template.method}`, + { ...json, ...template.params }, + { headers: { 'Content-Type': 'application/json' } } + ).then(res => { + console.timeEnd(`${i}-${template.params.type}`) + fs.writeFile( + path.resolve(__dirname, template.filename(i)), + Buffer.from(res.data.result.image, 'base64'), + err => err && console.error(err) + ) + }).catch(console.error) +}, nCallsLimit) + +for (let i = 0; i < nQuotes; i++) { + const backgroundColor = Math.random() < 0.3 ? '#FFFFFF' : '' + const messages = Array.from( + { length: i % 5 + 1 }, + (_, k) => buildMessage(k, Math.random() < 0.3) + ) + + const json = { + botToken: process.env.BOT_TOKEN, + backgroundColor, + width: 512, + height: 768, + scale: 2, + messages + } + + for (let template of testTemplates) { + queue.push({ json, template, i }, err => { + err && console.error(err) + }) + } +} diff --git a/test/html/.gitkeep b/test/html/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/image/.gitkeep b/test/image/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/quote/.gitkeep b/test/quote/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/runtime.js b/test/runtime.js new file mode 100644 index 0000000..efbae13 --- /dev/null +++ b/test/runtime.js @@ -0,0 +1,55 @@ +const axios = require('axios') +const path = require('path') +const fs = require('fs') +const { Telegraf } = require('telegraf') + +require('dotenv').config({ path: './.env' }) +require('../app') + +const bot = new Telegraf(process.env.BOT_TOKEN) + +bot.on('message', async ctx => { + const message = JSON.parse(JSON.stringify(ctx.message)) + message.avatar = true + message.media = message.photo || message.sticker || message.animation?.thumb || null + if (message.media?.is_video) { + message.media = message.media.thumb + } + + await axios.post( + `http://localhost:${process.env.PORT}/generate`, + { + type: 'image', + format: 'png', + messages: [message] + }, + { headers: { 'Content-Type': 'application/json' } } + ).then(async res => { + fs.writeFileSync( + path.resolve(__dirname, 'response.png'), + Buffer.from(res.data.result.image, 'base64'), + err => err && console.error(err) + ) + + await ctx.replyWithPhoto({ source: path.resolve(__dirname, 'response.png') }) + }).catch(console.error) + + await axios.post( + `http://localhost:${process.env.PORT}/generate`, + { + type: 'html', + format: 'html', + messages: [message] + }, + { headers: { 'Content-Type': 'application/json' } } + ).then(async res => { + fs.writeFileSync( + path.resolve(__dirname, 'response.html'), + Buffer.from(res.data.result.image, 'base64'), + err => err && console.error(err) + ) + }).catch(console.error) +}) + +bot.catch(console.error) +bot.launch() diff --git a/test/stress.js b/test/stress.js new file mode 100644 index 0000000..80fc0aa --- /dev/null +++ b/test/stress.js @@ -0,0 +1,116 @@ +const axios = require('axios') +const path = require('path') +const fs = require('fs') +const LoremIpsum = require('lorem-ipsum').LoremIpsum + +require('dotenv').config({ path: './.env' }) +require('../app') + +const lorem = new LoremIpsum({ + sentencesPerParagraph: { + max: 4, + min: 1 + }, + wordsPerSentence: { + max: 8, + min: 2 + } +}) + +const testTemplates = [ + { + method: 'generate', + params: { + type: 'quote', + format: 'webp' + }, + filename: (index) => `quote/${index}.webp` + }, { + method: 'generate', + params: { + type: 'image', + format: 'png' + }, + filename: (index) => `image/${index}.png` + }, { + method: 'generate', + params: { + type: 'html', + format: 'html' + }, + filename: (index) => `html/${index}.html` + } +] + +const nQuotes = parseInt(process.argv[2]) + +const buildMessage = (index, hasReply) => { + const showAvatar = index === 0 || Math.random() < 0.3 + const fromId = Math.floor(Math.random() * 100) + 1 + const fromName = lorem.generateWords(Math.floor(Math.random() * 2) + 1) + const photo = { url: 'https://telegra.ph/file/59952c903fdfb10b752b3.jpg' } + const paragraphs = Array.from( + { length: Math.floor(Math.random(3)) + 1 }, + (_, k) => lorem.generateParagraphs(1) + ) + const text = paragraphs.join('\n\n') + + const media = { + url: `https://via.placeholder.com/${ + Math.floor(Math.random() * 1900) + 100 + }x${ + Math.floor(Math.random() * 1900) + 100 + }` + } + + const replyMessage = hasReply ? buildMessage(index, false) : null + + return { + entities: [], + avatar: showAvatar, + from: { + id: fromId, + name: fromName, + photo: Math.random() < 0.5 ? photo : null + }, + text, + replyMessage, + media: Math.random() < 0.3 ? media : null, + mediaType: Math.random() < 0.5 ? 'sticker' : 'image' + } +} + +;(async () => { + for (let i = 0; i < nQuotes; i++) { + const backgroundColor = Math.random() < 0.3 ? '#FFFFFF' : '' + const messages = Array.from( + { length: i % 5 + 1 }, + (_, k) => buildMessage(k, Math.random() < 0.3) + ) + + const json = { + botToken: process.env.BOT_TOKEN, + backgroundColor, + width: 512, + height: 768, + scale: 2, + messages + } + + for (let template of testTemplates) { + console.time(`${i}-${template.params.type}`) + await axios.post( + `http://localhost:${process.env.PORT}/${template.method}`, + { ...json, ...template.params }, + { headers: { 'Content-Type': 'application/json' } } + ).then(res => { + console.timeEnd(`${i}-${template.params.type}`) + fs.writeFile( + path.resolve(__dirname, template.filename(i)), + Buffer.from(res.data.result.image, 'base64'), + err => err && console.error(err) + ) + }).catch(console.error) + } + } +})() diff --git a/utils/color-liminance.js b/utils/color-liminance.js new file mode 100644 index 0000000..8022b06 --- /dev/null +++ b/utils/color-liminance.js @@ -0,0 +1,19 @@ +module.exports = (hex, lum) => { + hex = String(hex).replace(/[^0-9a-f]/gi, '') + if (hex.length < 6) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2] + } + lum = lum || 0 + + // convert to decimal and change luminosity + let rgb = '#' + let c + let i + for (i = 0; i < 3; i++) { + c = parseInt(hex.substr(i * 2, 2), 16) + c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16) + rgb += ('00' + c).substr(c.length) + } + + return rgb +} diff --git a/utils/compile.js b/utils/compile.js new file mode 100644 index 0000000..478c1e8 --- /dev/null +++ b/utils/compile.js @@ -0,0 +1,19 @@ +const path = require('path') +const fs = require('fs') +const Handlebars = require('handlebars') + +const cache = {} + +module.exports = (viewName, data) => { + let view = cache[viewName] + + if (view == null) { + const template = fs.readFileSync( + path.resolve(__dirname, `../views/${viewName}.hbs`) + ).toString('utf-8') + view = Handlebars.compile(template) + cache[viewName] = view + } + + return view(data) +} diff --git a/utils/emoji-image.js b/utils/emoji-image.js deleted file mode 100644 index db5562b..0000000 --- a/utils/emoji-image.js +++ /dev/null @@ -1,106 +0,0 @@ -const path = require('path') -const fs = require('fs') -const loadImageFromUrl = require('./image-load-url') -const EmojiDbLib = require('emoji-db') -const promiseAllStepN = require('./promise-concurrent') - -const emojiDb = new EmojiDbLib({ useDefaultDb: true }) - -const emojiJFilesDir = '../assets/emoji/' - -const brandFoledIds = { - apple: 325, - google: 313, - twitter: 322, - joypixels: 340, - blob: 56 -} - -const emojiJsonByBrand = { - apple: 'emoji-apple-image.json', - google: 'emoji-google-image.json', - twitter: 'emoji-twitter-image.json', - joypixels: 'emoji-joypixels-image.json', - blob: 'emoji-blob-image.json' -} - -let emojiImageByBrand = { - apple: [], - google: [], - twitter: [], - joypixels: [], - blob: [] -} - -async function downloadEmoji (brand) { - console.log('emoji image load start') - - const emojiImage = emojiImageByBrand[brand] - - const emojiJsonFile = path.resolve( - __dirname, - emojiJFilesDir + emojiJsonByBrand[brand] - ) - - const dbData = emojiDb.dbData - const dbArray = Object.keys(dbData) - const emojiPromiseArray = [] - - for (const key of dbArray) { - const emoji = dbData[key] - - if (!emoji.qualified && !emojiImage[key]) { - emojiPromiseArray.push(async () => { - let brandFolderName = brand - if (brand === 'blob') brandFolderName = 'google' - - const fileUrl = `${process.env.EMOJI_DOMAIN}/thumbs/60/${brandFolderName}/${brandFoledIds[brand]}/${emoji.image.file_name}` - - const img = await loadImageFromUrl(fileUrl, (headers) => { - return !headers['content-type'].match(/image/) - }) - - const base64 = img.toString('base64') - - if (base64) { - return { - key, - base64 - } - } - }) - } - } - - const donwloadResult = await promiseAllStepN(200)(emojiPromiseArray) - - for (const emojiData of donwloadResult) { - if (emojiData) emojiImage[emojiData.key] = emojiData.base64 - } - - if (Object.keys(emojiImage).length > 0) { - const emojiJson = JSON.stringify(emojiImage, null, 2) - - fs.writeFile(emojiJsonFile, emojiJson, (err) => { - if (err) return console.log(err) - }) - } - - console.log('emoji image load end') -} - -for (const brand in emojiJsonByBrand) { - const emojiJsonFile = path.resolve( - __dirname, - emojiJFilesDir + emojiJsonByBrand[brand] - ) - - try { - if (fs.existsSync(emojiJsonFile)) emojiImageByBrand[brand] = require(emojiJsonFile) - } catch (error) { - console.log(error) - } - // downloadEmoji(brand) -} - -module.exports = emojiImageByBrand diff --git a/utils/format-html.js b/utils/format-html.js new file mode 100644 index 0000000..ff40b6f --- /dev/null +++ b/utils/format-html.js @@ -0,0 +1,178 @@ +const telegram = require('./telegram') + +const escapedChars = { + '"': '"', + '&': '&', + '<': '<', + '>': '>' +} + +const escapeHTML = (string) => { + const chars = [...string] + return chars.map(char => escapedChars[char] || char).join('') +} + +module.exports = async (text = '', entities = []) => { + const available = [...entities] + const opened = [] + const result = [] + const customEmojiSlices = [] + const requiredCustomEmojiIds = new Set() + + for (let offset = 0; offset < text.length; offset++) { + while (true) { + const index = available.findIndex((entity) => entity.offset === offset) + if (index === -1) { + break + } + const entity = available[index] + switch (entity.type) { + case 'bold': + result.push('') + break + case 'italic': + result.push('') + break + case 'code': + result.push('') + break + case 'pre': + if (entity.language) { + result.push(``) + } else { + result.push('') + } + break + case 'strikethrough': + result.push('') + break + case 'underline': + result.push('') + break + case 'text_mention': + result.push(``) + break + case 'text_link': + result.push(``) + break + case 'url': + result.push(``) + break + case 'spoiler': + result.push(``) + break + case 'mention': + result.push(``) + break + case 'hashtag': + result.push(``) + break + case 'cashtag': + result.push(``) + break + case 'bot_command': + result.push(``) + break + case 'email': + result.push(`') + break + case 'italic': + result.push('') + break + case 'code': + result.push('') + break + case 'pre': + if (entity.language) { + result.push('') + } else { + result.push('') + } + break + case 'strikethrough': + result.push('') + break + case 'underline': + result.push('') + break + case 'text_mention': + case 'text_link': + case 'url': + result.push('') + break + case 'spoiler': + case 'mention': + case 'hashtag': + case 'cashtag': + case 'bot_command': + case 'email': + case 'phone_number': + result.push('') + break + case 'custom_emoji': + customEmojiSlices[customEmojiSlices.length - 1].endIndex = result.length + result.push('') + break + } + opened.splice(index, 1) + } + } + + if (customEmojiSlices.length) { + const customEmojiFileURLs = {} + const customEmojiStickers = await telegram.callApi('getCustomEmojiStickers', { + custom_emoji_ids: [...requiredCustomEmojiIds] + }).catch(() => {}) + + if (Array.isArray(customEmojiStickers)) { + await Promise.all(customEmojiStickers.map( + async sticker => (async () => { + const fileId = sticker.thumb.file_id + const fileURL = await telegram.getFileLink(fileId).catch(() => {}) + customEmojiFileURLs[sticker.custom_emoji_id] = fileURL + })() + )) + + for (let slice of customEmojiSlices) { + if (customEmojiFileURLs[slice.emojiId]) { + const emojiURL = customEmojiFileURLs[slice.emojiId] + let altRepr = '' + + for (let i = slice.beginIndex; i < slice.endIndex; i++) { + altRepr += result[i] + result[i] = '' + } + result[slice.endIndex] = `${altRepr}` + } + } + } + } + + return result.join('') +} diff --git a/utils/get-avatar-url.js b/utils/get-avatar-url.js new file mode 100644 index 0000000..8ac1a28 --- /dev/null +++ b/utils/get-avatar-url.js @@ -0,0 +1,35 @@ +const LRU = require('lru-cache') + +const telegram = require('./telegram') + +const avatarCache = new LRU({ + max: 20, + maxAge: 1000 * 60 * 5 +}) + +module.exports = async (user) => { + let avatarURL = avatarCache.get(user.id) + + if (!avatarURL && user.photo?.big_file_id) { + avatarURL = await telegram.getFileLink(user.photo.big_file_id).catch(console.error) + } + + if (!avatarURL) { + const chat = await telegram.getChat(user.id).catch(console.error) + if (chat?.photo?.big_file_id) { + avatarURL = await telegram.getFileLink(chat.photo.big_file_id).catch(console.error) + } + } + + if (!avatarURL && user.username) { + avatarURL = `https://telega.one/i/userpic/320/${user.username}.jpg` + } + + if (!avatarURL) { + return null + } + + avatarCache.set(user.id, avatarURL) + return avatarURL +} + diff --git a/utils/get-background.js b/utils/get-background.js new file mode 100644 index 0000000..1b4d279 --- /dev/null +++ b/utils/get-background.js @@ -0,0 +1,33 @@ +const colorLuminance = require('./color-liminance') +const { createCanvas } = require('canvas') + +const normalizeColor = (color) => { + const canvas = createCanvas(0, 0) + const canvasCtx = canvas.getContext('2d') + + canvasCtx.fillStyle = color + color = canvasCtx.fillStyle + + return color +} + +module.exports = (backgroundColor) => { + let backgroundColorOne, backgroundColorTwo + const backgroundColorSplit = backgroundColor.split('/') + + // TODO effect colors with CSS + if (backgroundColorSplit && backgroundColorSplit.length > 1 && backgroundColorSplit[0] !== '') { + backgroundColorOne = normalizeColor(backgroundColorSplit[0]) + backgroundColorTwo = normalizeColor(backgroundColorSplit[1]) + } else if (backgroundColor.startsWith('//')) { + backgroundColor = normalizeColor(backgroundColor.replace('//', '')) + backgroundColorOne = colorLuminance(backgroundColor, 0.35) + backgroundColorTwo = colorLuminance(backgroundColor, -0.15) + } else { + backgroundColor = normalizeColor(backgroundColor) + backgroundColorOne = backgroundColor + backgroundColorTwo = backgroundColor + } + + return { backgroundColor, backgroundColorOne, backgroundColorTwo } +} diff --git a/utils/image-load-path.js b/utils/image-load-path.js deleted file mode 100644 index 6671ade..0000000 --- a/utils/image-load-path.js +++ /dev/null @@ -1,9 +0,0 @@ -const fs = require('fs') - -module.exports = (path) => { - return new Promise((resolve, reject) => { - fs.readFile(path, (_error, data) => { - resolve(data) - }) - }) -} diff --git a/utils/image-load-url.js b/utils/image-load-url.js deleted file mode 100644 index 0548c57..0000000 --- a/utils/image-load-url.js +++ /dev/null @@ -1,23 +0,0 @@ -const https = require('https') - -module.exports = (url, filter = false) => { - return new Promise((resolve, reject) => { - https.get(url, (res) => { - if (filter && filter(res.headers)) { - resolve(Buffer.concat([])) - } - - const chunks = [] - - res.on('error', (err) => { - reject(err) - }) - res.on('data', (chunk) => { - chunks.push(chunk) - }) - res.on('end', () => { - resolve(Buffer.concat(chunks)) - }) - }) - }) -} diff --git a/utils/index.js b/utils/index.js index 94ee398..46be5f5 100644 --- a/utils/index.js +++ b/utils/index.js @@ -1,7 +1,10 @@ module.exports = { - QuoteGenerate: require('./quote-generate'), - loadImageFromUrl: require('./image-load-url'), - loadImageFromPath: require('./image-load-path'), - promiseAllStepN: require('./promise-concurrent'), - userName: require('./user-name') + telegram: require('./telegram'), + render: require('./render'), + getBackground: require('./get-background'), + compile: require('./compile'), + getAvatarURL: require('./get-avatar-url'), + formatHTML: require('./format-html'), + colorLuminance: require('./color-liminance'), + lightOrDark: require('./light-or-dark') } diff --git a/utils/light-or-dark.js b/utils/light-or-dark.js new file mode 100644 index 0000000..9d07ddf --- /dev/null +++ b/utils/light-or-dark.js @@ -0,0 +1,38 @@ +// https://codepen.io/andreaswik/pen/YjJqpK +module.exports = (color) => { + let r, g, b + + // Check the format of the color, HEX or RGB? + if (color.match(/^rgb/)) { + // If HEX --> store the red, green, blue values in separate variables + color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/) + + r = color[1] + g = color[2] + b = color[3] + } else { + // If RGB --> Convert it to HEX: http://gist.github.com/983661 + color = +('0x' + color.slice(1).replace( + color.length < 5 && /./g, '$&$&' + ) + ) + + r = color >> 16 + g = color >> 8 & 255 + b = color & 255 + } + + // HSP (Highly Sensitive Poo) equation from http://alienryderflex.com/hsp.html + const hsp = Math.sqrt( + 0.299 * (r * r) + + 0.587 * (g * g) + + 0.114 * (b * b) + ) + + // Using the HSP value, determine whether the color is light or dark + if (hsp > 127.5) { + return 'light' + } else { + return 'dark' + } +} diff --git a/utils/page-pool.js b/utils/page-pool.js new file mode 100644 index 0000000..32f2819 --- /dev/null +++ b/utils/page-pool.js @@ -0,0 +1,45 @@ +const path = require('path') +const fs = require('fs') +const { webkit } = require('playwright') + +const promise = webkit.launch().then(browser => browser.newContext()) +const files = {} // cache of contents from .html files +const pages = {} // arrays of browser pages by name + +const popPage = async (pageName) => { + const context = await promise + if (!Array.isArray(pages[pageName])) { + pages[pageName] = [] + } + + if (pages[pageName].length) { + const page = pages[pageName].pop() + return page + } else { + // if pool of appropriate pages is empty, create new page + let html = files[pageName] + if (html == null) { + html = fs.readFileSync(path.resolve(__dirname, `../pages/${pageName}.html`)) + .toString('utf-8') + .replace('{{> CSSresetURL}}', `http://localhost:${process.env.PORT}/assets/reset.min.css`) + files[pageName] = html + } + + const page = await context.newPage() + page.on('console', message => console.log(message.text())) + await page.setContent(html) + + return page + } +} + +const pushPage = (pageName, page) => { + if (!Array.isArray(pages[pageName])) { + pages[pageName] = [] + } + pages[pageName].push(page) +} + +module.exports = { + popPage, pushPage +} diff --git a/utils/promise-concurrent.js b/utils/promise-concurrent.js deleted file mode 100644 index 7c82774..0000000 --- a/utils/promise-concurrent.js +++ /dev/null @@ -1,29 +0,0 @@ -function promiseAllStepN (n, list) { - let tail = list.splice(n) - let head = list - let resolved = [] - let processed = 0 - return new Promise(resolve => { - head.forEach(x => { - let res = x() - resolved.push(res) - res.then(y => { - runNext() - return y - }) - }) - function runNext () { - if (processed == tail.length) { - resolve(Promise.all(resolved)) - } else { - resolved.push(tail[processed]().then(x => { - runNext() - return x - })) - processed++ - } - } - }) -} - -module.exports = n => list => promiseAllStepN(n, list) diff --git a/utils/quote-generate.js b/utils/quote-generate.js index 52dbfa3..bb8542c 100644 --- a/utils/quote-generate.js +++ b/utils/quote-generate.js @@ -1,7 +1,6 @@ const fs = require('fs') -const { createCanvas, registerFont } = require('canvas') +const { createCanvas, registerFont, Image, loadImage } = require('canvas') const EmojiDbLib = require('emoji-db') -const { loadImage } = require('canvas') const loadImageFromUrl = require('./image-load-url') const sharp = require('sharp') const Jimp = require('jimp') @@ -9,9 +8,15 @@ const smartcrop = require('smartcrop-sharp') const runes = require('runes') const lottie = require('lottie-node') const zlib = require('zlib') -const { Telegram } = require('telegraf') + +const render = require('./render') +const getView = require('./get-view') + +const drawAvatar = require('./draw-avatar') +const lightOrDark = require('./light-or-dark') const emojiDb = new EmojiDbLib({ useDefaultDb: true }) +const buildContent = getView('message') function loadFont () { console.log('font load start') @@ -34,13 +39,6 @@ loadFont() const emojiImageByBrand = require('./emoji-image') -const LRU = require('lru-cache') - -const avatarCache = new LRU({ - max: 20, - maxAge: 1000 * 60 * 5 -}) - // write a nodejs function that accepts 2 colors. the first is the background color and the second is the text color. as a result, the first color should come out brighter or darker depending on the contrast. for example, if the first text is dark, then make the second brighter and return it. you need to change not the background color, but the text color // here are all the possible colors that will be passed as the second argument. the first color can be any @@ -109,99 +107,8 @@ class ColorContrast { class QuoteGenerate { - constructor (botToken) { - this.telegram = new Telegram(botToken) - } - - async avatarImageLatters (letters, color) { - const size = 500 - const canvas = createCanvas(size, size) - const context = canvas.getContext('2d') - - const gradient = context.createLinearGradient(0, 0, canvas.width, canvas.height) - - gradient.addColorStop(0, color[0]) - gradient.addColorStop(1, color[1]) - - context.fillStyle = gradient - context.fillRect(0, 0, canvas.width, canvas.height) - - const drawLetters = await this.drawMultilineText( - letters, - null, - size / 2, - '#FFF', - 0, - size, - size * 5, - size * 5 - ) - - context.drawImage(drawLetters, (canvas.width - drawLetters.width) / 2, (canvas.height - drawLetters.height) / 1.5) - - return canvas.toBuffer() - } - - async downloadAvatarImage (user) { - let avatarImage - - let nameLatters - if (user.first_name && user.last_name) nameLatters = runes(user.first_name)[0] + (runes(user.last_name || '')[0]) - else { - let name = user.first_name || user.name || user.title - name = name.toUpperCase() - const nameWord = name.split(' ') - - if (nameWord.length > 1) nameLatters = runes(nameWord[0])[0] + runes(nameWord.splice(-1)[0])[0] - else nameLatters = runes(nameWord[0])[0] - } - - const cacheKey = user.id - - const avatarImageCache = avatarCache.get(cacheKey) - - const avatarColorArray = [ - [ '#FF885E', '#FF516A' ], // red - [ '#FFCD6A', '#FFA85C' ], // orange - [ '#E0A2F3', '#D669ED' ], // purple - [ '#A0DE7E', '#54CB68' ], // green - [ '#53EDD6', '#28C9B7' ], // sea - [ '#72D5FD', '#2A9EF1' ], // blue - [ '#FFA8A8', '#FF719A' ] // pink - ] - - const nameIndex = Math.abs(user.id) % 7 - - const avatarColor = avatarColorArray[nameIndex] - - if (avatarImageCache) { - avatarImage = avatarImageCache - } else if (user.photo && user.photo.url) { - avatarImage = await loadImage(user.photo.url) - } else { - try { - let userPhoto, userPhotoUrl - - if (user.photo && user.photo.big_file_id) userPhotoUrl = await this.telegram.getFileLink(user.photo.big_file_id).catch(console.error) - - if (!userPhotoUrl) { - const getChat = await this.telegram.getChat(user.id).catch(console.error) - if (getChat && getChat.photo && getChat.photo.big_file_id) userPhoto = getChat.photo.big_file_id - - if (userPhoto) userPhotoUrl = await this.telegram.getFileLink(userPhoto) - else if (user.username) userPhotoUrl = `https://telega.one/i/userpic/320/${user.username}.jpg` - else avatarImage = await loadImage(await this.avatarImageLatters(nameLatters, avatarColor)) - } - - if (userPhotoUrl) avatarImage = await loadImage(userPhotoUrl) - - avatarCache.set(cacheKey, avatarImage) - } catch (error) { - avatarImage = await loadImage(await this.avatarImageLatters(nameLatters, avatarColor)) - } - } - - return avatarImage + constructor (telegramBot) { + this.telegram = telegramBot } ungzip (input, options) { @@ -258,45 +165,6 @@ class QuoteGenerate { .map(x => parseInt(x, 16)) } - // https://codepen.io/andreaswik/pen/YjJqpK - lightOrDark (color) { - let r, g, b - - // Check the format of the color, HEX or RGB? - if (color.match(/^rgb/)) { - // If HEX --> store the red, green, blue values in separate variables - color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/) - - r = color[1] - g = color[2] - b = color[3] - } else { - // If RGB --> Convert it to HEX: http://gist.github.com/983661 - color = +('0x' + color.slice(1).replace( - color.length < 5 && /./g, '$&$&' - ) - ) - - r = color >> 16 - g = color >> 8 & 255 - b = color & 255 - } - - // HSP (Highly Sensitive Poo) equation from http://alienryderflex.com/hsp.html - const hsp = Math.sqrt( - 0.299 * (r * r) + - 0.587 * (g * g) + - 0.114 * (b * b) - ) - - // Using the HSP value, determine whether the color is light or dark - if (hsp > 127.5) { - return 'light' - } else { - return 'dark' - } - } - async drawMultilineText (text, entities, fontSize, fontColor, textX, textY, maxWidth, maxHeight, emojiBrand = 'apple') { if (maxWidth > 10000) maxWidth = 10000 if (maxHeight > 10000) maxHeight = 10000 @@ -680,29 +548,6 @@ class QuoteGenerate { return canvas } - async drawAvatar (user) { - const avatarImage = await this.downloadAvatarImage(user) - - if (avatarImage) { - const avatarSize = avatarImage.naturalHeight - - const canvas = createCanvas(avatarSize, avatarSize) - const canvasCtx = canvas.getContext('2d') - - const avatarX = 0 - const avatarY = 0 - - canvasCtx.beginPath() - canvasCtx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2, true) - canvasCtx.clip() - canvasCtx.closePath() - canvasCtx.restore() - canvasCtx.drawImage(avatarImage, avatarX, avatarY, avatarSize, avatarSize) - - return canvas - } - } - drawLineSegment (ctx, x, y, width, isEven) { ctx.lineWidth = 35 // how thick the line is ctx.strokeStyle = '#aec6cf' // what color our line is @@ -897,7 +742,7 @@ class QuoteGenerate { height *= scale // check background style color black/light - const backStyle = this.lightOrDark(backgroundColorOne) + const backStyle = lightOrDark(backgroundColorOne) // historyPeer1NameFg: #c03d33; // red @@ -940,12 +785,8 @@ class QuoteGenerate { '#FF7FD5' // pink ] - console.log(Math.abs(message.from.id) % 7) - // user name color - let nameIndex = 1 - if (message.from.id) nameIndex = Math.abs(message.from.id) % 7 - + const nameIndex = message.from.id ? Math.abs(message.from.id) % 7 : 1 const nameColorArray = backStyle === 'light' ? nameColorLight : nameColorDark let nameColor = nameColorArray[nameIndex] @@ -958,6 +799,41 @@ class QuoteGenerate { nameColor = colorContrast.adjustContrast(this.colorLuminance(backgroundColorTwo, 0.55), nameColor) } + + const avatarImage = await drawAvatar(message.from) + + const content = buildContent({ + width, + height, + scale, + theme: backStyle, + nameColor, + name: message?.from?.name ?? '', + emojiStatus: message?.from?.emoji_status ?? '', + avatarURL: avatarImage.toDataURL(), + text: message.text ?? '', + }) + + const image = await render(content, '#quote') + + // convert to canvas for the generator + const canvas = createCanvas(width, height) + const canvasCtx = canvas.getContext('2d') + const img = new Image() + + return new Promise((resolve, reject) => { + img.onload = () => { + canvasCtx.drawImage(img, 0, 0, width, height) + resolve(canvas) + } + img.onerror = console.error + img.src = image + }) + + + // deprecated + // TODO: completely replace, test, remove + const nameSize = 22 * scale let nameCanvas @@ -996,10 +872,8 @@ class QuoteGenerate { ) } - let fontSize = 24 * scale - - let textColor = '#fff' - if (backStyle === 'light') textColor = '#000' + const fontSize = 24 * scale + const textColor = backStyle == 'light' ? '#000' : '#fff' let textCanvas if (message.text) { @@ -1017,7 +891,7 @@ class QuoteGenerate { } let avatarCanvas - if (message.avatar) avatarCanvas = await this.drawAvatar(message.from) + if (message.avatar) avatarCanvas = await drawAvatar(message.from) let replyName, replyNameColor, replyText if (message.replyMessage && message.replyMessage.name && message.replyMessage.text) { diff --git a/utils/render.js b/utils/render.js new file mode 100644 index 0000000..669dbdc --- /dev/null +++ b/utils/render.js @@ -0,0 +1,24 @@ +const { popPage, pushPage } = require('./page-pool') + +module.exports = async (pageName, content) => { + const page = await popPage(pageName) + const body = await page.locator('body') + + await body.evaluate((element, content) => { element.innerHTML = content }, content) + + // wait for all images will be loaded + await page.waitForFunction(() => { + const images = Array.from(document.querySelectorAll('img')) + return images.every(img => img.complete) + }) + + const quote = await body.locator('#quote') + const screenshot = await quote.screenshot({ + type: 'png', + scale: 'css', + omitBackground: true + }) + + pushPage(pageName, page) + return screenshot +} diff --git a/utils/telegram.js b/utils/telegram.js new file mode 100644 index 0000000..325e251 --- /dev/null +++ b/utils/telegram.js @@ -0,0 +1,6 @@ +const { Telegraf } = require('telegraf') + +const botToken = process.env.BOT_TOKEN +const bot = new Telegraf(botToken) + +module.exports = bot.telegram diff --git a/utils/user-name.js b/utils/user-name.js deleted file mode 100644 index 63073c9..0000000 --- a/utils/user-name.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = (user, url = false) => { - let name = user.first_name - - if (user.last_name) name += ` ${user.last_name}` - name = name.replace(//g, '>') - - if (url) return `${name}` - return name -} diff --git a/views/html.hbs b/views/html.hbs new file mode 100644 index 0000000..de5fe34 --- /dev/null +++ b/views/html.hbs @@ -0,0 +1,51 @@ +
+ +

@QuotLyBot

+
diff --git a/views/image.hbs b/views/image.hbs new file mode 100644 index 0000000..6eed850 --- /dev/null +++ b/views/image.hbs @@ -0,0 +1,51 @@ +
+ +

@QuotLyBot

+
diff --git a/views/png.hbs b/views/png.hbs new file mode 100644 index 0000000..fe0889f --- /dev/null +++ b/views/png.hbs @@ -0,0 +1,51 @@ +
+ +

@QuotLyBot

+
diff --git a/views/quote.hbs b/views/quote.hbs new file mode 100644 index 0000000..fe0889f --- /dev/null +++ b/views/quote.hbs @@ -0,0 +1,51 @@ +
+ +

@QuotLyBot

+