diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ba231c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +.git +.gitignore +.env +config.env +*.md +!README.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..56b5b32 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-slim + +# Install ffmpeg and other deps +RUN apt-get update && apt-get install -y ffmpeg && apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +RUN chmod +x /usr/src/app/src/plugins/yt-dlp/yt-dlp + +VOLUME /usr/src/app/config.env + +CMD ["npm", "start"] \ No newline at end of file diff --git a/README.md b/README.md index e2e35ff..a154166 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ Kowalski is a a simple Telegram bot made in Node.js. ## Self-host requirements -- Node.js 20 or newer (you can also use Bun) +- Node.js 20 or newer (you can also use [Bun](https://bun.sh)) - A Telegram bot (create one at [@BotFather](https://t.me/botfather)) -- Latest version of Node.js - FFmpeg (only for the `/yt` command) +- Docker and Docker Compose (only required for Docker setup) ## Run it yourself, develop or contribute with Kowalski @@ -36,6 +36,47 @@ After editing the file, save all changes and run the bot with ``npm start``. > [!TIP] > To deal with dependencies, just run ``npm install`` or ``npm i`` at any moment to install all of them. +## Running with Docker + +> [!IMPORTANT] +> Please complete the above steps to prepare your local copy for building. You do not need to install FFmpeg on your host system. + +You can also run Kowalski using Docker, which simplifies the setup process. Make sure you have Docker and Docker Compose installed. + +### Using Docker Compose + +1. **Make sure to setup your `config.env` file first!** + +2. **Run the container** + + ```bash + docker compose up -d + ``` + +> [!NOTE] +> The `-d` flag causes Kowalski to run in the background. If you're just playing around, you may not want to use this flag. + +### Using Docker Run + +If you prefer to use Docker directly, you can use these instructions instead. + +1. **Make sure to setup your `config.env` file first!** + +2. **Build the image** + + ```bash + docker build -t kowalski . + ``` + +3. **Run the container** + + ```bash + docker run -d --name kowalski --restart unless-stopped -v $(pwd)/config.env:/usr/src/app/config.env:ro kowalski + ``` + +> [!NOTE] +> The `-d` flag causes Kowalski to run in the background. If you're just playing around, you may not want to use this flag. + ## config.env Functions - **botSource**: Put the link to your bot source code. @@ -48,6 +89,18 @@ After editing the file, save all changes and run the bot with ``npm start``. - Take care of your ``config.env`` file, as it is so much important and needs to be secret (like your passwords), as anyone can do whatever they want to the bot with this token! +## Troubleshooting + +### YouTube Downloading + +**Q:** I get a "Permission denied (EACCES)" error in the console when running the `/yt` command + +**A:** Make sure `src/plugins/yt-dlp/yt-dlp` is executable. You can do this on Linux like so: + +```bash +chmod +x src/plugins/yt-dlp/yt-dlp +``` + ## About/License BSD-3-Clause - 2024 Lucas Gabriel (lucmsilva). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..981d90a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + kowalski: + build: . + container_name: kowalski + restart: unless-stopped + volumes: + - ./config.env:/usr/src/app/config.env:ro + environment: + - NODE_ENV=production \ No newline at end of file diff --git a/src/bot.js b/src/bot.js index ad909db..472bc10 100644 --- a/src/bot.js +++ b/src/bot.js @@ -6,6 +6,12 @@ require('@dotenvx/dotenvx').config({ path: "config.env" }); require('./plugins/ytdlp-wrapper.js'); // require('./plugins/termlogger.js'); +// Ensures bot token is set, and not default value +if (!process.env.botToken || process.env.botToken === 'InsertYourBotTokenHere') { + console.error('Bot token is not set. Please set the bot token in the config.env file.') + process.exit(1) +} + const bot = new Telegraf(process.env.botToken); const maxRetries = process.env.maxRetries || 5; let restartCount = 0; diff --git a/src/commands/crew.js b/src/commands/crew.js index 18b0eb9..52a3703 100644 --- a/src/commands/crew.js +++ b/src/commands/crew.js @@ -62,7 +62,7 @@ async function handleAdminCommand(ctx, action, successMessage, errorMessage) { reply_to_message_id: ctx.message.message_id }); } catch (error) { - ctx.reply(errorMessage.replace('{error}', error.message), { + ctx.reply(errorMessage.replace(/{error}/g, error.message), { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id }); @@ -91,12 +91,12 @@ module.exports = (bot) => { handleAdminCommand(ctx, async () => { try { const commitHash = await getGitCommitHash(); - await ctx.reply(Strings.gitCurrentCommit.replace('{commitHash}', commitHash), { + await ctx.reply(Strings.gitCurrentCommit.replace(/{commitHash}/g, commitHash), { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id }); } catch (error) { - ctx.reply(Strings.gitErrRetrievingCommit.replace('{error}', error), { + ctx.reply(Strings.gitErrRetrievingCommit.replace(/{error}/g, error), { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id }); @@ -109,12 +109,12 @@ module.exports = (bot) => { handleAdminCommand(ctx, async () => { try { const result = await updateBot(); - await ctx.reply(Strings.botUpdated.replace('{result}', result), { + await ctx.reply(Strings.botUpdated.replace(/{result}/g, result), { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id }); } catch (error) { - ctx.reply(Strings.errorUpdatingBot.replace('{error}', error), { + ctx.reply(Strings.errorUpdatingBot.replace(/{error}/g, error), { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id }); @@ -127,7 +127,7 @@ module.exports = (bot) => { const botName = ctx.message.text.split(' ').slice(1).join(' '); handleAdminCommand(ctx, async () => { await ctx.telegram.setMyName(botName); - }, Strings.botNameChanged.replace('{botName}', botName), Strings.botNameErr.replace('{error}', error)); + }, Strings.botNameChanged.replace(/{botName}/g, botName), Strings.botNameErr.replace(/{error}/g, error)); }); bot.command('setbotdesc', spamwatchMiddleware, async (ctx) => { @@ -135,7 +135,7 @@ module.exports = (bot) => { const botDesc = ctx.message.text.split(' ').slice(1).join(' '); handleAdminCommand(ctx, async () => { await ctx.telegram.setMyDescription(botDesc); - }, Strings.botDescChanged.replace('{botDesc}', botDesc), Strings.botDescErr.replace('{error}', error)); + }, Strings.botDescChanged.replace(/{botDesc}/g, botDesc), Strings.botDescErr.replace(/{error}/g, error)); }); bot.command('botkickme', spamwatchMiddleware, async (ctx) => { @@ -159,7 +159,7 @@ module.exports = (bot) => { caption: botFile }); } catch (error) { - ctx.reply(Strings.unexpectedErr.replace('{error}', error.message), { + ctx.reply(Strings.unexpectedErr.replace(/{error}/g, error.message), { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id }); diff --git a/src/commands/help.js b/src/commands/help.js index 58dfa86..ab9e601 100644 --- a/src/commands/help.js +++ b/src/commands/help.js @@ -6,8 +6,8 @@ async function sendHelpMessage(ctx, isEditing) { const Strings = getStrings(ctx.from.language_code); const botInfo = await ctx.telegram.getMe(); const helpText = Strings.botHelp - .replace('{botName}', botInfo.first_name) - .replace("{sourceLink}", process.env.botSource); + .replace(/{botName}/g, botInfo.first_name) + .replace(/{sourceLink}/g, process.env.botSource); const options = { parse_mode: 'Markdown', disable_web_page_preview: true, @@ -35,7 +35,7 @@ module.exports = (bot) => { bot.command("about", spamwatchMiddleware, async (ctx) => { const Strings = getStrings(ctx.from.language_code); - const aboutMsg = Strings.botAbout.replace("{sourceLink}", `${process.env.botSource}`); + const aboutMsg = Strings.botAbout.replace(/{sourceLink}/g, `${process.env.botSource}`); ctx.reply(aboutMsg, { parse_mode: 'Markdown', diff --git a/src/commands/main.js b/src/commands/main.js index c111603..6333296 100644 --- a/src/commands/main.js +++ b/src/commands/main.js @@ -6,7 +6,7 @@ module.exports = (bot) => { bot.start(spamwatchMiddleware, async (ctx) => { const Strings = getStrings(ctx.from.language_code); const botInfo = await ctx.telegram.getMe(); - const startMsg = Strings.botWelcome.replace('{botName}', botInfo.first_name); + const startMsg = Strings.botWelcome.replace(/{botName}/g, botInfo.first_name); ctx.reply(startMsg, { parse_mode: 'Markdown', diff --git a/src/commands/youtube.js b/src/commands/youtube.js index 68728ee..2442f95 100644 --- a/src/commands/youtube.js +++ b/src/commands/youtube.js @@ -1,198 +1,212 @@ -const { getStrings } = require('../plugins/checklang.js'); -const { isOnSpamWatch } = require('../plugins/lib-spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../plugins/lib-spamwatch/Middleware.js')(isOnSpamWatch); -const { execFile } = require('child_process'); -const os = require('os'); -const fs = require('fs'); -const path = require('path'); - -const ytDlpPaths = { - linux: path.resolve(__dirname, '../plugins/yt-dlp/yt-dlp'), - win32: path.resolve(__dirname, '../plugins/yt-dlp/yt-dlp.exe'), - darwin: path.resolve(__dirname, '../plugins/yt-dlp/yt-dlp_macos'), -}; - -const getYtDlpPath = () => { - const platform = os.platform(); - return ytDlpPaths[platform] || ytDlpPaths.linux; -}; - - -const ffmpegPaths = { - linux: '/usr/bin/ffmpeg', - win32: path.resolve(__dirname, '../plugins/ffmpeg/bin/ffmpeg.exe'), -}; - -const getFfmpegPath = () => { - const platform = os.platform(); - return ffmpegPaths[platform] || ffmpegPaths.linux; -}; - -const downloadFromYoutube = async (command, args) => { - return new Promise((resolve, reject) => { - execFile(command, args, (error, stdout, stderr) => { - if (error) { - reject({ error, stdout, stderr }); - } else { - resolve({ stdout, stderr }); - } - }); - }); -}; - -const getApproxSize = async (command, videoUrl) => { - let args = []; - if (fs.existsSync(path.resolve(__dirname, "../props/cookies.txt"))) { - args = [videoUrl, '--compat-opt', 'manifest-filesize-approx', '-O', 'filesize_approx', '--cookies', path.resolve(__dirname, "../props/cookies.txt")]; - } else { - args = [videoUrl, '--compat-opt', 'manifest-filesize-approx', '-O', 'filesize_approx']; - } - try { - const { stdout } = await downloadFromYoutube(command, args); - const sizeInBytes = parseInt(stdout.trim(), 10); - if (!isNaN(sizeInBytes)) { - return sizeInBytes / (1024 * 1024); - } else { - return 0; - } - } catch (error) { - throw error; - } -}; - -module.exports = (bot) => { - bot.command(['yt', 'ytdl', 'sdl', 'video', 'dl'], spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); - const ytDlpPath = getYtDlpPath(); - const userId = ctx.from.id; - const videoUrl = ctx.message.text.split(' ').slice(1).join(' '); - const mp4File = `tmp/${userId}.mp4`; - const tempMp4File = `tmp/${userId}.f137.mp4`; - const tempWebmFile = `tmp/${userId}.f251.webm`; - let cmdArgs = ""; - const dlpCommand = ytDlpPath; - const ffmpegPath = getFfmpegPath(); - const ffmpegArgs = ['-i', tempMp4File, '-i', tempWebmFile, '-c:v copy -c:a copy -strict -2', mp4File]; - - if (!videoUrl) { - return ctx.reply(Strings.ytDownload.noLink, { - parse_mode: "Markdown", - disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id - }); - }; - - if (fs.existsSync(path.resolve(__dirname, "../props/cookies.txt"))) { - cmdArgs = "--max-filesize 2G --no-playlist --cookies src/props/cookies.txt --merge-output-format mp4 -o"; - } else { - cmdArgs = `--max-filesize 2G --no-playlist --merge-output-format mp4 -o`; - } - - try { - const downloadingMessage = await ctx.reply(Strings.ytDownload.checkingSize, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id, - }); - - if (fs.existsSync(ytDlpPath)) { - const approxSizeInMB = await Promise.race([ - getApproxSize(ytDlpPath, videoUrl), - ]); - - await ctx.telegram.editMessageText( - ctx.chat.id, - downloadingMessage.message_id, - null, - Strings.ytDownload.downloadingVid, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id, - }, - ); - - const dlpArgs = [videoUrl, ...cmdArgs.split(' '), mp4File]; - await downloadFromYoutube(dlpCommand, dlpArgs); - - await ctx.telegram.editMessageText( - ctx.chat.id, - downloadingMessage.message_id, - null, - Strings.ytDownload.uploadingVid, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id, - }, - ); - - if (fs.existsSync(tempMp4File)) { - await downloadFromYoutube(ffmpegPath, ffmpegArgs); - } - - if (fs.existsSync(mp4File)) { - const message = Strings.ytDownload.msgDesc.replace("{userMention}", `[${ctx.from.first_name}](tg://user?id=${userId})`) - - try { - await ctx.replyWithVideo({ - source: mp4File - }, { - caption: message, - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id, - }); - - fs.unlinkSync(mp4File); - } catch (error) { - if (toString(error).includes("Request Entity Too Large")) { - await ctx.telegram.editMessageText( - ctx.chat.id, - downloadingMessage.message_id, - null, - Strings.ytDownload.uploadLimit, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id, - }, - ); - } else { - const errMsg = Strings.ytDownload.uploadErr.replace("{error}", error) - await ctx.telegram.editMessageText( - ctx.chat.id, - downloadingMessage.message_id, - null, - errMsg, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id, - }, - ); - }; - - fs.unlinkSync(mp4File); - } - } else { - await ctx.reply(mp4File, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id, - }); - } - } else { - await ctx.telegram.editMessageText( - ctx.chat.id, - downloadingMessage.message_id, - null, - Strings.ytDownload.libNotFound, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id, - }, - ); - } - } catch (error) { - const errMsg = Strings.ytDownload.uploadErr.replace("{error}", error) - await ctx.telegram.editMessageText( - ctx.chat.id, - downloadingMessage.message_id, - null, - errMsg, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id, - }, - ); - } - }); +const { getStrings } = require('../plugins/checklang.js'); +const { isOnSpamWatch } = require('../plugins/lib-spamwatch/spamwatch.js'); +const spamwatchMiddleware = require('../plugins/lib-spamwatch/Middleware.js')(isOnSpamWatch); +const { execFile } = require('child_process'); +const os = require('os'); +const fs = require('fs'); +const path = require('path'); + +const ytDlpPaths = { + linux: path.resolve(__dirname, '../plugins/yt-dlp/yt-dlp'), + win32: path.resolve(__dirname, '../plugins/yt-dlp/yt-dlp.exe'), + darwin: path.resolve(__dirname, '../plugins/yt-dlp/yt-dlp_macos'), +}; + +const getYtDlpPath = () => { + const platform = os.platform(); + return ytDlpPaths[platform] || ytDlpPaths.linux; +}; + + +const ffmpegPaths = { + linux: '/usr/bin/ffmpeg', + win32: path.resolve(__dirname, '../plugins/ffmpeg/bin/ffmpeg.exe'), +}; + +const getFfmpegPath = () => { + const platform = os.platform(); + return ffmpegPaths[platform] || ffmpegPaths.linux; +}; + +const downloadFromYoutube = async (command, args) => { + return new Promise((resolve, reject) => { + execFile(command, args, (error, stdout, stderr) => { + if (error) { + reject({ error, stdout, stderr }); + } else { + resolve({ stdout, stderr }); + } + }); + }); +}; + +const getApproxSize = async (command, videoUrl) => { + let args = []; + if (fs.existsSync(path.resolve(__dirname, "../props/cookies.txt"))) { + args = [videoUrl, '--compat-opt', 'manifest-filesize-approx', '-O', 'filesize_approx', '--cookies', path.resolve(__dirname, "../props/cookies.txt")]; + } else { + args = [videoUrl, '--compat-opt', 'manifest-filesize-approx', '-O', 'filesize_approx']; + } + try { + const { stdout } = await downloadFromYoutube(command, args); + const sizeInBytes = parseInt(stdout.trim(), 10); + if (!isNaN(sizeInBytes)) { + return sizeInBytes / (1024 * 1024); + } else { + return 0; + } + } catch (error) { + throw error; + } +}; + +module.exports = (bot) => { + bot.command(['yt', 'ytdl', 'sdl', 'video', 'dl'], spamwatchMiddleware, async (ctx) => { + const Strings = getStrings(ctx.from.language_code); + const ytDlpPath = getYtDlpPath(); + const userId = ctx.from.id; + const videoUrl = ctx.message.text.split(' ').slice(1).join(' '); + const mp4File = `tmp/${userId}.mp4`; + const tempMp4File = `tmp/${userId}.f137.mp4`; + const tempWebmFile = `tmp/${userId}.f251.webm`; + let cmdArgs = ""; + const dlpCommand = ytDlpPath; + const ffmpegPath = getFfmpegPath(); + const ffmpegArgs = ['-i', tempMp4File, '-i', tempWebmFile, '-c:v copy -c:a copy -strict -2', mp4File]; + + if (!videoUrl) { + return ctx.reply(Strings.ytDownload.noLink, { + parse_mode: "Markdown", + disable_web_page_preview: true, + reply_to_message_id: ctx.message.message_id + }); + }; + + if (fs.existsSync(path.resolve(__dirname, "../props/cookies.txt"))) { + cmdArgs = "--max-filesize 2G --no-playlist --cookies src/props/cookies.txt --merge-output-format mp4 -o"; + } else { + cmdArgs = `--max-filesize 2G --no-playlist --merge-output-format mp4 -o`; + } + + try { + const downloadingMessage = await ctx.reply(Strings.ytDownload.checkingSize, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }); + + if (fs.existsSync(ytDlpPath)) { + const approxSizeInMB = await Promise.race([ + getApproxSize(ytDlpPath, videoUrl), + ]); + + if (approxSizeInMB > 50) { + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + Strings.ytDownload.uploadLimit, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + + return; + } + + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + Strings.ytDownload.downloadingVid, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + + const dlpArgs = [videoUrl, ...cmdArgs.split(' '), mp4File]; + await downloadFromYoutube(dlpCommand, dlpArgs); + + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + Strings.ytDownload.uploadingVid, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + + if (fs.existsSync(tempMp4File)) { + await downloadFromYoutube(ffmpegPath, ffmpegArgs); + } + + if (fs.existsSync(mp4File)) { + const message = Strings.ytDownload.msgDesc.replace("{userMention}", `[${ctx.from.first_name}](tg://user?id=${userId})`) + + try { + await ctx.replyWithVideo({ + source: mp4File + }, { + caption: message, + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }); + + fs.unlinkSync(mp4File); + } catch (error) { + if (error.response.description.includes("Request Entity Too Large")) { + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + Strings.ytDownload.uploadLimit, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + } else { + const errMsg = Strings.ytDownload.uploadErr.replace("{error}", error) + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + errMsg, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + }; + + fs.unlinkSync(mp4File); + } + } else { + await ctx.reply(mp4File, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }); + } + } else { + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + Strings.ytDownload.libNotFound, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + } + } catch (error) { + const errMsg = Strings.ytDownload.uploadErr.replace("{error}", error) + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + errMsg, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + } + }); }; \ No newline at end of file diff --git a/src/locales/english.json b/src/locales/english.json index 0a4291b..b2fb10a 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -1,5 +1,5 @@ { - "botWelcome": "*Hello! I am {botName}!*\nI was made with love by some nerds who really love programming!\n\n*Before using, you need to read the privacy policy (/privacy) to understand where your data goes when using this bot.*\n\nAlso, you can use /help to see the bot commands!", + "botWelcome": "*Hello! I'm {botName}!*\nI was made with love by some nerds who really love programming!\n\n*By using {botName}, you affirm that you have read to and agree with the privacy policy (/privacy). This helps you understand where your data goes when using this bot.*\n\nAlso, you can use /help to see the bot commands!", "botHelp": "*Hey, I'm {botName}, a simple bot made entirely from scratch in Telegraf and Node.js by some nerds who really love programming.*\n\nClick on the buttons below to see which commands you can use!\n", "botPrivacy": "Check out [this link](https://blog.lucmsilva.com/posts/lynx-privacy-policy) to read the bot's privacy policy.", "botAbout": "*About the bot*\n\nThe bot base was originally created by [Lucas Gabriel (lucmsilva)](https://github.com/lucmsilva651), now maintained by several people.\n\nThe bot's purpose is to bring fun to your groups here on Telegram in a relaxed and simple way. The bot also features some very useful commands, which you can see using the help command (/help).\n\nSpecial thanks to @givfnz2 for his many contributions to the bot!\n\nSee the source code: [Click here to go to GitHub]({sourceLink})", diff --git a/src/locales/portuguese.json b/src/locales/portuguese.json index 570f308..6b492f2 100644 --- a/src/locales/portuguese.json +++ b/src/locales/portuguese.json @@ -1,5 +1,5 @@ { - "botWelcome": "*Olá! Eu sou o {botName}!*\n\n*Antes de usar, você precisa ler a política de privacidade (/privacy) para entender onde seus dados vão ao usar este bot.*\n\nAlém disso, você pode usar /help para ver os meus comandos!", + "botWelcome": "*Olá! Eu sou o {botName}!*\n\n*Ao usar o {botName}, você afirma que leu e concorda com a política de privacidade (/privacy). Isso ajuda você a entender onde seus dados vão ao usar este bot.*\n\nAlém disso, você pode usar /help para ver os meus comandos!", "botHelp": "*Oi, eu sou o {botName}, um bot simples feito do zero em Telegraf e Node.js por uns nerds que gostam de programação.*\n\nVeja o código fonte: [Clique aqui para ir ao GitHub]({sourceLink})\n\nClique nos botões abaixo para ver quais comandos você pode usar!\n", "botPrivacy": "Acesse [este link](https://blog.lucmsilva.com/posts/lynx-privacy-policy) para ler a política de privacidade do bot.", "botAbout": "*Sobre o bot*\n\nA base deste bot foi feita originalmente por [Lucas Gabriel (lucmsilva)](https://github.com/lucmsilva651), agora sendo mantido por várias pessoas.\n\nA intenção do bot é trazer diversão para os seus grupos aqui no Telegram de uma maneira bem descontraida e simples. O bot também conta com alguns comandos bem úteis, que você consegue ver com o comando de ajuda (/help).\n\nAgradecimento especial ao @givfnz2 pelas suas várias contribuições ao bot!\n\nVeja o código fonte: [Clique aqui para ir ao GitHub]({sourceLink})",