From 07045d8e09a3609e0d3d3fb330b45019f4eb49f3 Mon Sep 17 00:00:00 2001 From: Aidan Date: Tue, 29 Apr 2025 15:39:10 -0400 Subject: [PATCH 01/47] Migrate to TypeScript, minor changes and fixes (#46) * docs: linting, require bun for ts * rf: js -> ts * chore: bump * docs: add ts badge * chore: bump types * fix: add types for context to animal commands * [m] hf: add bot type * fix/types: add bot, ctx types, fix emoji on /dice cmd, add todo * fix/types: bot admin checking fixes, other misc fixes, add types --------- Co-authored-by: Lucas Gabriel --- .dockerignore | 1 - config.env.example => .env.example | 0 .gitignore | 7 +- Dockerfile | 2 +- README.md | 24 ++-- docker-compose.yml | 2 +- nodemon.json | 5 +- package.json | 9 +- src/{bot.js => bot.ts} | 23 ++-- src/commands/{animal.js => animal.ts} | 46 ++++--- src/commands/{codename.js => codename.ts} | 28 +++-- src/commands/{crew.js => crew.ts} | 115 ++++++++++++------ src/commands/fun.js | 101 --------------- src/commands/fun.ts | 112 +++++++++++++++++ src/commands/{gsmarena.js => gsmarena.ts} | 39 +++--- src/commands/{help.js => help.ts} | 25 ++-- src/commands/{http.js => http.ts} | 16 +-- src/commands/{info.js => info.ts} | 14 ++- src/commands/{lastfm.js => lastfm.ts} | 25 ++-- src/commands/{main.js => main.ts} | 14 ++- src/commands/{modarchive.js => modarchive.ts} | 27 ++-- src/commands/{ponyapi.js => ponyapi.ts} | 67 ++++++++-- src/commands/quotes.ts | 32 +++++ src/commands/{randompony.js => randompony.ts} | 16 +-- src/commands/{weather.js => weather.ts} | 16 +-- src/commands/wiki.ts | 38 ++++++ src/commands/{youtube.js => youtube.ts} | 31 +++-- src/locales/english.json | 4 +- src/plugins/{checklang.js => checklang.ts} | 4 +- src/plugins/verifyInput.js | 14 --- src/plugins/verifyInput.ts | 10 ++ .../{ytDlpWrapper.js => ytDlpWrapper.ts} | 8 +- 32 files changed, 550 insertions(+), 325 deletions(-) rename config.env.example => .env.example (100%) rename src/{bot.js => bot.ts} (77%) rename src/commands/{animal.js => animal.ts} (67%) rename src/commands/{codename.js => codename.ts} (62%) rename src/commands/{crew.js => crew.ts} (62%) delete mode 100644 src/commands/fun.js create mode 100644 src/commands/fun.ts rename src/commands/{gsmarena.js => gsmarena.ts} (91%) rename src/commands/{help.js => help.ts} (85%) rename src/commands/{http.js => http.ts} (84%) rename src/commands/{info.js => info.ts} (85%) rename src/commands/{lastfm.js => lastfm.ts} (92%) rename src/commands/{main.js => main.ts} (60%) rename src/commands/{modarchive.js => modarchive.ts} (73%) rename src/commands/{ponyapi.js => ponyapi.ts} (86%) create mode 100644 src/commands/quotes.ts rename src/commands/{randompony.js => randompony.ts} (72%) rename src/commands/{weather.js => weather.ts} (90%) create mode 100644 src/commands/wiki.ts rename src/commands/{youtube.js => youtube.ts} (89%) rename src/plugins/{checklang.js => checklang.ts} (94%) delete mode 100644 src/plugins/verifyInput.js create mode 100644 src/plugins/verifyInput.ts rename src/plugins/{ytDlpWrapper.js => ytDlpWrapper.ts} (91%) diff --git a/.dockerignore b/.dockerignore index ba231c9..33e390a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,5 @@ npm-debug.log .git .gitignore .env -config.env *.md !README.md \ No newline at end of file diff --git a/config.env.example b/.env.example similarity index 100% rename from config.env.example rename to .env.example diff --git a/.gitignore b/.gitignore index ba85ba8..6b42f1f 100644 --- a/.gitignore +++ b/.gitignore @@ -136,11 +136,12 @@ dist lastfm.json sw-blocklist.txt package-lock.json -bun.lock -bun.lockb tmp/ # Executables *.exe yt-dlp -ffmpeg \ No newline at end of file +ffmpeg + +# Bun +bun.lock* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 473f9b7..7971682 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,6 @@ COPY . . RUN chmod +x /usr/src/app/src/plugins/yt-dlp/yt-dlp -VOLUME /usr/src/app/config.env +VOLUME /usr/src/app/.env CMD ["npm", "start"] \ No newline at end of file diff --git a/README.md b/README.md index 71e5cff..37e61a7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![GitHub License](https://img.shields.io/github/license/abocn/TelegramBot)](https://github.com/abocn/TelegramBot/blob/main/LICENSE) +[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff)](https://www.typescriptlang.org) [![CodeQL](https://github.com/abocn/TelegramBot/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/abocn/TelegramBot/actions/workflows/github-code-scanning/codeql) [![Dependabot Updates](https://github.com/abocn/TelegramBot/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/abocn/TelegramBot/actions/workflows/dependabot/dependabot-updates) @@ -14,7 +15,7 @@ Kowalski is a a simple Telegram bot made in Node.js. > [!IMPORTANT] > You will only need all of them if you are not running it dockerized. Read ["Running with Docker"](#running-with-docker) for more information. -- Node.js 23 or newer (you can also use [Bun](https://bun.sh)) +- [Bun](https://bun.sh) (latest is suggested) - A Telegram bot (create one at [@BotFather](https://t.me/botfather)) - FFmpeg (only for the `/yt` command) - Docker and Docker Compose (only required for Docker setup) @@ -27,18 +28,20 @@ First, clone the repo with Git: git clone --recurse-submodules https://github.com/ABOCN/TelegramBot ``` -Next, inside the repository directory, create a `config.env` file with some content, which you can see the [example .env file](config.env.example) to fill info with. To see the meaning of each one, see [the Functions section](#configenv-functions). +Next, inside the repository directory, create an `.env` file with some content, which you can see the [example .env file](.env.example) to fill info with. To see the meaning of each one, see [the Functions section](#env-functions). -After editing the file, save all changes and run the bot with ``npm start``. +After editing the file, save all changes and run the bot with ``bun start``. > [!TIP] -> To deal with dependencies, just run ``npm install`` or ``npm i`` at any moment to install all of them. +> To deal with dependencies, just run ``bun install`` or ``bun 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. +--- + > [!NOTE] > Using the `-d` flag when running causes Kowalski to run in the background. If you're just playing around or testing, you may not want to use this flag. @@ -46,7 +49,7 @@ You can also run Kowalski using Docker, which simplifies the setup process. Make ### Using Docker Compose -1. **Make sure to setup your `config.env` file first!** +1. **Make sure to setup your `.env` file first!** 2. **Run the container** @@ -58,7 +61,7 @@ You can also run Kowalski using Docker, which simplifies the setup process. Make If you prefer to use Docker directly, you can use these instructions instead. -1. **Make sure to setup your `config.env` file first!** +1. **Make sure to setup your `.env` file first!** 2. **Build the image** @@ -69,12 +72,13 @@ If you prefer to use Docker directly, you can use these instructions instead. 3. **Run the container** ```bash - docker run -d --name kowalski --restart unless-stopped -v $(pwd)/config.env:/usr/src/app/config.env:ro kowalski + docker run -d --name kowalski --restart unless-stopped -v $(pwd)/.env:/usr/src/app/.env:ro kowalski ``` -## config.env Functions +## .env Functions + > [!IMPORTANT] -> 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! +> Take care of your ``.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! - **botSource**: Put the link to your bot source code. - **botPrivacy**: Put the link to your bot privacy policy. @@ -99,7 +103,7 @@ chmod +x src/plugins/yt-dlp/yt-dlp ## Contributors - + Profile pictures of Kowalski contributors Made with [contrib.rocks](https://contrib.rocks). diff --git a/docker-compose.yml b/docker-compose.yml index 981d90a..0aab44a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,6 @@ services: container_name: kowalski restart: unless-stopped volumes: - - ./config.env:/usr/src/app/config.env:ro + - ./.env:/usr/src/app/.env:ro environment: - NODE_ENV=production \ No newline at end of file diff --git a/nodemon.json b/nodemon.json index d7508b0..918bcb8 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,3 +1,6 @@ { - "ignore": ["src/props/*.json", "src/props/*.txt"] + "ignore": ["src/props/*.json", "src/props/*.txt"], + "watch": ["src"], + "ext": "ts,js", + "exec": "bun src/bot.ts" } \ No newline at end of file diff --git a/package.json b/package.json index 379cc96..db30850 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "scripts": { - "start": "nodemon src/bot.js" + "start": "nodemon src/bot.ts" }, "dependencies": { - "@dotenvx/dotenvx": "^1.28.0", - "axios": "^1.7.9", + "@dotenvx/dotenvx": "^1.41.0", + "@types/node": "^22.15.2", + "axios": "^1.9.0", "node-html-parser": "^7.0.1", - "nodemon": "^3.1.7", + "nodemon": "^3.1.10", "telegraf": "^4.16.3", "winston": "^3.17.0" } diff --git a/src/bot.js b/src/bot.ts similarity index 77% rename from src/bot.js rename to src/bot.ts index f053de7..3422e56 100644 --- a/src/bot.js +++ b/src/bot.ts @@ -1,13 +1,13 @@ -const { Telegraf } = require('telegraf'); -const path = require('path'); -const fs = require('fs'); -const { isOnSpamWatch } = require('./spamwatch/spamwatch.js'); -require('@dotenvx/dotenvx').config({ path: "config.env" }); -require('./plugins/ytDlpWrapper.js'); +import { Telegraf } from 'telegraf'; +import path from 'path'; +import fs from 'fs'; +import { isOnSpamWatch } from './spamwatch/spamwatch'; +import '@dotenvx/dotenvx'; +import './plugins/ytDlpWrapper'; // 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.') + console.error('Bot token is not set. Please set the bot token in the .env file.') process.exit(1) } @@ -19,10 +19,13 @@ const loadCommands = () => { const commandsPath = path.join(__dirname, 'commands'); try { - const files = fs.readdirSync(commandsPath); + const files = fs.readdirSync(commandsPath) + .filter(file => file.endsWith('.ts') || file.endsWith('.js')); + files.forEach((file) => { try { - const command = require(path.join(commandsPath, file)); + const commandPath = path.join(commandsPath, file); + const command = require(commandPath).default || require(commandPath); if (typeof command === 'function') { command(bot, isOnSpamWatch); } @@ -43,7 +46,7 @@ const startBot = async () => { restartCount = 0; } catch (error) { console.error('Failed to start bot:', error.message); - if (restartCount < maxRetries) { + if (restartCount < Number(maxRetries)) { restartCount++; console.log(`Retrying to start bot... Attempt ${restartCount}`); setTimeout(startBot, 5000); diff --git a/src/commands/animal.js b/src/commands/animal.ts similarity index 67% rename from src/commands/animal.js rename to src/commands/animal.ts index 332e0e3..89eecba 100644 --- a/src/commands/animal.js +++ b/src/commands/animal.ts @@ -1,66 +1,76 @@ -const Resources = require('../props/resources.json'); -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); -const axios = require("axios"); +import Resources from '../props/resources.json'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import axios from 'axios'; +import { Context, Telegraf } from 'telegraf'; -module.exports = (bot) => { - bot.command("duck", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +export default (bot: Telegraf) => { + bot.command("duck", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); try { const response = await axios(Resources.duckApi); ctx.replyWithPhoto(response.data.url, { caption: "🦆", + // reply_to_message_id works fine, using this for now to avoid errors + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } catch (error) { const message = Strings.duckApiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); return; } }); - bot.command("fox", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command("fox", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); try { const response = await axios(Resources.foxApi); ctx.replyWithPhoto(response.data.image, { caption: "🦊", + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } catch (error) { const message = Strings.foxApiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); return; } }); - bot.command("dog", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command("dog", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); try { const response = await axios(Resources.dogApi); ctx.replyWithPhoto(response.data.message, { caption: "🐶", + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } catch (error) { const message = Strings.foxApiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); return; } }); - bot.command("cat", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command("cat", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); const apiUrl = `${Resources.catApi}?json=true`; const response = await axios.get(apiUrl); const data = response.data; @@ -70,17 +80,19 @@ module.exports = (bot) => { await ctx.replyWithPhoto(imageUrl, { caption: `🐱`, parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } catch (error) { ctx.reply(Strings.catImgErr, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }; }); - bot.command(['soggy', 'soggycat'], spamwatchMiddleware, async (ctx) => { + bot.command(['soggy', 'soggycat'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const userInput = ctx.message.text.split(' ')[1]; switch (true) { @@ -89,6 +101,7 @@ module.exports = (bot) => { Resources.soggyCat2, { caption: Resources.soggyCat2, parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); break; @@ -96,6 +109,7 @@ module.exports = (bot) => { case (userInput === "3" || userInput === "sticker"): ctx.replyWithSticker( Resources.soggyCatSticker, { + // @ts-ignore reply_to_message_id: ctx.message.message_id }); break; @@ -105,6 +119,7 @@ module.exports = (bot) => { Resources.soggyCatAlt, { caption: Resources.soggyCatAlt, parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); break; @@ -114,6 +129,7 @@ module.exports = (bot) => { Resources.soggyCat, { caption: Resources.soggyCat, parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); break; diff --git a/src/commands/codename.js b/src/commands/codename.ts similarity index 62% rename from src/commands/codename.js rename to src/commands/codename.ts index 138ae51..7f4a3e5 100644 --- a/src/commands/codename.js +++ b/src/commands/codename.ts @@ -1,11 +1,14 @@ -const Resources = require('../props/resources.json'); -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); -const axios = require('axios'); -const { verifyInput } = require('../plugins/verifyInput.js'); +import Resources from '../props/resources.json'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import axios from 'axios'; +import verifyInput from '../plugins/verifyInput'; +import { Context, Telegraf } from 'telegraf'; -async function getDeviceList() { +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +async function getDeviceList({ Strings, ctx }: { Strings: any, ctx: Context & { message: { text: string } } }) { try { const response = await axios.get(Resources.codenameApi); return response.data @@ -15,27 +18,29 @@ async function getDeviceList() { return ctx.reply(message, { parse_mode: "Markdown", + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } } -module.exports = (bot) => { - bot.command(['codename', 'whatis'], spamwatchMiddleware, async (ctx) => { +export default (bot: Telegraf) => { + bot.command(['codename', 'whatis'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const userInput = ctx.message.text.split(" ").slice(1).join(" "); - const Strings = getStrings(ctx.from.language_code); + const Strings = getStrings(ctx.from?.language_code); const { noCodename } = Strings.codenameCheck if(verifyInput(ctx, userInput, noCodename)){ return; } - const jsonRes = await getDeviceList() + const jsonRes = await getDeviceList({ Strings, ctx }) const phoneSearch = Object.keys(jsonRes).find((codename) => codename === userInput); if (!phoneSearch) { return ctx.reply(Strings.codenameCheck.notFound, { parse_mode: "Markdown", + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } @@ -50,6 +55,7 @@ module.exports = (bot) => { return ctx.reply(message, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }) diff --git a/src/commands/crew.js b/src/commands/crew.ts similarity index 62% rename from src/commands/crew.js rename to src/commands/crew.ts index e2282ea..c55b774 100644 --- a/src/commands/crew.js +++ b/src/commands/crew.ts @@ -1,9 +1,12 @@ -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); -const os = require('os'); -const { exec } = require('child_process'); -const { error } = require('console'); +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import os from 'os'; +import { exec } from 'child_process'; +import { error } from 'console'; +import { Context, Telegraf } from 'telegraf'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); function getGitCommitHash() { return new Promise((resolve, reject) => { @@ -29,7 +32,7 @@ function updateBot() { }); } -function formatUptime(uptime) { +function formatUptime(uptime: number) { const hours = Math.floor(uptime / 3600); const minutes = Math.floor((uptime % 3600) / 60); const seconds = Math.floor(uptime % 60); @@ -50,152 +53,190 @@ function getSystemInfo() { `*Uptime:* \`${formatUptime(uptime())}\`\n\n`; } -async function handleAdminCommand(ctx, action, successMessage, errorMessage) { - const Strings = getStrings(ctx.from.language_code); - const userId = ctx.from.id; - const adminArray = JSON.parse("[" + process.env.botAdmins + "]"); - if (adminArray.includes(userId)) { +async function handleAdminCommand(ctx: Context & { message: { text: string } }, action: () => Promise, successMessage: string, errorMessage: string) { + const Strings = getStrings(ctx.from?.language_code); + const userId = ctx.from?.id; + const adminArray = process.env.botAdmins ? process.env.botAdmins.split(',').map(id => parseInt(id.trim())) : []; + if (userId && adminArray.includes(userId)) { try { await action(); - ctx.reply(successMessage, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id - }); + if (successMessage) { + ctx.reply(successMessage, { + parse_mode: 'Markdown', + // @ts-ignore + reply_to_message_id: ctx.message.message_id + }); + } } catch (error) { ctx.reply(errorMessage.replace(/{error}/g, error.message), { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } } else { ctx.reply(Strings.noPermission, { + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } } -module.exports = (bot) => { - bot.command('getbotstats', spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); +export default (bot: Telegraf) => { + bot.command('getbotstats', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); handleAdminCommand(ctx, async () => { const stats = getSystemInfo(); await ctx.reply(stats, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }, '', Strings.errorRetrievingStats); }); - bot.command('getbotcommit', spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command('getbotcommit', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); handleAdminCommand(ctx, async () => { try { const commitHash = await getGitCommitHash(); await ctx.reply(Strings.gitCurrentCommit.replace(/{commitHash}/g, commitHash), { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } catch (error) { ctx.reply(Strings.gitErrRetrievingCommit.replace(/{error}/g, error), { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } }, '', Strings.gitErrRetrievingCommit); }); - bot.command('updatebot', spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command('updatebot', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); handleAdminCommand(ctx, async () => { try { const result = await updateBot(); await ctx.reply(Strings.botUpdated.replace(/{result}/g, result), { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } catch (error) { ctx.reply(Strings.errorUpdatingBot.replace(/{error}/g, error), { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } }, '', Strings.errorUpdatingBot); }); - bot.command('setbotname', spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command('setbotname', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); const botName = ctx.message.text.split(' ').slice(1).join(' '); handleAdminCommand(ctx, async () => { await ctx.telegram.setMyName(botName); }, Strings.botNameChanged.replace(/{botName}/g, botName), Strings.botNameErr.replace(/{error}/g, error)); }); - bot.command('setbotdesc', spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command('setbotdesc', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); const botDesc = ctx.message.text.split(' ').slice(1).join(' '); handleAdminCommand(ctx, async () => { await ctx.telegram.setMyDescription(botDesc); }, Strings.botDescChanged.replace(/{botDesc}/g, botDesc), Strings.botDescErr.replace(/{error}/g, error)); }); - bot.command('botkickme', spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command('botkickme', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); handleAdminCommand(ctx, async () => { + if (!ctx.chat) { + ctx.reply(Strings.chatNotFound, { + parse_mode: 'Markdown', + // @ts-ignore + reply_to_message_id: ctx.message.message_id + }); + return; + } ctx.reply(Strings.kickingMyself, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); await ctx.telegram.leaveChat(ctx.chat.id); }, '', Strings.kickingMyselfErr); }); - bot.command('getfile', spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command('getfile', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); const botFile = ctx.message.text.split(' ').slice(1).join(' '); + + if (!botFile) { + ctx.reply(Strings.noFileProvided, { + parse_mode: 'Markdown', + // @ts-ignore + reply_to_message_id: ctx.message.message_id + }); + return; + } + handleAdminCommand(ctx, async () => { try { await ctx.replyWithDocument({ + // @ts-ignore source: botFile, caption: botFile + }, { + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } catch (error) { ctx.reply(Strings.unexpectedErr.replace(/{error}/g, error.message), { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } }, '', Strings.unexpectedErr); }); - bot.command('run', spamwatchMiddleware, async (ctx) => { + bot.command('run', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const command = ctx.message.text.split(' ').slice(1).join(' '); handleAdminCommand(ctx, async () => { if (!command) { - return ctx.reply('Por favor, forneça um comando para executar.'); + ctx.reply('Por favor, forneça um comando para executar.'); + return; } exec(command, (error, stdout, stderr) => { if (error) { return ctx.reply(`\`${error.message}\``, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } if (stderr) { return ctx.reply(`\`${stderr}\``, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } ctx.reply(`\`${stdout}\``, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }); }, '', "Nope!"); }); - bot.command('eval', spamwatchMiddleware, async (ctx) => { + bot.command('eval', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const code = ctx.message.text.split(' ').slice(1).join(' '); if (!code) { return ctx.reply('Por favor, forneça um código para avaliar.'); @@ -205,19 +246,21 @@ module.exports = (bot) => { const result = eval(code); ctx.reply(`Result: ${result}`, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } catch (error) { ctx.reply(`Error: ${error.message}`, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } }); - bot.command('crash', spamwatchMiddleware, async (ctx) => { + bot.command('crash', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { handleAdminCommand(ctx, async () => { - ctx.reply(null); + ctx.reply('Crashed!'); }, '', "Nope!"); }); }; diff --git a/src/commands/fun.js b/src/commands/fun.js deleted file mode 100644 index 0c64bbe..0000000 --- a/src/commands/fun.js +++ /dev/null @@ -1,101 +0,0 @@ -const Resources = require('../props/resources.json'); -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); - -function sendRandomReply(ctx, gifUrl, textKey) { - const Strings = getStrings(ctx.from.language_code); - const randomNumber = Math.floor(Math.random() * 100); - const shouldSendGif = randomNumber > 50; - - const caption = Strings[textKey].replace('{randomNum}', randomNumber) - - if (shouldSendGif) { - ctx.replyWithAnimation(gifUrl, { - caption, - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id - }).catch(err => { - gifErr = gifErr.replace('{err}', err); - ctx.reply(Strings.gifErr, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id - }); - }); - } else { - ctx.reply(caption, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id - }); - } -} - - -async function handleDiceCommand(ctx, emoji, delay) { - const Strings = getStrings(ctx.from.language_code); - - const result = await ctx.sendDice({ emoji, reply_to_message_id: ctx.message.message_id }); - const botResponse = Strings.funEmojiResult - .replace('{emoji}', result.dice.emoji) - .replace('{value}', result.dice.value); - - setTimeout(() => { - ctx.reply(botResponse, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id - }); - }, delay); -} - -function getRandomInt(max) { - return Math.floor(Math.random() * (max + 1)); -} - -module.exports = (bot) => { - bot.command('random', spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); - const randomValue = getRandomInt(11); - const randomVStr = Strings.randomNum.replace('{number}', randomValue); - - ctx.reply( - randomVStr, { - parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id - }); - }); - - bot.command('dice', spamwatchMiddleware, async (ctx) => { - await handleDiceCommand(ctx, undefined, 4000); - }); - - bot.command('slot', spamwatchMiddleware, async (ctx) => { - await handleDiceCommand(ctx, '🎰', 3000); - }); - - bot.command('ball', spamwatchMiddleware, async (ctx) => { - await handleDiceCommand(ctx, '⚽', 3000); - }); - - bot.command('dart', spamwatchMiddleware, async (ctx) => { - await handleDiceCommand(ctx, '🎯', 3000); - }); - - bot.command('bowling', spamwatchMiddleware, async (ctx) => { - await handleDiceCommand(ctx, '🎳', 3000); - }); - - bot.command('idice', spamwatchMiddleware, async (ctx) => { - ctx.replyWithSticker( - Resources.infiniteDice, { - reply_to_message_id: ctx.message.message_id - }); - }); - - bot.command('furry', spamwatchMiddleware, async (ctx) => { - sendRandomReply(ctx, Resources.furryGif, 'furryAmount'); - }); - - bot.command('gay', spamwatchMiddleware, async (ctx) => { - sendRandomReply(ctx, Resources.gayFlag, 'gayAmount'); - }); -}; \ No newline at end of file diff --git a/src/commands/fun.ts b/src/commands/fun.ts new file mode 100644 index 0000000..b1437bf --- /dev/null +++ b/src/commands/fun.ts @@ -0,0 +1,112 @@ +import Resources from '../props/resources.json'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import { Context, Telegraf } from 'telegraf'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +function sendRandomReply(ctx: Context & { message: { text: string } }, gifUrl: string, textKey: string) { + const Strings = getStrings(ctx.from?.language_code); + const randomNumber = Math.floor(Math.random() * 100); + const shouldSendGif = randomNumber > 50; + + const caption = Strings[textKey].replace('{randomNum}', randomNumber) + + if (shouldSendGif) { + ctx.replyWithAnimation(gifUrl, { + caption, + parse_mode: 'Markdown', + // @ts-ignore + reply_to_message_id: ctx.message.message_id + }).catch(err => { + const gifErr = Strings.gifErr.replace('{err}', err); + ctx.reply(gifErr, { + parse_mode: 'Markdown', + // @ts-ignore + reply_to_message_id: ctx.message.message_id + }); + }); + } else { + ctx.reply(caption, { + parse_mode: 'Markdown', + // @ts-ignore + reply_to_message_id: ctx.message.message_id + }); + } +} + + +async function handleDiceCommand(ctx: Context & { message: { text: string } }, emoji: string, delay: number) { + const Strings = getStrings(ctx.from?.language_code); + + // @ts-ignore + const result = await ctx.sendDice({ emoji, reply_to_message_id: ctx.message.message_id }); + const botResponse = Strings.funEmojiResult + .replace('{emoji}', result.dice.emoji) + .replace('{value}', result.dice.value); + + setTimeout(() => { + ctx.reply(botResponse, { + parse_mode: 'Markdown', + // @ts-ignore + reply_to_message_id: ctx.message.message_id + }); + }, delay); +} + +function getRandomInt(max) { + return Math.floor(Math.random() * (max + 1)); +} + +export default (bot: Telegraf) => { + bot.command('random', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code); + const randomValue = getRandomInt(11); + const randomVStr = Strings.randomNum.replace('{number}', randomValue); + + ctx.reply( + randomVStr, { + parse_mode: 'Markdown', + // @ts-ignore + reply_to_message_id: ctx.message.message_id + }); + }); + + // TODO: maybe send custom stickers to match result of the roll? i think there are pre-existing ones + bot.command('dice', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + await handleDiceCommand(ctx, '🎲', 4000); + }); + + bot.command('slot', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + await handleDiceCommand(ctx, '🎰', 3000); + }); + + bot.command('ball', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + await handleDiceCommand(ctx, '⚽', 3000); + }); + + bot.command('dart', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + await handleDiceCommand(ctx, '🎯', 3000); + }); + + bot.command('bowling', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + await handleDiceCommand(ctx, '🎳', 3000); + }); + + bot.command('idice', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + ctx.replyWithSticker( + Resources.infiniteDice, { + // @ts-ignore + reply_to_message_id: ctx.message.message_id + }); + }); + + bot.command('furry', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + sendRandomReply(ctx, Resources.furryGif, 'furryAmount'); + }); + + bot.command('gay', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + sendRandomReply(ctx, Resources.gayFlag, 'gayAmount'); + }); +}; \ No newline at end of file diff --git a/src/commands/gsmarena.js b/src/commands/gsmarena.ts similarity index 91% rename from src/commands/gsmarena.js rename to src/commands/gsmarena.ts index 23478c3..43a20ed 100644 --- a/src/commands/gsmarena.js +++ b/src/commands/gsmarena.ts @@ -4,18 +4,23 @@ // With some help from GPT (I don't really like AI but whatever) // If this were a kang, I would not be giving credits to him! -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import axios from 'axios'; +import { parse } from 'node-html-parser'; -const axios = require('axios'); -const { parse } = require('node-html-parser'); +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -class PhoneSearchResult { - constructor(name, url) { - this.name = name; - this.url = url; - Object.freeze(this); - } +interface PhoneSearchResult { + name: string; + url: string; +} + +interface PhoneDetails { + specs: Record>; + name?: string; + url?: string; + picture?: string; } const HEADERS = { @@ -32,7 +37,7 @@ function getDataFromSpecs(specsData, category, attributes) { .join("\n"); } -function parseSpecs(specsData) { +function parseSpecs(specsData: PhoneDetails): PhoneDetails { const categories = { "status": ["Launch", ["Status"]], "network": ["Network", ["Technology"]], @@ -69,7 +74,7 @@ function parseSpecs(specsData) { const [cat, attrs] = categories[key]; acc[key] = getDataFromSpecs(specsData, cat, attrs) || ""; return acc; - }, {}); + }, { specs: {} } as PhoneDetails); parsedData["name"] = specsData.name || ""; parsedData["url"] = specsData.url || ""; @@ -77,7 +82,7 @@ function parseSpecs(specsData) { return parsedData; } -function formatPhone(phone) { +function formatPhone(phone: PhoneDetails) { const formattedPhone = parseSpecs(phone); const attributesDict = { "Status": "status", @@ -132,7 +137,7 @@ async function fetchHtml(url) { } } -async function searchPhone(phone) { +async function searchPhone(phone: string): Promise { try { const searchUrl = `https://m.gsmarena.com/results.php3?sQuickSearch=yes&sName=${encodeURIComponent(phone)}`; const htmlContent = await fetchHtml(searchUrl); @@ -142,7 +147,7 @@ async function searchPhone(phone) { return foundPhones.map((phoneTag) => { const name = phoneTag.querySelector('img')?.getAttribute('title') || ""; const url = phoneTag.querySelector('a')?.getAttribute('href') || ""; - return new PhoneSearchResult(name, url); + return { name, url }; }); } catch (error) { console.error("Error searching for phone:", error); @@ -164,7 +169,7 @@ async function checkPhoneDetails(url) { return { ...specsData, name, picture, url: `https://www.gsmarena.com/${url}` }; } catch (error) { console.error("Error fetching phone details:", error); - return {}; + return { specs: {}, name: "", url: "", picture: "" }; } } @@ -201,7 +206,7 @@ function getUsername(ctx){ return userName; } -module.exports = (bot) => { +export default (bot) => { bot.command(['d', 'device'], spamwatchMiddleware, async (ctx) => { const userId = ctx.from.id; const userName = getUsername(ctx); diff --git a/src/commands/help.js b/src/commands/help.ts similarity index 85% rename from src/commands/help.js rename to src/commands/help.ts index b4cc44b..95ea804 100644 --- a/src/commands/help.js +++ b/src/commands/help.ts @@ -1,6 +1,17 @@ -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +interface MessageOptions { + parse_mode: string; + disable_web_page_preview: boolean; + reply_markup: { + inline_keyboard: { text: any; callback_data: string; }[][]; + }; + reply_to_message_id?: number; +} async function sendHelpMessage(ctx, isEditing) { const Strings = getStrings(ctx.from.language_code); @@ -11,8 +22,8 @@ async function sendHelpMessage(ctx, isEditing) { function getMessageId(ctx) { return ctx.message?.message_id || ctx.callbackQuery?.message?.message_id; }; - const createOptions = (ctx, includeReplyTo = false) => { - const options = { + const createOptions = (ctx, includeReplyTo = false): MessageOptions => { + const options: MessageOptions = { parse_mode: 'Markdown', disable_web_page_preview: true, reply_markup: { @@ -39,9 +50,9 @@ async function sendHelpMessage(ctx, isEditing) { }; } -module.exports = (bot) => { +export default (bot) => { bot.help(spamwatchMiddleware, async (ctx) => { - await sendHelpMessage(ctx); + await sendHelpMessage(ctx, false); }); bot.command("about", spamwatchMiddleware, async (ctx) => { diff --git a/src/commands/http.js b/src/commands/http.ts similarity index 84% rename from src/commands/http.js rename to src/commands/http.ts index 1382ad6..5e47a49 100644 --- a/src/commands/http.js +++ b/src/commands/http.ts @@ -1,11 +1,13 @@ -const Resources = require('../props/resources.json'); -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); -const axios = require('axios'); -const { verifyInput } = require('../plugins/verifyInput.js'); +import Resources from '../props/resources.json'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import axios from 'axios'; +import verifyInput from '../plugins/verifyInput'; -module.exports = (bot) => { +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +export default (bot) => { bot.command("http", spamwatchMiddleware, async (ctx) => { const Strings = getStrings(ctx.from.language_code); const userInput = ctx.message.text.split(' ')[1]; diff --git a/src/commands/info.js b/src/commands/info.ts similarity index 85% rename from src/commands/info.js rename to src/commands/info.ts index eed4874..a19651c 100644 --- a/src/commands/info.js +++ b/src/commands/info.ts @@ -1,6 +1,8 @@ -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); async function getUserInfo(ctx) { const Strings = getStrings(ctx.from.language_code); @@ -9,7 +11,7 @@ async function getUserInfo(ctx) { lastName = " "; } - userInfo = Strings.userInfo + const userInfo = Strings.userInfo .replace('{userName}', `${ctx.from.first_name} ${lastName}` || Strings.varStrings.varUnknown) .replace('{userId}', ctx.from.id || Strings.varStrings.varUnknown) .replace('{userHandle}', ctx.from.username ? `@${ctx.from.username}` : Strings.varStrings.varNone) @@ -22,7 +24,7 @@ async function getUserInfo(ctx) { async function getChatInfo(ctx) { const Strings = getStrings(ctx.from.language_code); if (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup') { - chatInfo = Strings.chatInfo + const chatInfo = Strings.chatInfo .replace('{chatId}', ctx.chat.id || Strings.varStrings.varUnknown) .replace('{chatName}', ctx.chat.title || Strings.varStrings.varUnknown) .replace('{chatHandle}', ctx.chat.username ? `@${ctx.chat.username}` : Strings.varStrings.varNone) @@ -40,7 +42,7 @@ async function getChatInfo(ctx) { } } -module.exports = (bot) => { +export default (bot) => { bot.command('chatinfo', spamwatchMiddleware, async (ctx) => { const chatInfo = await getChatInfo(ctx); ctx.reply( diff --git a/src/commands/lastfm.js b/src/commands/lastfm.ts similarity index 92% rename from src/commands/lastfm.js rename to src/commands/lastfm.ts index 1b1976f..39d6c8d 100644 --- a/src/commands/lastfm.js +++ b/src/commands/lastfm.ts @@ -1,9 +1,11 @@ -const Resources = require('../props/resources.json'); -const fs = require('fs'); -const axios = require('axios'); -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); +import Resources from '../props/resources.json'; +import fs from 'fs'; +import axios from 'axios'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); const scrobbler_url = Resources.lastFmApi; const api_key = process.env.lastKey; @@ -35,7 +37,7 @@ function saveUsers() { } } -async function getFromMusicBrainz(mbid) { +async function getFromMusicBrainz(mbid: string) { try { const response = await axios.get(`${Resources.musicBrainzApi}${mbid}`); const imgObjLarge = response.data.images[0]?.thumbnails?.['1200']; @@ -58,7 +60,7 @@ function getFromLast(track) { return imageUrl; } -module.exports = (bot) => { +export default (bot) => { loadUsers(); bot.command('setuser', (ctx) => { @@ -149,7 +151,7 @@ module.exports = (bot) => { const artistUrl = `https://www.last.fm/music/${encodeURIComponent(artistName)}`; const userUrl = `https://www.last.fm/user/${encodeURIComponent(lastfmUser)}`; - let num_plays = ''; + let num_plays = 0; try { const response_plays = await axios.get(scrobbler_url, { params: { @@ -164,11 +166,8 @@ module.exports = (bot) => { 'User-Agent': `@${botInfo.username}-node-telegram-bot` } }); + num_plays = response_plays.data.track.userplaycount; - - if (!num_plays || num_plays === undefined) { - num_plays = 0; - }; } catch (err) { console.log(err) const message = Strings.lastFm.apiErr diff --git a/src/commands/main.js b/src/commands/main.ts similarity index 60% rename from src/commands/main.js rename to src/commands/main.ts index 975762d..2729173 100644 --- a/src/commands/main.js +++ b/src/commands/main.ts @@ -1,9 +1,11 @@ -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; -module.exports = (bot) => { - bot.start(spamwatchMiddleware, async (ctx) => { +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +export default (bot: any) => { + bot.start(spamwatchMiddleware, async (ctx: any) => { const Strings = getStrings(ctx.from.language_code); const botInfo = await ctx.telegram.getMe(); const startMsg = Strings.botWelcome.replace(/{botName}/g, botInfo.first_name); @@ -14,7 +16,7 @@ module.exports = (bot) => { }); }); - bot.command('privacy', spamwatchMiddleware, async (ctx) => { + bot.command('privacy', spamwatchMiddleware, async (ctx: any) => { const Strings = getStrings(ctx.from.language_code); const message = Strings.botPrivacy.replace("{botPrivacy}", process.env.botPrivacy); diff --git a/src/commands/modarchive.js b/src/commands/modarchive.ts similarity index 73% rename from src/commands/modarchive.js rename to src/commands/modarchive.ts index 4e89370..eac76a8 100644 --- a/src/commands/modarchive.js +++ b/src/commands/modarchive.ts @@ -1,12 +1,19 @@ -const Resources = require('../props/resources.json'); -const axios = require('axios'); -const fs = require('fs'); -const path = require('path'); -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); +import Resources from '../props/resources.json'; +import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; -async function downloadModule(moduleId) { +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +interface ModuleResult { + filePath: string; + fileName: string; +} + +async function downloadModule(moduleId: string): Promise { try { const downloadUrl = `${Resources.modArchiveApi}${moduleId}`; const response = await axios({ @@ -39,12 +46,12 @@ async function downloadModule(moduleId) { } } -module.exports = (bot) => { +export default (bot) => { bot.command(['modarchive', 'tma'], spamwatchMiddleware, async (ctx) => { const Strings = getStrings(ctx.from.language_code); const moduleId = ctx.message.text.split(' ')[1]; - if (moduleId == NaN || null) { + if (Number.isNaN(moduleId) || null) { return ctx.reply(Strings.maInvalidModule, { parse_mode: "Markdown", reply_to_message_id: ctx.message.message_id diff --git a/src/commands/ponyapi.js b/src/commands/ponyapi.ts similarity index 86% rename from src/commands/ponyapi.js rename to src/commands/ponyapi.ts index e5fbf94..dfa2e1c 100644 --- a/src/commands/ponyapi.js +++ b/src/commands/ponyapi.ts @@ -1,15 +1,56 @@ -const Resources = require('../props/resources.json'); -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); -const axios = require("axios"); -const { verifyInput } = require('../plugins/verifyInput.js'); +import Resources from '../props/resources.json'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import axios from 'axios'; +import verifyInput from '../plugins/verifyInput'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +interface Character { + id: string; + name: string; + alias: string; + url: string; + sex: string; + residence: string; + occupation: string; + kind: string; + image: string[]; +} + +interface Episode { + id: string; + name: string; + image: string; + url: string; + season: string; + episode: string; + overall: string; + airdate: string; + storyby: string; + writtenby: string; + storyboard: string; +} + +interface Comic { + id: string; + name: string; + series: string; + image: string; + url: string; + writer: string; + artist: string; + colorist: string; + letterer: string; + editor: string; +} function capitalizeFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } -module.exports = (bot) => { +export default (bot) => { bot.command("mlp", spamwatchMiddleware, async (ctx) => { const Strings = getStrings(ctx.from.language_code); @@ -34,11 +75,11 @@ module.exports = (bot) => { try { const response = await axios(apiUrl); - const charactersArray = []; + const charactersArray: Character[] = []; if (Array.isArray(response.data.data)) { response.data.data.forEach(character => { - let aliases = []; + let aliases: string[] = []; if (character.alias) { if (typeof character.alias === 'string') { aliases.push(character.alias); @@ -107,7 +148,7 @@ module.exports = (bot) => { try { const response = await axios(apiUrl); - const episodeArray = []; + const episodeArray: Episode[] = []; if (Array.isArray(response.data.data)) { response.data.data.forEach(episode => { @@ -175,15 +216,15 @@ module.exports = (bot) => { try { const response = await axios(apiUrl); - const comicArray = []; + const comicArray: Comic[] = []; if (Array.isArray(response.data.data)) { response.data.data.forEach(comic => { - let letterers = []; + let letterers: string[] = []; if (comic.letterer) { if (typeof comic.letterer === 'string') { letterers.push(comic.letterer); } else if (Array.isArray(comic.letterer)) { - letterers = aliases.concat(comic.letterer); + letterers = letterers.concat(comic.letterer); } } comicArray.push({ diff --git a/src/commands/quotes.ts b/src/commands/quotes.ts new file mode 100644 index 0000000..4c59f2a --- /dev/null +++ b/src/commands/quotes.ts @@ -0,0 +1,32 @@ +/* +import Resources from '../props/resources.json'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import escape from 'markdown-escape'; +import axios from 'axios'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +export default (bot) => { + bot.command("quote", spamwatchMiddleware, async (ctx) => { + const Strings = getStrings(ctx.from.language_code); + + try { + const response = await axios.get(Resources.quoteApi); + const data = response.data; + + ctx.reply(escape(`${escape(Strings.quoteResult)}\n> *${escape(data.quote)}*\n_${escape(data.author)}_`), { + reply_to_message_id: ctx.message.message_id, + parse_mode: 'Markdown' + }); + } catch (error) { + console.error(error); + ctx.reply(Strings.quoteErr, { + reply_to_message_id: ctx.message.id, + parse_mode: 'MarkdownV2' + }); + }; + }); +}; +*/ \ No newline at end of file diff --git a/src/commands/randompony.js b/src/commands/randompony.ts similarity index 72% rename from src/commands/randompony.js rename to src/commands/randompony.ts index 1be1e73..0ca140d 100644 --- a/src/commands/randompony.js +++ b/src/commands/randompony.ts @@ -1,15 +1,17 @@ -const Resources = require('../props/resources.json'); -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); -const axios = require("axios"); +import Resources from '../props/resources.json'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import axios from 'axios'; -module.exports = (bot) => { +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +export default (bot) => { bot.command(["rpony", "randompony", "mlpart"], spamwatchMiddleware, async (ctx) => { const Strings = getStrings(ctx.from.language_code); try { const response = await axios(Resources.randomPonyApi); - let tags = []; + let tags: string[] = []; if (response.data.pony.tags) { if (typeof response.data.pony.tags === 'string') { diff --git a/src/commands/weather.js b/src/commands/weather.ts similarity index 90% rename from src/commands/weather.js rename to src/commands/weather.ts index be1ebe9..716726c 100644 --- a/src/commands/weather.js +++ b/src/commands/weather.ts @@ -2,12 +2,14 @@ // Copyright (c) 2024 BubbalooTeam. (https://github.com/BubbalooTeam) // Minor code changes by lucmsilva (https://github.com/lucmsilva651) -const Resources = require('../props/resources.json'); -const axios = require('axios'); -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); -const { verifyInput } = require('../plugins/verifyInput.js'); +import Resources from '../props/resources.json'; +import axios from 'axios'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import verifyInput from '../plugins/verifyInput'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); const statusEmojis = { 0: '⛈', 1: '⛈', 2: '⛈', 3: '⛈', 4: '⛈', 5: '🌨', 6: '🌨', 7: '🌨', @@ -31,7 +33,7 @@ function getLocaleUnit(countryCode) { } } -module.exports = (bot) => { +export default (bot) => { bot.command(['clima', 'weather'], spamwatchMiddleware, async (ctx) => { const userLang = ctx.from.language_code || "en-US"; const Strings = getStrings(userLang); diff --git a/src/commands/wiki.ts b/src/commands/wiki.ts new file mode 100644 index 0000000..f6ca6d0 --- /dev/null +++ b/src/commands/wiki.ts @@ -0,0 +1,38 @@ +/* +import axios from "axios"; + +function capitalizeFirstLetter(string: string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +function mediaWikiToMarkdown(input: string) { + input = input.replace(/===(.*?)===/g, '*$1*'); + input = input.replace(/==(.*?)==/g, '*$1*'); + input = input.replace(/=(.*?)=/g, '*$1*'); + input = input.replace(/'''(.*?)'''/g, '**$1**'); + input = input.replace(/''(.*?)''/g, '_$1_'); + input = input.replace(/^\*\s/gm, '- '); + input = input.replace(/^\#\s/gm, '1. '); + input = input.replace(/{{Quote(.*?)}}/g, "```\n$1```\n"); + input = input.replace(/\[\[(.*?)\|?(.*?)\]\]/g, (_, link, text) => { + const sanitizedLink = link.replace(/ /g, '_'); + return text ? `[${text}](${sanitizedLink})` : `[${sanitizedLink}](${sanitizedLink})`; + }); + input = input.replace(/\[\[File:(.*?)\|.*?\]\]/g, '![$1](https://en.wikipedia.org/wiki/File:$1)'); + + return input; +} + +export default (bot) => { + bot.command("wiki", async (ctx) => { + const userInput = capitalizeFirstLetter(ctx.message.text.split(' ')[1]); + const apiUrl = `https://en.wikipedia.org/w/index.php?title=${userInput}&action=raw`; + const response = await axios(apiUrl, { headers: { 'Accept': "text/plain" } }); + const convertedResponse = response.data.replace(/<\/?div>/g, "").replace(/{{Infobox.*?}}/s, ""); + + const result = mediaWikiToMarkdown(convertedResponse).slice(0, 2048); + + ctx.reply(result, { parse_mode: 'Markdown', disable_web_page_preview: true, reply_to_message_id: ctx.message.message_id }); + }); +}; +*/ \ No newline at end of file diff --git a/src/commands/youtube.js b/src/commands/youtube.ts similarity index 89% rename from src/commands/youtube.js rename to src/commands/youtube.ts index d3fa755..9fa6364 100644 --- a/src/commands/youtube.js +++ b/src/commands/youtube.ts @@ -1,10 +1,12 @@ -const { getStrings } = require('../plugins/checkLang.js'); -const { isOnSpamWatch } = require('../spamwatch/spamwatch.js'); -const spamwatchMiddleware = require('../spamwatch/Middleware.js')(isOnSpamWatch); -const { execFile } = require('child_process'); -const os = require('os'); -const fs = require('fs'); -const path = require('path'); +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import { execFile } from 'child_process'; +import os from 'os'; +import fs from 'fs'; +import path from 'path'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); const ytDlpPaths = { linux: path.resolve(__dirname, '../plugins/yt-dlp/yt-dlp'), @@ -28,7 +30,7 @@ const getFfmpegPath = () => { return ffmpegPaths[platform] || ffmpegPaths.linux; }; -const downloadFromYoutube = async (command, args) => { +const downloadFromYoutube = async (command: string, args: string[]): Promise<{ stdout: string; stderr: string }> => { return new Promise((resolve, reject) => { execFile(command, args, (error, stdout, stderr) => { if (error) { @@ -40,8 +42,8 @@ const downloadFromYoutube = async (command, args) => { }); }; -const getApproxSize = async (command, videoUrl) => { - let args = []; +const getApproxSize = async (command: string, videoUrl: string): Promise => { + let args: string[] = []; 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 { @@ -60,7 +62,7 @@ const getApproxSize = async (command, videoUrl) => { } }; -module.exports = (bot) => { +export default (bot) => { bot.command(['yt', 'ytdl', 'sdl', 'video', 'dl'], spamwatchMiddleware, async (ctx) => { const Strings = getStrings(ctx.from.language_code); const ytDlpPath = getYtDlpPath(); @@ -198,11 +200,8 @@ module.exports = (bot) => { } } catch (error) { const errMsg = Strings.ytDownload.uploadErr.replace("{error}", error) - await ctx.telegram.editMessageText( - ctx.chat.id, - downloadingMessage.message_id, - null, - errMsg, { + // will no longer edit the message as the message context is not outside the try block + await ctx.reply(errMsg, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id, }, diff --git a/src/locales/english.json b/src/locales/english.json index 26e1244..5e21a15 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -110,5 +110,7 @@ "notFound": "Phone not found.", "resultMsg": "*Name:* `{name}`\n*Brand:* `{brand}`\n*Model:* `{model}`\n*Codename:* `{codename}`", "apiErr": "An error occurred while fetching data from the API.\n\n`{err}`" - } + }, + "chatNotFound": "Chat not found.", + "noFileProvided": "Please provide a file to send." } \ No newline at end of file diff --git a/src/plugins/checklang.js b/src/plugins/checklang.ts similarity index 94% rename from src/plugins/checklang.js rename to src/plugins/checklang.ts index bd8f126..a8d1b79 100644 --- a/src/plugins/checklang.js +++ b/src/plugins/checklang.ts @@ -17,6 +17,4 @@ function getStrings(languageCode) { } } -module.exports = { - getStrings -}; \ No newline at end of file +export { getStrings }; \ No newline at end of file diff --git a/src/plugins/verifyInput.js b/src/plugins/verifyInput.js deleted file mode 100644 index feae352..0000000 --- a/src/plugins/verifyInput.js +++ /dev/null @@ -1,14 +0,0 @@ -function verifyInput(ctx, userInput, message, verifyNaN = false) { - if (!userInput || (verifyNaN && isNaN(userInput))) { - ctx.reply(message, { - parse_mode: "Markdown", - reply_to_message_id: ctx.message.message_id - }); - return true; - } - return false; -} - -module.exports = { - verifyInput -}; \ No newline at end of file diff --git a/src/plugins/verifyInput.ts b/src/plugins/verifyInput.ts new file mode 100644 index 0000000..b1355fd --- /dev/null +++ b/src/plugins/verifyInput.ts @@ -0,0 +1,10 @@ +export default function verifyInput(ctx: any, userInput: string, message: string, verifyNaN = false) { + if (!userInput || (verifyNaN && isNaN(Number(userInput)))) { // not sure why isNaN is used here, but the input should be a number + ctx.reply(message, { + parse_mode: "Markdown", + reply_to_message_id: ctx.message.message_id + }); + return true; + } + return false; +} diff --git a/src/plugins/ytDlpWrapper.js b/src/plugins/ytDlpWrapper.ts similarity index 91% rename from src/plugins/ytDlpWrapper.js rename to src/plugins/ytDlpWrapper.ts index 8d2298e..2313b4c 100644 --- a/src/plugins/ytDlpWrapper.js +++ b/src/plugins/ytDlpWrapper.ts @@ -1,7 +1,7 @@ -const axios = require('axios'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); +import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; const downloadDir = path.resolve(__dirname, 'yt-dlp'); From 2ce89efe7f2a683546e0c34006951f5fbbbb9d52 Mon Sep 17 00:00:00 2001 From: Luquinhas Date: Tue, 29 Apr 2025 16:55:43 -0300 Subject: [PATCH 02/47] Update spamwatch lib reference --- src/spamwatch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spamwatch b/src/spamwatch index 2f532fd..cee30dc 160000 --- a/src/spamwatch +++ b/src/spamwatch @@ -1 +1 @@ -Subproject commit 2f532fdd0d83566c27d22ad7ca2dfbcf4680786d +Subproject commit cee30dc64217e7ec235635f5bf5066eac56eec87 From d07cc0390f71a74cb6bf8d42e3652e719fee6d3e Mon Sep 17 00:00:00 2001 From: Lucas Gabriel Date: Tue, 29 Apr 2025 17:42:39 -0300 Subject: [PATCH 03/47] Update Dockerfile - Bun instead of NPM --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7971682..78a6e3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-slim +FROM oven/bun # Install ffmpeg and other deps RUN apt-get update && apt-get install -y ffmpeg git && apt-get clean && rm -rf /var/lib/apt/lists/* @@ -15,4 +15,4 @@ RUN chmod +x /usr/src/app/src/plugins/yt-dlp/yt-dlp VOLUME /usr/src/app/.env -CMD ["npm", "start"] \ No newline at end of file +CMD ["bun", "start"] From ac7119dee522d89f6603945da85120c61b22759f Mon Sep 17 00:00:00 2001 From: Lucas Gabriel Date: Tue, 29 Apr 2025 17:45:04 -0300 Subject: [PATCH 04/47] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 78a6e3c..7a0c006 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /usr/src/app COPY package*.json ./ -RUN npm install +RUN bun i COPY . . From 19ce5295b10795715a84ca845d8015f20f61ac6e Mon Sep 17 00:00:00 2001 From: Aidan Date: Tue, 29 Apr 2025 19:27:08 -0400 Subject: [PATCH 05/47] feat: add checking and logging for valid yt url --- package.json | 3 ++- src/commands/youtube.ts | 31 +++++++++++++++++++++---------- src/locales/english.json | 3 ++- src/locales/portuguese.json | 3 ++- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index db30850..d617c84 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "node-html-parser": "^7.0.1", "nodemon": "^3.1.10", "telegraf": "^4.16.3", - "winston": "^3.17.0" + "winston": "^3.17.0", + "youtube-url": "^0.5.0" } } diff --git a/src/commands/youtube.ts b/src/commands/youtube.ts index 9fa6364..09ce859 100644 --- a/src/commands/youtube.ts +++ b/src/commands/youtube.ts @@ -5,6 +5,7 @@ import { execFile } from 'child_process'; import os from 'os'; import fs from 'fs'; import path from 'path'; +import * as ytUrl from 'youtube-url'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -66,15 +67,19 @@ export default (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]; + const userId: number = ctx.from.id; + const videoUrl: string = ctx.message.text.split(' ').slice(1).join(' '); + const videoUrlSafe: boolean = ytUrl.valid(videoUrl); + const randId: string = Math.random().toString(36).substring(2, 15); + const mp4File: string = `tmp/${userId}-${randId}.mp4`; + const tempMp4File: string = `tmp/${userId}-${randId}.f137.mp4`; + const tempWebmFile: string = `tmp/${userId}-${randId}.f251.webm`; + let cmdArgs: string = ""; + const dlpCommand: string = ytDlpPath; + const ffmpegPath: string = getFfmpegPath(); + const ffmpegArgs: string[] = ['-i', tempMp4File, '-i', tempWebmFile, '-c:v copy -c:a copy -strict -2', mp4File]; + + console.log(`DOWNLOADING: ${videoUrl}\nSAFE: ${videoUrlSafe}\n`) if (!videoUrl) { return ctx.reply(Strings.ytDownload.noLink, { @@ -82,7 +87,13 @@ export default (bot) => { disable_web_page_preview: true, reply_to_message_id: ctx.message.message_id }); - }; + } else if (!videoUrlSafe) { + return ctx.reply(Strings.ytDownload.notYtLink, { + 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"; diff --git a/src/locales/english.json b/src/locales/english.json index 5e21a15..773c490 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -75,7 +75,8 @@ "uploadErr": "Error uploading file. Please try again later.\n\n{error}", "uploadLimit": "*This video exceeds the 50 MB upload limit imposed by Telegram on our bot. Please try another video. We're doing our best to increase this limit.*", "sizeLimitWarn": "*This video had its quality reduced because it exceeded the 50MB limit for uploads imposed by Telegram.*", - "noLink": "Please provide a link to a video to download." + "noLink": "Please provide a link to a video to download.", + "notYtLink": "Please provide a valid YouTube link to download." }, "botUpdated": "Bot updated with success.\n\n```{result}```", "errorUpdatingBot": "Error updating bot\n\n{error}", diff --git a/src/locales/portuguese.json b/src/locales/portuguese.json index 08025e9..c7b1b4b 100644 --- a/src/locales/portuguese.json +++ b/src/locales/portuguese.json @@ -75,7 +75,8 @@ "uploadErr": "Erro ao enviar o arquivo. Tente novamente mais tarde.\n\n{error}", "uploadLimit": "*Este vídeo excede o limite de carregamento de 50 MB imposto pelo Telegram ao nosso bot. Por favor, tente outro vídeo. Estamos fazendo o possível para aumentar esse limite.*", "sizeLimitWarn": "*Esse vídeo teve a qualidade reduzida por estar excedendo o limite de 50MB para uploads imposto pelo Telegram.*", - "noLink": "*Por favor, forneça um link de um vídeo para download.*" + "noLink": "*Por favor, forneça um link de um vídeo para download.*", + "notYtLink": "*Forneça um link válido do YouTube para fazer o download.*" }, "botUpdated": "Bot atualizado com sucesso.\n\n```{result}```", "errorUpdatingBot": "Erro ao atualizar o bot\n\n{error}", From 8de4f4067e94a80478c4b35b99c2c8af5f511943 Mon Sep 17 00:00:00 2001 From: Aidan Date: Tue, 29 Apr 2025 20:02:18 -0400 Subject: [PATCH 06/47] feat/fix/rf: add types, no api key handling in weather cmd, misc fixes --- src/commands/http.ts | 16 +++++++++---- src/commands/info.ts | 48 +++++++++++++++++++++---------------- src/commands/ponyapi.ts | 29 +++++++++++++++------- src/commands/randompony.ts | 10 +++++--- src/commands/weather.ts | 18 ++++++++++---- src/locales/english.json | 3 ++- src/locales/portuguese.json | 3 ++- src/plugins/checklang.ts | 4 ++-- 8 files changed, 84 insertions(+), 47 deletions(-) diff --git a/src/commands/http.ts b/src/commands/http.ts index 5e47a49..8dc6853 100644 --- a/src/commands/http.ts +++ b/src/commands/http.ts @@ -4,12 +4,13 @@ import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import verifyInput from '../plugins/verifyInput'; +import { Context, Telegraf } from 'telegraf'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -export default (bot) => { - bot.command("http", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); +export default (bot: Telegraf) => { + bot.command("http", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code || 'en'); const userInput = ctx.message.text.split(' ')[1]; const apiUrl = Resources.httpApi; const { invalidCode } = Strings.httpCodes @@ -31,11 +32,13 @@ export default (bot) => { .replace("{description}", codeInfo.description); await ctx.reply(message, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } else { await ctx.reply(Strings.httpCodes.notFound, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }; @@ -43,13 +46,14 @@ export default (bot) => { const message = Strings.httpCodes.fetchErr.replace("{error}", error); ctx.reply(message, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }; }); - bot.command("httpcat", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command("httpcat", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code || 'en'); const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(/\s+/g, ''); const { invalidCode } = Strings.httpCodes @@ -63,11 +67,13 @@ export default (bot) => { await ctx.replyWithPhoto(apiUrl, { caption: `🐱 ${apiUrl}`, parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } catch (error) { ctx.reply(Strings.catImgErr, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } diff --git a/src/commands/info.ts b/src/commands/info.ts index a19651c..416240a 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -1,64 +1,70 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import { Context, Telegraf } from 'telegraf'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -async function getUserInfo(ctx) { - const Strings = getStrings(ctx.from.language_code); - let lastName = ctx.from.last_name; +async function getUserInfo(ctx: Context & { message: { text: string } }) { + const Strings = getStrings(ctx.from?.language_code || 'en'); + let lastName = ctx.from?.last_name; if (lastName === undefined) { lastName = " "; } const userInfo = Strings.userInfo - .replace('{userName}', `${ctx.from.first_name} ${lastName}` || Strings.varStrings.varUnknown) - .replace('{userId}', ctx.from.id || Strings.varStrings.varUnknown) - .replace('{userHandle}', ctx.from.username ? `@${ctx.from.username}` : Strings.varStrings.varNone) - .replace('{userPremium}', ctx.from.is_premium ? Strings.varStrings.varYes : Strings.varStrings.varNo) - .replace('{userLang}', ctx.from.language_code || Strings.varStrings.varUnknown); + .replace('{userName}', `${ctx.from?.first_name} ${lastName}` || Strings.varStrings.varUnknown) + .replace('{userId}', ctx.from?.id || Strings.varStrings.varUnknown) + .replace('{userHandle}', ctx.from?.username ? `@${ctx.from?.username}` : Strings.varStrings.varNone) + .replace('{userPremium}', ctx.from?.is_premium ? Strings.varStrings.varYes : Strings.varStrings.varNo) + .replace('{userLang}', ctx.from?.language_code || Strings.varStrings.varUnknown); return userInfo; } -async function getChatInfo(ctx) { - const Strings = getStrings(ctx.from.language_code); - if (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup') { +async function getChatInfo(ctx: Context & { message: { text: string } }) { + const Strings = getStrings(ctx.from?.language_code || 'en'); + if (ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup') { const chatInfo = Strings.chatInfo - .replace('{chatId}', ctx.chat.id || Strings.varStrings.varUnknown) - .replace('{chatName}', ctx.chat.title || Strings.varStrings.varUnknown) - .replace('{chatHandle}', ctx.chat.username ? `@${ctx.chat.username}` : Strings.varStrings.varNone) - .replace('{chatMembersCount}', await ctx.getChatMembersCount(ctx.chat.id || Strings.varStrings.varUnknown)) - .replace('{chatType}', ctx.chat.type || Strings.varStrings.varUnknown) - .replace('{isForum}', ctx.chat.is_forum ? Strings.varStrings.varYes : Strings.varStrings.varNo); + .replace('{chatId}', ctx.chat?.id || Strings.varStrings.varUnknown) + .replace('{chatName}', ctx.chat?.title || Strings.varStrings.varUnknown) + // @ts-ignore + .replace('{chatHandle}', ctx.chat?.username ? `@${ctx.chat?.username}` : Strings.varStrings.varNone) + .replace('{chatMembersCount}', await ctx.getChatMembersCount()) + .replace('{chatType}', ctx.chat?.type || Strings.varStrings.varUnknown) + // @ts-ignore + .replace('{isForum}', ctx.chat?.is_forum ? Strings.varStrings.varYes : Strings.varStrings.varNo); return chatInfo; } else { return ctx.reply( Strings.groupOnly, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } } -export default (bot) => { - bot.command('chatinfo', spamwatchMiddleware, async (ctx) => { +export default (bot: Telegraf) => { + bot.command('chatinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const chatInfo = await getChatInfo(ctx); ctx.reply( chatInfo, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id } ); }); - bot.command('userinfo', spamwatchMiddleware, async (ctx) => { + bot.command('userinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const userInfo = await getUserInfo(ctx); ctx.reply( userInfo, { parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id, + // @ts-ignore + reply_to_message_id: ctx.message.message_id } ); }); diff --git a/src/commands/ponyapi.ts b/src/commands/ponyapi.ts index dfa2e1c..25fc473 100644 --- a/src/commands/ponyapi.ts +++ b/src/commands/ponyapi.ts @@ -4,6 +4,7 @@ import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import verifyInput from '../plugins/verifyInput'; +import { Telegraf, Context } from 'telegraf'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -50,19 +51,20 @@ function capitalizeFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } -export default (bot) => { - bot.command("mlp", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); +export default (bot: Telegraf) => { + bot.command("mlp", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code || 'en'); ctx.reply(Strings.ponyApi.helpDesc, { parse_mode: 'Markdown', + // @ts-ignore disable_web_page_preview: true, reply_to_message_id: ctx.message.message_id }); }); - bot.command("mlpchar", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command("mlpchar", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code || 'en'); const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); const { noCharName } = Strings.ponyApi @@ -116,12 +118,14 @@ export default (bot) => { ctx.replyWithPhoto(charactersArray[0].image[0], { caption: `${result}`, parse_mode: 'Markdown', + // @ts-ignore disable_web_page_preview: true, reply_to_message_id: ctx.message.message_id }); } else { ctx.reply(Strings.ponyApi.noCharFound, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }; @@ -129,13 +133,14 @@ export default (bot) => { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }; }); - bot.command("mlpep", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command("mlpep", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code || 'en'); const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); const { noEpisodeNum } = Strings.ponyApi @@ -184,12 +189,14 @@ export default (bot) => { ctx.replyWithPhoto(episodeArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', + // @ts-ignore disable_web_page_preview: true, reply_to_message_id: ctx.message.message_id }); } else { ctx.reply(Strings.ponyApi.noEpisodeFound, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }; @@ -197,13 +204,14 @@ export default (bot) => { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }; }); - bot.command("mlpcomic", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + bot.command("mlpcomic", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code || 'en'); const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); const { noComicName } = Strings.ponyApi @@ -257,12 +265,14 @@ export default (bot) => { ctx.replyWithPhoto(comicArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', + // @ts-ignore disable_web_page_preview: true, reply_to_message_id: ctx.message.message_id }); } else { ctx.reply(Strings.ponyApi.noComicFound, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }; @@ -270,6 +280,7 @@ export default (bot) => { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); }; diff --git a/src/commands/randompony.ts b/src/commands/randompony.ts index 0ca140d..3ecf4b4 100644 --- a/src/commands/randompony.ts +++ b/src/commands/randompony.ts @@ -3,12 +3,14 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; +import { Telegraf, Context } from 'telegraf'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -export default (bot) => { - bot.command(["rpony", "randompony", "mlpart"], spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); +export default (bot: Telegraf) => { + // TODO: this would greatly benefit from a loading message + bot.command(["rpony", "randompony", "mlpart"], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(ctx.from?.language_code || 'en'); try { const response = await axios(Resources.randomPonyApi); let tags: string[] = []; @@ -24,12 +26,14 @@ export default (bot) => { ctx.replyWithPhoto(response.data.pony.representations.full, { caption: `${response.data.pony.sourceURL}\n\n${tags.length > 0 ? tags.join(', ') : ''}`, parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); } catch (error) { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', + // @ts-ignore reply_to_message_id: ctx.message.message_id }); return; diff --git a/src/commands/weather.ts b/src/commands/weather.ts index 716726c..6c1a529 100644 --- a/src/commands/weather.ts +++ b/src/commands/weather.ts @@ -21,10 +21,10 @@ const statusEmojis = { 43: '❄️', 44: 'n/a', 45: '🌧', 46: '🌨', 47: '🌩' }; -const getStatusEmoji = (statusCode) => statusEmojis[statusCode] || 'n/a'; +const getStatusEmoji = (statusCode: number) => statusEmojis[statusCode] || 'n/a'; -function getLocaleUnit(countryCode) { - const fahrenheitCountries = ['US', 'BS', 'BZ', 'KY', 'LR']; +function getLocaleUnit(countryCode: string) { + const fahrenheitCountries: string[] = ['US', 'BS', 'BZ', 'KY', 'LR']; if (fahrenheitCountries.includes(countryCode)) { return { temperatureUnit: 'F', speedUnit: 'mph', apiUnit: 'e' }; @@ -44,10 +44,18 @@ export default (bot) => { return; } - const location = userInput; - const apiKey = process.env.weatherKey; + const location: string = userInput; + const apiKey: string = process.env.weatherKey || ''; + + if (!apiKey || apiKey === "InsertYourWeatherDotComApiKeyHere") { + return ctx.reply(Strings.weatherStatus.apiKeyErr, { + parse_mode: "Markdown", + reply_to_message_id: ctx.message.message_id + }); + } try { + // TODO: this also needs to be sanitized and validated const locationResponse = await axios.get(`${Resources.weatherApi}/location/search`, { params: { apiKey: apiKey, diff --git a/src/locales/english.json b/src/locales/english.json index 773c490..f8bb0ec 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -49,7 +49,8 @@ "provideLocation": "*Please provide a location.*", "invalidLocation": "*Invalid location. Try again.*", "resultMsg": "*Weather in {addressFirst}:*\n\n*Status:* `{getStatusEmoji(iconCode)} {wxPhraseLong}`\n*Temperature:* `{temperature} °{temperatureUnit}`\n*Feels like:* `{temperatureFeelsLike} °{temperatureUnit2}`\n*Humidity:* `{relativeHumidity}%`\n*Wind speed:* `{windSpeed} {speedUnit}`", - "apiErr": "*An error occurred while retrieving the weather. Please try again later.*\n\n`{error}`" + "apiErr": "*An error occurred while retrieving the weather. Please try again later.*\n\n`{error}`", + "apiKeyErr": "*An API key was not set by the bot owner. Please try again later.*" }, "mainCommands": "Main commands", "mainCommandsDesc": "*Main commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy", diff --git a/src/locales/portuguese.json b/src/locales/portuguese.json index c7b1b4b..2e485f7 100644 --- a/src/locales/portuguese.json +++ b/src/locales/portuguese.json @@ -49,7 +49,8 @@ "provideLocation": "*Por favor, forneça uma localização.*", "invalidLocation": "*Localização inválida. Tente novamente.*", "resultMsg": "*Clima em {addressFirst}:*\n\n*Estado:* `{getStatusEmoji(iconCode)} {wxPhraseLong}`\n*Temperatura:* `{temperature} °{temperatureUnit}`\n*Sensação térmica:* `{temperatureFeelsLike} °{temperatureUnit2}`\n*Umidade:* `{relativeHumidity}%`\n*Velocidade do vento:* `{windSpeed} {speedUnit}`", - "apiErr": "*Ocorreu um erro ao obter o clima. Tente novamente mais tarde.*\n\n`{error}`" + "apiErr": "*Ocorreu um erro ao obter o clima. Tente novamente mais tarde.*\n\n`{error}`", + "apiKeyErr": "*Uma chave de API não foi definida pelo proprietário do bot. Tente novamente mais tarde.*" }, "mainCommands": "Comandos principais", "mainCommandsDesc": "*Comandos principais*\n\n- /help: Exibe a ajuda do bot\n- /start: Inicia o bot\n- /privacy: Leia a política de privacidade do bot", diff --git a/src/plugins/checklang.ts b/src/plugins/checklang.ts index a8d1b79..650747c 100644 --- a/src/plugins/checklang.ts +++ b/src/plugins/checklang.ts @@ -7,8 +7,8 @@ const languageFiles = { 'en-gb': '../locales/english.json' }; -function getStrings(languageCode) { - const filePath = languageFiles[languageCode] || languageFiles['en']; +function getStrings(languageCode: string) { + const filePath: string = languageFiles[languageCode] || languageFiles['en']; try { return require(filePath); } catch (error) { From f8298dcf53686448f7e4d920ab3155df4d610e66 Mon Sep 17 00:00:00 2001 From: Aidan Date: Tue, 29 Apr 2025 20:27:47 -0400 Subject: [PATCH 07/47] [m] docs: add translations section --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 37e61a7..8fa5b60 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ Kowalski is a a simple Telegram bot made in Node.js. - You can find Kowalski at [@KowalskiNodeBot](https://t.me/KowalskiNodeBot) on Telegram. +## Translations + + +Translation status + + ## Self-host requirements > [!IMPORTANT] From f69f576e2423068d24a4e445c00dc4ccc298fd19 Mon Sep 17 00:00:00 2001 From: Lucas Gabriel Date: Thu, 1 May 2025 14:17:16 -0300 Subject: [PATCH 08/47] Update dependabot.yml --- .github/dependabot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b417983..5f0889c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,4 +8,4 @@ updates: - package-ecosystem: "npm" # See documentation for possible values directory: "/" # Location of package manifests schedule: - interval: "daily" + interval: "weekly" From 6dce40d333278d9b991aaa20490eb9c680f55398 Mon Sep 17 00:00:00 2001 From: Giovani Finazzi <53719063+GiovaniFZ@users.noreply.github.com> Date: Thu, 1 May 2025 16:38:38 -0300 Subject: [PATCH 09/47] fix chatinfo in dms (#52) --- src/commands/info.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/commands/info.ts b/src/commands/info.ts index 416240a..2bf2b2d 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -37,12 +37,7 @@ async function getChatInfo(ctx: Context & { message: { text: string } }) { return chatInfo; } else { - return ctx.reply( - Strings.groupOnly, { - parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id - }); + return Strings.groupOnly } } From 87c987c16d3798f46014d312f091c3f76d0c062d Mon Sep 17 00:00:00 2001 From: Giovani Finazzi <53719063+GiovaniFZ@users.noreply.github.com> Date: Fri, 2 May 2025 21:08:13 -0300 Subject: [PATCH 10/47] ref: replace "ctx.from.language_code" with a function to get the language code and fix ts implementation for "reply_to_message_id" (#51) Co-authored-by: Lucas Gabriel --- src/commands/animal.ts | 120 +++++++++++++++---------------- src/commands/codename.ts | 31 ++++---- src/commands/crew.ts | 17 ++--- src/commands/fun.ts | 9 +-- src/commands/gsmarena.ts | 2 +- src/commands/help.ts | 7 +- src/commands/http.ts | 22 +++--- src/commands/main.ts | 12 ++-- src/commands/modarchive.ts | 16 +++-- src/commands/ponyapi.ts | 58 +++++++-------- src/commands/randompony.ts | 11 +-- src/commands/weather.ts | 12 ++-- src/commands/wiki.ts | 7 +- src/plugins/checklang.ts | 5 +- src/plugins/verifyInput.ts | 10 ++- src/utils/language-code.ts | 9 +++ src/utils/reply-to-message-id.ts | 5 ++ 17 files changed, 193 insertions(+), 160 deletions(-) create mode 100644 src/utils/language-code.ts create mode 100644 src/utils/reply-to-message-id.ts diff --git a/src/commands/animal.ts b/src/commands/animal.ts index 89eecba..2b8352e 100644 --- a/src/commands/animal.ts +++ b/src/commands/animal.ts @@ -4,135 +4,129 @@ import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import { Context, Telegraf } from 'telegraf'; +import { replyToMessageId } from '../utils/reply-to-message-id'; +import { languageCode } from '../utils/language-code'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); export default (bot: Telegraf) => { bot.command("duck", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const reply_to_message_id = replyToMessageId(ctx); try { const response = await axios(Resources.duckApi); ctx.replyWithPhoto(response.data.url, { caption: "🦆", - // reply_to_message_id works fine, using this for now to avoid errors - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } catch (error) { + const Strings = getStrings(languageCode(ctx)); const message = Strings.duckApiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); return; } }); bot.command("fox", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); try { const response = await axios(Resources.foxApi); ctx.replyWithPhoto(response.data.image, { caption: "🦊", - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } catch (error) { const message = Strings.foxApiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); return; } }); bot.command("dog", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); try { const response = await axios(Resources.dogApi); ctx.replyWithPhoto(response.data.message, { caption: "🐶", - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } catch (error) { const message = Strings.foxApiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); return; } }); bot.command("cat", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); const apiUrl = `${Resources.catApi}?json=true`; const response = await axios.get(apiUrl); const data = response.data; const imageUrl = `${data.url}`; + const reply_to_message_id = replyToMessageId(ctx); try { await ctx.replyWithPhoto(imageUrl, { caption: `🐱`, parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } catch (error) { ctx.reply(Strings.catImgErr, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); }; }); bot.command(['soggy', 'soggycat'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const userInput = ctx.message.text.split(' ')[1]; - - switch (true) { - case (userInput === "2" || userInput === "thumb"): - ctx.replyWithPhoto( - Resources.soggyCat2, { - caption: Resources.soggyCat2, - parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id - }); - break; - - case (userInput === "3" || userInput === "sticker"): - ctx.replyWithSticker( - Resources.soggyCatSticker, { - // @ts-ignore - reply_to_message_id: ctx.message.message_id - }); - break; - - case (userInput === "4" || userInput === "alt"): - ctx.replyWithPhoto( - Resources.soggyCatAlt, { - caption: Resources.soggyCatAlt, - parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id - }); - break; - - default: - ctx.replyWithPhoto( - Resources.soggyCat, { - caption: Resources.soggyCat, - parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id - }); - break; - }; - }); + const userInput = ctx.message.text.split(' ')[1]; + const reply_to_message_id = replyToMessageId(ctx); + + switch (true) { + case (userInput === "2" || userInput === "thumb"): + ctx.replyWithPhoto( + Resources.soggyCat2, { + caption: Resources.soggyCat2, + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }); + break; + + case (userInput === "3" || userInput === "sticker"): + ctx.replyWithSticker( + Resources.soggyCatSticker, { + // ...({ reply_to_message_id }) // to-do: fix this + }); + break; + + case (userInput === "4" || userInput === "alt"): + ctx.replyWithPhoto( + Resources.soggyCatAlt, { + caption: Resources.soggyCatAlt, + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }); + break; + + default: + ctx.replyWithPhoto( + Resources.soggyCat, { + caption: Resources.soggyCat, + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }); + break; + }; + }); } \ No newline at end of file diff --git a/src/commands/codename.ts b/src/commands/codename.ts index 7f4a3e5..ba0e3b4 100644 --- a/src/commands/codename.ts +++ b/src/commands/codename.ts @@ -5,10 +5,19 @@ import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import verifyInput from '../plugins/verifyInput'; import { Context, Telegraf } from 'telegraf'; +import { languageCode } from '../utils/language-code'; +import { replyToMessageId } from '../utils/reply-to-message-id'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); +interface Device { + brand: string; + codename: string; + model: string; +} + async function getDeviceList({ Strings, ctx }: { Strings: any, ctx: Context & { message: { text: string } } }) { + const reply_to_message_id = replyToMessageId(ctx); try { const response = await axios.get(Resources.codenameApi); return response.data @@ -18,8 +27,7 @@ async function getDeviceList({ Strings, ctx }: { Strings: any, ctx: Context & { return ctx.reply(message, { parse_mode: "Markdown", - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } } @@ -27,10 +35,11 @@ async function getDeviceList({ Strings, ctx }: { Strings: any, ctx: Context & { export default (bot: Telegraf) => { bot.command(['codename', 'whatis'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const userInput = ctx.message.text.split(" ").slice(1).join(" "); - const Strings = getStrings(ctx.from?.language_code); - const { noCodename } = Strings.codenameCheck - - if(verifyInput(ctx, userInput, noCodename)){ + const Strings = getStrings(languageCode(ctx)); + const { noCodename } = Strings.codenameCheck; + const reply_to_message_id = replyToMessageId(ctx); + + if (verifyInput(ctx, userInput, noCodename)) { return; } @@ -40,13 +49,12 @@ export default (bot: Telegraf) => { if (!phoneSearch) { return ctx.reply(Strings.codenameCheck.notFound, { parse_mode: "Markdown", - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } - + const deviceDetails = jsonRes[phoneSearch]; - const device = deviceDetails.find((item) => item.brand) || deviceDetails[0]; + const device = deviceDetails.find((item: Device) => item.brand) || deviceDetails[0]; const message = Strings.codenameCheck.resultMsg .replace('{brand}', device.brand) .replace('{codename}', userInput) @@ -55,8 +63,7 @@ export default (bot: Telegraf) => { return ctx.reply(message, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); }) } \ No newline at end of file diff --git a/src/commands/crew.ts b/src/commands/crew.ts index c55b774..0614441 100644 --- a/src/commands/crew.ts +++ b/src/commands/crew.ts @@ -5,6 +5,7 @@ import os from 'os'; import { exec } from 'child_process'; import { error } from 'console'; import { Context, Telegraf } from 'telegraf'; +import { languageCode } from '../utils/language-code'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -54,7 +55,7 @@ function getSystemInfo() { } async function handleAdminCommand(ctx: Context & { message: { text: string } }, action: () => Promise, successMessage: string, errorMessage: string) { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); const userId = ctx.from?.id; const adminArray = process.env.botAdmins ? process.env.botAdmins.split(',').map(id => parseInt(id.trim())) : []; if (userId && adminArray.includes(userId)) { @@ -84,7 +85,7 @@ async function handleAdminCommand(ctx: Context & { message: { text: string } }, export default (bot: Telegraf) => { bot.command('getbotstats', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); handleAdminCommand(ctx, async () => { const stats = getSystemInfo(); await ctx.reply(stats, { @@ -96,7 +97,7 @@ export default (bot: Telegraf) => { }); bot.command('getbotcommit', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); handleAdminCommand(ctx, async () => { try { const commitHash = await getGitCommitHash(); @@ -116,7 +117,7 @@ export default (bot: Telegraf) => { }); bot.command('updatebot', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); handleAdminCommand(ctx, async () => { try { const result = await updateBot(); @@ -136,7 +137,7 @@ export default (bot: Telegraf) => { }); bot.command('setbotname', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); const botName = ctx.message.text.split(' ').slice(1).join(' '); handleAdminCommand(ctx, async () => { await ctx.telegram.setMyName(botName); @@ -144,7 +145,7 @@ export default (bot: Telegraf) => { }); bot.command('setbotdesc', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); const botDesc = ctx.message.text.split(' ').slice(1).join(' '); handleAdminCommand(ctx, async () => { await ctx.telegram.setMyDescription(botDesc); @@ -152,7 +153,7 @@ export default (bot: Telegraf) => { }); bot.command('botkickme', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); handleAdminCommand(ctx, async () => { if (!ctx.chat) { ctx.reply(Strings.chatNotFound, { @@ -172,7 +173,7 @@ export default (bot: Telegraf) => { }); bot.command('getfile', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); const botFile = ctx.message.text.split(' ').slice(1).join(' '); if (!botFile) { diff --git a/src/commands/fun.ts b/src/commands/fun.ts index b1437bf..c18d460 100644 --- a/src/commands/fun.ts +++ b/src/commands/fun.ts @@ -3,11 +3,12 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import { Context, Telegraf } from 'telegraf'; +import { languageCode } from '../utils/language-code'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); function sendRandomReply(ctx: Context & { message: { text: string } }, gifUrl: string, textKey: string) { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); const randomNumber = Math.floor(Math.random() * 100); const shouldSendGif = randomNumber > 50; @@ -38,7 +39,7 @@ function sendRandomReply(ctx: Context & { message: { text: string } }, gifUrl: s async function handleDiceCommand(ctx: Context & { message: { text: string } }, emoji: string, delay: number) { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); // @ts-ignore const result = await ctx.sendDice({ emoji, reply_to_message_id: ctx.message.message_id }); @@ -55,13 +56,13 @@ async function handleDiceCommand(ctx: Context & { message: { text: string } }, e }, delay); } -function getRandomInt(max) { +function getRandomInt(max: number) { return Math.floor(Math.random() * (max + 1)); } export default (bot: Telegraf) => { bot.command('random', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code); + const Strings = getStrings(languageCode(ctx)); const randomValue = getRandomInt(11); const randomVStr = Strings.randomNum.replace('{number}', randomValue); diff --git a/src/commands/gsmarena.ts b/src/commands/gsmarena.ts index 43a20ed..c44206a 100644 --- a/src/commands/gsmarena.ts +++ b/src/commands/gsmarena.ts @@ -127,7 +127,7 @@ function formatPhone(phone: PhoneDetails) { return `\n\nName: ${formattedPhone.name}\n\n${attributes}\n\n${deviceImage}\n\n${deviceUrl}`; } -async function fetchHtml(url) { +async function fetchHtml(url: string) { try { const response = await axios.get(url, { headers: HEADERS }); return response.data; diff --git a/src/commands/help.ts b/src/commands/help.ts index 95ea804..39191c1 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,6 +1,7 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import { languageCode } from '../utils/language-code'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -14,7 +15,7 @@ interface MessageOptions { } async function sendHelpMessage(ctx, isEditing) { - const Strings = getStrings(ctx.from.language_code); + const Strings = getStrings(languageCode(ctx)); const botInfo = await ctx.telegram.getMe(); const helpText = Strings.botHelp .replace(/{botName}/g, botInfo.first_name) @@ -56,7 +57,7 @@ export default (bot) => { }); bot.command("about", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); + const Strings = getStrings(languageCode(ctx)); const aboutMsg = Strings.botAbout.replace(/{sourceLink}/g, `${process.env.botSource}`); ctx.reply(aboutMsg, { parse_mode: 'Markdown', @@ -67,7 +68,7 @@ export default (bot) => { bot.on('callback_query', async (ctx) => { const callbackData = ctx.callbackQuery.data; - const Strings = getStrings(ctx.from.language_code); + const Strings = getStrings(languageCode(ctx)); const options = { parse_mode: 'Markdown', disable_web_page_preview: true, diff --git a/src/commands/http.ts b/src/commands/http.ts index 8dc6853..53c1359 100644 --- a/src/commands/http.ts +++ b/src/commands/http.ts @@ -5,12 +5,14 @@ import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import verifyInput from '../plugins/verifyInput'; import { Context, Telegraf } from 'telegraf'; +import { languageCode } from '../utils/language-code'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); export default (bot: Telegraf) => { bot.command("http", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code || 'en'); + const reply_to_message_id = ctx.message.message_id; + const Strings = getStrings(languageCode(ctx)); const userInput = ctx.message.text.split(' ')[1]; const apiUrl = Resources.httpApi; const { invalidCode } = Strings.httpCodes @@ -32,28 +34,26 @@ export default (bot: Telegraf) => { .replace("{description}", codeInfo.description); await ctx.reply(message, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } else { await ctx.reply(Strings.httpCodes.notFound, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); }; } catch (error) { const message = Strings.httpCodes.fetchErr.replace("{error}", error); ctx.reply(message, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); }; }); bot.command("httpcat", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code || 'en'); + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = ctx.message.message_id; const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(/\s+/g, ''); const { invalidCode } = Strings.httpCodes @@ -67,14 +67,12 @@ export default (bot: Telegraf) => { await ctx.replyWithPhoto(apiUrl, { caption: `🐱 ${apiUrl}`, parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } catch (error) { ctx.reply(Strings.catImgErr, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } }); diff --git a/src/commands/main.ts b/src/commands/main.ts index 2729173..a5d581c 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -1,18 +1,22 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import { Context, Telegraf } from 'telegraf'; +import { replyToMessageId } from '../utils/reply-to-message-id'; +import { languageCode } from '../utils/language-code'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -export default (bot: any) => { - bot.start(spamwatchMiddleware, async (ctx: any) => { - const Strings = getStrings(ctx.from.language_code); +export default (bot: Telegraf) => { + bot.start(spamwatchMiddleware, async (ctx: Context) => { + const Strings = getStrings(languageCode(ctx)); const botInfo = await ctx.telegram.getMe(); + const reply_to_message_id = replyToMessageId(ctx) const startMsg = Strings.botWelcome.replace(/{botName}/g, botInfo.first_name); ctx.reply(startMsg, { parse_mode: 'Markdown', - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); }); diff --git a/src/commands/modarchive.ts b/src/commands/modarchive.ts index eac76a8..721a517 100644 --- a/src/commands/modarchive.ts +++ b/src/commands/modarchive.ts @@ -5,6 +5,9 @@ import path from 'path'; import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import { languageCode } from '../utils/language-code'; +import { Context, Telegraf } from 'telegraf'; +import { replyToMessageId } from '../utils/reply-to-message-id'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -46,15 +49,16 @@ async function downloadModule(moduleId: string): Promise { } } -export default (bot) => { +export default (bot: Telegraf) => { bot.command(['modarchive', 'tma'], spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(ctx.from.language_code); - const moduleId = ctx.message.text.split(' ')[1]; + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); + const moduleId = ctx.message?.text.split(' ')[1]; if (Number.isNaN(moduleId) || null) { return ctx.reply(Strings.maInvalidModule, { parse_mode: "Markdown", - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } @@ -65,14 +69,14 @@ export default (bot) => { await ctx.replyWithDocument({ source: filePath }, { caption: fileName, - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); fs.unlinkSync(filePath); } else { ctx.reply(Strings.maDownloadError, { parse_mode: "Markdown", - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } }); diff --git a/src/commands/ponyapi.ts b/src/commands/ponyapi.ts index 25fc473..2202949 100644 --- a/src/commands/ponyapi.ts +++ b/src/commands/ponyapi.ts @@ -5,6 +5,8 @@ import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import verifyInput from '../plugins/verifyInput'; import { Telegraf, Context } from 'telegraf'; +import { languageCode } from '../utils/language-code'; +import { replyToMessageId } from '../utils/reply-to-message-id'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -47,24 +49,24 @@ interface Comic { editor: string; } -function capitalizeFirstLetter(string) { - return string.charAt(0).toUpperCase() + string.slice(1); +function capitalizeFirstLetter(letter: string) { + return letter.charAt(0).toUpperCase() + letter.slice(1); } export default (bot: Telegraf) => { bot.command("mlp", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code || 'en'); + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); ctx.reply(Strings.ponyApi.helpDesc, { parse_mode: 'Markdown', - // @ts-ignore - disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id, disable_web_page_preview: true }) }); }); bot.command("mlpchar", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code || 'en'); + const reply_to_message_id = replyToMessageId(ctx); + const Strings = getStrings(languageCode(ctx) || 'en'); const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); const { noCharName } = Strings.ponyApi @@ -118,30 +120,27 @@ export default (bot: Telegraf) => { ctx.replyWithPhoto(charactersArray[0].image[0], { caption: `${result}`, parse_mode: 'Markdown', - // @ts-ignore - disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id, disable_web_page_preview: true }) }); } else { ctx.reply(Strings.ponyApi.noCharFound, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); }; } catch (error) { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); }; }); bot.command("mlpep", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code || 'en'); + const Strings = getStrings(languageCode(ctx) || 'en'); const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); + const reply_to_message_id = replyToMessageId(ctx); const { noEpisodeNum } = Strings.ponyApi @@ -156,7 +155,7 @@ export default (bot: Telegraf) => { const episodeArray: Episode[] = []; if (Array.isArray(response.data.data)) { - response.data.data.forEach(episode => { + response.data.data.forEach((episode: Episode) => { episodeArray.push({ id: episode.id, name: episode.name, @@ -189,30 +188,29 @@ export default (bot: Telegraf) => { ctx.replyWithPhoto(episodeArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', - // @ts-ignore - disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id, disable_web_page_preview: true }) }); } else { ctx.reply(Strings.ponyApi.noEpisodeFound, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + + ...({ reply_to_message_id }) }); }; } catch (error) { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + + ...({ reply_to_message_id }) }); }; }); bot.command("mlpcomic", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code || 'en'); + const Strings = getStrings(languageCode(ctx) || 'en'); const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); + const reply_to_message_id = replyToMessageId(ctx); const { noComicName } = Strings.ponyApi @@ -265,23 +263,21 @@ export default (bot: Telegraf) => { ctx.replyWithPhoto(comicArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', - // @ts-ignore - disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id, disable_web_page_preview: true }) }); } else { ctx.reply(Strings.ponyApi.noComicFound, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + + ...({ reply_to_message_id }) }); }; } catch (error) { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + + ...({ reply_to_message_id }) }); }; }); diff --git a/src/commands/randompony.ts b/src/commands/randompony.ts index 3ecf4b4..be91fcf 100644 --- a/src/commands/randompony.ts +++ b/src/commands/randompony.ts @@ -4,13 +4,16 @@ import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import { Telegraf, Context } from 'telegraf'; +import { languageCode } from '../utils/language-code'; +import { replyToMessageId } from '../utils/reply-to-message-id'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); export default (bot: Telegraf) => { // TODO: this would greatly benefit from a loading message bot.command(["rpony", "randompony", "mlpart"], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(ctx.from?.language_code || 'en'); + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); try { const response = await axios(Resources.randomPonyApi); let tags: string[] = []; @@ -26,15 +29,13 @@ export default (bot: Telegraf) => { ctx.replyWithPhoto(response.data.pony.representations.full, { caption: `${response.data.pony.sourceURL}\n\n${tags.length > 0 ? tags.join(', ') : ''}`, parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } catch (error) { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); return; } diff --git a/src/commands/weather.ts b/src/commands/weather.ts index 6c1a529..f72c343 100644 --- a/src/commands/weather.ts +++ b/src/commands/weather.ts @@ -8,6 +8,7 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import verifyInput from '../plugins/verifyInput'; +import { Context, Telegraf } from 'telegraf'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -33,8 +34,9 @@ function getLocaleUnit(countryCode: string) { } } -export default (bot) => { +export default (bot: Telegraf) => { bot.command(['clima', 'weather'], spamwatchMiddleware, async (ctx) => { + const reply_to_message_id = ctx.message.message_id; const userLang = ctx.from.language_code || "en-US"; const Strings = getStrings(userLang); const userInput = ctx.message.text.split(' ').slice(1).join(' '); @@ -50,7 +52,7 @@ export default (bot) => { if (!apiKey || apiKey === "InsertYourWeatherDotComApiKeyHere") { return ctx.reply(Strings.weatherStatus.apiKeyErr, { parse_mode: "Markdown", - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } @@ -69,7 +71,7 @@ export default (bot) => { if (!locationData || !locationData.address) { return ctx.reply(Strings.weatherStatus.invalidLocation, { parse_mode: "Markdown", - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } @@ -106,13 +108,13 @@ export default (bot) => { ctx.reply(weatherMessage, { parse_mode: "Markdown", - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } catch (error) { const message = Strings.weatherStatus.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: "Markdown", - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); } }); diff --git a/src/commands/wiki.ts b/src/commands/wiki.ts index f6ca6d0..bec7df1 100644 --- a/src/commands/wiki.ts +++ b/src/commands/wiki.ts @@ -1,5 +1,7 @@ /* import axios from "axios"; +import { Context, Telegraf } from "telegraf"; +import { replyToMessageId } from "../utils/reply-to-message-id"; function capitalizeFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); @@ -23,7 +25,7 @@ function mediaWikiToMarkdown(input: string) { return input; } -export default (bot) => { +export default (bot: Telegraf) => { bot.command("wiki", async (ctx) => { const userInput = capitalizeFirstLetter(ctx.message.text.split(' ')[1]); const apiUrl = `https://en.wikipedia.org/w/index.php?title=${userInput}&action=raw`; @@ -31,8 +33,9 @@ export default (bot) => { const convertedResponse = response.data.replace(/<\/?div>/g, "").replace(/{{Infobox.*?}}/s, ""); const result = mediaWikiToMarkdown(convertedResponse).slice(0, 2048); + const reply_to_message_id = replyToMessageId(ctx); - ctx.reply(result, { parse_mode: 'Markdown', disable_web_page_preview: true, reply_to_message_id: ctx.message.message_id }); + ctx.reply(result, { parse_mode: 'Markdown', ...({ reply_to_message_id, disable_web_page_preview: true }) }); }); }; */ \ No newline at end of file diff --git a/src/plugins/checklang.ts b/src/plugins/checklang.ts index 650747c..a38445a 100644 --- a/src/plugins/checklang.ts +++ b/src/plugins/checklang.ts @@ -7,7 +7,10 @@ const languageFiles = { 'en-gb': '../locales/english.json' }; -function getStrings(languageCode: string) { +function getStrings(languageCode?: string) { + if (!languageCode) { + return require(languageFiles['en']); + } const filePath: string = languageFiles[languageCode] || languageFiles['en']; try { return require(filePath); diff --git a/src/plugins/verifyInput.ts b/src/plugins/verifyInput.ts index b1355fd..b0f27c0 100644 --- a/src/plugins/verifyInput.ts +++ b/src/plugins/verifyInput.ts @@ -1,8 +1,12 @@ -export default function verifyInput(ctx: any, userInput: string, message: string, verifyNaN = false) { - if (!userInput || (verifyNaN && isNaN(Number(userInput)))) { // not sure why isNaN is used here, but the input should be a number +import { Context } from "telegraf"; +import { replyToMessageId } from "../utils/reply-to-message-id"; + +export default function verifyInput(ctx: Context, userInput: string, message: string, verifyNaN = false) { + const reply_to_message_id = replyToMessageId(ctx); + if (!userInput || (verifyNaN && isNaN(Number(userInput)))) { ctx.reply(message, { parse_mode: "Markdown", - reply_to_message_id: ctx.message.message_id + ...({ reply_to_message_id }) }); return true; } diff --git a/src/utils/language-code.ts b/src/utils/language-code.ts new file mode 100644 index 0000000..027b85b --- /dev/null +++ b/src/utils/language-code.ts @@ -0,0 +1,9 @@ +import { Context } from "telegraf"; + +export const languageCode = (ctx: Context) => { + if(ctx.from) { + return ctx.from.language_code + } else { + return 'en' + } +} \ No newline at end of file diff --git a/src/utils/reply-to-message-id.ts b/src/utils/reply-to-message-id.ts new file mode 100644 index 0000000..7308ea0 --- /dev/null +++ b/src/utils/reply-to-message-id.ts @@ -0,0 +1,5 @@ +import { Context } from "telegraf" + +export const replyToMessageId = (ctx: Context) => { + return ctx.message?.message_id +} \ No newline at end of file From 404a801c61a4fccdbdda2c690e61a8eb2b9f9e1b Mon Sep 17 00:00:00 2001 From: Aidan Date: Fri, 2 May 2025 20:16:36 -0400 Subject: [PATCH 11/47] fix: fix params --- src/commands/animal.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/animal.ts b/src/commands/animal.ts index 2b8352e..63cbb7b 100644 --- a/src/commands/animal.ts +++ b/src/commands/animal.ts @@ -105,9 +105,9 @@ export default (bot: Telegraf) => { case (userInput === "3" || userInput === "sticker"): ctx.replyWithSticker( - Resources.soggyCatSticker, { - // ...({ reply_to_message_id }) // to-do: fix this - }); + Resources.soggyCatSticker, + reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : undefined + ); break; case (userInput === "4" || userInput === "alt"): From eefe253bc61da4230ba580c9884d4d4f69a2ef28 Mon Sep 17 00:00:00 2001 From: Luquinhas Date: Fri, 2 May 2025 22:40:11 -0300 Subject: [PATCH 12/47] Remove ``winston``, bump ``@dotenvx/dotenvx`` and change ``@types/node`` to ``@types/bun`` --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index d617c84..f3d7aac 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,12 @@ "start": "nodemon src/bot.ts" }, "dependencies": { - "@dotenvx/dotenvx": "^1.41.0", - "@types/node": "^22.15.2", + "@dotenvx/dotenvx": "^1.42.2", + "@types/bun": "^1.2.11", "axios": "^1.9.0", "node-html-parser": "^7.0.1", "nodemon": "^3.1.10", "telegraf": "^4.16.3", - "winston": "^3.17.0", "youtube-url": "^0.5.0" } } From 4f88a85ccb1d53eb2692e6c0a12e7fee82430b34 Mon Sep 17 00:00:00 2001 From: Aidan Date: Wed, 7 May 2025 14:40:21 -0400 Subject: [PATCH 13/47] [FEATURE] Add /ask command (#54) * docs: add ai documentation * docker: update docker files for ai/regular versions, lint * feat: add initial /ask command * Delete docker-compose.yml * docker: ignore ollama folder in builds * fix: add emojis to help commands, capitalize, add ai commands to help menu * feat: add better logging, thought handling improvements --- .dockerignore | 3 +- .env.example | 3 + .gitignore | 8 +- README.md | 30 ++- docker-compose.yml.ai.example | 15 ++ ...-compose.yml => docker-compose.yml.example | 2 +- src/commands/ai.ts | 245 ++++++++++++++++++ src/commands/help.ts | 7 +- src/locales/english.json | 44 ++-- src/locales/portuguese.json | 7 +- src/utils/log.ts | 82 ++++++ src/utils/rate-limiter.ts | 235 +++++++++++++++++ 12 files changed, 654 insertions(+), 27 deletions(-) create mode 100644 docker-compose.yml.ai.example rename docker-compose.yml => docker-compose.yml.example (84%) create mode 100644 src/commands/ai.ts create mode 100644 src/utils/log.ts create mode 100644 src/utils/rate-limiter.ts diff --git a/.dockerignore b/.dockerignore index 33e390a..9fe19f3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ npm-debug.log .gitignore .env *.md -!README.md \ No newline at end of file +!README.md +ollama/ \ No newline at end of file diff --git a/.env.example b/.env.example index 452211b..997fbc5 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ botSource = "https://github.com/ABOCN/TelegramBot" # insert token here botToken = "" +# ai features +# ollamaApi = "http://ollama:11434" + # misc (botAdmins isnt a array here!) maxRetries = 9999 botAdmins = 00000000, 00000000, 00000000 diff --git a/.gitignore b/.gitignore index 6b42f1f..278fef8 100644 --- a/.gitignore +++ b/.gitignore @@ -144,4 +144,10 @@ yt-dlp ffmpeg # Bun -bun.lock* \ No newline at end of file +bun.lock* + +# Ollama +ollama/ + +# Docker +docker-compose.yml \ No newline at end of file diff --git a/README.md b/README.md index 8fa5b60..a0e4aab 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Kowalski is a a simple Telegram bot made in Node.js. - FFmpeg (only for the `/yt` command) - Docker and Docker Compose (only required for Docker setup) +_AI features require a higher-end system with a CPU/GPU_ + ## Running locally (non-Docker setup) First, clone the repo with Git: @@ -55,9 +57,23 @@ You can also run Kowalski using Docker, which simplifies the setup process. Make ### Using Docker Compose -1. **Make sure to setup your `.env` file first!** +1. **Copy compose file** -2. **Run the container** + _Without AI (Ollama)_ + + ```bash + mv docker-compose.yml.example docker-compose.yml + ``` + + _With AI (Ollama)_ + + ```bash + mv docker-compose.yml.ai.example docker-compose.yml + ``` + +2. **Make sure to setup your `.env` file first!** + +3. **Run the container** ```bash docker compose up -d @@ -81,6 +97,9 @@ If you prefer to use Docker directly, you can use these instructions instead. docker run -d --name kowalski --restart unless-stopped -v $(pwd)/.env:/usr/src/app/.env:ro kowalski ``` +> [!NOTE] +> You must setup Ollama on your own if you would like to use AI features. + ## .env Functions > [!IMPORTANT] @@ -90,6 +109,7 @@ If you prefer to use Docker directly, you can use these instructions instead. - **botPrivacy**: Put the link to your bot privacy policy. - **maxRetries**: Maximum number of retries for a failing command on Kowalski. Default is 5. If the limit is hit, the bot will crash past this number. - **botToken**: Put your bot token that you created at [@BotFather](https://t.me/botfather). +- **ollamaApi** (optional): Ollama API endpoint for various AI features, will be disabled if not set - **botAdmins**: Put the ID of the people responsible for managing the bot. They can use some administrative + exclusive commands on any group. - **lastKey**: Last.fm API key, for use on `lastfm.js` functions, like see who is listening to what song and etc. - **weatherKey**: Weather.com API key, used for the `/weather` command. @@ -106,6 +126,12 @@ If you prefer to use Docker directly, you can use these instructions instead. chmod +x src/plugins/yt-dlp/yt-dlp ``` +### AI + +**Q:** How can I disable AI features? + +**A:** AI features are disabled by default, unless you have set `ollamaApi` in your `.env` file. Please remove or comment out this line to disable all AI functionality. + ## Contributors diff --git a/docker-compose.yml.ai.example b/docker-compose.yml.ai.example new file mode 100644 index 0000000..2c516f7 --- /dev/null +++ b/docker-compose.yml.ai.example @@ -0,0 +1,15 @@ +services: + kowalski: + build: . + container_name: kowalski + restart: unless-stopped + volumes: + - ./.env:/usr/src/app/.env:ro + environment: + - NODE_ENV=production + ollama: + image: ollama/ollama + container_name: kowalski-ollama + restart: unless-stopped + volumes: + - ./ollama:/root/.ollama \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml.example similarity index 84% rename from docker-compose.yml rename to docker-compose.yml.example index 0aab44a..f3bb819 100644 --- a/docker-compose.yml +++ b/docker-compose.yml.example @@ -6,4 +6,4 @@ services: volumes: - ./.env:/usr/src/app/.env:ro environment: - - NODE_ENV=production \ No newline at end of file + - NODE_ENV=production \ No newline at end of file diff --git a/src/commands/ai.ts b/src/commands/ai.ts new file mode 100644 index 0000000..e62489c --- /dev/null +++ b/src/commands/ai.ts @@ -0,0 +1,245 @@ +// AI.TS +// by ihatenodejs/Aidan +// +// ----------------------------------------------------------------------- +// +// This is free and unencumbered software released into the public domain. +// +// Anyone is free to copy, modify, publish, use, compile, sell, or +// distribute this software, either in source code form or as a compiled +// binary, for any purpose, commercial or non-commercial, and by any +// means. +// +// In jurisdictions that recognize copyright laws, the author or authors +// of this software dedicate any and all copyright interest in the +// software to the public domain. We make this dedication for the benefit +// of the public at large and to the detriment of our heirs and +// successors. We intend this dedication to be an overt act of +// relinquishment in perpetuity of all present and future rights to this +// software under copyright law. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// For more information, please refer to + +import { isOnSpamWatch } from "../spamwatch/spamwatch" +import spamwatchMiddlewareModule from "../spamwatch/Middleware" +import { Telegraf, Context } from "telegraf" +import type { Message } from "telegraf/types" +import { replyToMessageId } from "../utils/reply-to-message-id" +import { getStrings } from "../plugins/checklang" +import { languageCode } from "../utils/language-code" +import axios from "axios" +import { rateLimiter } from "../utils/rate-limiter" +import { logger } from "../utils/log" + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch) +//const model = "qwen3:0.6b" +const model = "deepseek-r1:1.5b" + +type TextContext = Context & { message: Message.TextMessage } + +export function sanitizeForJson(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') +} + +async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message) { + const Strings = getStrings(languageCode(ctx)) + + if (!ctx.chat) return { + "success": false, + "error": Strings.unexpectedErr.replace("{error}", "No chat found"), + } + + try { + const aiResponse = await axios.post(`${process.env.ollamaApi}/api/generate`, { + model: model, + prompt: prompt, + stream: true, + }, { + responseType: "stream", + }) + + let fullResponse = "" + let thoughts = "" + let lastUpdate = Date.now() + + for await (const chunk of aiResponse.data) { + const lines = chunk.toString().split('\n') + for (const line of lines) { + if (!line.trim()) continue + let ln = JSON.parse(line) + + if (ln.response.includes("")) { logger.logThinking(true) } else if (ln.response.includes("")) { logger.logThinking(false) } + + try { + const now = Date.now() + + if (ln.response) { + const patchedThoughts = ln.response.replace("", "`Thinking...`").replace("", "`Finished thinking`") + thoughts += patchedThoughts + fullResponse += patchedThoughts + + if (now - lastUpdate >= 1000) { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + thoughts, + { parse_mode: 'Markdown' } + ) + lastUpdate = now + } + } + } catch (e) { + console.error("Error parsing chunk:", e) + } + } + } + + return { + "success": true, + "response": fullResponse, + } + } catch (error: any) { + let shouldPullModel = false + + if (error.response?.data?.error) { + if (error.response.data.error.includes(`model '${model}' not found`) || error.status === 404) { + shouldPullModel = true + } else { + console.error("[!] 1", error.response.data.error) + return { + "success": false, + "error": error.response.data.error, + } + } + } else if (error.status === 404) { + shouldPullModel = true + } + + if (shouldPullModel) { + ctx.telegram.editMessageText(ctx.chat.id, replyGenerating.message_id, undefined, `🔄 Pulling ${model} from ollama...`) + console.log(`[i] Pulling ${model} from ollama...`) + + const pullModelStream = await axios.post(`${process.env.ollamaApi}/api/pull`, { + model: model, + stream: false, + }) + + if (pullModelStream.data.status !== ("success")) { + console.error("[!] Something went wrong:", pullModelStream.data) + return { + "success": false, + "error": `❌ Something went wrong while pulling ${model}, please try your command again!`, + } + } + + console.log("[i] Model pulled successfully") + return { + "success": true, + "response": `✅ Pulled ${model} successfully, please retry the command.`, + } + } + + if (error.response) { + console.error("[!] 2", error.response) + return { + "success": false, + "error": error.response, + } + } + + if (error.statusText) { + console.error("[!] 3", error.statusText) + return { + "success": false, + "error": error.statusText, + } + } + + return { + "success": false, + "error": "An unexpected error occurred", + } + } +} + +export default (bot: Telegraf) => { + bot.command("ask", spamwatchMiddleware, async (ctx) => { + if (!ctx.message || !('text' in ctx.message)) return; + const textCtx = ctx as TextContext; + const reply_to_message_id = replyToMessageId(textCtx) + const Strings = getStrings(languageCode(textCtx)) + const message = textCtx.message.text + const author = ("@" + ctx.from?.username) || ctx.from?.first_name + + logger.logCmdStart(author) + + if (!process.env.ollamaApi) { + await ctx.reply(Strings.aiDisabled, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + return + } + + const replyGenerating = await ctx.reply(Strings.askGenerating.replace("{model}", model), { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + + const fixedMsg = message.replace(/\/ask /, "") + if (fixedMsg.length < 1) { + await ctx.reply(Strings.askNoMessage, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + return + } + + logger.logPrompt(fixedMsg) + + const prompt = sanitizeForJson( +`You are a helpful assistant named Kowalski, who has been given a message from a user. + +The message is: + +${fixedMsg}`) + const aiResponse = await getResponse(prompt, textCtx, replyGenerating) + if (!aiResponse) return + + if (aiResponse.success && aiResponse.response) { + if (!ctx.chat) return + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + aiResponse.response, + { parse_mode: 'Markdown' } + ) + } else { + if (!ctx.chat) return + const error = Strings.unexpectedErr.replace("{error}", aiResponse.error) + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + error, + { parse_mode: 'Markdown' } + ) + console.error("[!] Error sending response:", aiResponse.error) + } + }) +} \ No newline at end of file diff --git a/src/commands/help.ts b/src/commands/help.ts index 39191c1..3a6d3a0 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -32,7 +32,8 @@ async function sendHelpMessage(ctx, isEditing) { [{ text: Strings.mainCommands, callback_data: 'helpMain' }, { text: Strings.usefulCommands, callback_data: 'helpUseful' }], [{ text: Strings.interactiveEmojis, callback_data: 'helpInteractive' }, { text: Strings.funnyCommands, callback_data: 'helpFunny' }], [{ text: Strings.lastFm.helpEntry, callback_data: 'helpLast' }, { text: Strings.animalCommands, callback_data: 'helpAnimals' }], - [{ text: Strings.ytDownload.helpEntry, callback_data: 'helpYouTube' }, { text: Strings.ponyApi.helpEntry, callback_data: 'helpMLP' }] + [{ text: Strings.ytDownload.helpEntry, callback_data: 'helpYouTube' }, { text: Strings.ponyApi.helpEntry, callback_data: 'helpMLP' }], + [{ text: Strings.aiCmds, callback_data: 'helpAi' }] ] } }; @@ -112,6 +113,10 @@ export default (bot) => { await ctx.answerCbQuery(); await ctx.editMessageText(Strings.ponyApi.helpDesc, options); break; + case 'helpAi': + await ctx.answerCbQuery(); + await ctx.editMessageText(Strings.aiCmdsDesc, options); + break; case 'helpBack': await ctx.answerCbQuery(); await sendHelpMessage(ctx, true); diff --git a/src/locales/english.json b/src/locales/english.json index f8bb0ec..76cef32 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -33,8 +33,8 @@ "funEmojiResult": "*You rolled {emoji} and got* `{value}`*!*\nYou don't know what that means? Me neither!", "gifErr": "*Something went wrong while sending the GIF. Please try again later.*\n\n{err}", "lastFm": { - "helpEntry": "Last.fm", - "helpDesc": "*Last.fm*\n\n- /lt | /lmu | /last | /lfm: Shows the last song from your Last.fm profile + the number of plays.\n- /setuser ``: Sets the user for the command above.", + "helpEntry": "🎵 Last.fm", + "helpDesc": "🎵 *Last.fm*\n\n- /lt | /lmu | /last | /lfm: Shows the last song from your Last.fm profile + the number of plays.\n- /setuser ``: Sets the user for the command above.", "noUser": "*Please provide a Last.fm username.*\nExample: `/setuser `", "noUserSet": "*You haven't set your Last.fm username yet.*\nUse the command /setuser to set.\n\nExample: `/setuser `", "noRecentTracks": "*No recent tracks found for Last.fm user* `{lastfmUser}`*.*", @@ -52,25 +52,27 @@ "apiErr": "*An error occurred while retrieving the weather. Please try again later.*\n\n`{error}`", "apiKeyErr": "*An API key was not set by the bot owner. Please try again later.*" }, - "mainCommands": "Main commands", - "mainCommandsDesc": "*Main commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy", - "usefulCommands": "Useful commands", - "usefulCommandsDesc": "*Useful commands*\n\n- /chatinfo: Send information about the group\n- /userinfo: Send information about yourself\n- /d | /device ``: Search for a device on GSMArena and show its specs.\n/codename | /whatis ``: Shows what device is based on the codename. Example: `/codename begonia`\n- /weather | /clima ``: See weather status for a specific location.\n- /modarchive | /tma ``: Download a module from The Mod Archive.\n- /http ``: Send details about a specific HTTP code. Example: `/http 404`", - "funnyCommands": "Funny commands", - "funnyCommandsDesc": "*Funny commands*\n\n- /gay: Check if you are gay\n- /furry: Check if you are a furry\n- /random: Pick a random number between 0-10", - "interactiveEmojis": "Interactive emojis", - "interactiveEmojisDesc": "*Interactive emojis*\n\n- /dice: Roll a dice\n- /idice: Infinitely roll a colored dice\n- /slot: Try to combine the figures!\n- /ball: Try to kick the ball into the goal!\n- /bowling: Try to hit the pins!\n- /dart: Try to hit the target!", - "animalCommands": "Animals", - "animalCommandsDesc": "*Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat ``: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`", + "mainCommands": "ℹ️ Main Commands", + "mainCommandsDesc": "ℹ️ *Main Commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy", + "usefulCommands": "🛠️ Useful Commands", + "usefulCommandsDesc": "🛠️ *Useful commands*\n\n- /chatinfo: Send information about the group\n- /userinfo: Send information about yourself\n- /d | /device ``: Search for a device on GSMArena and show its specs.\n/codename | /whatis ``: Shows what device is based on the codename. Example: `/codename begonia`\n- /weather | /clima ``: See weather status for a specific location.\n- /modarchive | /tma ``: Download a module from The Mod Archive.\n- /http ``: Send details about a specific HTTP code. Example: `/http 404`", + "funnyCommands": "😂 Funny Commands", + "funnyCommandsDesc": "😂 *Funny Commands*\n\n- /gay: Check if you are gay\n- /furry: Check if you are a furry\n- /random: Pick a random number between 0-10", + "interactiveEmojis": "🎲 Interactive Emojis", + "interactiveEmojisDesc": "🎲 *Interactive emojis*\n\n- /dice: Roll a dice\n- /idice: Infinitely roll a colored dice\n- /slot: Try to combine the figures!\n- /ball: Try to kick the ball into the goal!\n- /bowling: Try to hit the pins!\n- /dart: Try to hit the target!", + "animalCommands": "🐱 Animals", + "animalCommandsDesc": "🐱 *Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat ``: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`", + "aiCmds": "✨ AI Commands", + "aiCmdsDesc": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI", "maInvalidModule": "Please provide a valid module ID from The Mod Archive.\nExample: `/modarchive 81574`", "maDownloadError": "Error downloading the file. Check the module ID and try again.", "ytDownload": { - "helpEntry": "Video download", - "helpDesc": "*Video download*\n\n- /yt | /ytdl | /sdl | /dl | /video ` diff --git a/docker-compose.yml.example b/docker-compose.yml similarity index 84% rename from docker-compose.yml.example rename to docker-compose.yml index f3bb819..0aab44a 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml @@ -6,4 +6,4 @@ services: volumes: - ./.env:/usr/src/app/.env:ro environment: - - NODE_ENV=production \ No newline at end of file + - NODE_ENV=production \ No newline at end of file diff --git a/docker-compose.yml.ai.example b/docker-compose.yml.ai.example deleted file mode 100644 index 2c516f7..0000000 --- a/docker-compose.yml.ai.example +++ /dev/null @@ -1,15 +0,0 @@ -services: - kowalski: - build: . - container_name: kowalski - restart: unless-stopped - volumes: - - ./.env:/usr/src/app/.env:ro - environment: - - NODE_ENV=production - ollama: - image: ollama/ollama - container_name: kowalski-ollama - restart: unless-stopped - volumes: - - ./ollama:/root/.ollama \ No newline at end of file diff --git a/src/commands/ai.ts b/src/commands/ai.ts deleted file mode 100644 index e62489c..0000000 --- a/src/commands/ai.ts +++ /dev/null @@ -1,245 +0,0 @@ -// AI.TS -// by ihatenodejs/Aidan -// -// ----------------------------------------------------------------------- -// -// This is free and unencumbered software released into the public domain. -// -// Anyone is free to copy, modify, publish, use, compile, sell, or -// distribute this software, either in source code form or as a compiled -// binary, for any purpose, commercial or non-commercial, and by any -// means. -// -// In jurisdictions that recognize copyright laws, the author or authors -// of this software dedicate any and all copyright interest in the -// software to the public domain. We make this dedication for the benefit -// of the public at large and to the detriment of our heirs and -// successors. We intend this dedication to be an overt act of -// relinquishment in perpetuity of all present and future rights to this -// software under copyright law. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// -// For more information, please refer to - -import { isOnSpamWatch } from "../spamwatch/spamwatch" -import spamwatchMiddlewareModule from "../spamwatch/Middleware" -import { Telegraf, Context } from "telegraf" -import type { Message } from "telegraf/types" -import { replyToMessageId } from "../utils/reply-to-message-id" -import { getStrings } from "../plugins/checklang" -import { languageCode } from "../utils/language-code" -import axios from "axios" -import { rateLimiter } from "../utils/rate-limiter" -import { logger } from "../utils/log" - -const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch) -//const model = "qwen3:0.6b" -const model = "deepseek-r1:1.5b" - -type TextContext = Context & { message: Message.TextMessage } - -export function sanitizeForJson(text: string): string { - return text - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t') -} - -async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message) { - const Strings = getStrings(languageCode(ctx)) - - if (!ctx.chat) return { - "success": false, - "error": Strings.unexpectedErr.replace("{error}", "No chat found"), - } - - try { - const aiResponse = await axios.post(`${process.env.ollamaApi}/api/generate`, { - model: model, - prompt: prompt, - stream: true, - }, { - responseType: "stream", - }) - - let fullResponse = "" - let thoughts = "" - let lastUpdate = Date.now() - - for await (const chunk of aiResponse.data) { - const lines = chunk.toString().split('\n') - for (const line of lines) { - if (!line.trim()) continue - let ln = JSON.parse(line) - - if (ln.response.includes("")) { logger.logThinking(true) } else if (ln.response.includes("")) { logger.logThinking(false) } - - try { - const now = Date.now() - - if (ln.response) { - const patchedThoughts = ln.response.replace("", "`Thinking...`").replace("", "`Finished thinking`") - thoughts += patchedThoughts - fullResponse += patchedThoughts - - if (now - lastUpdate >= 1000) { - await rateLimiter.editMessageWithRetry( - ctx, - ctx.chat.id, - replyGenerating.message_id, - thoughts, - { parse_mode: 'Markdown' } - ) - lastUpdate = now - } - } - } catch (e) { - console.error("Error parsing chunk:", e) - } - } - } - - return { - "success": true, - "response": fullResponse, - } - } catch (error: any) { - let shouldPullModel = false - - if (error.response?.data?.error) { - if (error.response.data.error.includes(`model '${model}' not found`) || error.status === 404) { - shouldPullModel = true - } else { - console.error("[!] 1", error.response.data.error) - return { - "success": false, - "error": error.response.data.error, - } - } - } else if (error.status === 404) { - shouldPullModel = true - } - - if (shouldPullModel) { - ctx.telegram.editMessageText(ctx.chat.id, replyGenerating.message_id, undefined, `🔄 Pulling ${model} from ollama...`) - console.log(`[i] Pulling ${model} from ollama...`) - - const pullModelStream = await axios.post(`${process.env.ollamaApi}/api/pull`, { - model: model, - stream: false, - }) - - if (pullModelStream.data.status !== ("success")) { - console.error("[!] Something went wrong:", pullModelStream.data) - return { - "success": false, - "error": `❌ Something went wrong while pulling ${model}, please try your command again!`, - } - } - - console.log("[i] Model pulled successfully") - return { - "success": true, - "response": `✅ Pulled ${model} successfully, please retry the command.`, - } - } - - if (error.response) { - console.error("[!] 2", error.response) - return { - "success": false, - "error": error.response, - } - } - - if (error.statusText) { - console.error("[!] 3", error.statusText) - return { - "success": false, - "error": error.statusText, - } - } - - return { - "success": false, - "error": "An unexpected error occurred", - } - } -} - -export default (bot: Telegraf) => { - bot.command("ask", spamwatchMiddleware, async (ctx) => { - if (!ctx.message || !('text' in ctx.message)) return; - const textCtx = ctx as TextContext; - const reply_to_message_id = replyToMessageId(textCtx) - const Strings = getStrings(languageCode(textCtx)) - const message = textCtx.message.text - const author = ("@" + ctx.from?.username) || ctx.from?.first_name - - logger.logCmdStart(author) - - if (!process.env.ollamaApi) { - await ctx.reply(Strings.aiDisabled, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - return - } - - const replyGenerating = await ctx.reply(Strings.askGenerating.replace("{model}", model), { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - - const fixedMsg = message.replace(/\/ask /, "") - if (fixedMsg.length < 1) { - await ctx.reply(Strings.askNoMessage, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - return - } - - logger.logPrompt(fixedMsg) - - const prompt = sanitizeForJson( -`You are a helpful assistant named Kowalski, who has been given a message from a user. - -The message is: - -${fixedMsg}`) - const aiResponse = await getResponse(prompt, textCtx, replyGenerating) - if (!aiResponse) return - - if (aiResponse.success && aiResponse.response) { - if (!ctx.chat) return - await rateLimiter.editMessageWithRetry( - ctx, - ctx.chat.id, - replyGenerating.message_id, - aiResponse.response, - { parse_mode: 'Markdown' } - ) - } else { - if (!ctx.chat) return - const error = Strings.unexpectedErr.replace("{error}", aiResponse.error) - await rateLimiter.editMessageWithRetry( - ctx, - ctx.chat.id, - replyGenerating.message_id, - error, - { parse_mode: 'Markdown' } - ) - console.error("[!] Error sending response:", aiResponse.error) - } - }) -} \ No newline at end of file diff --git a/src/commands/help.ts b/src/commands/help.ts index 3a6d3a0..39191c1 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -32,8 +32,7 @@ async function sendHelpMessage(ctx, isEditing) { [{ text: Strings.mainCommands, callback_data: 'helpMain' }, { text: Strings.usefulCommands, callback_data: 'helpUseful' }], [{ text: Strings.interactiveEmojis, callback_data: 'helpInteractive' }, { text: Strings.funnyCommands, callback_data: 'helpFunny' }], [{ text: Strings.lastFm.helpEntry, callback_data: 'helpLast' }, { text: Strings.animalCommands, callback_data: 'helpAnimals' }], - [{ text: Strings.ytDownload.helpEntry, callback_data: 'helpYouTube' }, { text: Strings.ponyApi.helpEntry, callback_data: 'helpMLP' }], - [{ text: Strings.aiCmds, callback_data: 'helpAi' }] + [{ text: Strings.ytDownload.helpEntry, callback_data: 'helpYouTube' }, { text: Strings.ponyApi.helpEntry, callback_data: 'helpMLP' }] ] } }; @@ -113,10 +112,6 @@ export default (bot) => { await ctx.answerCbQuery(); await ctx.editMessageText(Strings.ponyApi.helpDesc, options); break; - case 'helpAi': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.aiCmdsDesc, options); - break; case 'helpBack': await ctx.answerCbQuery(); await sendHelpMessage(ctx, true); diff --git a/src/locales/english.json b/src/locales/english.json index 76cef32..f8bb0ec 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -33,8 +33,8 @@ "funEmojiResult": "*You rolled {emoji} and got* `{value}`*!*\nYou don't know what that means? Me neither!", "gifErr": "*Something went wrong while sending the GIF. Please try again later.*\n\n{err}", "lastFm": { - "helpEntry": "🎵 Last.fm", - "helpDesc": "🎵 *Last.fm*\n\n- /lt | /lmu | /last | /lfm: Shows the last song from your Last.fm profile + the number of plays.\n- /setuser ``: Sets the user for the command above.", + "helpEntry": "Last.fm", + "helpDesc": "*Last.fm*\n\n- /lt | /lmu | /last | /lfm: Shows the last song from your Last.fm profile + the number of plays.\n- /setuser ``: Sets the user for the command above.", "noUser": "*Please provide a Last.fm username.*\nExample: `/setuser `", "noUserSet": "*You haven't set your Last.fm username yet.*\nUse the command /setuser to set.\n\nExample: `/setuser `", "noRecentTracks": "*No recent tracks found for Last.fm user* `{lastfmUser}`*.*", @@ -52,27 +52,25 @@ "apiErr": "*An error occurred while retrieving the weather. Please try again later.*\n\n`{error}`", "apiKeyErr": "*An API key was not set by the bot owner. Please try again later.*" }, - "mainCommands": "ℹ️ Main Commands", - "mainCommandsDesc": "ℹ️ *Main Commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy", - "usefulCommands": "🛠️ Useful Commands", - "usefulCommandsDesc": "🛠️ *Useful commands*\n\n- /chatinfo: Send information about the group\n- /userinfo: Send information about yourself\n- /d | /device ``: Search for a device on GSMArena and show its specs.\n/codename | /whatis ``: Shows what device is based on the codename. Example: `/codename begonia`\n- /weather | /clima ``: See weather status for a specific location.\n- /modarchive | /tma ``: Download a module from The Mod Archive.\n- /http ``: Send details about a specific HTTP code. Example: `/http 404`", - "funnyCommands": "😂 Funny Commands", - "funnyCommandsDesc": "😂 *Funny Commands*\n\n- /gay: Check if you are gay\n- /furry: Check if you are a furry\n- /random: Pick a random number between 0-10", - "interactiveEmojis": "🎲 Interactive Emojis", - "interactiveEmojisDesc": "🎲 *Interactive emojis*\n\n- /dice: Roll a dice\n- /idice: Infinitely roll a colored dice\n- /slot: Try to combine the figures!\n- /ball: Try to kick the ball into the goal!\n- /bowling: Try to hit the pins!\n- /dart: Try to hit the target!", - "animalCommands": "🐱 Animals", - "animalCommandsDesc": "🐱 *Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat ``: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`", - "aiCmds": "✨ AI Commands", - "aiCmdsDesc": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI", + "mainCommands": "Main commands", + "mainCommandsDesc": "*Main commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy", + "usefulCommands": "Useful commands", + "usefulCommandsDesc": "*Useful commands*\n\n- /chatinfo: Send information about the group\n- /userinfo: Send information about yourself\n- /d | /device ``: Search for a device on GSMArena and show its specs.\n/codename | /whatis ``: Shows what device is based on the codename. Example: `/codename begonia`\n- /weather | /clima ``: See weather status for a specific location.\n- /modarchive | /tma ``: Download a module from The Mod Archive.\n- /http ``: Send details about a specific HTTP code. Example: `/http 404`", + "funnyCommands": "Funny commands", + "funnyCommandsDesc": "*Funny commands*\n\n- /gay: Check if you are gay\n- /furry: Check if you are a furry\n- /random: Pick a random number between 0-10", + "interactiveEmojis": "Interactive emojis", + "interactiveEmojisDesc": "*Interactive emojis*\n\n- /dice: Roll a dice\n- /idice: Infinitely roll a colored dice\n- /slot: Try to combine the figures!\n- /ball: Try to kick the ball into the goal!\n- /bowling: Try to hit the pins!\n- /dart: Try to hit the target!", + "animalCommands": "Animals", + "animalCommandsDesc": "*Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat ``: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`", "maInvalidModule": "Please provide a valid module ID from The Mod Archive.\nExample: `/modarchive 81574`", "maDownloadError": "Error downloading the file. Check the module ID and try again.", "ytDownload": { - "helpEntry": "📺 YouTube Download", - "helpDesc": "📺 *YouTube Download*\n\n- /yt | /ytdl | /sdl | /dl | /video `${userName}, please select your device:`; + const testUser = `${userName}, please select your device:`; const options = { parse_mode: 'HTML', reply_to_message_id: ctx.message.message_id, @@ -230,8 +246,7 @@ export default (bot) => { inline_keyboard: results.map(result => [{ text: result.name, callback_data: `details:${result.url}:${ctx.from.id}` }]) } }; - ctx.reply(testUser, options); - + await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, testUser, options); }); bot.action(/details:(.+):(.+)/, async (ctx) => { From 81294f572121887162bc329ad91909b6769e6dd4 Mon Sep 17 00:00:00 2001 From: Lucas Gabriel Date: Sat, 28 Jun 2025 16:22:15 -0300 Subject: [PATCH 29/47] [FEATURE] Add AI-based /ask command (complementing #54) (#56) * docs: add ai documentation * docker: update docker files for ai/regular versions, lint * feat: add initial /ask command * Delete docker-compose.yml * docker: ignore ollama folder in builds * fix: add emojis to help commands, capitalize, add ai commands to help menu * feat: add better logging, thought handling improvements * bug fixes, better logging and seperation of ai, update docs for ai * clean, remove prompt and user info from logs, more docs edits * system prompt change (plaintext only), parse out /think * clean up, axios tweaks * cleanup, logging of ratelimit --------- Co-authored-by: Aidan --- .dockerignore | 3 +- .env.example | 5 + .gitignore | 8 +- README.md | 46 ++- docker-compose.yml.ai.example | 15 + ...-compose.yml => docker-compose.yml.example | 2 +- src/bot.ts | 15 +- src/commands/ai.ts | 287 ++++++++++++++++++ src/commands/help.ts | 7 +- src/locales/english.json | 44 +-- src/locales/portuguese.json | 7 +- src/utils/log.ts | 83 +++++ src/utils/rate-limiter.ts | 246 +++++++++++++++ 13 files changed, 733 insertions(+), 35 deletions(-) create mode 100644 docker-compose.yml.ai.example rename docker-compose.yml => docker-compose.yml.example (84%) create mode 100644 src/commands/ai.ts create mode 100644 src/utils/log.ts create mode 100644 src/utils/rate-limiter.ts diff --git a/.dockerignore b/.dockerignore index 33e390a..9fe19f3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ npm-debug.log .gitignore .env *.md -!README.md \ No newline at end of file +!README.md +ollama/ \ No newline at end of file diff --git a/.env.example b/.env.example index af81e1d..8e9cb3f 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,11 @@ botSource = "https://github.com/ABOCN/TelegramBot" # insert token here botToken = "" +# ai features +ollamaEnabled = false +# ollamaApi = "http://ollama:11434" +# handlerTimeout = "600_000" # set higher if you expect to download larger models + # misc (botAdmins isnt a array here!) maxRetries = 9999 botAdmins = 00000000, 00000000, 00000000 diff --git a/.gitignore b/.gitignore index 6b42f1f..278fef8 100644 --- a/.gitignore +++ b/.gitignore @@ -144,4 +144,10 @@ yt-dlp ffmpeg # Bun -bun.lock* \ No newline at end of file +bun.lock* + +# Ollama +ollama/ + +# Docker +docker-compose.yml \ No newline at end of file diff --git a/README.md b/README.md index 8fa5b60..3cc7c99 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,6 @@ Kowalski is a a simple Telegram bot made in Node.js. - You can find Kowalski at [@KowalskiNodeBot](https://t.me/KowalskiNodeBot) on Telegram. -## Translations - - -Translation status - - ## Self-host requirements > [!IMPORTANT] @@ -26,6 +20,11 @@ Kowalski is a a simple Telegram bot made in Node.js. - FFmpeg (only for the `/yt` command) - Docker and Docker Compose (only required for Docker setup) +### AI Requirements + +- High-end CPU *or* GPU (~ 6GB vRAM) +- If using CPU, enough RAM to load the models (~6GB w/ defaults) + ## Running locally (non-Docker setup) First, clone the repo with Git: @@ -55,9 +54,28 @@ You can also run Kowalski using Docker, which simplifies the setup process. Make ### Using Docker Compose -1. **Make sure to setup your `.env` file first!** +1. **Copy compose file** -2. **Run the container** + _Without AI (Ollama)_ + + ```bash + mv docker-compose.yml.example docker-compose.yml + ``` + + _With AI (Ollama)_ + + ```bash + mv docker-compose.yml.ai.example docker-compose.yml + ``` + +2. **Make sure to setup your `.env` file first!** + + > [!TIP] + > If you intend to setup AI, the defaults for Docker are already included (just uncomment) and don't need to be changed. + > + > Further setup may be needed for GPUs. See the Ollama documentation for more. + +3. **Run the container** ```bash docker compose up -d @@ -81,6 +99,9 @@ If you prefer to use Docker directly, you can use these instructions instead. docker run -d --name kowalski --restart unless-stopped -v $(pwd)/.env:/usr/src/app/.env:ro kowalski ``` +> [!NOTE] +> You must setup Ollama on your own if you would like to use AI features. + ## .env Functions > [!IMPORTANT] @@ -90,6 +111,9 @@ If you prefer to use Docker directly, you can use these instructions instead. - **botPrivacy**: Put the link to your bot privacy policy. - **maxRetries**: Maximum number of retries for a failing command on Kowalski. Default is 5. If the limit is hit, the bot will crash past this number. - **botToken**: Put your bot token that you created at [@BotFather](https://t.me/botfather). +- **ollamaEnabled** (optional): Enables/disables AI features +- **ollamaApi** (optional): Ollama API endpoint for various AI features, will be disabled if not set +- **handlerTimeout** (default: `600_000`): How long handlers will wait before timing out. Set this high if using large AI models. - **botAdmins**: Put the ID of the people responsible for managing the bot. They can use some administrative + exclusive commands on any group. - **lastKey**: Last.fm API key, for use on `lastfm.js` functions, like see who is listening to what song and etc. - **weatherKey**: Weather.com API key, used for the `/weather` command. @@ -106,6 +130,12 @@ If you prefer to use Docker directly, you can use these instructions instead. chmod +x src/plugins/yt-dlp/yt-dlp ``` +### AI + +**Q:** How can I disable AI features? + +**A:** AI features are disabled by default, unless you have set `ollamaEnabled` to `true` in your `.env` file. Set it back to `false` to disable. + ## Contributors diff --git a/docker-compose.yml.ai.example b/docker-compose.yml.ai.example new file mode 100644 index 0000000..2c516f7 --- /dev/null +++ b/docker-compose.yml.ai.example @@ -0,0 +1,15 @@ +services: + kowalski: + build: . + container_name: kowalski + restart: unless-stopped + volumes: + - ./.env:/usr/src/app/.env:ro + environment: + - NODE_ENV=production + ollama: + image: ollama/ollama + container_name: kowalski-ollama + restart: unless-stopped + volumes: + - ./ollama:/root/.ollama \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml.example similarity index 84% rename from docker-compose.yml rename to docker-compose.yml.example index 0aab44a..f3bb819 100644 --- a/docker-compose.yml +++ b/docker-compose.yml.example @@ -6,4 +6,4 @@ services: volumes: - ./.env:/usr/src/app/.env:ro environment: - - NODE_ENV=production \ No newline at end of file + - NODE_ENV=production \ No newline at end of file diff --git a/src/bot.ts b/src/bot.ts index 3422e56..04d2c97 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import { isOnSpamWatch } from './spamwatch/spamwatch'; import '@dotenvx/dotenvx'; import './plugins/ytDlpWrapper'; +import { preChecks } from './commands/ai'; // Ensures bot token is set, and not default value if (!process.env.botToken || process.env.botToken === 'InsertYourBotTokenHere') { @@ -11,7 +12,17 @@ if (!process.env.botToken || process.env.botToken === 'InsertYourBotTokenHere') process.exit(1) } -const bot = new Telegraf(process.env.botToken); +// Detect AI and run pre-checks +if (process.env.ollamaEnabled === "true") { + if (!(await preChecks())) { + process.exit(1) + } +} + +const bot = new Telegraf( + process.env.botToken, + { handlerTimeout: Number(process.env.handlerTimeout) || 600_000 } +); const maxRetries = process.env.maxRetries || 5; let restartCount = 0; @@ -21,7 +32,7 @@ const loadCommands = () => { try { const files = fs.readdirSync(commandsPath) .filter(file => file.endsWith('.ts') || file.endsWith('.js')); - + files.forEach((file) => { try { const commandPath = path.join(commandsPath, file); diff --git a/src/commands/ai.ts b/src/commands/ai.ts new file mode 100644 index 0000000..0e27578 --- /dev/null +++ b/src/commands/ai.ts @@ -0,0 +1,287 @@ +// AI.TS +// by ihatenodejs/Aidan +// +// ----------------------------------------------------------------------- +// +// This is free and unencumbered software released into the public domain. +// +// Anyone is free to copy, modify, publish, use, compile, sell, or +// distribute this software, either in source code form or as a compiled +// binary, for any purpose, commercial or non-commercial, and by any +// means. +// +// In jurisdictions that recognize copyright laws, the author or authors +// of this software dedicate any and all copyright interest in the +// software to the public domain. We make this dedication for the benefit +// of the public at large and to the detriment of our heirs and +// successors. We intend this dedication to be an overt act of +// relinquishment in perpetuity of all present and future rights to this +// software under copyright law. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// For more information, please refer to + +import { isOnSpamWatch } from "../spamwatch/spamwatch" +import spamwatchMiddlewareModule from "../spamwatch/Middleware" +import { Telegraf, Context } from "telegraf" +import type { Message } from "telegraf/types" +import { replyToMessageId } from "../utils/reply-to-message-id" +import { getStrings } from "../plugins/checklang" +import { languageCode } from "../utils/language-code" +import axios from "axios" +import { rateLimiter } from "../utils/rate-limiter" +import { logger } from "../utils/log" + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch) +export const flash_model = "gemma3:4b" +export const thinking_model = "deepseek-r1:1.5b" + +type TextContext = Context & { message: Message.TextMessage } + +export function sanitizeForJson(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') +} + +export async function preChecks() { + const envs = [ + "ollamaApi", + ] + + for (const env of envs) { + if (!process.env[env]) { + console.error(`[✨ AI | !] ❌ ${env} not set!`) + return false + } + } + console.log("[✨ AI] Pre-checks passed\n") + return true +} + +async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string) { + const Strings = getStrings(languageCode(ctx)) + + if (!ctx.chat) { + return { + success: false, + error: Strings.unexpectedErr.replace("{error}", "No chat found"), + } + } + + try { + const aiResponse = await axios.post( + `${process.env.ollamaApi}/api/generate`, + { + model, + prompt, + stream: true, + }, + { + responseType: "stream", + } + ) + + let fullResponse = "" + let thoughts = "" + let lastUpdate = Date.now() + + const stream = aiResponse.data + for await (const chunk of stream) { + const lines = chunk.toString().split('\n') + for (const line of lines) { + if (!line.trim()) continue + let ln + try { + ln = JSON.parse(line) + } catch (e) { + console.error("[✨ AI | !] Error parsing chunk:", e) + continue + } + + if (model === thinking_model) { + if (ln.response.includes('')) { + const thinkMatch = ln.response.match(/([\s\S]*?)<\/think>/) + if (thinkMatch && thinkMatch[1].trim().length > 0) { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, true) + } else if (!thinkMatch) { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, true) + } + } else if (ln.response.includes('')) { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, false) + } + } + + const now = Date.now() + if (ln.response) { + if (model === thinking_model) { + let patchedThoughts = ln.response + const thinkTagRx = /([\s\S]*?)<\/think>/g + patchedThoughts = patchedThoughts.replace(thinkTagRx, (match, p1) => p1.trim().length > 0 ? '`Thinking...`' + p1 + '`Finished thinking`' : '') + patchedThoughts = patchedThoughts.replace(//g, '`Thinking...`') + patchedThoughts = patchedThoughts.replace(/<\/think>/g, '`Finished thinking`') + thoughts += patchedThoughts + fullResponse += patchedThoughts + } else { + fullResponse += ln.response + } + if (now - lastUpdate >= 1000) { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + thoughts, + { parse_mode: 'Markdown' } + ) + lastUpdate = now + } + } + } + } + + return { + success: true, + response: fullResponse, + } + } catch (error: any) { + let shouldPullModel = false + if (error.response) { + const errData = error.response.data?.error + const errStatus = error.response.status + if (errData && (errData.includes(`model '${model}' not found`) || errStatus === 404)) { + shouldPullModel = true + } else { + console.error("[✨ AI | !] Error zone 1:", errData) + return { success: false, error: errData } + } + } else if (error.request) { + console.error("[✨ AI | !] No response received:", error.request) + return { success: false, error: "No response received from server" } + } else { + console.error("[✨ AI | !] Error zone 3:", error.message) + return { success: false, error: error.message } + } + + if (shouldPullModel) { + ctx.telegram.editMessageText(ctx.chat.id, replyGenerating.message_id, undefined, `🔄 Pulling ${model} from ollama...\n\nThis may take a few minutes...`) + console.log(`[✨ AI | i] Pulling ${model} from ollama...`) + try { + await axios.post( + `${process.env.ollamaApi}/api/pull`, + { + model, + stream: false, + timeout: process.env.ollamaApiTimeout || 10000, + } + ) + } catch (e: any) { + if (e.response) { + console.error("[✨ AI | !] Something went wrong:", e.response.data?.error) + return { + success: false, + error: `❌ Something went wrong while pulling ${model}, please try your command again!`, + } + } else if (e.request) { + console.error("[✨ AI | !] No response received while pulling:", e.request) + return { + success: false, + error: `❌ No response received while pulling ${model}, please try again!`, + } + } else { + console.error("[✨ AI | !] Error while pulling:", e.message) + return { + success: false, + error: `❌ Error while pulling ${model}: ${e.message}`, + } + } + } + console.log(`[✨ AI | i] ${model} pulled successfully`) + return { + success: true, + response: `✅ Pulled ${model} successfully, please retry the command.`, + } + } + } +} + +export default (bot: Telegraf) => { + const botName = bot.botInfo?.first_name && bot.botInfo?.last_name ? `${bot.botInfo.first_name} ${bot.botInfo.last_name}` : "Kowalski" + + bot.command(["ask", "think"], spamwatchMiddleware, async (ctx) => { + if (!ctx.message || !('text' in ctx.message)) return + const isAsk = ctx.message.text.startsWith("/ask") + const model = isAsk ? flash_model : thinking_model + const textCtx = ctx as TextContext + const reply_to_message_id = replyToMessageId(textCtx) + const Strings = getStrings(languageCode(textCtx)) + const message = textCtx.message.text + const author = ("@" + ctx.from?.username) || ctx.from?.first_name + + logger.logCmdStart(author, model === flash_model ? "ask" : "think") + + if (!process.env.ollamaApi) { + await ctx.reply(Strings.aiDisabled, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + return + } + + const replyGenerating = await ctx.reply(Strings.askGenerating.replace("{model}", model), { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + + const fixedMsg = message.replace(/\/(ask|think) /, "") + if (fixedMsg.length < 1) { + await ctx.reply(Strings.askNoMessage, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + return + } + + logger.logPrompt(fixedMsg) + + const prompt = sanitizeForJson( +`You are a plaintext-only, helpful assistant called ${botName}. +Current Date/Time (UTC): ${new Date().toLocaleString()} + +--- + +Respond to the user's message: +${fixedMsg}`) + const aiResponse = await getResponse(prompt, textCtx, replyGenerating, model) + if (!aiResponse) return + + if (!ctx.chat) return + if (aiResponse.success && aiResponse.response) { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + aiResponse.response, + { parse_mode: 'Markdown' } + ) + return + } + const error = Strings.unexpectedErr.replace("{error}", aiResponse.error) + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + error, + { parse_mode: 'Markdown' } + ) + }) +} \ No newline at end of file diff --git a/src/commands/help.ts b/src/commands/help.ts index 39191c1..3a6d3a0 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -32,7 +32,8 @@ async function sendHelpMessage(ctx, isEditing) { [{ text: Strings.mainCommands, callback_data: 'helpMain' }, { text: Strings.usefulCommands, callback_data: 'helpUseful' }], [{ text: Strings.interactiveEmojis, callback_data: 'helpInteractive' }, { text: Strings.funnyCommands, callback_data: 'helpFunny' }], [{ text: Strings.lastFm.helpEntry, callback_data: 'helpLast' }, { text: Strings.animalCommands, callback_data: 'helpAnimals' }], - [{ text: Strings.ytDownload.helpEntry, callback_data: 'helpYouTube' }, { text: Strings.ponyApi.helpEntry, callback_data: 'helpMLP' }] + [{ text: Strings.ytDownload.helpEntry, callback_data: 'helpYouTube' }, { text: Strings.ponyApi.helpEntry, callback_data: 'helpMLP' }], + [{ text: Strings.aiCmds, callback_data: 'helpAi' }] ] } }; @@ -112,6 +113,10 @@ export default (bot) => { await ctx.answerCbQuery(); await ctx.editMessageText(Strings.ponyApi.helpDesc, options); break; + case 'helpAi': + await ctx.answerCbQuery(); + await ctx.editMessageText(Strings.aiCmdsDesc, options); + break; case 'helpBack': await ctx.answerCbQuery(); await sendHelpMessage(ctx, true); diff --git a/src/locales/english.json b/src/locales/english.json index 4bc9c06..fadfcd6 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -33,8 +33,8 @@ "funEmojiResult": "*You rolled {emoji} and got* `{value}`*!*\nYou don't know what that means? Me neither!", "gifErr": "*Something went wrong while sending the GIF. Please try again later.*\n\n{err}", "lastFm": { - "helpEntry": "Last.fm", - "helpDesc": "*Last.fm*\n\n- /lt | /lmu | /last | /lfm: Shows the last song from your Last.fm profile + the number of plays.\n- /setuser ``: Sets the user for the command above.", + "helpEntry": "🎵 Last.fm", + "helpDesc": "🎵 *Last.fm*\n\n- /lt | /lmu | /last | /lfm: Shows the last song from your Last.fm profile + the number of plays.\n- /setuser ``: Sets the user for the command above.", "noUser": "*Please provide a Last.fm username.*\nExample: `/setuser `", "noUserSet": "*You haven't set your Last.fm username yet.*\nUse the command /setuser to set.\n\nExample: `/setuser `", "noRecentTracks": "*No recent tracks found for Last.fm user* `{lastfmUser}`*.*", @@ -52,25 +52,27 @@ "apiErr": "*An error occurred while retrieving the weather. Please try again later.*\n\n`{error}`", "apiKeyErr": "*An API key was not set by the bot owner. Please try again later.*" }, - "mainCommands": "Main commands", - "mainCommandsDesc": "*Main commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy", - "usefulCommands": "Useful commands", - "usefulCommandsDesc": "*Useful commands*\n\n- /chatinfo: Send information about the group\n- /userinfo: Send information about yourself\n- /d | /device ``: Search for a device on GSMArena and show its specs.\n/codename | /whatis ``: Shows what device is based on the codename. Example: `/codename begonia`\n- /weather | /clima ``: See weather status for a specific location.\n- /modarchive | /tma ``: Download a module from The Mod Archive.\n- /http ``: Send details about a specific HTTP code. Example: `/http 404`", - "funnyCommands": "Funny commands", - "funnyCommandsDesc": "*Funny commands*\n\n- /gay: Check if you are gay\n- /furry: Check if you are a furry\n- /random: Pick a random number between 0-10", - "interactiveEmojis": "Interactive emojis", - "interactiveEmojisDesc": "*Interactive emojis*\n\n- /dice: Roll a dice\n- /idice: Infinitely roll a colored dice\n- /slot: Try to combine the figures!\n- /ball: Try to kick the ball into the goal!\n- /bowling: Try to hit the pins!\n- /dart: Try to hit the target!", - "animalCommands": "Animals", - "animalCommandsDesc": "*Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat ``: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`", + "mainCommands": "ℹ️ Main Commands", + "mainCommandsDesc": "ℹ️ *Main Commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy", + "usefulCommands": "🛠️ Useful Commands", + "usefulCommandsDesc": "🛠️ *Useful commands*\n\n- /chatinfo: Send information about the group\n- /userinfo: Send information about yourself\n- /d | /device ``: Search for a device on GSMArena and show its specs.\n/codename | /whatis ``: Shows what device is based on the codename. Example: `/codename begonia`\n- /weather | /clima ``: See weather status for a specific location.\n- /modarchive | /tma ``: Download a module from The Mod Archive.\n- /http ``: Send details about a specific HTTP code. Example: `/http 404`", + "funnyCommands": "😂 Funny Commands", + "funnyCommandsDesc": "😂 *Funny Commands*\n\n- /gay: Check if you are gay\n- /furry: Check if you are a furry\n- /random: Pick a random number between 0-10", + "interactiveEmojis": "🎲 Interactive Emojis", + "interactiveEmojisDesc": "🎲 *Interactive emojis*\n\n- /dice: Roll a dice\n- /idice: Infinitely roll a colored dice\n- /slot: Try to combine the figures!\n- /ball: Try to kick the ball into the goal!\n- /bowling: Try to hit the pins!\n- /dart: Try to hit the target!", + "animalCommands": "🐱 Animals", + "animalCommandsDesc": "🐱 *Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat ``: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`", + "aiCmds": "✨ AI Commands", + "aiCmdsDesc": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI", "maInvalidModule": "Please provide a valid module ID from The Mod Archive.\nExample: `/modarchive 81574`", "maDownloadError": "Error downloading the file. Check the module ID and try again.", "ytDownload": { - "helpEntry": "Video download", - "helpDesc": "*Video download*\n\n- /yt | /ytdl | /sdl | /dl | /video `${userName}, please select your device:`; - const options = { - parse_mode: 'HTML', - reply_to_message_id: ctx.message.message_id, - disable_web_page_preview: true, - reply_markup: { - inline_keyboard: results.map(result => [{ text: result.name, callback_data: `details:${result.url}:${ctx.from.id}` }]) - } + if (deviceSelectionCache[userId]?.timeout) { + clearTimeout(deviceSelectionCache[userId].timeout); + } + deviceSelectionCache[userId] = { + results, + timeout: setTimeout(() => { delete deviceSelectionCache[userId]; }, 5 * 60 * 1000) }; - await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, testUser, options); + + if (lastSelectionMessageId[userId]) { + try { + await ctx.telegram.editMessageText( + ctx.chat.id, + lastSelectionMessageId[userId], + undefined, + Strings.gsmarenaSelectDevice || "[TODO: Add gsmarenaSelectDevice to locales] Please select your device:", + { + parse_mode: 'HTML', + reply_to_message_id: ctx.message.message_id, + disable_web_page_preview: true, + reply_markup: { + inline_keyboard: results.map((result, idx) => { + const callbackData = `gsmadetails:${idx}:${ctx.from.id}`; + return [{ text: result.name, callback_data: callbackData }]; + }) + } + } + ); + } catch (e) { + const testUser = `${userName}, ${Strings.gsmarenaSelectDevice || "[TODO: Add gsmarenaSelectDevice to locales] please select your device:"}`; + const options = { + parse_mode: 'HTML', + reply_to_message_id: ctx.message.message_id, + disable_web_page_preview: true, + reply_markup: { + inline_keyboard: results.map((result, idx) => { + const callbackData = `gsmadetails:${idx}:${ctx.from.id}`; + return [{ text: result.name, callback_data: callbackData }]; + }) + } + }; + const selectionMsg = await ctx.reply(testUser, options); + lastSelectionMessageId[userId] = selectionMsg.message_id; + } + } else { + const testUser = `${userName}, ${Strings.gsmarenaSelectDevice || "[TODO: Add gsmarenaSelectDevice to locales] please select your device:"}`; + const inlineKeyboard = results.map((result, idx) => { + const callbackData = `gsmadetails:${idx}:${ctx.from.id}`; + return [{ text: result.name, callback_data: callbackData }]; + }); + const options = { + parse_mode: 'HTML', + reply_to_message_id: ctx.message.message_id, + disable_web_page_preview: true, + reply_markup: { + inline_keyboard: inlineKeyboard + } + }; + const selectionMsg = await ctx.reply(testUser, options); + lastSelectionMessageId[userId] = selectionMsg.message_id; + } + await ctx.telegram.deleteMessage(ctx.chat.id, statusMsg.message_id).catch(() => {}); }); - bot.action(/details:(.+):(.+)/, async (ctx) => { - const url = ctx.match[1]; + bot.action(/gsmadetails:(\d+):(\d+)/, async (ctx) => { + const idx = parseInt(ctx.match[1]); const userId = parseInt(ctx.match[2]); const userName = getUsername(ctx); + const Strings = getStrings(languageCode(ctx)); const callbackQueryUserId = ctx.update.callback_query.from.id; if (userId !== callbackQueryUserId) { - return ctx.answerCbQuery(`${userName}, you are not allowed to interact with this.`); + return ctx.answerCbQuery(`${userName}, ${Strings.gsmarenaNotAllowed || "[TODO: Add gsmarenaNotAllowed to locales] you are not allowed to interact with this."}`); } ctx.answerCbQuery(); + const cache = deviceSelectionCache[userId]; + if (!cache || !cache.results[idx]) { + return ctx.reply(Strings.gsmarenaInvalidOrExpired || "[TODO: Add gsmarenaInvalidOrExpired to locales] Whoops, invalid or expired option. Please try again.", { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); + } + const url = cache.results[idx].url; + const phoneDetails = await checkPhoneDetails(url); if (phoneDetails.name) { const message = formatPhone(phoneDetails); - ctx.editMessageText(`${userName}, these are the details of your device:` + message, { parse_mode: 'HTML', disable_web_page_preview: false }); + ctx.editMessageText(`${userName}, ${Strings.gsmarenaDeviceDetails || "[TODO: Add gsmarenaDeviceDetails to locales] these are the details of your device:"}` + message, { parse_mode: 'HTML', disable_web_page_preview: false }); } else { - ctx.reply("Error fetching phone details.", { reply_to_message_id: ctx.message.message_id }); + ctx.reply(Strings.gsmarenaErrorFetchingDetails || "[TODO: Add gsmarenaErrorFetchingDetails to locales] Error fetching phone details.", { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } }); }; diff --git a/src/commands/help.ts b/src/commands/help.ts index 3a6d3a0..f01f5e5 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,21 +1,38 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; -import { languageCode } from '../utils/language-code'; +import type { Context } from 'telegraf'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); +async function getUserAndStrings(ctx: Context, db?: any): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + interface MessageOptions { parse_mode: string; disable_web_page_preview: boolean; reply_markup: { - inline_keyboard: { text: any; callback_data: string; }[][]; + inline_keyboard: { text: string; callback_data: string; }[][]; }; reply_to_message_id?: number; } -async function sendHelpMessage(ctx, isEditing) { - const Strings = getStrings(languageCode(ctx)); +async function sendHelpMessage(ctx, isEditing, db) { + const { Strings } = await getUserAndStrings(ctx, db); const botInfo = await ctx.telegram.getMe(); const helpText = Strings.botHelp .replace(/{botName}/g, botInfo.first_name) @@ -33,14 +50,14 @@ async function sendHelpMessage(ctx, isEditing) { [{ text: Strings.interactiveEmojis, callback_data: 'helpInteractive' }, { text: Strings.funnyCommands, callback_data: 'helpFunny' }], [{ text: Strings.lastFm.helpEntry, callback_data: 'helpLast' }, { text: Strings.animalCommands, callback_data: 'helpAnimals' }], [{ text: Strings.ytDownload.helpEntry, callback_data: 'helpYouTube' }, { text: Strings.ponyApi.helpEntry, callback_data: 'helpMLP' }], - [{ text: Strings.aiCmds, callback_data: 'helpAi' }] + [{ text: Strings.ai.helpEntry, callback_data: 'helpAi' }] ] } }; if (includeReplyTo) { const messageId = getMessageId(ctx); if (messageId) { - options.reply_to_message_id = messageId; + (options as any).reply_parameters = { message_id: messageId }; }; }; return options; @@ -52,78 +69,78 @@ async function sendHelpMessage(ctx, isEditing) { }; } -export default (bot) => { +export default (bot, db) => { bot.help(spamwatchMiddleware, async (ctx) => { - await sendHelpMessage(ctx, false); + await sendHelpMessage(ctx, false, db); }); bot.command("about", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); const aboutMsg = Strings.botAbout.replace(/{sourceLink}/g, `${process.env.botSource}`); ctx.reply(aboutMsg, { parse_mode: 'Markdown', disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); - }) + }); - bot.on('callback_query', async (ctx) => { - const callbackData = ctx.callbackQuery.data; - const Strings = getStrings(languageCode(ctx)); - const options = { - parse_mode: 'Markdown', - disable_web_page_preview: true, - reply_markup: JSON.stringify({ - inline_keyboard: [ - [{ text: Strings.varStrings.varBack, callback_data: 'helpBack' }], - ] - }) - }; + const options = (Strings) => ({ + parse_mode: 'Markdown', + disable_web_page_preview: true, + reply_markup: JSON.stringify({ + inline_keyboard: [ + [{ text: Strings.varStrings.varBack, callback_data: 'helpBack' }], + ] + }) + }); - switch (callbackData) { - case 'helpMain': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.mainCommandsDesc, options); - break; - case 'helpUseful': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.usefulCommandsDesc, options); - break; - case 'helpInteractive': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.interactiveEmojisDesc, options); - break; - case 'helpFunny': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.funnyCommandsDesc, options); - break; - case 'helpLast': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.lastFm.helpDesc, options); - break; - case 'helpYouTube': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.ytDownload.helpDesc, options); - break; - case 'helpAnimals': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.animalCommandsDesc, options); - break; - case 'helpMLP': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.ponyApi.helpDesc, options); - break; - case 'helpAi': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.aiCmdsDesc, options); - break; - case 'helpBack': - await ctx.answerCbQuery(); - await sendHelpMessage(ctx, true); - break; - default: - await ctx.answerCbQuery(Strings.errInvalidOption); - break; - } + bot.action('helpMain', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.mainCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpUseful', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.usefulCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpInteractive', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.interactiveEmojisDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpFunny', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.funnyCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpLast', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.lastFm.helpDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpYouTube', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.ytDownload.helpDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpAnimals', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.animalCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpMLP', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.ponyApi.helpDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpAi', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.ai.helpDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpBack', async (ctx) => { + await sendHelpMessage(ctx, true, db); + await ctx.answerCbQuery(); }); } diff --git a/src/commands/http.ts b/src/commands/http.ts index b1fe636..9ef0fdb 100644 --- a/src/commands/http.ts +++ b/src/commands/http.ts @@ -5,14 +5,37 @@ import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import verifyInput from '../plugins/verifyInput'; import { Context, Telegraf } from 'telegraf'; +import * as schema from '../db/schema'; import { languageCode } from '../utils/language-code'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -export default (bot: Telegraf) => { +async function getUserAndStrings(ctx: Context, db?: NodePgDatabase): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + if (from.language_code && languageCode === 'en') { + languageCode = from.language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', from.id); + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + +export default (bot: Telegraf, db) => { bot.command("http", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const reply_to_message_id = ctx.message.message_id; - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); const userInput = ctx.message.text.split(' ')[1]; const apiUrl = Resources.httpApi; const { invalidCode } = Strings.httpCodes @@ -34,19 +57,19 @@ export default (bot: Telegraf) => { .replace("{description}", codeInfo.description); await ctx.reply(message, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } else { await ctx.reply(Strings.httpCodes.notFound, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; } catch (error) { - const message = Strings.httpCodes.fetchErr.replace("{error}", error); + const message = Strings.httpCodes.fetchErr.replace('{error}', error); ctx.reply(message, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; }); @@ -63,7 +86,7 @@ export default (bot: Telegraf) => { if (userInput.length !== 3) { ctx.reply(Strings.httpCodes.invalidCode, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }) return } @@ -74,12 +97,12 @@ export default (bot: Telegraf) => { await ctx.replyWithPhoto(apiUrl, { caption: `🐱 ${apiUrl}`, parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } catch (error) { ctx.reply(Strings.catImgErr, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } }); diff --git a/src/commands/info.ts b/src/commands/info.ts index 2bf2b2d..c9f8042 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -2,64 +2,81 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import { Context, Telegraf } from 'telegraf'; +import * as schema from '../db/schema'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -async function getUserInfo(ctx: Context & { message: { text: string } }) { - const Strings = getStrings(ctx.from?.language_code || 'en'); +async function getUserAndStrings(ctx: Context, db?: NodePgDatabase): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + if (from.language_code && languageCode === 'en') { + languageCode = from.language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', from.id); + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + +async function getUserInfo(ctx: Context & { message: { text: string } }, db: any) { + const { Strings } = await getUserAndStrings(ctx, db); let lastName = ctx.from?.last_name; if (lastName === undefined) { lastName = " "; } - const userInfo = Strings.userInfo .replace('{userName}', `${ctx.from?.first_name} ${lastName}` || Strings.varStrings.varUnknown) .replace('{userId}', ctx.from?.id || Strings.varStrings.varUnknown) .replace('{userHandle}', ctx.from?.username ? `@${ctx.from?.username}` : Strings.varStrings.varNone) .replace('{userPremium}', ctx.from?.is_premium ? Strings.varStrings.varYes : Strings.varStrings.varNo) .replace('{userLang}', ctx.from?.language_code || Strings.varStrings.varUnknown); - return userInfo; } -async function getChatInfo(ctx: Context & { message: { text: string } }) { - const Strings = getStrings(ctx.from?.language_code || 'en'); - if (ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup') { +async function getChatInfo(ctx: Context & { message: { text: string } }, db: any) { + const { Strings } = await getUserAndStrings(ctx, db); + if ((ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup')) { + const chat = ctx.chat as (typeof ctx.chat & { username?: string; is_forum?: boolean }); const chatInfo = Strings.chatInfo - .replace('{chatId}', ctx.chat?.id || Strings.varStrings.varUnknown) - .replace('{chatName}', ctx.chat?.title || Strings.varStrings.varUnknown) - // @ts-ignore - .replace('{chatHandle}', ctx.chat?.username ? `@${ctx.chat?.username}` : Strings.varStrings.varNone) + .replace('{chatId}', chat?.id || Strings.varStrings.varUnknown) + .replace('{chatName}', chat?.title || Strings.varStrings.varUnknown) + .replace('{chatHandle}', chat?.username ? `@${chat.username}` : Strings.varStrings.varNone) .replace('{chatMembersCount}', await ctx.getChatMembersCount()) - .replace('{chatType}', ctx.chat?.type || Strings.varStrings.varUnknown) - // @ts-ignore - .replace('{isForum}', ctx.chat?.is_forum ? Strings.varStrings.varYes : Strings.varStrings.varNo); - + .replace('{chatType}', chat?.type || Strings.varStrings.varUnknown) + .replace('{isForum}', chat?.is_forum ? Strings.varStrings.varYes : Strings.varStrings.varNo); return chatInfo; } else { - return Strings.groupOnly + return Strings.groupOnly; } } -export default (bot: Telegraf) => { +export default (bot: Telegraf, db) => { bot.command('chatinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const chatInfo = await getChatInfo(ctx); + const chatInfo = await getChatInfo(ctx, db); ctx.reply( chatInfo, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) } ); }); bot.command('userinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const userInfo = await getUserInfo(ctx); + const userInfo = await getUserInfo(ctx, db); ctx.reply( userInfo, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) } ); }); diff --git a/src/commands/lastfm.ts b/src/commands/lastfm.ts index 39d6c8d..d51ca25 100644 --- a/src/commands/lastfm.ts +++ b/src/commands/lastfm.ts @@ -72,7 +72,7 @@ export default (bot) => { return ctx.reply(Strings.lastFm.noUser, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -84,7 +84,7 @@ export default (bot) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }); @@ -94,12 +94,12 @@ export default (bot) => { const lastfmUser = users[userId]; const genericImg = Resources.lastFmGenericImg; const botInfo = await ctx.telegram.getMe(); - + if (!lastfmUser) { return ctx.reply(Strings.lastFm.noUserSet, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -124,7 +124,7 @@ export default (bot) => { return ctx.reply(noRecent, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -137,8 +137,8 @@ export default (bot) => { if (albumMbid) { imageUrl = await getFromMusicBrainz(albumMbid); - } - + } + if (!imageUrl) { imageUrl = getFromLast(track); } @@ -166,7 +166,7 @@ export default (bot) => { 'User-Agent': `@${botInfo.username}-node-telegram-bot` } }); - + num_plays = response_plays.data.track.userplaycount; } catch (err) { console.log(err) @@ -176,7 +176,7 @@ export default (bot) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -200,13 +200,13 @@ export default (bot) => { caption: message, parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } else { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; } catch (err) { @@ -217,7 +217,7 @@ export default (bot) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; }); diff --git a/src/commands/main.ts b/src/commands/main.ts index a5d581c..a6c48ba 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -3,31 +3,390 @@ import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import { Context, Telegraf } from 'telegraf'; import { replyToMessageId } from '../utils/reply-to-message-id'; -import { languageCode } from '../utils/language-code'; +import * as schema from '../db/schema'; +import { eq } from 'drizzle-orm'; +import { ensureUserInDb } from '../utils/ensure-user'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { models } from './ai'; +import { langs } from '../locales/config'; + +type UserRow = typeof schema.usersTable.$inferSelect; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -export default (bot: Telegraf) => { - bot.start(spamwatchMiddleware, async (ctx: Context) => { - const Strings = getStrings(languageCode(ctx)); - const botInfo = await ctx.telegram.getMe(); - const reply_to_message_id = replyToMessageId(ctx) - const startMsg = Strings.botWelcome.replace(/{botName}/g, botInfo.first_name); +async function getUserAndStrings(ctx: Context, db: NodePgDatabase): Promise<{ user: UserRow | null, Strings: any, languageCode: string }> { + let user: UserRow | null = null; + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { user, Strings, languageCode }; + } + const { id, language_code } = ctx.from; + if (id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(id)), limit: 1 }); + if (dbUser.length === 0) { + await ensureUserInDb(ctx, db); + const newUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(id)), limit: 1 }); + if (newUser.length > 0) { + user = newUser[0]; + languageCode = user.languageCode; + } + } else { + user = dbUser[0]; + languageCode = user.languageCode; + } + } + if (!user && language_code) { + languageCode = language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', id); + } + const Strings = getStrings(languageCode); + return { user, Strings, languageCode }; +} - ctx.reply(startMsg, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); +type SettingsMenu = { text: string, reply_markup: any }; +function getSettingsMenu(user: UserRow, Strings: any): SettingsMenu { + const langObj = langs.find(l => l.code === user.languageCode); + const langLabel = langObj ? langObj.label : user.languageCode; + return { + text: Strings.settings.selectSetting, + reply_markup: { + inline_keyboard: [ + [ + { text: `✨ ${Strings.settings.ai.aiEnabled}: ${user.aiEnabled ? Strings.settings.enabled : Strings.settings.disabled}`, callback_data: 'settings_aiEnabled' }, + { text: `🧠 ${Strings.settings.ai.aiModel}: ${user.customAiModel}`, callback_data: 'settings_aiModel' } + ], + [ + { text: `🌡️ ${Strings.settings.ai.aiTemperature}: ${user.aiTemperature}`, callback_data: 'settings_aiTemperature' }, + { text: `🌐 ${langLabel}`, callback_data: 'settings_language' } + ] + ] + } + }; +} + +export default (bot: Telegraf, db: NodePgDatabase) => { + bot.start(spamwatchMiddleware, async (ctx: Context) => { + const { user, Strings } = await getUserAndStrings(ctx, db); + const botInfo = await ctx.telegram.getMe(); + const reply_to_message_id = replyToMessageId(ctx); + const startMsg = Strings.botWelcome.replace(/{botName}/g, botInfo.first_name); + if (!user) return; + ctx.reply( + startMsg.replace( + /{aiEnabled}/g, + user.aiEnabled ? Strings.settings.enabled : Strings.settings.disabled + ).replace( + /{aiModel}/g, + user.customAiModel + ).replace( + /{aiTemperature}/g, + user.aiTemperature.toString() + ).replace( + /{aiRequests}/g, + user.aiRequests.toString() + ).replace( + /{aiCharacters}/g, + user.aiCharacters.toString() + ).replace( + /{languageCode}/g, + user.languageCode + ), { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + } + ); }); - bot.command('privacy', spamwatchMiddleware, async (ctx: any) => { - const Strings = getStrings(ctx.from.language_code); - const message = Strings.botPrivacy.replace("{botPrivacy}", process.env.botPrivacy); + bot.command(["settings"], spamwatchMiddleware, async (ctx: Context) => { + const reply_to_message_id = replyToMessageId(ctx); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const menu = getSettingsMenu(user, Strings); + await ctx.reply( + menu.text, + { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + } + ); + }); + const updateSettingsKeyboard = async (ctx: Context, user: UserRow, Strings: any) => { + const menu = getSettingsMenu(user, Strings); + await ctx.editMessageReplyMarkup(menu.reply_markup); + }; + + bot.action('settings_aiEnabled', async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + await db.update(schema.usersTable) + .set({ aiEnabled: !user.aiEnabled }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + await updateSettingsKeyboard(ctx, updatedUser, Strings); + } catch (err) { + console.error('Error handling settings_aiEnabled callback:', err); + } + }); + + bot.action('settings_aiModel', async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + try { + await ctx.editMessageText( + `${Strings.settings.ai.selectSeries}`, + { + reply_markup: { + inline_keyboard: models.map(series => [ + { text: series.label, callback_data: `selectseries_${series.name}` } + ]).concat([[ + { text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_back' } + ]]) + } + } + ); + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('Unexpected Telegram error:', err); + } + } catch (err) { + console.error('Error handling settings_aiModel callback:', err); + } + }); + + bot.action(/^selectseries_.+$/, async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const data = (ctx.callbackQuery as any).data; + const seriesName = data.replace('selectseries_', ''); + const series = models.find(s => s.name === seriesName); + if (!series) return; + const desc = user.languageCode === 'pt' ? series.descriptionPt : series.descriptionEn; + try { + await ctx.editMessageText( + `${Strings.settings.ai.seriesDescription.replace('{seriesDescription}', desc)}\n\n${Strings.settings.ai.selectParameterSize.replace('{seriesLabel}', series.label)}\n\n${Strings.settings.ai.parameterSizeExplanation}`, + { + reply_markup: { + inline_keyboard: series.models.map(m => [ + { text: `${m.label} (${m.parameterSize})`, callback_data: `setmodel_${series.name}_${m.name}` } + ]).concat([[ + { text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_aiModel' } + ]]) + } + } + ); + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('Unexpected Telegram error:', err); + } + } catch (err) { + console.error('Error handling selectseries callback:', err); + } + }); + + bot.action(/^setmodel_.+$/, async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const data = (ctx.callbackQuery as any).data; + const parts = data.split('_'); + const seriesName = parts[1]; + const modelName = parts.slice(2).join('_'); + const series = models.find(s => s.name === seriesName); + const model = series?.models.find(m => m.name === modelName); + if (!series || !model) return; + await db.update(schema.usersTable) + .set({ customAiModel: model.name }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + const menu = getSettingsMenu(updatedUser, Strings); + try { + if (ctx.callbackQuery.message) { + await ctx.editMessageText( + menu.text, + { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + } + ); + } else { + await ctx.reply(menu.text, { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + }); + } + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('[Settings] Unexpected Telegram error:', err); + } + } catch (err) { + console.error('Error handling setmodel callback:', err); + } + }); + + bot.action('settings_aiTemperature', async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const temps = [0.2, 0.5, 0.7, 0.9, 1.2]; + try { + await ctx.editMessageReplyMarkup({ + inline_keyboard: temps.map(t => [{ text: t.toString(), callback_data: `settemp_${t}` }]).concat([[{ text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_back' }]]) + }); + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('Unexpected Telegram error:', err); + } + } catch (err) { + console.error('Error handling settings_aiTemperature callback:', err); + } + }); + + bot.action(/^settemp_.+$/, async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const data = (ctx.callbackQuery as any).data; + const temp = parseFloat(data.replace('settemp_', '')); + await db.update(schema.usersTable) + .set({ aiTemperature: temp }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + await updateSettingsKeyboard(ctx, updatedUser, Strings); + } catch (err) { + console.error('Error handling settemp callback:', err); + } + }); + + bot.action('settings_language', async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + try { + await ctx.editMessageReplyMarkup({ + inline_keyboard: langs.map(l => [{ text: l.label, callback_data: `setlang_${l.code}` }]).concat([[{ text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_back' }]]) + }); + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('Unexpected Telegram error:', err); + } + } catch (err) { + console.error('Error handling settings_language callback:', err); + } + }); + + bot.action('settings_back', async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + await updateSettingsKeyboard(ctx, user, Strings); + } catch (err) { + console.error('Error handling settings_back callback:', err); + } + }); + + bot.command('privacy', spamwatchMiddleware, async (ctx: Context) => { + const { Strings } = await getUserAndStrings(ctx, db); + if (!ctx.from || !ctx.message) return; + const message = Strings.botPrivacy.replace("{botPrivacy}", process.env.botPrivacy ?? ""); ctx.reply(message, { parse_mode: 'Markdown', - disable_web_page_preview: true, reply_to_message_id: ctx.message.message_id - }); + } as any); + }); + + bot.action(/^setlang_.+$/, async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user } = await getUserAndStrings(ctx, db); + if (!user) { + console.log('[Settings] No user found'); + return; + } + const data = (ctx.callbackQuery as any).data; + const lang = data.replace('setlang_', ''); + await db.update(schema.usersTable) + .set({ languageCode: lang }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + const updatedStrings = getStrings(updatedUser.languageCode); + const menu = getSettingsMenu(updatedUser, updatedStrings); + try { + if (ctx.callbackQuery.message) { + await ctx.editMessageText( + menu.text, + { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + } + ); + } else { + await ctx.reply(menu.text, { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + }); + } + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('[Settings] Unexpected Telegram error:', err); + } + } catch (err) { + console.error('[Settings] Error handling setlang callback:', err); + } }); }; \ No newline at end of file diff --git a/src/commands/modarchive.ts b/src/commands/modarchive.ts index 7d1489e..5f7333b 100644 --- a/src/commands/modarchive.ts +++ b/src/commands/modarchive.ts @@ -24,22 +24,17 @@ async function downloadModule(moduleId: string): Promise { method: 'GET', responseType: 'stream', }); - const disposition = response.headers['content-disposition']; let fileName = moduleId; - if (disposition && disposition.includes('filename=')) { fileName = disposition .split('filename=')[1] .split(';')[0] .replace(/['"]/g, ''); } - - const filePath = path.resolve(__dirname, fileName); - + const filePath = path.join(__dirname, fileName); const writer = fs.createWriteStream(filePath); response.data.pipe(writer); - return new Promise((resolve, reject) => { writer.on('finish', () => resolve({ filePath, fileName })); writer.on('error', reject); @@ -49,39 +44,41 @@ async function downloadModule(moduleId: string): Promise { } } -export default (bot: Telegraf) => { - bot.command(['modarchive', 'tma'], spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(languageCode(ctx)); - const reply_to_message_id = replyToMessageId(ctx); - const moduleId = ctx.message?.text.split(' ')[1]; - - if (Number.isNaN(moduleId) || null) { - return ctx.reply(Strings.maInvalidModule, { - parse_mode: "Markdown", - ...({ reply_to_message_id }) - }); - } - const numberRegex = /^\d+$/; - const isNumber = numberRegex.test(moduleId); - if (isNumber) { - const result = await downloadModule(moduleId); - if (result) { - const { filePath, fileName } = result; - const regexExtension = /\.\w+$/i; - const hasExtension = regexExtension.test(fileName); - if (hasExtension) { - await ctx.replyWithDocument({ source: filePath }, { - caption: fileName, - ...({ reply_to_message_id }) - }); - fs.unlinkSync(filePath); - return; - } - } - } +export const modarchiveHandler = async (ctx: Context) => { + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); + const moduleId = ctx.message && 'text' in ctx.message && typeof ctx.message.text === 'string' + ? ctx.message.text.split(' ')[1]?.trim() + : undefined; + if (!moduleId || !/^\d+$/.test(moduleId)) { return ctx.reply(Strings.maInvalidModule, { parse_mode: "Markdown", - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); + } + const result = await downloadModule(moduleId); + if (result) { + const { filePath, fileName } = result; + const regexExtension = /\.\w+$/i; + const hasExtension = regexExtension.test(fileName); + if (hasExtension) { + try { + await ctx.replyWithDocument({ source: filePath }, { + caption: fileName, + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } finally { + try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ } + } + return; + } + } + return ctx.reply(Strings.maInvalidModule, { + parse_mode: "Markdown", + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; + +export default (bot: Telegraf) => { + bot.command(['modarchive', 'tma'], spamwatchMiddleware, modarchiveHandler); +}; diff --git a/src/commands/ponyapi.ts b/src/commands/ponyapi.ts index daf99c7..7f6320c 100644 --- a/src/commands/ponyapi.ts +++ b/src/commands/ponyapi.ts @@ -53,34 +53,38 @@ function capitalizeFirstLetter(letter: string) { return letter.charAt(0).toUpperCase() + letter.slice(1); } +function sendReply(ctx: Context, text: string, reply_to_message_id?: number) { + return ctx.reply(text, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); +} + +function sendPhoto(ctx: Context, photo: string, caption: string, reply_to_message_id?: number) { + return ctx.replyWithPhoto(photo, { + caption, + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); +} + export default (bot: Telegraf) => { bot.command("mlp", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const Strings = getStrings(languageCode(ctx)); const reply_to_message_id = replyToMessageId(ctx); - - ctx.reply(Strings.ponyApi.helpDesc, { - parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) - }); + sendReply(ctx, Strings.ponyApi.helpDesc, reply_to_message_id); }); bot.command("mlpchar", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const { message } = ctx; const reply_to_message_id = replyToMessageId(ctx); const Strings = getStrings(languageCode(ctx) || 'en'); - const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); - const { noCharName } = Strings.ponyApi + const userInput = message.text.split(' ').slice(1).join(' ').trim().replace(/\s+/g, '+'); + const { noCharName } = Strings.ponyApi; - if (verifyInput(ctx, userInput, noCharName)) { - return; - } - - // if special characters or numbers (max 30 characters) - if (/[^a-zA-Z\s]/.test(userInput) || userInput.length > 30) { - ctx.reply(Strings.mlpInvalidCharacter, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - return; + if (verifyInput(ctx, userInput, noCharName)) return; + if (!userInput || /[^a-zA-Z\s]/.test(userInput) || userInput.length > 30) { + return sendReply(ctx, Strings.mlpInvalidCharacter, reply_to_message_id); } const capitalizedInput = capitalizeFirstLetter(userInput); @@ -88,62 +92,29 @@ export default (bot: Telegraf) => { try { const response = await axios(apiUrl); - const charactersArray: Character[] = []; - - if (Array.isArray(response.data.data)) { - response.data.data.forEach(character => { - let aliases: string[] = []; - if (character.alias) { - if (typeof character.alias === 'string') { - aliases.push(character.alias); - } else if (Array.isArray(character.alias)) { - aliases = aliases.concat(character.alias); - } - } - - charactersArray.push({ - id: character.id, - name: character.name, - alias: aliases.length > 0 ? aliases.join(', ') : Strings.varStrings.varNone, - url: character.url, - sex: character.sex, - residence: character.residence ? character.residence.replace(/\n/g, ' / ') : Strings.varStrings.varNone, - occupation: character.occupation ? character.occupation.replace(/\n/g, ' / ') : Strings.varStrings.varNone, - kind: character.kind ? character.kind.join(', ') : Strings.varStrings.varNone, - image: character.image - }); - }); - }; - - if (charactersArray.length > 0) { + const data = response.data.data; + if (Array.isArray(data) && data.length > 0) { + const character = data[0]; + const aliases = Array.isArray(character.alias) + ? character.alias.join(', ') + : character.alias || Strings.varStrings.varNone; const result = Strings.ponyApi.charRes - .replace("{id}", charactersArray[0].id) - .replace("{name}", charactersArray[0].name) - .replace("{alias}", charactersArray[0].alias) - .replace("{url}", charactersArray[0].url) - .replace("{sex}", charactersArray[0].sex) - .replace("{residence}", charactersArray[0].residence) - .replace("{occupation}", charactersArray[0].occupation) - .replace("{kind}", charactersArray[0].kind); - - ctx.replyWithPhoto(charactersArray[0].image[0], { - caption: `${result}`, - parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) - }); + .replace("{id}", character.id) + .replace("{name}", character.name) + .replace("{alias}", aliases) + .replace("{url}", character.url) + .replace("{sex}", character.sex) + .replace("{residence}", character.residence ? character.residence.replace(/\n/g, ' / ') : Strings.varStrings.varNone) + .replace("{occupation}", character.occupation ? character.occupation.replace(/\n/g, ' / ') : Strings.varStrings.varNone) + .replace("{kind}", Array.isArray(character.kind) ? character.kind.join(', ') : Strings.varStrings.varNone); + sendPhoto(ctx, character.image[0], result, reply_to_message_id); } else { - ctx.reply(Strings.ponyApi.noCharFound, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - }; - } catch (error) { - const message = Strings.ponyApi.apiErr.replace('{error}', error.message); - ctx.reply(message, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - }; + sendReply(ctx, Strings.ponyApi.noCharFound, reply_to_message_id); + } + } catch (error: any) { + const message = Strings.ponyApi.apiErr.replace('{error}', error.message || 'Unknown error'); + sendReply(ctx, message, reply_to_message_id); + } }); bot.command("mlpep", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { @@ -157,10 +128,10 @@ export default (bot: Telegraf) => { return; } - if (Number(userInput) > 100) { + if (Number(userInput) > 10000) { ctx.reply(Strings.mlpInvalidEpisode, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); return; } @@ -205,21 +176,19 @@ export default (bot: Telegraf) => { ctx.replyWithPhoto(episodeArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } else { ctx.reply(Strings.ponyApi.noEpisodeFound, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; } catch (error) { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; }); @@ -239,7 +208,7 @@ export default (bot: Telegraf) => { if (/[^a-zA-Z\s]/.test(userInput) || userInput.length > 30) { ctx.reply(Strings.mlpInvalidCharacter, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); return; } @@ -289,21 +258,19 @@ export default (bot: Telegraf) => { ctx.replyWithPhoto(comicArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } else { ctx.reply(Strings.ponyApi.noComicFound, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; } catch (error) { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; }); diff --git a/src/commands/randompony.ts b/src/commands/randompony.ts index 175f283..de24016 100644 --- a/src/commands/randompony.ts +++ b/src/commands/randompony.ts @@ -9,39 +9,40 @@ import { replyToMessageId } from '../utils/reply-to-message-id'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -export default (bot: Telegraf) => { - // TODO: this would greatly benefit from a loading message - bot.command(["rpony", "randompony", "mlpart"], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); - const reply_to_message_id = replyToMessageId(ctx); - ctx.reply(Strings.ponyApi.searching, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - try { - const response = await axios(Resources.randomPonyApi); - let tags: string[] = []; - - if (response.data.pony.tags) { - if (typeof response.data.pony.tags === 'string') { - tags.push(response.data.pony.tags); - } else if (Array.isArray(response.data.pony.tags)) { - tags = tags.concat(response.data.pony.tags); - } - } - - ctx.replyWithPhoto(response.data.pony.representations.full, { - caption: `${response.data.pony.sourceURL}\n\n${tags.length > 0 ? tags.join(', ') : ''}`, - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - } catch (error) { - const message = Strings.ponyApi.apiErr.replace('{error}', error.message); - ctx.reply(message, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - return; - } +export const randomponyHandler = async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); + ctx.reply(Strings.ponyApi.searching, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); + try { + const response = await axios(Resources.randomPonyApi); + let tags: string[] = []; + + if (response.data.pony.tags) { + if (typeof response.data.pony.tags === 'string') { + tags.push(response.data.pony.tags); + } else if (Array.isArray(response.data.pony.tags)) { + tags = tags.concat(response.data.pony.tags); + } + } + + ctx.replyWithPhoto(response.data.pony.representations.full, { + caption: `${response.data.pony.sourceURL}\n\n${tags.length > 0 ? tags.join(', ') : ''}`, + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } catch (error) { + const message = Strings.ponyApi.apiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + return; + } +}; + +export default (bot: Telegraf) => { + bot.command(["rpony", "randompony", "mlpart"], spamwatchMiddleware, randomponyHandler); } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..208bc56 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,23 @@ +import { + integer, + pgTable, + varchar, + timestamp, + boolean, + real +} from "drizzle-orm/pg-core"; + +export const usersTable = pgTable("users", { + telegramId: varchar({ length: 255 }).notNull().primaryKey(), + username: varchar({ length: 255 }).notNull(), + firstName: varchar({ length: 255 }).notNull(), + lastName: varchar({ length: 255 }).notNull(), + aiEnabled: boolean().notNull().default(false), + customAiModel: varchar({ length: 255 }).notNull().default("deepseek-r1:1.5b"), + aiTemperature: real().notNull().default(0.9), + aiRequests: integer().notNull().default(0), + aiCharacters: integer().notNull().default(0), + languageCode: varchar({ length: 255 }).notNull(), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp().notNull().defaultNow(), +}); diff --git a/src/locales/config.ts b/src/locales/config.ts new file mode 100644 index 0000000..7da7d37 --- /dev/null +++ b/src/locales/config.ts @@ -0,0 +1,4 @@ +export const langs = [ + { code: 'en', label: 'English' }, + { code: 'pt', label: 'Português' } +]; \ No newline at end of file diff --git a/src/locales/english.json b/src/locales/english.json index fadfcd6..e1ad103 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -1,4 +1,5 @@ { + "userNotFound": "User not found.", "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\nCheck out the source code: [Click here to go to GitHub]({sourceLink})\n\nClick on the buttons below to see which commands you can use!\n", "botPrivacy": "Check out [this link]({botPrivacy}) to read the bot's privacy policy.", @@ -53,7 +54,7 @@ "apiKeyErr": "*An API key was not set by the bot owner. Please try again later.*" }, "mainCommands": "ℹ️ Main Commands", - "mainCommandsDesc": "ℹ️ *Main Commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy", + "mainCommandsDesc": "ℹ️ *Main Commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy\n- /settings: Show your user settings", "usefulCommands": "🛠️ Useful Commands", "usefulCommandsDesc": "🛠️ *Useful commands*\n\n- /chatinfo: Send information about the group\n- /userinfo: Send information about yourself\n- /d | /device ``: Search for a device on GSMArena and show its specs.\n/codename | /whatis ``: Shows what device is based on the codename. Example: `/codename begonia`\n- /weather | /clima ``: See weather status for a specific location.\n- /modarchive | /tma ``: Download a module from The Mod Archive.\n- /http ``: Send details about a specific HTTP code. Example: `/http 404`", "funnyCommands": "😂 Funny Commands", @@ -62,8 +63,15 @@ "interactiveEmojisDesc": "🎲 *Interactive emojis*\n\n- /dice: Roll a dice\n- /idice: Infinitely roll a colored dice\n- /slot: Try to combine the figures!\n- /ball: Try to kick the ball into the goal!\n- /bowling: Try to hit the pins!\n- /dart: Try to hit the target!", "animalCommands": "🐱 Animals", "animalCommandsDesc": "🐱 *Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat ``: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`", - "aiCmds": "✨ AI Commands", - "aiCmdsDesc": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI", + "ai": { + "helpEntry": "✨ AI Commands", + "helpDesc": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI\n- /think ``: Ask a thinking model about a question", + "disabled": "✨ AI features are currently disabled", + "pulling": "🔄 *Pulling {model} from Ollama...*\n\nThis may take a few minutes...", + "askGenerating": "✨ _{model} is working..._", + "askNoMessage": "Please provide a message to ask the model.", + "languageCode": "Language" + }, "maInvalidModule": "Please provide a valid module ID from The Mod Archive.\nExample: `/modarchive 81574`", "maDownloadError": "Error downloading the file. Check the module ID and try again.", "ytDownload": { @@ -81,6 +89,33 @@ "noLink": "Please provide a link to a video to download.", "botDetection": "My server is being rate limited by the video provider! Please try again later, or ask the bot owner to add their cookies/account." }, + "settings": { + "helpEntry": "🔧 Settings", + "helpDesc": "🔧 *Settings*\n\n- /settings: Show your settings", + "mainSettings": "🔧 *Settings*\n\n- AI Enabled: {aiEnabled}\n- /ai Custom Model: {aiModel}\n- AI Temperature: {aiTemperature}\n- Total AI Requests: {aiRequests}\n- Total AI Characters Sent/Recieved: {aiCharacters}\n- Language: {languageCode}", + "enabled": "Enabled", + "disabled": "Disabled", + "selectSetting": "Please select a setting to modify or view.", + "ai": { + "aiEnabled": "AI Enabled", + "aiModel": "AI Model", + "aiTemperature": "AI Temperature", + "aiRequests": "Total AI Requests", + "aiCharacters": "Total AI Characters Sent/Recieved", + "languageCode": "Language", + "aiEnabledSetTo": "AI Enabled set to {aiEnabled}", + "aiModelSetTo": "AI Model set to {aiModel}", + "aiTemperatureSetTo": "AI Temperature set to {aiTemperature}", + "back": "Back", + "selectSeries": "Please select a model series.", + "seriesDescription": "{seriesDescription}", + "selectParameterSize": "Please select a parameter size for {seriesLabel}.", + "parameterSizeExplanation": "Parameter size (e.g. 2B, 4B) refers to the number of parameters in the model. Larger models may be more capable but require more resources.", + "modelSetTo": "Model set to {aiModel} ({parameterSize})" + }, + "languageCodeSetTo": "Language set to {languageCode}", + "unknownAction": "Unknown action." + }, "botUpdated": "Bot updated with success.\n\n```{result}```", "errorUpdatingBot": "Error updating bot\n\n{error}", "catImgErr": "Sorry, but I couldn't get the cat photo you wanted.", @@ -120,6 +155,13 @@ }, "chatNotFound": "Chat not found.", "noFileProvided": "Please provide a file to send.", - "askGenerating": "✨ _{model} is working..._", - "aiDisabled": "AI features are currently disabled" + "gsmarenaProvidePhoneName": "Please provide the phone name.", + "gsmarenaSearchingFor": "Searching for `{phone}`...", + "gsmarenaNoPhonesFound": "No phones found for `{phone}`.", + "gsmarenaNoPhonesFoundBoth": "No phones found for `{name}` and `{phone}`.", + "gsmarenaSelectDevice": "Please select your device:", + "gsmarenaNotAllowed": "you are not allowed to interact with this.", + "gsmarenaInvalidOrExpired": "Whoops, invalid or expired option. Please try again.", + "gsmarenaDeviceDetails": "these are the details of your device:", + "gsmarenaErrorFetchingDetails": "Error fetching phone details." } \ No newline at end of file diff --git a/src/locales/portuguese.json b/src/locales/portuguese.json index 415eeb1..63b3a4c 100644 --- a/src/locales/portuguese.json +++ b/src/locales/portuguese.json @@ -33,8 +33,8 @@ "funEmojiResult": "*Você lançou {emoji} e obteve *`{value}`*!*\nVocê não sabe o que isso significa? Nem eu!", "gifErr": "*Algo deu errado ao enviar o GIF. Tente novamente mais tarde.*\n\n{err}", "lastFm": { - "helpEntry": "Last.fm", - "helpDesc": "*Last.fm*\n\n- /lt | /lmu | /last | /lfm: Mostra a última música do seu perfil no Last.fm + o número de reproduções.\n- /setuser ``: Define o usuário para o comando acima.", + "helpEntry": "🎵 Last.fm", + "helpDesc": "🎵 *Last.fm*\n\n- /lt | /lmu | /last | /lfm: Mostra a última música do seu perfil no Last.fm + o número de reproduções.\n- /setuser ``: Define o usuário para o comando acima.", "noUser": "*Por favor, forneça um nome de usuário do Last.fm.*\nExemplo: `/setuser `", "noUserSet": "*Você ainda não definiu seu nome de usuário do Last.fm.*\nUse o comando /setuser para definir.\n\nExemplo: `/setuser `", "noRecentTracks": "*Nenhuma faixa recente encontrada para o usuário do Last.fm* `{lastfmUser}`*.*", @@ -52,27 +52,34 @@ "apiErr": "*Ocorreu um erro ao obter o clima. Tente novamente mais tarde.*\n\n`{error}`", "apiKeyErr": "*Uma chave de API não foi definida pelo proprietário do bot. Tente novamente mais tarde.*" }, - "mainCommands": "Comandos principais", - "mainCommandsDesc": "*Comandos principais*\n\n- /help: Exibe a ajuda do bot\n- /start: Inicia o bot\n- /privacy: Leia a política de privacidade do bot", - "usefulCommands": "Comandos úteis", - "usefulCommandsDesc": "*Comandos úteis*\n\n- /chatinfo: Envia informações sobre o grupo\n- /userinfo: Envia informações sobre você\n- /d | /device ``: Pesquisa um dispositivo no GSMArena e mostra suas especificações.\n- /weather | /clima ``: Veja o status do clima para uma localização específica\n- /modarchive | /tma ``: Baixa um módulo do The Mod Archive.\n- /http ``: Envia detalhes sobre um código HTTP específico. Exemplo: `/http 404`", - "funnyCommands": "Comandos engraçados", + "mainCommands": "ℹ️ Comandos principais", + "mainCommandsDesc": "ℹ️ *Comandos principais*\n\n- /help: Exibe a ajuda do bot\n- /start: Inicia o bot\n- /privacy: Leia a política de privacidade do bot\n- /settings: Exibe suas configurações", + "usefulCommands": "🛠️ Comandos úteis", + "usefulCommandsDesc": "🛠️ *Comandos úteis*\n\n- /chatinfo: Envia informações sobre o grupo\n- /userinfo: Envia informações sobre você\n- /d | /device ``: Pesquisa um dispositivo no GSMArena e mostra suas especificações.\n- /weather | /clima ``: Veja o status do clima para uma localização específica\n- /modarchive | /tma ``: Baixa um módulo do The Mod Archive.\n- /http ``: Envia detalhes sobre um código HTTP específico. Exemplo: `/http 404`", + "funnyCommands": "😂 Comandos engraçados", "funnyCommandsDesc": "*Comandos engraçados*\n\n- /gay: Verifique se você é gay\n- /furry: Verifique se você é furry\n- /random: Escolhe um número aleatório entre 0-10", - "interactiveEmojis": "Emojis interativos", - "interactiveEmojisDesc": "*Emojis interativos*\n\n- /dice: Jogue um dado\n- /idice: Role infinitamente um dado colorido\n- /slot: Tente combinar as figuras!\n- /ball: Tente chutar a bola no gol!\n- /bowling: Tente derrubar os pinos!\n- /dart: Tente acertar o alvo!", - "animalCommands": "Animais", - "animalCommandsDesc": "*Animais*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Envia o [meme do gato encharcado](https://knowyourmeme.com/memes/soggy-cat)\n- /cat - Envia uma foto aleatória de um gato.\n- /fox - Envia uma foto aleatória de uma raposa.\n- /duck - Envia uma foto aleatória de um pato.\n- /dog - Envia uma imagem aleatória de um cachorro.\n- /httpcat ``: Envia memes de gato do http.cat com o código HTTP especificado. Exemplo: `/httpcat 404`", - "aiCmds": "Comandos de IA", - "aiCmdsDesc": "*Comandos de IA*\n\n- /ask ``: Fazer uma pergunta a uma IA", + "interactiveEmojis": "🎲 Emojis interativos", + "interactiveEmojisDesc": "🎲 *Emojis interativos*\n\n- /dice: Jogue um dado\n- /idice: Role infinitamente um dado colorido\n- /slot: Tente combinar as figuras!\n- /ball: Tente chutar a bola no gol!\n- /bowling: Tente derrubar os pinos!\n- /dart: Tente acertar o alvo!", + "animalCommands": "🐱 Animais", + "animalCommandsDesc": "🐱 *Animais*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Envia o [meme do gato encharcado](https://knowyourmeme.com/memes/soggy-cat)\n- /cat - Envia uma foto aleatória de um gato.\n- /fox - Envia uma foto aleatória de uma raposa.\n- /duck - Envia uma foto aleatória de um pato.\n- /dog - Envia uma imagem aleatória de um cachorro.\n- /httpcat ``: Envia memes de gato do http.cat com o código HTTP especificado. Exemplo: `/httpcat 404`", + "ai": { + "helpEntry": "✨ Comandos de IA", + "helpDesc": "✨ *Comandos de IA*\n\n- /ask ``: Fazer uma pergunta a uma IA\n- /think ``: Fazer uma pergunta a um modelo de pensamento", + "disabled": "✨ Os recursos de IA estão desativados no momento", + "pulling": "🔄 *Puxando {model} do Ollama...*\n\nIsso pode levar alguns minutos...", + "askGenerating": "✨ _{model} está funcionando..._", + "askNoMessage": "Por favor, forneça uma mensagem para fazer a pergunta ao modelo.", + "languageCode": "Idioma" + }, "maInvalidModule": "Por favor, forneça um ID de módulo válido do The Mod Archive.\nExemplo: `/modarchive 81574`", "maDownloadError": "Erro ao baixar o arquivo. Verifique o ID do módulo e tente novamente.", "ytDownload": { - "helpEntry": "Download de vídeos", - "helpDesc": "*Download de vídeos*\n\n- /yt | /ytdl | /sdl | /dl | /video ``: Baixa um vídeo de algumas plataformas (ex: YouTube, Instagram, Facebook, etc.).\n\nConsulte [este link](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) para obter mais informações e saber quais serviços são compatíveis.\n\n*Nota: O Telegram está atualmente limitando os uploads de bots a 50MB, o que significa que se o vídeo que você deseja baixar for maior que 50MB, a qualidade será reduzida para tentar carregá-lo de qualquer maneira. Estamos fazendo o possível para contornar ou corrigir esse problema.*", - "downloadingVid": "*Baixando vídeo...*", + "helpEntry": "📺 Download de vídeos", + "helpDesc": "📺 *Download de vídeos*\n\n- /yt | /ytdl | /sdl | /dl | /video ``: Baixa um vídeo de algumas plataformas (ex: YouTube, Instagram, Facebook, etc.).\n\nConsulte [este link](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) para obter mais informações e saber quais serviços são compatíveis.\n\n*Nota: O Telegram está atualmente limitando os uploads de bots a 50MB, o que significa que se o vídeo que você deseja baixar for maior que 50MB, a qualidade será reduzida para tentar carregá-lo de qualquer maneira. Estamos fazendo o possível para contornar ou corrigir esse problema.*", + "downloadingVid": "⬇️ *Baixando vídeo...*", "libNotFound": "*Parece que o executável do yt-dlp não existe no nosso servidor...\n\nNesse caso, o problema está no nosso lado! Aguarde até que tenhamos notado e resolvido o problema.*", - "checkingSize": "Verificando se o vídeo excede o limite de 50 MB...", - "uploadingVid": "*Enviando vídeo...*", + "checkingSize": "🔎 *Verificando se o vídeo excede o limite de 50 MB...*", + "uploadingVid": "⬆️ *Enviando vídeo...*", "msgDesc": "{userMention}*, aqui está o seu vídeo baixado.*", "downloadErr": "*Erro durante o download do vídeo do YT:*\n\n`{err}`", "uploadErr": "Erro ao enviar o arquivo. Tente novamente mais tarde.", @@ -81,6 +88,33 @@ "noLink": "*Por favor, forneça um link de um vídeo para download.*", "botDetection": "Meu servidor está com a taxa limitada pelo provedor de vídeo! Tente novamente mais tarde ou peça ao proprietário do bot para adicionar seus cookies/conta." }, + "settings": { + "helpEntry": "🔧 Configurações", + "helpDesc": "🔧 *Configurações*\n\n- /settings: Mostrar suas configurações", + "mainSettings": "🔧 *Configurações*\n\n- Inteligência Artificial Ativado: {aiEnabled}\n- /ai Modelo personalizado: {aiModel}\n- Inteligência Artificial Temperatura: {aiTemperature}\n- Total de Requests: {aiRequests}\n- Total de Caracteres Enviados/Recebidos: {aiCharacters}\n- Idioma: {languageCode}", + "enabled": "Ativado", + "disabled": "Desativado", + "selectSetting": "Por favor, selecione uma configuração para modificar ou visualizar.", + "ai": { + "aiEnabled": "IA", + "aiModel": "Modelo", + "aiTemperature": "Temperatura", + "aiRequests": "Total de Requests", + "aiCharacters": "Total de Caracteres Enviados/Recebidos", + "languageCode": "Idioma", + "aiEnabledSetTo": "Inteligência Artificial definido para {aiEnabled}", + "aiModelSetTo": "Modelo personalizado definido para {aiModel}", + "aiTemperatureSetTo": "Temperatura definida para {aiTemperature}", + "selectSeries": "Por favor, selecione uma série de modelos.", + "seriesDescription": "{seriesDescription}", + "selectParameterSize": "Por favor, selecione um tamanho de parâmetro para {seriesLabel}.", + "parameterSizeExplanation": "O tamanho do parâmetro (ex: 2B, 4B) refere-se ao número de parâmetros do modelo. Modelos maiores podem ser mais capazes, mas exigem mais recursos.", + "modelSetTo": "Modelo definido para {aiModel} ({parameterSize})", + "back": "Voltar" + }, + "languageCodeSetTo": "Idioma definido para {languageCode}", + "unknownAction": "Ação desconhecida." + }, "botUpdated": "Bot atualizado com sucesso.\n\n```{result}```", "errorUpdatingBot": "Erro ao atualizar o bot\n\n{error}", "catImgErr": "Desculpe, mas não consegui obter a foto do gato que você queria.", @@ -97,8 +131,8 @@ "resultMsg": "*Código HTTP*: `{code}`\n*Nome*: `{message}`\n*Descrição*: `{description}`" }, "ponyApi": { - "helpEntry": "My Little Pony", - "helpDesc": "*My Little Pony*\n\n- /mlp: Exibe esta mensagem de ajuda.\n- /mlpchar ``: Mostra informações específicas sobre um personagem de My Little Pony em inglês. Exemplo: `/mlpchar twilight`\n- /mlpep: Mostra informações específicas sobre um episódio de My Little Pony em inglês. Exemplo: `/mlpep 136`\n- /mlpcomic ``: Mostra informações específicas sobre uma comic de My Little Pony em inglês. Exemplo: `/mlpcomic Nightmare Rarity`\n- /rpony | /randompony | /mlpart: Envia uma arte aleatória feita pela comunidade de My Little Pony.", + "helpEntry": "🐴 My Little Pony", + "helpDesc": "🐴 *My Little Pony*\n\n- /mlp: Exibe esta mensagem de ajuda.\n- /mlpchar ``: Mostra informações específicas sobre um personagem de My Little Pony em inglês. Exemplo: `/mlpchar twilight`\n- /mlpep: Mostra informações específicas sobre um episódio de My Little Pony em inglês. Exemplo: `/mlpep 136`\n- /mlpcomic ``: Mostra informações específicas sobre uma comic de My Little Pony em inglês. Exemplo: `/mlpcomic Nightmare Rarity`\n- /rpony | /randompony | /mlpart: Envia uma arte aleatória feita pela comunidade de My Little Pony.", "charRes": "*{name} (ID: {id})*\n\n*Apelido:* `{alias}`\n*Sexo:* `{sex}`\n*Residência:* `{residence}`\n*Ocupação:* `{occupation}`\n*Tipo:* `{kind}`\n\n*URL no Fandom:*\n[{url}]({url})", "epRes": "*{name} (ID: {id})*\n\n*Temporada:* `{season}`\n*Episódio:* `{episode}`\n*Número do Episódio:* `{overall}`\n*Data de lançamento:* `{airdate}`\n*História por:* `{storyby}`\n*Escrito por:* `{writtenby}`\n*Storyboard:* `{storyboard}`\n\n*URL no Fandom:*\n[{url}]({url})", "comicRes": "*{name} (ID: {id})*\n\n*Série:* `{series}`\n*Roteirista:* `{writer}`\n*Artista:* `{artist}`\n*Colorista:* `{colorist}`\n*Letrista:* `{letterer}`\n*Editor:* `{editor}`\n\n*URL no Fandom:*\n[{url}]({url})", @@ -119,6 +153,14 @@ "apiErr": "Ocorreu um erro ao buscar os dados da API.\n\n`{err}`" }, "noFileProvided": "Por favor, forneça um arquivo para envio.", - "askGenerating": "✨ _{modelo} está funcionando..._", - "aiDisabled": "Os recursos de IA estão desativados no momento" + "gsmarenaProvidePhoneName": "Por favor, forneça o nome do celular.", + "gsmarenaSearchingFor": "Procurando por `{phone}`...", + "gsmarenaNoPhonesFound": "Nenhum celular encontrado para `{phone}`.", + "gsmarenaNoPhonesFoundBoth": "Nenhum celular encontrado para `{name}` e `{phone}`.", + "gsmarenaSelectDevice": "Por favor, selecione seu dispositivo:", + "gsmarenaNotAllowed": "você não tem permissão para interagir com isso.", + "gsmarenaInvalidOrExpired": "Ops! Opção inválida ou expirada. Por favor, tente novamente.", + "gsmarenaDeviceDetails": "estes são os detalhes do seu dispositivo:", + "gsmarenaErrorFetchingDetails": "Erro ao buscar detalhes do celular.", + "userNotFound": "Usuário não encontrado." } diff --git a/src/utils/ensure-user.ts b/src/utils/ensure-user.ts new file mode 100644 index 0000000..0654476 --- /dev/null +++ b/src/utils/ensure-user.ts @@ -0,0 +1,64 @@ +// ENSURE-USER.TS +// by ihatenodejs/Aidan +// +// ----------------------------------------------------------------------- +// +// This is free and unencumbered software released into the public domain. +// +// Anyone is free to copy, modify, publish, use, compile, sell, or +// distribute this software, either in source code form or as a compiled +// binary, for any purpose, commercial or non-commercial, and by any +// means. +// +// In jurisdictions that recognize copyright laws, the author or authors +// of this software dedicate any and all copyright interest in the +// software to the public domain. We make this dedication for the benefit +// of the public at large and to the detriment of our heirs and +// successors. We intend this dedication to be an overt act of +// relinquishment in perpetuity of all present and future rights to this +// software under copyright law. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// For more information, please refer to + +import { usersTable } from '../db/schema'; + +export async function ensureUserInDb(ctx, db) { + if (!ctx.from) return; + const telegramId = String(ctx.from.id); + const username = ctx.from.username || ''; + const firstName = ctx.from.first_name || ' '; + const lastName = ctx.from.last_name || ' '; + const languageCode = ctx.from.language_code || 'en'; + + const existing = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, telegramId), limit: 1 }); + if (existing.length === 0) { + const userToInsert = { + telegramId, + username, + firstName, + lastName, + languageCode, + aiEnabled: false, + customAiModel: "deepseek-r1:1.5b", + aiTemperature: 0.9, + aiRequests: 0, + aiCharacters: 0, + }; + console.log('[💽 DB] Inserting user with values:', userToInsert); + try { + await db.insert(usersTable).values(userToInsert); + console.log(`[💽 DB] Added new user: ${username || firstName} (${telegramId})`); + } catch (err) { + console.error('[💽 DB] Error inserting user:', err); + throw err; + } + } +} diff --git a/src/utils/log.ts b/src/utils/log.ts index 67019a8..2f7e9ac 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -63,19 +63,24 @@ class Logger { console.log(`[✨ AI | PROMPT] ${prompt.length} chars input`) } - logError(error: any): void { - if (error.response?.error_code === 429) { - const retryAfter = error.response.parameters?.retry_after || 1 - console.error(`[✨ AI | RATE_LIMIT] Too Many Requests - retry after ${retryAfter}s`) - } else if (error.response?.error_code === 400 && error.response?.description?.includes("can't parse entities")) { - console.error("[✨ AI | PARSE_ERROR] Markdown parsing failed, retrying with plain text") - } else { - const errorDetails = { - code: error.response?.error_code, - description: error.response?.description, - method: error.on?.method + logError(error: unknown): void { + if (typeof error === 'object' && error !== null && 'response' in error) { + const err = error as { response?: { error_code?: number, parameters?: { retry_after?: number }, description?: string }, on?: { method?: string } }; + if (err.response?.error_code === 429) { + const retryAfter = err.response.parameters?.retry_after || 1; + console.error(`[✨ AI | RATE_LIMIT] Too Many Requests - retry after ${retryAfter}s`); + } else if (err.response?.error_code === 400 && err.response?.description?.includes("can't parse entities")) { + console.error("[✨ AI | PARSE_ERROR] Markdown parsing failed, retrying with plain text"); + } else { + const errorDetails = { + code: err.response?.error_code, + description: err.response?.description, + method: err.on?.method + }; + console.error("[✨ AI | ERROR]", JSON.stringify(errorDetails, null, 2)); } - console.error("[✨ AI | ERROR]", JSON.stringify(errorDetails, null, 2)) + } else { + console.error("[✨ AI | ERROR]", error); } } } diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index 777bb4f..b65ebb2 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -90,7 +90,14 @@ class RateLimiter { return chunks } - private handleTelegramError(error: unknown, messageKey: string, options: any, ctx: Context, chatId: number, messageId: number): boolean { + private handleTelegramError( + error: unknown, + messageKey: string, + options: Record, + ctx: Context, + chatId: number, + messageId: number + ): boolean { if (!isTelegramError(error)) return false if (error.response.error_code === 429) { const retryAfter = error.response.parameters?.retry_after || 1 @@ -130,7 +137,7 @@ class RateLimiter { ctx: Context, chatId: number, messageId: number, - options: any + options: Record ): Promise { const messageKey = this.getMessageKey(chatId, messageId) const latestText = this.pendingUpdates.get(messageKey) @@ -184,7 +191,7 @@ class RateLimiter { const newMessage = await ctx.telegram.sendMessage(chatId, chunk, { ...options, reply_to_message_id: messageId - }) + } as any) logger.logChunk(chatId, newMessage.message_id, chunk, true) this.overflowMessages.set(messageKey, newMessage.message_id) } @@ -226,7 +233,7 @@ class RateLimiter { chatId: number, messageId: number, text: string, - options: any + options: Record ): Promise { const messageKey = this.getMessageKey(chatId, messageId) this.pendingUpdates.set(messageKey, text) From 04271f87b1bf7f602d986871dcff4b7fb32a0a48 Mon Sep 17 00:00:00 2001 From: Aidan Date: Mon, 30 Jun 2025 02:24:18 -0400 Subject: [PATCH 33/47] remove log --- src/utils/ensure-user.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/ensure-user.ts b/src/utils/ensure-user.ts index 0654476..7726ffb 100644 --- a/src/utils/ensure-user.ts +++ b/src/utils/ensure-user.ts @@ -52,7 +52,6 @@ export async function ensureUserInDb(ctx, db) { aiRequests: 0, aiCharacters: 0, }; - console.log('[💽 DB] Inserting user with values:', userToInsert); try { await db.insert(usersTable).values(userToInsert); console.log(`[💽 DB] Added new user: ${username || firstName} (${telegramId})`); From 5270d2cae5a839cbd2889167fa8c875eb9277a87 Mon Sep 17 00:00:00 2001 From: Aidan Date: Mon, 30 Jun 2025 11:24:51 -0400 Subject: [PATCH 34/47] cleanup, bug fixes, better markdown parsing, better model display --- src/commands/ai.ts | 109 ++++++++++++++++++++++-------------- src/commands/main.ts | 6 +- src/locales/english.json | 4 +- src/locales/portuguese.json | 4 +- 4 files changed, 77 insertions(+), 46 deletions(-) diff --git a/src/commands/ai.ts b/src/commands/ai.ts index 4431f56..97a8c48 100644 --- a/src/commands/ai.ts +++ b/src/commands/ai.ts @@ -220,6 +220,10 @@ function extractAxiosErrorMessage(error: unknown): string { return 'An unexpected error occurred.'; } +function escapeMarkdown(text: string): string { + return text.replace(/([*_])/g, '\\$1'); +} + async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string, aiTemperature: number): Promise<{ success: boolean; response?: string; error?: string }> { const Strings = getStrings(languageCode(ctx)); if (!ctx.chat) { @@ -228,6 +232,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me error: Strings.unexpectedErr.replace("{error}", "No chat found"), }; } + const modelHeader = `🤖 *${model}* | 🌡️ *${aiTemperature}*\n\n`; try { const aiResponse = await axios.post( `${process.env.ollamaApi}/api/generate`, @@ -246,6 +251,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me let fullResponse = ""; let thoughts = ""; let lastUpdate = Date.now(); + let sentHeader = false; const stream: NodeJS.ReadableStream = aiResponse.data as any; for await (const chunk of stream) { const lines = chunk.toString().split('\n'); @@ -275,23 +281,24 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me if (model === thinking_model) { let patchedThoughts = ln.response; const thinkTagRx = /([\s\S]*?)<\/think>/g; - patchedThoughts = patchedThoughts.replace(thinkTagRx, (match, p1) => p1.trim().length > 0 ? '`Thinking...`' + p1 + '`Finished thinking`' : ''); - patchedThoughts = patchedThoughts.replace(//g, '`Thinking...`'); - patchedThoughts = patchedThoughts.replace(/<\/think>/g, '`Finished thinking`'); + patchedThoughts = patchedThoughts.replace(thinkTagRx, (p1) => p1.trim().length > 0 ? '`' + Strings.ai.thinking + '`' + p1 + '`' + Strings.ai.finishedThinking + '`' : ''); + patchedThoughts = patchedThoughts.replace(//g, '`' + Strings.ai.thinking + '`'); + patchedThoughts = patchedThoughts.replace(/<\/think>/g, '`' + Strings.ai.finishedThinking + '`'); thoughts += patchedThoughts; fullResponse += patchedThoughts; } else { fullResponse += ln.response; } - if (now - lastUpdate >= 1000) { + if (now - lastUpdate >= 1000 || !sentHeader) { await rateLimiter.editMessageWithRetry( ctx, ctx.chat.id, replyGenerating.message_id, - thoughts, + modelHeader + fullResponse, { parse_mode: 'Markdown' } ); lastUpdate = now; + sentHeader = true; } } } @@ -315,7 +322,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me Strings.ai.pulling.replace("{model}", model), { parse_mode: 'Markdown' } ); - console.log(`[✨ AI | i] Pulling ${model} from ollama...`); + console.log(`[✨ AI] Pulling ${model} from ollama...`); try { await axios.post( `${process.env.ollamaApi}/api/pull`, @@ -330,13 +337,13 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me console.error("[✨ AI | !] Pull error:", pullMsg); return { success: false, - error: `❌ Something went wrong while pulling ${model}: ${pullMsg}`, + error: `❌ Something went wrong while pulling ${escapeMarkdown(model)}: ${escapeMarkdown(pullMsg)}`, }; } - console.log(`[✨ AI | i] ${model} pulled successfully`); + console.log(`[✨ AI] ${model} pulled successfully`); return { success: true, - response: `✅ Pulled ${model} successfully, please retry the command.`, + response: `✅ Pulled ${escapeMarkdown(model)} successfully, please retry the command.`, }; } } @@ -347,13 +354,13 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me } } -async function handleAiReply(ctx: TextContext, db: NodePgDatabase, model: string, prompt: string, replyGenerating: Message, aiTemperature: number) { +async function handleAiReply(ctx: TextContext, model: string, prompt: string, replyGenerating: Message, aiTemperature: number) { const Strings = getStrings(languageCode(ctx)); const aiResponse = await getResponse(prompt, ctx, replyGenerating, model, aiTemperature); if (!aiResponse) return; if (!ctx.chat) return; + const modelHeader = `🤖 *${model}* | 🌡️ *${aiTemperature}*\n\n`; if (aiResponse.success && aiResponse.response) { - const modelHeader = `🤖 *${model}* | 🌡️ *${aiTemperature}*\n\n`; await rateLimiter.editMessageWithRetry( ctx, ctx.chat.id, @@ -387,6 +394,14 @@ async function getUserWithStringsAndModel(ctx: Context, db: NodePgDatabase m.name === name); + if (found) return found.label; + } + return name; +} + export default (bot: Telegraf, db: NodePgDatabase) => { const botName = bot.botInfo?.first_name && bot.botInfo?.last_name ? `${bot.botInfo.first_name} ${bot.botInfo.last_name}` : "Kowalski" @@ -427,44 +442,56 @@ export default (bot: Telegraf, db: NodePgDatabase) => { logger.logPrompt(fixedMsg) const prompt = sanitizeForJson(await usingSystemPrompt(textCtx, db, botName)) - await handleAiReply(textCtx, db, model, prompt, replyGenerating, aiTemperature) + await handleAiReply(textCtx, model, prompt, replyGenerating, aiTemperature) }) bot.command(["ai"], spamwatchMiddleware, async (ctx) => { - if (!ctx.message || !('text' in ctx.message)) return - const textCtx = ctx as TextContext - const reply_to_message_id = replyToMessageId(textCtx) - const { Strings, customAiModel, aiTemperature } = await getUserWithStringsAndModel(textCtx, db) - const message = textCtx.message.text - const author = ("@" + ctx.from?.username) || ctx.from?.first_name + try { + if (!ctx.message || !("text" in ctx.message)) return + const textCtx = ctx as TextContext + const reply_to_message_id = replyToMessageId(textCtx) + const { Strings, customAiModel, aiTemperature } = await getUserWithStringsAndModel(textCtx, db) + const message = textCtx.message.text + const author = ("@" + ctx.from?.username) || ctx.from?.first_name - logger.logCmdStart(author, "ask") + logger.logCmdStart(author, "ask") - if (!process.env.ollamaApi) { - await ctx.reply(Strings.ai.disabled, { + if (!process.env.ollamaApi) { + await ctx.reply(Strings.ai.disabled, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + return + } + + const fixedMsg = message.replace(/^\/ai(@\w+)?\s*/, "").trim() + if (fixedMsg.length < 1) { + await ctx.reply(Strings.ai.askNoMessage, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + return + } + + const modelLabel = getModelLabelByName(customAiModel) + const replyGenerating = await ctx.reply(Strings.ai.askGenerating.replace("{model}", modelLabel), { parse_mode: 'Markdown', ...({ reply_to_message_id }) }) - return + + logger.logPrompt(fixedMsg) + + const prompt = sanitizeForJson(await usingSystemPrompt(textCtx, db, botName)) + await handleAiReply(textCtx, customAiModel, prompt, replyGenerating, aiTemperature) + } catch (err) { + const Strings = getStrings(languageCode(ctx)); + if (ctx && ctx.reply) { + try { + await ctx.reply(Strings.unexpectedErr.replace("{error}", (err && err.message ? err.message : String(err))), { parse_mode: 'Markdown' }) + } catch (e) { + console.error("[✨ AI | !] Failed to send error reply:", e) + } + } } - - const fixedMsg = message.replace(/^\/ai(@\w+)?\s*/, "").trim() - if (fixedMsg.length < 1) { - await ctx.reply(Strings.ai.askNoMessage, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - return - } - - const replyGenerating = await ctx.reply(Strings.ai.askGenerating.replace("{model}", customAiModel), { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - - logger.logPrompt(fixedMsg) - - const prompt = sanitizeForJson(await usingSystemPrompt(textCtx, db, botName)) - await handleAiReply(textCtx, db, customAiModel, prompt, replyGenerating, aiTemperature) }) } \ No newline at end of file diff --git a/src/commands/main.ts b/src/commands/main.ts index a6c48ba..fe76037 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -7,7 +7,7 @@ import * as schema from '../db/schema'; import { eq } from 'drizzle-orm'; import { ensureUserInDb } from '../utils/ensure-user'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; -import { models } from './ai'; +import { models, getModelLabelByName } from './ai'; import { langs } from '../locales/config'; type UserRow = typeof schema.usersTable.$inferSelect; @@ -54,7 +54,7 @@ function getSettingsMenu(user: UserRow, Strings: any): SettingsMenu { inline_keyboard: [ [ { text: `✨ ${Strings.settings.ai.aiEnabled}: ${user.aiEnabled ? Strings.settings.enabled : Strings.settings.disabled}`, callback_data: 'settings_aiEnabled' }, - { text: `🧠 ${Strings.settings.ai.aiModel}: ${user.customAiModel}`, callback_data: 'settings_aiModel' } + { text: `🧠 ${Strings.settings.ai.aiModel}: ${getModelLabelByName(user.customAiModel)}`, callback_data: 'settings_aiModel' } ], [ { text: `🌡️ ${Strings.settings.ai.aiTemperature}: ${user.aiTemperature}`, callback_data: 'settings_aiTemperature' }, @@ -78,7 +78,7 @@ export default (bot: Telegraf, db: NodePgDatabase) => { user.aiEnabled ? Strings.settings.enabled : Strings.settings.disabled ).replace( /{aiModel}/g, - user.customAiModel + getModelLabelByName(user.customAiModel) ).replace( /{aiTemperature}/g, user.aiTemperature.toString() diff --git a/src/locales/english.json b/src/locales/english.json index e1ad103..cd83da6 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -70,7 +70,9 @@ "pulling": "🔄 *Pulling {model} from Ollama...*\n\nThis may take a few minutes...", "askGenerating": "✨ _{model} is working..._", "askNoMessage": "Please provide a message to ask the model.", - "languageCode": "Language" + "languageCode": "Language", + "thinking": "Thinking...", + "finishedThinking": "Finished thinking" }, "maInvalidModule": "Please provide a valid module ID from The Mod Archive.\nExample: `/modarchive 81574`", "maDownloadError": "Error downloading the file. Check the module ID and try again.", diff --git a/src/locales/portuguese.json b/src/locales/portuguese.json index 63b3a4c..2d04a8f 100644 --- a/src/locales/portuguese.json +++ b/src/locales/portuguese.json @@ -69,7 +69,9 @@ "pulling": "🔄 *Puxando {model} do Ollama...*\n\nIsso pode levar alguns minutos...", "askGenerating": "✨ _{model} está funcionando..._", "askNoMessage": "Por favor, forneça uma mensagem para fazer a pergunta ao modelo.", - "languageCode": "Idioma" + "languageCode": "Idioma", + "thinking": "Pensando...", + "finishedThinking": "Pensamento finalizado" }, "maInvalidModule": "Por favor, forneça um ID de módulo válido do The Mod Archive.\nExemplo: `/modarchive 81574`", "maDownloadError": "Erro ao baixar o arquivo. Verifique o ID do módulo e tente novamente.", From df49bc4157dcfed668e8ecb3f8a85661cdd32387 Mon Sep 17 00:00:00 2001 From: Aidan Date: Mon, 30 Jun 2025 20:24:42 -0400 Subject: [PATCH 35/47] KOW-27 commands respect aiEnabled now, message sending fix, show warning model cant use links, add phi, deepseek 7b, clean --- src/commands/ai.ts | 62 +++++++++++++++++++++++++++++-------- src/locales/english.json | 4 ++- src/locales/portuguese.json | 4 ++- src/utils/rate-limiter.ts | 7 +++-- 4 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/commands/ai.ts b/src/commands/ai.ts index 97a8c48..de69903 100644 --- a/src/commands/ai.ts +++ b/src/commands/ai.ts @@ -69,7 +69,7 @@ interface OllamaResponse { export const models: ModelInfo[] = [ { name: 'gemma3n', - label: 'Gemma3n', + label: 'gemma3n', descriptionEn: 'Gemma3n is a family of open, light on-device models for general tasks.', descriptionPt: 'Gemma3n é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.', models: [ @@ -79,7 +79,7 @@ export const models: ModelInfo[] = [ }, { name: 'gemma3-abliterated', - label: 'Gemma3 Uncensored', + label: 'gemma3 Uncensored', descriptionEn: 'Gemma3-abliterated is a family of open, uncensored models for general tasks.', descriptionPt: 'Gemma3-abliterated é uma família de modelos abertos, não censurados, para tarefas gerais.', models: [ @@ -103,7 +103,18 @@ export const models: ModelInfo[] = [ descriptionPt: 'DeepSeek é um modelo de pesquisa para tarefas de raciocínio.', models: [ { name: 'deepseek-r1:1.5b', label: 'DeepSeek 1.5B', parameterSize: '1.5B' }, + { name: 'deepseek-r1:7b', label: 'DeepSeek 7B', parameterSize: '7B' }, { name: 'huihui_ai/deepseek-r1-abliterated:1.5b', label: 'DeepSeek Uncensored 1.5B', parameterSize: '1.5B' }, + { name: 'huihui_ai/deepseek-r1-abliterated:7b', label: 'DeepSeek Uncensored 7B', parameterSize: '7B' }, + ] + }, + { + name: 'phi3', + label: 'Phi3', + descriptionEn: 'Phi-3 is a family of lightweight 3B (Mini) and 14B (Medium) state-of-the-art open models by Microsoft.', + descriptionPt: 'Phi-3 é uma família de modelos leves de 3B (Mini) e 14B (Médio) de última geração, abertos pela Microsoft.', + models: [ + { name: 'phi3:3.8b', label: 'Phi3 3.8B', parameterSize: '3.8B' }, ] } ]; @@ -224,7 +235,11 @@ function escapeMarkdown(text: string): string { return text.replace(/([*_])/g, '\\$1'); } -async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string, aiTemperature: number): Promise<{ success: boolean; response?: string; error?: string }> { +function containsUrls(text: string): boolean { + return text.includes('http://') || text.includes('https://'); +} + +async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string, aiTemperature: number, originalMessage: string): Promise<{ success: boolean; response?: string; error?: string }> { const Strings = getStrings(languageCode(ctx)); if (!ctx.chat) { return { @@ -233,6 +248,8 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me }; } const modelHeader = `🤖 *${model}* | 🌡️ *${aiTemperature}*\n\n`; + const urlWarning = containsUrls(originalMessage) ? Strings.ai.urlWarning : ''; + try { const aiResponse = await axios.post( `${process.env.ollamaApi}/api/generate`, @@ -289,12 +306,12 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me } else { fullResponse += ln.response; } - if (now - lastUpdate >= 1000 || !sentHeader) { + if (now - lastUpdate >= 5000 || !sentHeader) { await rateLimiter.editMessageWithRetry( ctx, ctx.chat.id, replyGenerating.message_id, - modelHeader + fullResponse, + modelHeader + urlWarning + fullResponse, { parse_mode: 'Markdown' } ); lastUpdate = now; @@ -354,18 +371,21 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me } } -async function handleAiReply(ctx: TextContext, model: string, prompt: string, replyGenerating: Message, aiTemperature: number) { +async function handleAiReply(ctx: TextContext, model: string, prompt: string, replyGenerating: Message, aiTemperature: number, originalMessage: string) { const Strings = getStrings(languageCode(ctx)); - const aiResponse = await getResponse(prompt, ctx, replyGenerating, model, aiTemperature); + const aiResponse = await getResponse(prompt, ctx, replyGenerating, model, aiTemperature, originalMessage); if (!aiResponse) return; if (!ctx.chat) return; const modelHeader = `🤖 *${model}* | 🌡️ *${aiTemperature}*\n\n`; + + const urlWarning = containsUrls(originalMessage) ? Strings.ai.urlWarning : ''; + if (aiResponse.success && aiResponse.response) { await rateLimiter.editMessageWithRetry( ctx, ctx.chat.id, replyGenerating.message_id, - modelHeader + aiResponse.response, + modelHeader + urlWarning + aiResponse.response, { parse_mode: 'Markdown' } ); return; @@ -411,7 +431,7 @@ export default (bot: Telegraf, db: NodePgDatabase) => { const model = isAsk ? flash_model : thinking_model const textCtx = ctx as TextContext const reply_to_message_id = replyToMessageId(textCtx) - const { Strings, aiTemperature } = await getUserWithStringsAndModel(textCtx, db) + const { user, Strings, aiTemperature } = await getUserWithStringsAndModel(textCtx, db) const message = textCtx.message.text const author = ("@" + ctx.from?.username) || ctx.from?.first_name @@ -425,6 +445,14 @@ export default (bot: Telegraf, db: NodePgDatabase) => { return } + if (!user.aiEnabled) { + await ctx.reply(Strings.ai.disabledForUser, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + return + } + const fixedMsg = message.replace(/^\/(ask|think)(@\w+)?\s*/, "").trim() if (fixedMsg.length < 1) { await ctx.reply(Strings.ai.askNoMessage, { @@ -442,7 +470,7 @@ export default (bot: Telegraf, db: NodePgDatabase) => { logger.logPrompt(fixedMsg) const prompt = sanitizeForJson(await usingSystemPrompt(textCtx, db, botName)) - await handleAiReply(textCtx, model, prompt, replyGenerating, aiTemperature) + await handleAiReply(textCtx, model, prompt, replyGenerating, aiTemperature, fixedMsg) }) bot.command(["ai"], spamwatchMiddleware, async (ctx) => { @@ -450,7 +478,7 @@ export default (bot: Telegraf, db: NodePgDatabase) => { if (!ctx.message || !("text" in ctx.message)) return const textCtx = ctx as TextContext const reply_to_message_id = replyToMessageId(textCtx) - const { Strings, customAiModel, aiTemperature } = await getUserWithStringsAndModel(textCtx, db) + const { user, Strings, customAiModel, aiTemperature } = await getUserWithStringsAndModel(textCtx, db) const message = textCtx.message.text const author = ("@" + ctx.from?.username) || ctx.from?.first_name @@ -464,6 +492,14 @@ export default (bot: Telegraf, db: NodePgDatabase) => { return } + if (!user.aiEnabled) { + await ctx.reply(Strings.ai.disabledForUser, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + return + } + const fixedMsg = message.replace(/^\/ai(@\w+)?\s*/, "").trim() if (fixedMsg.length < 1) { await ctx.reply(Strings.ai.askNoMessage, { @@ -482,7 +518,7 @@ export default (bot: Telegraf, db: NodePgDatabase) => { logger.logPrompt(fixedMsg) const prompt = sanitizeForJson(await usingSystemPrompt(textCtx, db, botName)) - await handleAiReply(textCtx, customAiModel, prompt, replyGenerating, aiTemperature) + await handleAiReply(textCtx, customAiModel, prompt, replyGenerating, aiTemperature, fixedMsg) } catch (err) { const Strings = getStrings(languageCode(ctx)); if (ctx && ctx.reply) { @@ -494,4 +530,4 @@ export default (bot: Telegraf, db: NodePgDatabase) => { } } }) -} \ No newline at end of file +} diff --git a/src/locales/english.json b/src/locales/english.json index cd83da6..74aa29e 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -67,12 +67,14 @@ "helpEntry": "✨ AI Commands", "helpDesc": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI\n- /think ``: Ask a thinking model about a question", "disabled": "✨ AI features are currently disabled", + "disabledForUser": "✨ AI features are disabled for your account. You can enable them in /settings", "pulling": "🔄 *Pulling {model} from Ollama...*\n\nThis may take a few minutes...", "askGenerating": "✨ _{model} is working..._", "askNoMessage": "Please provide a message to ask the model.", "languageCode": "Language", "thinking": "Thinking...", - "finishedThinking": "Finished thinking" + "finishedThinking": "Finished thinking", + "urlWarning": "⚠️ *Warning: I cannot access or open links. Please provide the content directly if you need me to analyze something from a website.*\n\n" }, "maInvalidModule": "Please provide a valid module ID from The Mod Archive.\nExample: `/modarchive 81574`", "maDownloadError": "Error downloading the file. Check the module ID and try again.", diff --git a/src/locales/portuguese.json b/src/locales/portuguese.json index 2d04a8f..81959af 100644 --- a/src/locales/portuguese.json +++ b/src/locales/portuguese.json @@ -66,12 +66,14 @@ "helpEntry": "✨ Comandos de IA", "helpDesc": "✨ *Comandos de IA*\n\n- /ask ``: Fazer uma pergunta a uma IA\n- /think ``: Fazer uma pergunta a um modelo de pensamento", "disabled": "✨ Os recursos de IA estão desativados no momento", + "disabledForUser": "✨ Os recursos de IA estão desativados para sua conta. Você pode ativá-los em /settings", "pulling": "🔄 *Puxando {model} do Ollama...*\n\nIsso pode levar alguns minutos...", "askGenerating": "✨ _{model} está funcionando..._", "askNoMessage": "Por favor, forneça uma mensagem para fazer a pergunta ao modelo.", "languageCode": "Idioma", "thinking": "Pensando...", - "finishedThinking": "Pensamento finalizado" + "finishedThinking": "Pensamento finalizado", + "urlWarning": "⚠️ *Aviso: Não posso acessar ou abrir links. Por favor, forneça o conteúdo diretamente se precisar que eu analise algo de um site.*\n\n" }, "maInvalidModule": "Por favor, forneça um ID de módulo válido do The Mod Archive.\nExemplo: `/modarchive 81574`", "maDownloadError": "Erro ao baixar o arquivo. Verifique o ID do módulo e tente novamente.", diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index b65ebb2..10673d9 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -32,7 +32,7 @@ import { Context } from 'telegraf' import { logger } from './log' class RateLimiter { - private lastEditTime: number = 0 + private lastEditTimes: Map = new Map() private readonly minInterval: number = 5000 private pendingUpdates: Map = new Map() private updateQueue: Map = new Map() @@ -144,7 +144,8 @@ class RateLimiter { if (!latestText) return const now = Date.now() - const timeSinceLastEdit = now - this.lastEditTime + const lastEditTime = this.lastEditTimes.get(messageKey) || 0 + const timeSinceLastEdit = now - lastEditTime await this.waitForRateLimit(chatId, messageId) if (timeSinceLastEdit < this.minInterval) { @@ -217,7 +218,7 @@ class RateLimiter { } this.pendingUpdates.delete(messageKey) } - this.lastEditTime = Date.now() + this.lastEditTimes.set(messageKey, Date.now()) this.updateQueue.delete(messageKey) } catch (error: unknown) { if (!this.handleTelegramError(error, messageKey, options, ctx, chatId, messageId)) { From 23ebd021f38b531039d8ea7678b7f26ac15123e2 Mon Sep 17 00:00:00 2001 From: Aidan Date: Mon, 30 Jun 2025 23:43:30 -0400 Subject: [PATCH 36/47] ai queue, better markdown parsing, refactor, better feedback --- README.md | 1 + src/commands/ai.ts | 319 ++++++++++++++++++++++-------------- src/commands/main.ts | 8 +- src/locales/english.json | 45 +++-- src/locales/portuguese.json | 45 +++-- src/plugins/verifyInput.ts | 28 ++-- 6 files changed, 273 insertions(+), 173 deletions(-) diff --git a/README.md b/README.md index e035285..ba6ecef 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ If you prefer to use Docker directly, you can use these instructions instead. - **handlerTimeout** (optional): How long handlers will wait before timing out. Set this high if using large AI models. - **flashModel** (optional): Which model will be used for /ask - **thinkingModel** (optional): Which model will be used for /think +- **updateEveryChars** (optional): The amount of chars until message update triggers (for streaming response) - **databaseUrl**: Database server configuration (see `.env.example`) - **botAdmins**: Put the ID of the people responsible for managing the bot. They can use some administrative + exclusive commands on any group. - **lastKey**: Last.fm API key, for use on `lastfm.js` functions, like see who is listening to what song and etc. diff --git a/src/commands/ai.ts b/src/commands/ai.ts index de69903..3781e9f 100644 --- a/src/commands/ai.ts +++ b/src/commands/ai.ts @@ -119,31 +119,17 @@ export const models: ModelInfo[] = [ } ]; -const enSystemPrompt = `You are a plaintext-only, helpful assistant called {botName}. -Current Date/Time (UTC): {date} - ---- - -Respond to the user's message: -{message}` - -const ptSystemPrompt = `Você é um assistente de texto puro e útil chamado {botName}. -Data/Hora atual (UTC): {date} - ---- - -Responda à mensagem do usuário: -{message}` - -async function usingSystemPrompt(ctx: TextContext, db: NodePgDatabase, botName: string): Promise { +async function usingSystemPrompt(ctx: TextContext, db: NodePgDatabase, botName: string, message: string): Promise { const user = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 }); if (user.length === 0) await ensureUserInDb(ctx, db); const userData = user[0]; const lang = userData?.languageCode || "en"; + const Strings = getStrings(lang); const utcDate = new Date().toISOString(); - const prompt = lang === "pt" - ? ptSystemPrompt.replace("{botName}", botName).replace("{date}", utcDate).replace("{message}", ctx.message.text) - : enSystemPrompt.replace("{botName}", botName).replace("{date}", utcDate).replace("{message}", ctx.message.text); + const prompt = Strings.ai.systemPrompt + .replace("{botName}", botName) + .replace("{date}", utcDate) + .replace("{message}", message); return prompt; } @@ -156,6 +142,51 @@ export function sanitizeForJson(text: string): string { .replace(/\t/g, '\\t') } +function sanitizeMarkdownForTelegram(text: string): string { + let sanitizedText = text; + + const replacements: string[] = []; + const addReplacement = (match: string): string => { + replacements.push(match); + return `___PLACEHOLDER_${replacements.length - 1}___`; + }; + + sanitizedText = sanitizedText.replace(/```([\s\S]*?)```/g, addReplacement); + sanitizedText = sanitizedText.replace(/`([^`]+)`/g, addReplacement); + sanitizedText = sanitizedText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, addReplacement); + + const parts = sanitizedText.split(/(___PLACEHOLDER_\d+___)/g); + const processedParts = parts.map(part => { + if (part.match(/___PLACEHOLDER_\d+___/)) { + return part; + } else { + let processedPart = part; + processedPart = processedPart.replace(/^(#{1,6})\s+(.+)/gm, '*$2*'); + processedPart = processedPart.replace(/^(\s*)[-*]\s+/gm, '$1- '); + processedPart = processedPart.replace(/\*\*(.*?)\*\*/g, '*$1*'); + processedPart = processedPart.replace(/__(.*?)__/g, '*$1*'); + processedPart = processedPart.replace(/(^|\s)\*(?!\*)([^*]+?)\*(?!\*)/g, '$1_$2_'); + processedPart = processedPart.replace(/(^|\s)_(?!_)([^_]+?)_(?!_)/g, '$1_$2_'); + processedPart = processedPart.replace(/~~(.*?)~~/g, '~$1~'); + processedPart = processedPart.replace(/^\s*┃/gm, '>'); + processedPart = processedPart.replace(/^>\s?/gm, '> '); + + return processedPart; + } + }); + + sanitizedText = processedParts.join(''); + + sanitizedText = sanitizedText.replace(/___PLACEHOLDER_(\d+)___/g, (_, idx) => replacements[Number(idx)]); + + const codeBlockCount = (sanitizedText.match(/```/g) || []).length; + if (codeBlockCount % 2 !== 0) { + sanitizedText += '\n```'; + } + + return sanitizedText; +} + export async function preChecks() { const envs = [ "ollamaApi", @@ -232,7 +263,7 @@ function extractAxiosErrorMessage(error: unknown): string { } function escapeMarkdown(text: string): string { - return text.replace(/([*_])/g, '\\$1'); + return text.replace(/([_*\[\]()`>#\+\-=|{}.!~])/g, '\\$1'); } function containsUrls(text: string): boolean { @@ -244,10 +275,14 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me if (!ctx.chat) { return { success: false, - error: Strings.unexpectedErr.replace("{error}", "No chat found"), + error: Strings.unexpectedErr.replace("{error}", Strings.ai.noChatFound), }; } - const modelHeader = `🤖 *${model}* | 🌡️ *${aiTemperature}*\n\n`; + let status = Strings.ai.statusWaitingRender; + let modelHeader = Strings.ai.modelHeader + .replace("{model}", model) + .replace("{temperature}", aiTemperature) + .replace("{status}", status) + "\n\n"; const urlWarning = containsUrls(originalMessage) ? Strings.ai.urlWarning : ''; try { @@ -267,8 +302,9 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me ); let fullResponse = ""; let thoughts = ""; - let lastUpdate = Date.now(); + let lastUpdateCharCount = 0; let sentHeader = false; + let firstChunk = true; const stream: NodeJS.ReadableStream = aiResponse.data as any; for await (const chunk of stream) { const lines = chunk.toString().split('\n'); @@ -293,7 +329,6 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me logger.logThinking(ctx.chat.id, replyGenerating.message_id, false); } } - const now = Date.now(); if (ln.response) { if (model === thinking_model) { let patchedThoughts = ln.response; @@ -306,20 +341,51 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me } else { fullResponse += ln.response; } - if (now - lastUpdate >= 5000 || !sentHeader) { + if (firstChunk) { + status = Strings.ai.statusWaitingRender; + modelHeader = Strings.ai.modelHeader + .replace("{model}", model) + .replace("{temperature}", aiTemperature) + .replace("{status}", status) + "\n\n"; await rateLimiter.editMessageWithRetry( ctx, ctx.chat.id, replyGenerating.message_id, - modelHeader + urlWarning + fullResponse, + modelHeader + urlWarning + escapeMarkdown(fullResponse), { parse_mode: 'Markdown' } ); - lastUpdate = now; + lastUpdateCharCount = fullResponse.length; + sentHeader = true; + firstChunk = false; + continue; + } + const updateEveryChars = Number(process.env.updateEveryChars) || 100; + if (fullResponse.length - lastUpdateCharCount >= updateEveryChars || !sentHeader) { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + urlWarning + escapeMarkdown(fullResponse), + { parse_mode: 'Markdown' } + ); + lastUpdateCharCount = fullResponse.length; sentHeader = true; } } } } + status = Strings.ai.statusRendering; + modelHeader = Strings.ai.modelHeader + .replace("{model}", model) + .replace("{temperature}", aiTemperature) + .replace("{status}", status) + "\n\n"; + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + urlWarning + escapeMarkdown(fullResponse), + { parse_mode: 'Markdown' } + ); return { success: true, response: fullResponse, @@ -360,7 +426,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me console.log(`[✨ AI] ${model} pulled successfully`); return { success: true, - response: `✅ Pulled ${escapeMarkdown(model)} successfully, please retry the command.`, + response: Strings.ai.pulled.replace("{model}", escapeMarkdown(model)), }; } } @@ -376,16 +442,18 @@ async function handleAiReply(ctx: TextContext, model: string, prompt: string, re const aiResponse = await getResponse(prompt, ctx, replyGenerating, model, aiTemperature, originalMessage); if (!aiResponse) return; if (!ctx.chat) return; - const modelHeader = `🤖 *${model}* | 🌡️ *${aiTemperature}*\n\n`; - - const urlWarning = containsUrls(originalMessage) ? Strings.ai.urlWarning : ''; - if (aiResponse.success && aiResponse.response) { + const status = Strings.ai.statusComplete; + const modelHeader = Strings.ai.modelHeader + .replace("{model}", model) + .replace("{temperature}", aiTemperature) + .replace("{status}", status) + "\n\n"; + const urlWarning = containsUrls(originalMessage) ? Strings.ai.urlWarning : ''; await rateLimiter.editMessageWithRetry( ctx, ctx.chat.id, replyGenerating.message_id, - modelHeader + urlWarning + aiResponse.response, + modelHeader + urlWarning + sanitizeMarkdownForTelegram(aiResponse.response), { parse_mode: 'Markdown' } ); return; @@ -425,109 +493,112 @@ export function getModelLabelByName(name: string): string { export default (bot: Telegraf, db: NodePgDatabase) => { const botName = bot.botInfo?.first_name && bot.botInfo?.last_name ? `${bot.botInfo.first_name} ${bot.botInfo.last_name}` : "Kowalski" - bot.command(["ask", "think"], spamwatchMiddleware, async (ctx) => { - if (!ctx.message || !('text' in ctx.message)) return - const isAsk = ctx.message.text.startsWith("/ask") - const model = isAsk ? flash_model : thinking_model - const textCtx = ctx as TextContext - const reply_to_message_id = replyToMessageId(textCtx) - const { user, Strings, aiTemperature } = await getUserWithStringsAndModel(textCtx, db) - const message = textCtx.message.text - const author = ("@" + ctx.from?.username) || ctx.from?.first_name + interface AiRequest { + task: () => Promise; + ctx: TextContext; + wasQueued: boolean; + } - logger.logCmdStart(author, model === flash_model ? "ask" : "think") + const requestQueue: AiRequest[] = []; + let isProcessing = false; + + async function processQueue() { + if (isProcessing || requestQueue.length === 0) { + return; + } + + isProcessing = true; + const { task, ctx, wasQueued } = requestQueue.shift()!; + const { Strings } = await getUserWithStringsAndModel(ctx, db); + const reply_to_message_id = replyToMessageId(ctx); + + try { + if (wasQueued) { + await ctx.reply(Strings.ai.startingProcessing, { + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }), + parse_mode: 'Markdown' + }); + } + await task(); + } catch (error) { + console.error("[✨ AI | !] Error processing task:", error); + const errorMessage = error instanceof Error ? error.message : String(error); + await ctx.reply(Strings.unexpectedErr.replace("{error}", errorMessage), { + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }), + parse_mode: 'Markdown' + }); + } finally { + isProcessing = false; + processQueue(); + } + } + + async function aiCommandHandler(ctx: TextContext, command: 'ask' | 'think' | 'ai') { + const reply_to_message_id = replyToMessageId(ctx); + const { user, Strings, customAiModel, aiTemperature } = await getUserWithStringsAndModel(ctx, db); + const message = ctx.message.text; + const author = ("@" + ctx.from?.username) || ctx.from?.first_name || "Unknown"; + + let model: string; + let fixedMsg: string; + + if (command === 'ai') { + model = customAiModel || flash_model; + fixedMsg = message.replace(/^\/ai(@\w+)?\s*/, "").trim(); + logger.logCmdStart(author, "ask"); + } else { + model = command === 'ask' ? flash_model : thinking_model; + fixedMsg = message.replace(/^\/(ask|think)(@\w+)?\s*/, "").trim(); + logger.logCmdStart(author, command); + } if (!process.env.ollamaApi) { - await ctx.reply(Strings.ai.disabled, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - return + await ctx.reply(Strings.ai.disabled, { parse_mode: 'Markdown', ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) }); + return; } if (!user.aiEnabled) { - await ctx.reply(Strings.ai.disabledForUser, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - return + await ctx.reply(Strings.ai.disabledForUser, { parse_mode: 'Markdown', ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) }); + return; } - const fixedMsg = message.replace(/^\/(ask|think)(@\w+)?\s*/, "").trim() if (fixedMsg.length < 1) { - await ctx.reply(Strings.ai.askNoMessage, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - return + await ctx.reply(Strings.ai.askNoMessage, { parse_mode: 'Markdown', ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) }); + return; } - const replyGenerating = await ctx.reply(Strings.ai.askGenerating.replace("{model}", model), { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - - logger.logPrompt(fixedMsg) - - const prompt = sanitizeForJson(await usingSystemPrompt(textCtx, db, botName)) - await handleAiReply(textCtx, model, prompt, replyGenerating, aiTemperature, fixedMsg) - }) - - bot.command(["ai"], spamwatchMiddleware, async (ctx) => { - try { - if (!ctx.message || !("text" in ctx.message)) return - const textCtx = ctx as TextContext - const reply_to_message_id = replyToMessageId(textCtx) - const { user, Strings, customAiModel, aiTemperature } = await getUserWithStringsAndModel(textCtx, db) - const message = textCtx.message.text - const author = ("@" + ctx.from?.username) || ctx.from?.first_name - - logger.logCmdStart(author, "ask") - - if (!process.env.ollamaApi) { - await ctx.reply(Strings.ai.disabled, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - return - } - - if (!user.aiEnabled) { - await ctx.reply(Strings.ai.disabledForUser, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - return - } - - const fixedMsg = message.replace(/^\/ai(@\w+)?\s*/, "").trim() - if (fixedMsg.length < 1) { - await ctx.reply(Strings.ai.askNoMessage, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) - return - } - - const modelLabel = getModelLabelByName(customAiModel) + const task = async () => { + const modelLabel = getModelLabelByName(model); const replyGenerating = await ctx.reply(Strings.ai.askGenerating.replace("{model}", modelLabel), { parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }) + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + logger.logPrompt(fixedMsg); + const prompt = sanitizeForJson(await usingSystemPrompt(ctx, db, botName, fixedMsg)); + await handleAiReply(ctx, model, prompt, replyGenerating, aiTemperature, fixedMsg); + }; - logger.logPrompt(fixedMsg) - - const prompt = sanitizeForJson(await usingSystemPrompt(textCtx, db, botName)) - await handleAiReply(textCtx, customAiModel, prompt, replyGenerating, aiTemperature, fixedMsg) - } catch (err) { - const Strings = getStrings(languageCode(ctx)); - if (ctx && ctx.reply) { - try { - await ctx.reply(Strings.unexpectedErr.replace("{error}", (err && err.message ? err.message : String(err))), { parse_mode: 'Markdown' }) - } catch (e) { - console.error("[✨ AI | !] Failed to send error reply:", e) - } - } + if (isProcessing) { + requestQueue.push({ task, ctx, wasQueued: true }); + const position = requestQueue.length; + await ctx.reply(Strings.ai.inQueue.replace("{position}", String(position)), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + } else { + requestQueue.push({ task, ctx, wasQueued: false }); + processQueue(); } - }) + } + + bot.command(["ask", "think"], spamwatchMiddleware, async (ctx) => { + if (!ctx.message || !('text' in ctx.message)) return; + const command = ctx.message.text.startsWith('/ask') ? 'ask' : 'think'; + await aiCommandHandler(ctx as TextContext, command); + }); + + bot.command(["ai"], spamwatchMiddleware, async (ctx) => { + if (!ctx.message || !('text' in ctx.message)) return; + await aiCommandHandler(ctx as TextContext, 'ai'); + }); } diff --git a/src/commands/main.ts b/src/commands/main.ts index fe76037..55ccc00 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -146,7 +146,7 @@ export default (bot: Telegraf, db: NodePgDatabase) => { inline_keyboard: models.map(series => [ { text: series.label, callback_data: `selectseries_${series.name}` } ]).concat([[ - { text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_back' } + { text: `${Strings.varStrings.varBack}`, callback_data: 'settings_back' } ]]) } } @@ -185,7 +185,7 @@ export default (bot: Telegraf, db: NodePgDatabase) => { inline_keyboard: series.models.map(m => [ { text: `${m.label} (${m.parameterSize})`, callback_data: `setmodel_${series.name}_${m.name}` } ]).concat([[ - { text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_aiModel' } + { text: `${Strings.varStrings.varBack}`, callback_data: 'settings_aiModel' } ]]) } } @@ -262,7 +262,7 @@ export default (bot: Telegraf, db: NodePgDatabase) => { const temps = [0.2, 0.5, 0.7, 0.9, 1.2]; try { await ctx.editMessageReplyMarkup({ - inline_keyboard: temps.map(t => [{ text: t.toString(), callback_data: `settemp_${t}` }]).concat([[{ text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_back' }]]) + inline_keyboard: temps.map(t => [{ text: t.toString(), callback_data: `settemp_${t}` }]).concat([[{ text: `${Strings.varStrings.varBack}`, callback_data: 'settings_back' }]]) }); } catch (err) { if ( @@ -304,7 +304,7 @@ export default (bot: Telegraf, db: NodePgDatabase) => { if (!user) return; try { await ctx.editMessageReplyMarkup({ - inline_keyboard: langs.map(l => [{ text: l.label, callback_data: `setlang_${l.code}` }]).concat([[{ text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_back' }]]) + inline_keyboard: langs.map(l => [{ text: l.label, callback_data: `setlang_${l.code}` }]).concat([[{ text: `${Strings.varStrings.varBack}`, callback_data: 'settings_back' }]]) }); } catch (err) { if ( diff --git a/src/locales/english.json b/src/locales/english.json index 74aa29e..3acb49b 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -13,9 +13,9 @@ "varWas": "was", "varNone": "None", "varUnknown": "Unknown", - "varBack": "Back" + "varBack": "⬅️ Back" }, - "unexpectedErr": "Some unexpected error occurred during a bot action. Please report it to the developers.\n\n{error}", + "unexpectedErr": "An unexpected error occurred: {error}", "errInvalidOption": "Whoops! Invalid option!", "kickingMyself": "*Since you don't need me, I'll leave.*", "kickingMyselfErr": "Error leaving the chat.", @@ -65,22 +65,31 @@ "animalCommandsDesc": "🐱 *Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat ``: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`", "ai": { "helpEntry": "✨ AI Commands", - "helpDesc": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI\n- /think ``: Ask a thinking model about a question", - "disabled": "✨ AI features are currently disabled", - "disabledForUser": "✨ AI features are disabled for your account. You can enable them in /settings", - "pulling": "🔄 *Pulling {model} from Ollama...*\n\nThis may take a few minutes...", - "askGenerating": "✨ _{model} is working..._", - "askNoMessage": "Please provide a message to ask the model.", + "helpDesc": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI model\n- /think ``: Ask a thinking model about a question\n- /ai ``: Ask your custom-set AI model a question", + "disabled": "✨ AI features are currently disabled globally.", + "disabledForUser": "✨ AI features are disabled for your account.", + "pulling": "🔄 Model {model} not found locally, pulling...", + "askGenerating": "✨ Generating response with {model}...", + "askNoMessage": "✨ You need to ask me a question!", "languageCode": "Language", "thinking": "Thinking...", - "finishedThinking": "Finished thinking", - "urlWarning": "⚠️ *Warning: I cannot access or open links. Please provide the content directly if you need me to analyze something from a website.*\n\n" + "finishedThinking": "Done.", + "urlWarning": "\n\n⚠️ The user provided one or more URLs in their message. Please do not visit any suspicious URLs.", + "inQueue": "ℹ️ You are {position} in the queue.", + "startingProcessing": "✨ Starting to process your request...", + "systemPrompt": "You are a friendly assistant called {botName}, capable of Telegram MarkdownV2.\nYou are currently in a chat with a user, who has sent a message to you.\nCurrent Date/Time (UTC): {date}\n\n---\n\nRespond to the user's message:\n{message}", + "statusWaitingRender": "⏳ Waiting to Render...", + "statusRendering": "🖼️ Rendering...", + "statusComplete": "✅ Complete!", + "modelHeader": "🤖 *{model}* | 🌡️ *{temperature}* | {status}", + "noChatFound": "No chat found", + "pulled": "✅ Pulled {model} successfully, please retry the command." }, "maInvalidModule": "Please provide a valid module ID from The Mod Archive.\nExample: `/modarchive 81574`", "maDownloadError": "Error downloading the file. Check the module ID and try again.", "ytDownload": { - "helpEntry": "📺 YouTube Download", - "helpDesc": "📺 *YouTube Download*\n\n- /yt | /ytdl | /sdl | /dl | /video `