diff --git a/.dockerignore b/.dockerignore old mode 100755 new mode 100644 index cfdf0f2..33e390a --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,7 @@ node_modules -webui/node_modules npm-debug.log .git -webui/.git .gitignore -webui/.gitignore -.env* -webui/.env* -webui/.next +.env *.md -!README.md -ollama/ -db/ \ No newline at end of file +!README.md \ No newline at end of file diff --git a/.env.example b/.env.example old mode 100755 new mode 100644 index db7a321..af81e1d --- a/.env.example +++ b/.env.example @@ -5,19 +5,8 @@ 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 -# flashModel = "gemma3:4b" -# thinkingModel = "qwen3:4b" - -# database -databaseUrl = "postgres://kowalski:kowalski@localhost:5432/kowalski" - # misc (botAdmins isnt a array here!) maxRetries = 9999 botAdmins = 00000000, 00000000, 00000000 lastKey = "InsertYourLastFmApiKeyHere" weatherKey = "InsertYourWeatherDotComApiKeyHere" -longerLogs = true \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml old mode 100755 new mode 100644 diff --git a/.github/workflows/njsscan.yml b/.github/workflows/njsscan.yml old mode 100755 new mode 100644 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml old mode 100755 new mode 100644 diff --git a/.github/workflows/update-authors.yml b/.github/workflows/update-authors.yml old mode 100755 new mode 100644 diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index dbea724..6b42f1f --- a/.gitignore +++ b/.gitignore @@ -144,13 +144,4 @@ yt-dlp ffmpeg # Bun -bun.lock* - -# Ollama -ollama/ - -# Docker -docker-compose.yml - -# postgres -db/ \ No newline at end of file +bun.lock* \ No newline at end of file diff --git a/.gitmodules b/.gitmodules old mode 100755 new mode 100644 index 4a96795..cf3ce05 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "telegram/spamwatch"] - path = telegram/spamwatch +[submodule "src/spamwatch"] + path = src/spamwatch url = https://github.com/ABOCN/TelegramBot-SpamWatch diff --git a/AUTHORS b/AUTHORS old mode 100755 new mode 100644 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md old mode 100755 new mode 100644 diff --git a/Dockerfile b/Dockerfile old mode 100755 new mode 100644 index f0d7341..7a0c006 --- a/Dockerfile +++ b/Dockerfile @@ -1,37 +1,18 @@ FROM oven/bun # Install ffmpeg and other deps -RUN apt-get update && apt-get install -y \ - ffmpeg \ - git \ - supervisor \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ffmpeg git && apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app COPY package*.json ./ -RUN bun install -COPY webui/package*.json ./webui/ -WORKDIR /usr/src/app/webui -RUN bun install +RUN bun i -WORKDIR /usr/src/app COPY . . -WORKDIR /usr/src/app/webui -RUN bun run build - -RUN chmod +x /usr/src/app/telegram/plugins/yt-dlp/yt-dlp - -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +RUN chmod +x /usr/src/app/src/plugins/yt-dlp/yt-dlp VOLUME /usr/src/app/.env -EXPOSE 3000 - -ENV PYTHONUNBUFFERED=1 -ENV BUN_LOG_LEVEL=info - -CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] +CMD ["bun", "start"] diff --git a/LICENSE b/LICENSE old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 36ad1dc..8fa5b60 --- 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] @@ -19,12 +25,21 @@ Kowalski is a a simple Telegram bot made in Node.js. - 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) -- Postgres -### AI Requirements +## Running locally (non-Docker setup) -- High-end CPU *or* GPU (~ 6GB vRAM) -- If using CPU, enough RAM to load the models (~6GB w/ defaults) +First, clone the repo with Git: + +```bash +git clone --recurse-submodules https://github.com/ABOCN/TelegramBot +``` + +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 ``bun start``. + +> [!TIP] +> To deal with dependencies, just run ``bun install`` or ``bun i`` at any moment to install all of them. ## Running with Docker @@ -40,30 +55,9 @@ You can also run Kowalski using Docker, which simplifies the setup process. Make ### Using Docker Compose -1. **Copy compose file** - - _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 - ``` - 1. **Make sure to setup your `.env` file first!** - In order to successfuly deploy Kowalski, you will need to edit both your `.env` file and enter matching values in `webui/.env`. - - > [!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. - -1. **Run the container** +2. **Run the container** ```bash docker compose up -d @@ -75,76 +69,30 @@ If you prefer to use Docker directly, you can use these instructions instead. 1. **Make sure to setup your `.env` file first!** - In order to successfuly deploy Kowalski, you will need to edit both your `.env` file and enter matching values in `webui/.env`. - -1. **Build the image** +2. **Build the image** ```bash docker build -t kowalski . ``` -1. **Run the container** +3. **Run the container** ```bash 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. - -## Running locally (non-Docker/development setup) - -First, clone the repo with Git: - -```bash -git clone --recurse-submodules https://github.com/ABOCN/TelegramBot -``` - -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 ``bun start``. - -> [!TIP] -> To deal with dependencies, just run ``bun install`` or ``bun i`` at any moment to install all of them. - -### Efficant Local (w/ Docker) Development - -If you want to develop a component of Kowalski, without dealing with the headache of several terminals, we suggest you follow these guidelines: - -1. If you are working on one component, run it with Bun, and Dockerize the other components. -1. Minimize the amount of non-Dockerized components to reduce headaches. -1. You will have to change your `.env` a lot. This is a common source of issues. Make sure the hostname and port are correct. - ## .env Functions > [!IMPORTANT] > 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! -### Bot - - **botSource**: Put the link to your bot source code. - **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** (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. - **weatherKey**: Weather.com API key, used for the `/weather` command. -- **longerLogs**: Set to `true` to enable verbose logging whenever possible. - -> [!NOTE] -> Further, advanced fine-tuning and configuration can be done in TypeScript with the files in the `/config` folder. - -### WebUI - -- **botApiUrl**: Likely will stay the same, but changes the API that the bot exposes -- **databaseUrl**: Database server configuration (see `.env.example`) ## Troubleshooting @@ -152,18 +100,12 @@ If you want to develop a component of Kowalski, without dealing with the headach **Q:** I get a "Permission denied (EACCES)" error in the console when running the `/yt` command -**A:** Make sure `telegram/plugins/yt-dlp/yt-dlp` is executable. You can do this on Linux like so: +**A:** Make sure `src/plugins/yt-dlp/yt-dlp` is executable. You can do this on Linux like so: ```bash -chmod +x telegram/plugins/yt-dlp/yt-dlp +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 @@ -175,5 +117,3 @@ Made with [contrib.rocks](https://contrib.rocks). ## About/License BSD-3-Clause - 2024 Lucas Gabriel (lucmsilva). - -With some components under Unlicense. diff --git a/TERMS_OF_USE.md b/TERMS_OF_USE.md old mode 100755 new mode 100644 diff --git a/config/ai.ts b/config/ai.ts deleted file mode 100755 index 6c6ddb0..0000000 --- a/config/ai.ts +++ /dev/null @@ -1,420 +0,0 @@ -export interface ModelInfo { - name: string; - label: string; - descriptionEn: string; - descriptionPt: string; - models: Array<{ - name: string; - label: string; - parameterSize: string; - thinking: boolean; - uncensored: boolean; - }>; -} - -export const defaultFlashModel = "gemma3:4b" -export const defaultThinkingModel = "qwen3:4b" -export const unloadModelAfterB = 12 // how many billion params until model is auto-unloaded -export const maxUserQueueSize = 3 - -export const models: ModelInfo[] = [ - { - name: '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: [ - { - name: 'gemma3n:e2b', - label: 'Gemma3n e2b', - parameterSize: '2B', - thinking: false, - uncensored: false - }, - { - name: 'gemma3n:e4b', - label: 'Gemma3n e4b', - parameterSize: '4B', - thinking: false, - uncensored: false - }, - ] - }, - { - name: 'gemma3', - 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: [ - { - name: 'huihui_ai/gemma3-abliterated:1b', - label: 'Gemma3 Uncensored 1B', - parameterSize: '1B', - thinking: false, - uncensored: true - }, - { - name: 'huihui_ai/gemma3-abliterated:4b', - label: 'Gemma3 Uncensored 4B', - parameterSize: '4B', - thinking: false, - uncensored: true - }, - { - name: 'gemma3:1b', - label: 'Gemma3 1B', - parameterSize: '1B', - thinking: false, - uncensored: false - }, - { - name: 'gemma3:4b', - label: 'Gemma3 4B', - parameterSize: '4B', - thinking: false, - uncensored: false - }, - ] - }, - { - name: 'qwen3', - label: 'Qwen3', - descriptionEn: 'Qwen3 is a multilingual reasoning model series.', - descriptionPt: 'Qwen3 é uma série de modelos multilingues.', - models: [ - { - name: 'qwen3:0.6b', - label: 'Qwen3 0.6B', - parameterSize: '0.6B', - thinking: true, - uncensored: false - }, - { - name: 'qwen3:1.7b', - label: 'Qwen3 1.7B', - parameterSize: '1.7B', - thinking: true, - uncensored: false - }, - { - name: 'qwen3:4b', - label: 'Qwen3 4B', - parameterSize: '4B', - thinking: true, - uncensored: false - }, - { - name: 'qwen3:8b', - label: 'Qwen3 8B', - parameterSize: '8B', - thinking: true, - uncensored: false - }, - { - name: 'qwen3:14b', - label: 'Qwen3 14B', - parameterSize: '14B', - thinking: true, - uncensored: false - }, - { - name: 'qwen3:30b', - label: 'Qwen3 30B', - parameterSize: '30B', - thinking: true, - uncensored: false - }, - { - name: 'qwen3:32b', - label: 'Qwen3 32B', - parameterSize: '32B', - thinking: true, - uncensored: false - }, - ] - }, - { - name: 'qwen3-abliterated', - label: 'Qwen3 [ Uncensored ]', - descriptionEn: 'Qwen3-abliterated is a multilingual reasoning model series.', - descriptionPt: 'Qwen3-abliterated é uma série de modelos multilingues.', - models: [ - { - name: 'huihui_ai/qwen3-abliterated:0.6b', - label: 'Qwen3 Uncensored 0.6B', - parameterSize: '0.6B', - thinking: true, - uncensored: true - }, - { - name: 'huihui_ai/qwen3-abliterated:1.7b', - label: 'Qwen3 Uncensored 1.7B', - parameterSize: '1.7B', - thinking: true, - uncensored: true - }, - { - name: 'huihui_ai/qwen3-abliterated:4b', - label: 'Qwen3 Uncensored 4B', - parameterSize: '4B', - thinking: true, - uncensored: true - }, - { - name: 'huihui_ai/qwen3-abliterated:8b', - label: 'Qwen3 Uncensored 8B', - parameterSize: '8B', - thinking: true, - uncensored: true - }, - { - name: 'huihui_ai/qwen3-abliterated:14b', - label: 'Qwen3 Uncensored 14B', - parameterSize: '14B', - thinking: true, - uncensored: true - }, - { - name: 'huihui_ai/qwen3-abliterated:30b', - label: 'Qwen3 Uncensored 30B', - parameterSize: '30B', - thinking: true, - uncensored: true - }, - { - name: 'huihui_ai/qwen3-abliterated:32b', - label: 'Qwen3 Uncensored 32B', - parameterSize: '32B', - thinking: true, - uncensored: true - }, - ] - }, - { - name: 'qwq', - label: 'QwQ', - descriptionEn: 'QwQ is the reasoning model of the Qwen series.', - descriptionPt: 'QwQ é o modelo de raciocínio da série Qwen.', - models: [ - { - name: 'qwq:32b', - label: 'QwQ 32B', - parameterSize: '32B', - thinking: true, - uncensored: false - }, - { - name: 'huihui_ai/qwq-abliterated:32b', - label: 'QwQ Uncensored 32B', - parameterSize: '32B', - thinking: true, - uncensored: true - }, - ] - }, - { - name: 'llama4', - label: 'Llama4', - descriptionEn: 'The latest collection of multimodal models from Meta.', - descriptionPt: 'A coleção mais recente de modelos multimodais da Meta.', - models: [ - { - name: 'llama4:scout', - label: 'Llama4 109B A17B', - parameterSize: '109B', - thinking: false, - uncensored: false - }, - ] - }, - { - name: 'deepseek', - label: 'DeepSeek [ & Uncensored ]', - descriptionEn: 'DeepSeek is a research model for reasoning tasks.', - descriptionPt: 'DeepSeek é um modelo de pesquisa para tarefas de raciocínio.', - models: [ - { - name: 'deepseek-r1:1.5b', - label: 'DeepSeek 1.5B', - parameterSize: '1.5B', - thinking: true, - uncensored: false - }, - { - name: 'deepseek-r1:7b', - label: 'DeepSeek 7B', - parameterSize: '7B', - thinking: true, - uncensored: false - }, - { - name: 'deepseek-r1:8b', - label: 'DeepSeek 8B', - parameterSize: '8B', - thinking: true, - uncensored: false - }, - { - name: 'huihui_ai/deepseek-r1-abliterated:1.5b', - label: 'DeepSeek Uncensored 1.5B', - parameterSize: '1.5B', - thinking: true, - uncensored: true - }, - { - name: 'huihui_ai/deepseek-r1-abliterated:7b', - label: 'DeepSeek Uncensored 7B', - parameterSize: '7B', - thinking: true, - uncensored: true - }, - { - name: 'huihui_ai/deepseek-r1-abliterated:8b', - label: 'DeepSeek Uncensored 8B', - parameterSize: '8B', - thinking: true, - uncensored: true - }, - { - name: 'huihui_ai/deepseek-r1-abliterated:14b', - label: 'DeepSeek Uncensored 14B', - parameterSize: '14B', - thinking: true, - uncensored: true - }, - ] - }, - { - name: 'hermes3', - label: 'Hermes3', - descriptionEn: 'Hermes 3 is the latest version of the flagship Hermes series of LLMs by Nous Research.', - descriptionPt: 'Hermes 3 é a versão mais recente da série Hermes de LLMs da Nous Research.', - models: [ - { - name: 'hermes3:3b', - label: 'Hermes3 3B', - parameterSize: '3B', - thinking: false, - uncensored: false - }, - { - name: 'hermes3:8b', - label: 'Hermes3 8B', - parameterSize: '8B', - thinking: false, - uncensored: false - }, - ] - }, - { - name: 'mistral', - label: 'Mistral', - descriptionEn: 'The 7B model released by Mistral AI, updated to version 0.3.', - descriptionPt: 'O modelo 7B lançado pela Mistral AI, atualizado para a versão 0.3.', - models: [ - { - name: 'mistral:7b', - label: 'Mistral 7B', - parameterSize: '7B', - thinking: false, - uncensored: false - }, - ] - }, - { - name: 'phi4 [ & Uncensored ]', - label: 'Phi4', - descriptionEn: 'Phi-4 is a 14B parameter, state-of-the-art open model from Microsoft. ', - descriptionPt: 'Phi-4 é um modelo de 14B de última geração, aberto pela Microsoft.', - models: [ - { - name: 'phi4:14b', - label: 'Phi4 14B', - parameterSize: '14B', - thinking: false, - uncensored: false - }, - { - name: 'huihui_ai/phi4-abliterated:14b', - label: 'Phi4 Uncensored 14B', - parameterSize: '14B', - thinking: false, - uncensored: true - }, - ] - }, - { - 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', - thinking: false, - uncensored: false - }, - ] - }, - { - name: 'llama3', - label: 'Llama4', - descriptionEn: 'Llama 3, a lightweight model from Meta.', - descriptionPt: 'Llama 3, um modelo leve da Meta.', - models: [ - { - name: 'llama3:8b', - label: 'Llama3 8B', - parameterSize: '8B', - thinking: false, - uncensored: false - }, - ] - }, - { - name: 'llama3.1 [ Uncensored ]', - label: 'Llama3.1', - descriptionEn: 'Ablitered v3 llama-3.1 8b with uncensored prompt ', - descriptionPt: 'Llama3.1 é um modelo aberto, leve e para dispositivos locais, com prompt não censurado.', - models: [ - { - name: 'mannix/llama3.1-8b-abliterated:latest', - label: 'Llama3.1 8B', - parameterSize: '8B', - thinking: false, - uncensored: true - }, - ] - }, - { - name: 'llama3.2 [ & Uncensored ]', - label: 'Llama3.2', - descriptionEn: 'Llama3.2 is a family of open, lightweight models for general tasks.', - descriptionPt: 'Llama3.2 é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.', - models: [ - { - name: 'llama3.2:1b', - label: 'Llama3.2 1B', - parameterSize: '1B', - thinking: false, - uncensored: false - }, - { - name: 'llama3.2:3b', - label: 'Llama3.2 3B', - parameterSize: '3B', - thinking: false, - uncensored: false - }, - { - name: 'socialnetwooky/llama3.2-abliterated:3b_q8_0', - label: 'Llama3.2 Uncensored 3B', - parameterSize: '3B', - thinking: false, - uncensored: true - }, - ] - }, -]; \ No newline at end of file diff --git a/config/settings.ts b/config/settings.ts deleted file mode 100755 index 1fe94b3..0000000 --- a/config/settings.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const seriesPageSize = 4; -export const modelPageSize = 4; \ No newline at end of file diff --git a/database/schema.ts b/database/schema.ts deleted file mode 100755 index ce9a8ed..0000000 --- a/database/schema.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - integer, - pgTable, - varchar, - timestamp, - boolean, - real, - index -} 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), - showThinking: 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), - disabledCommands: varchar({ length: 255 }).array().notNull().default([]), - languageCode: varchar({ length: 255 }).notNull(), - aiTimeoutUntil: timestamp(), - aiMaxExecutionTime: integer().default(0), - createdAt: timestamp().notNull().defaultNow(), - updatedAt: timestamp().notNull().defaultNow(), -}); - -export const twoFactorTable = pgTable("two_factor", { - userId: varchar({ length: 255 }).notNull().references(() => usersTable.telegramId).primaryKey(), - currentCode: varchar({ length: 255 }).notNull(), - codeExpiresAt: timestamp().notNull(), - codeAttempts: integer().notNull().default(0), - createdAt: timestamp().notNull().defaultNow(), - updatedAt: timestamp().notNull().defaultNow(), -}, (table) => [ - index("idx_two_factor_user_id").on(table.userId), - index("idx_two_factor_code_expires_at").on(table.codeExpiresAt), -]); - -export const sessionsTable = pgTable("sessions", { - id: varchar({ length: 255 }).notNull().primaryKey(), - userId: varchar({ length: 255 }).notNull().references(() => usersTable.telegramId), - sessionToken: varchar({ length: 255 }).notNull().unique(), - expiresAt: timestamp().notNull(), - createdAt: timestamp().notNull().defaultNow(), - updatedAt: timestamp().notNull().defaultNow(), -}, (table) => [ - index("idx_sessions_user_id").on(table.userId), - index("idx_sessions_expires_at").on(table.expiresAt), -]); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0aab44a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + kowalski: + build: . + container_name: kowalski + restart: unless-stopped + volumes: + - ./.env:/usr/src/app/.env:ro + environment: + - 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 100755 index fe467ab..0000000 --- a/docker-compose.yml.ai.example +++ /dev/null @@ -1,30 +0,0 @@ -services: - kowalski: - build: . - container_name: kowalski - ports: - - "3000:3000" - volumes: - - ./.env:/usr/src/app/.env:ro - - ./telegram/props/lastfm.json:/usr/src/app/telegram/props/lastfm.json - environment: - - NODE_ENV=production - env_file: - - .env - depends_on: - - postgres - - ollama - ollama: - image: ollama/ollama - container_name: kowalski-ollama - volumes: - - ./ollama:/root/.ollama - postgres: - image: postgres:17 - container_name: kowalski-postgres - volumes: - - ./db:/var/lib/postgresql/data - environment: - - POSTGRES_USER=kowalski - - POSTGRES_PASSWORD=kowalski - - POSTGRES_DB=kowalski \ No newline at end of file diff --git a/docker-compose.yml.example b/docker-compose.yml.example deleted file mode 100755 index d94e78d..0000000 --- a/docker-compose.yml.example +++ /dev/null @@ -1,24 +0,0 @@ -services: - kowalski: - build: . - container_name: kowalski - ports: - - "3000:3000" - volumes: - - ./.env:/usr/src/app/.env:ro - - ./telegram/props/lastfm.json:/usr/src/app/telegram/props/lastfm.json - environment: - - NODE_ENV=production - env_file: - - .env - depends_on: - - postgres - postgres: - image: postgres:17 - container_name: kowalski-postgres - volumes: - - ./db:/var/lib/postgresql/data - environment: - - POSTGRES_USER=kowalski - - POSTGRES_PASSWORD=kowalski - - POSTGRES_DB=kowalski \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts deleted file mode 100755 index 51b8e1d..0000000 --- a/drizzle.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import 'dotenv/config'; -import { defineConfig } from 'drizzle-kit'; - -export default defineConfig({ - out: './drizzle', - schema: './database/schema.ts', - dialect: 'postgresql', - dbCredentials: { - url: process.env.databaseUrl!, - }, -}); diff --git a/nodemon.json b/nodemon.json old mode 100755 new mode 100644 index d9938ac..918bcb8 --- a/nodemon.json +++ b/nodemon.json @@ -1,6 +1,6 @@ -{ - "ignore": ["telegram/props/*.json", "telegram/props/*.txt"], - "watch": ["telegram", "database", "config"], +{ + "ignore": ["src/props/*.json", "src/props/*.txt"], + "watch": ["src"], "ext": "ts,js", - "exec": "bun telegram/bot.ts" + "exec": "bun src/bot.ts" } \ No newline at end of file diff --git a/package.json b/package.json old mode 100755 new mode 100644 index b5c33cc..5c53440 --- a/package.json +++ b/package.json @@ -1,25 +1,14 @@ { "scripts": { - "start": "nodemon telegram/bot.ts", - "docs": "bunx typedoc", - "serve:docs": "bun run serve-docs.ts" + "start": "nodemon src/bot.ts" }, "dependencies": { "@dotenvx/dotenvx": "^1.45.1", "@types/bun": "^1.2.17", "axios": "^1.10.0", - "dotenv": "^17.0.0", - "drizzle-orm": "^0.44.2", - "express": "^5.1.0", "node-html-parser": "^7.0.1", "nodemon": "^3.1.10", - "pg": "^8.16.3", "telegraf": "^4.16.3", "youtube-url": "^0.5.0" - }, - "devDependencies": { - "@types/pg": "^8.15.4", - "drizzle-kit": "^0.31.4", - "tsx": "^4.20.3" } } diff --git a/src/bot.ts b/src/bot.ts new file mode 100644 index 0000000..3422e56 --- /dev/null +++ b/src/bot.ts @@ -0,0 +1,79 @@ +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 .env file.') + process.exit(1) +} + +const bot = new Telegraf(process.env.botToken); +const maxRetries = process.env.maxRetries || 5; +let restartCount = 0; + +const loadCommands = () => { + const commandsPath = path.join(__dirname, 'commands'); + + try { + const files = fs.readdirSync(commandsPath) + .filter(file => file.endsWith('.ts') || file.endsWith('.js')); + + files.forEach((file) => { + try { + const commandPath = path.join(commandsPath, file); + const command = require(commandPath).default || require(commandPath); + if (typeof command === 'function') { + command(bot, isOnSpamWatch); + } + } catch (error) { + console.error(`Failed to load command file ${file}: ${error.message}`); + } + }); + } catch (error) { + console.error(`Failed to read commands directory: ${error.message}`); + } +}; + +const startBot = async () => { + const botInfo = await bot.telegram.getMe(); + console.log(`${botInfo.first_name} is running...`); + try { + await bot.launch(); + restartCount = 0; + } catch (error) { + console.error('Failed to start bot:', error.message); + if (restartCount < Number(maxRetries)) { + restartCount++; + console.log(`Retrying to start bot... Attempt ${restartCount}`); + setTimeout(startBot, 5000); + } else { + console.error('Maximum retry attempts reached. Exiting.'); + process.exit(1); + } + } +}; + +const handleShutdown = (signal) => { + console.log(`Received ${signal}. Stopping bot...`); + bot.stop(signal); + process.exit(0); +}; + +process.once('SIGINT', () => handleShutdown('SIGINT')); +process.once('SIGTERM', () => handleShutdown('SIGTERM')); + +process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error.message); + console.error(error.stack); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); +}); + +loadCommands(); +startBot(); diff --git a/src/commands/animal.ts b/src/commands/animal.ts new file mode 100644 index 0000000..63cbb7b --- /dev/null +++ b/src/commands/animal.ts @@ -0,0 +1,132 @@ +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'; +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 reply_to_message_id = replyToMessageId(ctx); + try { + const response = await axios(Resources.duckApi); + ctx.replyWithPhoto(response.data.url, { + caption: "🦆", + ...({ 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', + ...({ reply_to_message_id }) + }); + return; + } + }); + + bot.command("fox", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + 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: "🦊", + ...({ reply_to_message_id }) + }); + } catch (error) { + const message = Strings.foxApiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }); + return; + } + }); + + bot.command("dog", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + 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: "🐶", + ...({ reply_to_message_id }) + }); + } catch (error) { + const message = Strings.foxApiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }); + return; + } + }); + + bot.command("cat", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + 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', + ...({ reply_to_message_id }) + }); + } catch (error) { + ctx.reply(Strings.catImgErr, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }); + }; + }); + + bot.command(['soggy', 'soggycat'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + 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 ? { reply_parameters: { message_id: reply_to_message_id } } : undefined + ); + 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 new file mode 100644 index 0000000..ba0e3b4 --- /dev/null +++ b/src/commands/codename.ts @@ -0,0 +1,69 @@ +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'; +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 + } catch (error) { + const message = Strings.codenameCheck.apiErr + .replace('{error}', error.message); + + return ctx.reply(message, { + parse_mode: "Markdown", + ...({ reply_to_message_id }) + }); + } +} + +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(languageCode(ctx)); + const { noCodename } = Strings.codenameCheck; + const reply_to_message_id = replyToMessageId(ctx); + + if (verifyInput(ctx, userInput, noCodename)) { + return; + } + + 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", + ...({ reply_to_message_id }) + }); + } + + const deviceDetails = jsonRes[phoneSearch]; + const device = deviceDetails.find((item: Device) => item.brand) || deviceDetails[0]; + const message = Strings.codenameCheck.resultMsg + .replace('{brand}', device.brand) + .replace('{codename}', userInput) + .replace('{model}', device.model) + .replace('{name}', device.name); + + return ctx.reply(message, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }); + }) +} \ No newline at end of file diff --git a/telegram/commands/crew.ts b/src/commands/crew.ts old mode 100755 new mode 100644 similarity index 68% rename from telegram/commands/crew.ts rename to src/commands/crew.ts index f32950a..0614441 --- a/telegram/commands/crew.ts +++ b/src/commands/crew.ts @@ -5,32 +5,10 @@ import os from 'os'; import { exec } from 'child_process'; import { error } from 'console'; import { Context, Telegraf } from 'telegraf'; -import * as schema from '../../database/schema'; -import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { languageCode } from '../utils/language-code'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -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 }; -} - function getGitCommitHash() { return new Promise((resolve, reject) => { exec('git rev-parse --short HEAD', (error, stdout, stderr) => { @@ -77,7 +55,7 @@ function getSystemInfo() { } async function handleAdminCommand(ctx: Context & { message: { text: string } }, action: () => Promise, successMessage: string, errorMessage: string) { - const { Strings } = await getUserAndStrings(ctx); + 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)) { @@ -86,72 +64,80 @@ async function handleAdminCommand(ctx: Context & { message: { text: string } }, if (successMessage) { ctx.reply(successMessage, { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } } catch (error) { ctx.reply(errorMessage.replace(/{error}/g, error.message), { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } } else { ctx.reply(Strings.noPermission, { - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } } -export default (bot: Telegraf, db) => { +export default (bot: Telegraf) => { bot.command('getbotstats', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const { Strings } = await getUserAndStrings(ctx, db); + const Strings = getStrings(languageCode(ctx)); handleAdminCommand(ctx, async () => { const stats = getSystemInfo(); await ctx.reply(stats, { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); }, '', Strings.errorRetrievingStats); }); bot.command('getbotcommit', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const { Strings } = await getUserAndStrings(ctx, db); + const Strings = getStrings(languageCode(ctx)); handleAdminCommand(ctx, async () => { try { const commitHash = await getGitCommitHash(); await ctx.reply(Strings.gitCurrentCommit.replace(/{commitHash}/g, commitHash), { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } catch (error) { ctx.reply(Strings.gitErrRetrievingCommit.replace(/{error}/g, error), { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } }, '', Strings.gitErrRetrievingCommit); }); bot.command('updatebot', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const { Strings } = await getUserAndStrings(ctx, db); + const Strings = getStrings(languageCode(ctx)); handleAdminCommand(ctx, async () => { try { const result = await updateBot(); await ctx.reply(Strings.botUpdated.replace(/{result}/g, result), { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } catch (error) { ctx.reply(Strings.errorUpdatingBot.replace(/{error}/g, error), { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } }, '', Strings.errorUpdatingBot); }); bot.command('setbotname', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const { Strings } = await getUserAndStrings(ctx, db); + const Strings = getStrings(languageCode(ctx)); const botName = ctx.message.text.split(' ').slice(1).join(' '); handleAdminCommand(ctx, async () => { await ctx.telegram.setMyName(botName); @@ -159,7 +145,7 @@ export default (bot: Telegraf, db) => { }); bot.command('setbotdesc', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const { Strings } = await getUserAndStrings(ctx, db); + const Strings = getStrings(languageCode(ctx)); const botDesc = ctx.message.text.split(' ').slice(1).join(' '); handleAdminCommand(ctx, async () => { await ctx.telegram.setMyDescription(botDesc); @@ -167,31 +153,34 @@ export default (bot: Telegraf, db) => { }); bot.command('botkickme', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const { Strings } = await getUserAndStrings(ctx, db); + const Strings = getStrings(languageCode(ctx)); handleAdminCommand(ctx, async () => { if (!ctx.chat) { ctx.reply(Strings.chatNotFound, { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); return; } ctx.reply(Strings.kickingMyself, { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @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: Context & { message: { text: string } }) => { - const { Strings } = await getUserAndStrings(ctx, db); + const Strings = getStrings(languageCode(ctx)); const botFile = ctx.message.text.split(' ').slice(1).join(' '); if (!botFile) { ctx.reply(Strings.noFileProvided, { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); return; } @@ -203,12 +192,14 @@ export default (bot: Telegraf, db) => { source: botFile, caption: botFile }, { - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } catch (error) { ctx.reply(Strings.unexpectedErr.replace(/{error}/g, error.message), { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } }, '', Strings.unexpectedErr); @@ -226,18 +217,21 @@ export default (bot: Telegraf, db) => { if (error) { return ctx.reply(`\`${error.message}\``, { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } if (stderr) { return ctx.reply(`\`${stderr}\``, { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } ctx.reply(`\`${stdout}\``, { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); }); }, '', "Nope!"); @@ -253,12 +247,14 @@ export default (bot: Telegraf, db) => { const result = eval(code); ctx.reply(`Result: ${result}`, { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } catch (error) { ctx.reply(`Error: ${error.message}`, { parse_mode: 'Markdown', - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + // @ts-ignore + reply_to_message_id: ctx.message.message_id }); } }); diff --git a/src/commands/fun.ts b/src/commands/fun.ts new file mode 100644 index 0000000..c394241 --- /dev/null +++ b/src/commands/fun.ts @@ -0,0 +1,113 @@ +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'; +import { languageCode } from '../utils/language-code'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +function sendRandomReply(ctx: Context & { message: { text: string } }, gifUrl: string, textKey: string) { + const Strings = getStrings(languageCode(ctx)); + 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(languageCode(ctx)); + + // @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: 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(languageCode(ctx)); + const randomValue = getRandomInt(10); + 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/telegram/commands/gsmarena.ts b/src/commands/gsmarena.ts old mode 100755 new mode 100644 similarity index 56% rename from telegram/commands/gsmarena.ts rename to src/commands/gsmarena.ts index b345a00..c44206a --- a/telegram/commands/gsmarena.ts +++ b/src/commands/gsmarena.ts @@ -8,10 +8,6 @@ import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import { parse } from 'node-html-parser'; -import { getDeviceByCodename } from './codename'; -import { getStrings } from '../plugins/checklang'; -import { languageCode } from '../utils/language-code'; -import { isCommandDisabled } from '../utils/check-command-disabled'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -210,132 +206,54 @@ function getUsername(ctx){ return userName; } -const deviceSelectionCache: Record = {}; -const lastSelectionMessageId: Record = {}; - -export default (bot, db) => { +export default (bot) => { bot.command(['d', 'device'], spamwatchMiddleware, async (ctx) => { - if (await isCommandDisabled(ctx, db, 'device-specs')) return; - const userId = ctx.from.id; const userName = getUsername(ctx); - const Strings = getStrings(languageCode(ctx)); const phone = ctx.message.text.split(" ").slice(1).join(" "); if (!phone) { - return ctx.reply(Strings.gsmarenaProvidePhoneName || "[TODO: Add gsmarenaProvidePhoneName to locales] Please provide the phone name.", { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); + return ctx.reply("Please provide the phone name.", { reply_to_message_id: ctx.message.message_id }); } - console.log("[GSMArena] Searching for", phone); - const statusMsg = await ctx.reply((Strings.gsmarenaSearchingFor || "[TODO: Add gsmarenaSearchingFor to locales] Searching for {phone}...").replace('{phone}', phone), { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}), parse_mode: 'Markdown' }); - - let results = await searchPhone(phone); + const results = await searchPhone(phone); if (results.length === 0) { - const codenameResults = await getDeviceByCodename(phone.split(" ")[0]); - if (!codenameResults) { - await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, (Strings.gsmarenaNoPhonesFound || "[TODO: Add gsmarenaNoPhonesFound to locales] No phones found for {phone}.").replace('{phone}', phone), { parse_mode: 'Markdown' }); - return; - } - - await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, (Strings.gsmarenaSearchingFor || "[TODO: Add gsmarenaSearchingFor to locales] Searching for {phone}...").replace('{phone}', codenameResults.name), { parse_mode: 'Markdown' }); - const nameResults = await searchPhone(codenameResults.name); - if (nameResults.length === 0) { - await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, (Strings.gsmarenaNoPhonesFoundBoth || "[TODO: Add gsmarenaNoPhonesFoundBoth to locales] No phones found for {name} and {phone}.").replace('{name}', codenameResults.name).replace('{phone}', phone), { parse_mode: 'Markdown' }); - return; - } - results = nameResults; + return ctx.reply("No phones found.", { reply_to_message_id: ctx.message.message_id }); } - if (deviceSelectionCache[userId]?.timeout) { - clearTimeout(deviceSelectionCache[userId].timeout); - } - deviceSelectionCache[userId] = { - results, - timeout: setTimeout(() => { delete deviceSelectionCache[userId]; }, 5 * 60 * 1000) + const testUser = `${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}` }]) + } }; + ctx.reply(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(/gsmadetails:(\d+):(\d+)/, async (ctx) => { - const idx = parseInt(ctx.match[1]); + bot.action(/details:(.+):(.+)/, async (ctx) => { + const url = 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}, ${Strings.gsmarenaNotAllowed || "[TODO: Add gsmarenaNotAllowed to locales] you are not allowed to interact with this."}`); + return ctx.answerCbQuery(`${userName}, 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}, ${Strings.gsmarenaDeviceDetails || "[TODO: Add gsmarenaDeviceDetails to locales] these are the details of your device:"}` + message, { parse_mode: 'HTML', disable_web_page_preview: false }); + ctx.editMessageText(`${userName}, these are the details of your device:` + message, { parse_mode: 'HTML', disable_web_page_preview: false }); } else { - 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 } } : {}) }); + ctx.reply("Error fetching phone details.", { reply_to_message_id: ctx.message.message_id }); } }); }; diff --git a/src/commands/help.ts b/src/commands/help.ts new file mode 100644 index 0000000..39191c1 --- /dev/null +++ b/src/commands/help.ts @@ -0,0 +1,124 @@ +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); + +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(languageCode(ctx)); + const botInfo = await ctx.telegram.getMe(); + const helpText = Strings.botHelp + .replace(/{botName}/g, botInfo.first_name) + .replace(/{sourceLink}/g, process.env.botSource); + function getMessageId(ctx) { + return ctx.message?.message_id || ctx.callbackQuery?.message?.message_id; + }; + const createOptions = (ctx, includeReplyTo = false): MessageOptions => { + const options: MessageOptions = { + parse_mode: 'Markdown', + disable_web_page_preview: true, + reply_markup: { + inline_keyboard: [ + [{ 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' }] + ] + } + }; + if (includeReplyTo) { + const messageId = getMessageId(ctx); + if (messageId) { + options.reply_to_message_id = messageId; + }; + }; + return options; + }; + if (isEditing) { + await ctx.editMessageText(helpText, createOptions(ctx)); + } else { + await ctx.reply(helpText, createOptions(ctx, true)); + }; +} + +export default (bot) => { + bot.help(spamwatchMiddleware, async (ctx) => { + await sendHelpMessage(ctx, false); + }); + + bot.command("about", spamwatchMiddleware, async (ctx) => { + const Strings = getStrings(languageCode(ctx)); + 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 + }); + }) + + 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' }], + ] + }) + }; + + 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 'helpBack': + await ctx.answerCbQuery(); + await sendHelpMessage(ctx, true); + break; + default: + await ctx.answerCbQuery(Strings.errInvalidOption); + break; + } + }); +} diff --git a/telegram/commands/http.ts b/src/commands/http.ts old mode 100755 new mode 100644 similarity index 56% rename from telegram/commands/http.ts rename to src/commands/http.ts index 5fd4ef9..b1fe636 --- a/telegram/commands/http.ts +++ b/src/commands/http.ts @@ -5,40 +5,14 @@ import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import verifyInput from '../plugins/verifyInput'; import { Context, Telegraf } from 'telegraf'; -import * as schema from '../../database/schema'; import { languageCode } from '../utils/language-code'; -import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; -import { isCommandDisabled } from '../utils/check-command-disabled'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -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) => { +export default (bot: Telegraf) => { bot.command("http", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - if (await isCommandDisabled(ctx, db, 'http-status')) return; - const reply_to_message_id = ctx.message.message_id; - const { Strings } = await getUserAndStrings(ctx, db); + const Strings = getStrings(languageCode(ctx)); const userInput = ctx.message.text.split(' ')[1]; const apiUrl = Resources.httpApi; const { invalidCode } = Strings.httpCodes @@ -60,26 +34,24 @@ export default (bot: Telegraf, db) => { .replace("{description}", codeInfo.description); await ctx.reply(message, { parse_mode: 'Markdown', - ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + ...({ reply_to_message_id }) }); } else { await ctx.reply(Strings.httpCodes.notFound, { parse_mode: 'Markdown', - ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_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_parameters: { message_id: reply_to_message_id } } : {}) + ...({ reply_to_message_id }) }); }; }); bot.command("httpcat", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - if (await isCommandDisabled(ctx, db, 'animals-basic')) return; - 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, ''); @@ -91,7 +63,7 @@ export default (bot: Telegraf, db) => { if (userInput.length !== 3) { ctx.reply(Strings.httpCodes.invalidCode, { parse_mode: 'Markdown', - ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + ...({ reply_to_message_id }) }) return } @@ -102,12 +74,12 @@ export default (bot: Telegraf, db) => { await ctx.replyWithPhoto(apiUrl, { caption: `🐱 ${apiUrl}`, parse_mode: 'Markdown', - ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + ...({ reply_to_message_id }) }); } catch (error) { ctx.reply(Strings.catImgErr, { parse_mode: 'Markdown', - ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + ...({ reply_to_message_id }) }); } }); diff --git a/src/commands/info.ts b/src/commands/info.ts new file mode 100644 index 0000000..2bf2b2d --- /dev/null +++ b/src/commands/info.ts @@ -0,0 +1,66 @@ +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: 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); + + 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') { + 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('{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 Strings.groupOnly + } +} + +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: Context & { message: { text: string } }) => { + const userInfo = await getUserInfo(ctx); + ctx.reply( + userInfo, { + parse_mode: 'Markdown', + // @ts-ignore + reply_to_message_id: ctx.message.message_id + } + ); + }); +}; diff --git a/telegram/commands/lastfm.ts b/src/commands/lastfm.ts old mode 100755 new mode 100644 similarity index 84% rename from telegram/commands/lastfm.ts rename to src/commands/lastfm.ts index 2ccecbf..39d6c8d --- a/telegram/commands/lastfm.ts +++ b/src/commands/lastfm.ts @@ -4,14 +4,13 @@ import axios from 'axios'; import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; -import { isCommandDisabled } from '../utils/check-command-disabled'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); const scrobbler_url = Resources.lastFmApi; const api_key = process.env.lastKey; -const dbFile = 'telegram/props/lastfm.json'; +const dbFile = 'src/props/lastfm.json'; let users = {}; function loadUsers() { @@ -61,12 +60,10 @@ function getFromLast(track) { return imageUrl; } -export default (bot, db) => { +export default (bot) => { loadUsers(); - bot.command('setuser', async (ctx) => { - if (await isCommandDisabled(ctx, db, 'lastfm')) return; - + bot.command('setuser', (ctx) => { const userId = ctx.from.id; const Strings = getStrings(ctx.from.language_code); const lastUser = ctx.message.text.split(' ')[1]; @@ -75,7 +72,7 @@ export default (bot, db) => { return ctx.reply(Strings.lastFm.noUser, { parse_mode: "Markdown", disable_web_page_preview: true, - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + reply_to_message_id: ctx.message.message_id }); }; @@ -87,24 +84,22 @@ export default (bot, db) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + reply_to_message_id: ctx.message.message_id }); }); bot.command(['lt', 'lmu', 'last', 'lfm'], spamwatchMiddleware, async (ctx) => { - if (await isCommandDisabled(ctx, db, 'lastfm')) return; - const userId = ctx.from.id; const Strings = getStrings(ctx.from.language_code); 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, - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + reply_to_message_id: ctx.message.message_id }); }; @@ -129,7 +124,7 @@ export default (bot, db) => { return ctx.reply(noRecent, { parse_mode: "Markdown", disable_web_page_preview: true, - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + reply_to_message_id: ctx.message.message_id }); }; @@ -142,8 +137,8 @@ export default (bot, db) => { if (albumMbid) { imageUrl = await getFromMusicBrainz(albumMbid); - } - + } + if (!imageUrl) { imageUrl = getFromLast(track); } @@ -171,7 +166,7 @@ export default (bot, db) => { 'User-Agent': `@${botInfo.username}-node-telegram-bot` } }); - + num_plays = response_plays.data.track.userplaycount; } catch (err) { console.log(err) @@ -181,7 +176,7 @@ export default (bot, db) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + reply_to_message_id: ctx.message.message_id }); }; @@ -205,13 +200,13 @@ export default (bot, db) => { caption: message, parse_mode: "Markdown", disable_web_page_preview: true, - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + reply_to_message_id: ctx.message.message_id }); } else { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + reply_to_message_id: ctx.message.message_id }); }; } catch (err) { @@ -222,7 +217,7 @@ export default (bot, db) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + reply_to_message_id: ctx.message.message_id }); }; }); diff --git a/src/commands/main.ts b/src/commands/main.ts new file mode 100644 index 0000000..a5d581c --- /dev/null +++ b/src/commands/main.ts @@ -0,0 +1,33 @@ +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: 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 }) + }); + }); + + bot.command('privacy', spamwatchMiddleware, async (ctx: any) => { + const Strings = getStrings(ctx.from.language_code); + 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 + }); + }); +}; \ No newline at end of file diff --git a/telegram/commands/modarchive.ts b/src/commands/modarchive.ts old mode 100755 new mode 100644 similarity index 52% rename from telegram/commands/modarchive.ts rename to src/commands/modarchive.ts index 5d451a6..7d1489e --- a/telegram/commands/modarchive.ts +++ b/src/commands/modarchive.ts @@ -8,7 +8,6 @@ import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import { languageCode } from '../utils/language-code'; import { Context, Telegraf } from 'telegraf'; import { replyToMessageId } from '../utils/reply-to-message-id'; -import { isCommandDisabled } from '../utils/check-command-disabled'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -25,17 +24,22 @@ 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.join(__dirname, fileName); + + const filePath = path.resolve(__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); @@ -45,44 +49,39 @@ async function downloadModule(moduleId: string): Promise { } } -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)) { +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; + } + } + } return ctx.reply(Strings.maInvalidModule, { parse_mode: "Markdown", - ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_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, db) => { - bot.command(['modarchive', 'tma'], spamwatchMiddleware, async (ctx) => { - if (await isCommandDisabled(ctx, db, 'modarchive')) return; - await modarchiveHandler(ctx); }); }; diff --git a/telegram/commands/ponyapi.ts b/src/commands/ponyapi.ts old mode 100755 new mode 100644 similarity index 66% rename from telegram/commands/ponyapi.ts rename to src/commands/ponyapi.ts index 2bbc841..2202949 --- a/telegram/commands/ponyapi.ts +++ b/src/commands/ponyapi.ts @@ -7,7 +7,6 @@ import verifyInput from '../plugins/verifyInput'; import { Telegraf, Context } from 'telegraf'; import { languageCode } from '../utils/language-code'; import { replyToMessageId } from '../utils/reply-to-message-id'; -import { isCommandDisabled } from '../utils/check-command-disabled'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -54,42 +53,25 @@ 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, db) => { +export default (bot: Telegraf) => { bot.command("mlp", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - if (await isCommandDisabled(ctx, db, 'mlp-content')) return; - const Strings = getStrings(languageCode(ctx)); const reply_to_message_id = replyToMessageId(ctx); - sendReply(ctx, Strings.ponyApi.helpDesc, reply_to_message_id); + + ctx.reply(Strings.ponyApi.helpDesc, { + parse_mode: 'Markdown', + ...({ reply_to_message_id, disable_web_page_preview: true }) + }); }); bot.command("mlpchar", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - if (await isCommandDisabled(ctx, db, 'mlp-content')) return; - - const { message } = ctx; const reply_to_message_id = replyToMessageId(ctx); const Strings = getStrings(languageCode(ctx) || 'en'); - const userInput = message.text.split(' ').slice(1).join(' ').trim().replace(/\s+/g, '+'); - const { noCharName } = Strings.ponyApi; + const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); + const { noCharName } = Strings.ponyApi - 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); + if (verifyInput(ctx, userInput, noCharName)) { + return; } const capitalizedInput = capitalizeFirstLetter(userInput); @@ -97,34 +79,65 @@ export default (bot: Telegraf, db) => { try { const response = await axios(apiUrl); - 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 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 result = Strings.ponyApi.charRes - .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); + .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 }) + }); } else { - 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); - } + 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 }) + }); + }; }); bot.command("mlpep", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - if (await isCommandDisabled(ctx, db, 'mlp-content')) return; - const Strings = getStrings(languageCode(ctx) || 'en'); const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); const reply_to_message_id = replyToMessageId(ctx); @@ -135,14 +148,6 @@ export default (bot: Telegraf, db) => { return; } - if (Number(userInput) > 10000) { - ctx.reply(Strings.mlpInvalidEpisode, { - parse_mode: 'Markdown', - ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) - }); - return; - } - const apiUrl = `${Resources.ponyApi}/episode/by-overall/${userInput}`; try { @@ -183,26 +188,26 @@ export default (bot: Telegraf, db) => { ctx.replyWithPhoto(episodeArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', - ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + ...({ reply_to_message_id, disable_web_page_preview: true }) }); } else { ctx.reply(Strings.ponyApi.noEpisodeFound, { parse_mode: 'Markdown', - ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_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 } } : {}) + + ...({ reply_to_message_id }) }); }; }); bot.command("mlpcomic", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - if (await isCommandDisabled(ctx, db, 'mlp-content')) return; - const Strings = getStrings(languageCode(ctx) || 'en'); const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); const reply_to_message_id = replyToMessageId(ctx); @@ -213,15 +218,6 @@ export default (bot: Telegraf, db) => { 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 ? { reply_parameters: { message_id: reply_to_message_id } } : {}) - }); - return; - } - const apiUrl = `${Resources.ponyApi}/comics-story/${userInput}`; try { @@ -267,19 +263,21 @@ export default (bot: Telegraf, db) => { ctx.replyWithPhoto(comicArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', - ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + ...({ reply_to_message_id, disable_web_page_preview: true }) }); } else { ctx.reply(Strings.ponyApi.noComicFound, { parse_mode: 'Markdown', - ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_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 } } : {}) + + ...({ reply_to_message_id }) }); }; }); diff --git a/telegram/commands/quotes.ts b/src/commands/quotes.ts old mode 100755 new mode 100644 similarity index 100% rename from telegram/commands/quotes.ts rename to src/commands/quotes.ts diff --git a/src/commands/randompony.ts b/src/commands/randompony.ts new file mode 100644 index 0000000..175f283 --- /dev/null +++ b/src/commands/randompony.ts @@ -0,0 +1,47 @@ +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 { 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(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; + } + }); +} \ No newline at end of file diff --git a/telegram/commands/weather.ts b/src/commands/weather.ts old mode 100755 new mode 100644 similarity index 92% rename from telegram/commands/weather.ts rename to src/commands/weather.ts index 26a1b04..f72c343 --- a/telegram/commands/weather.ts +++ b/src/commands/weather.ts @@ -9,7 +9,6 @@ import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import verifyInput from '../plugins/verifyInput'; import { Context, Telegraf } from 'telegraf'; -import { isCommandDisabled } from '../utils/check-command-disabled'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -35,12 +34,10 @@ function getLocaleUnit(countryCode: string) { } } -export default (bot: Telegraf, db: any) => { - bot.command(['weather', 'clima'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - if (await isCommandDisabled(ctx, db, 'weather')) return; - +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 userLang = ctx.from.language_code || "en-US"; const Strings = getStrings(userLang); const userInput = ctx.message.text.split(' ').slice(1).join(' '); const { provideLocation } = Strings.weatherStatus diff --git a/telegram/commands/wiki.ts b/src/commands/wiki.ts old mode 100755 new mode 100644 similarity index 100% rename from telegram/commands/wiki.ts rename to src/commands/wiki.ts diff --git a/telegram/commands/youtube.ts b/src/commands/youtube.ts old mode 100755 new mode 100644 similarity index 96% rename from telegram/commands/youtube.ts rename to src/commands/youtube.ts index 5b20029..96d5d80 --- a/telegram/commands/youtube.ts +++ b/src/commands/youtube.ts @@ -2,7 +2,6 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import { execFile } from 'child_process'; -import { isCommandDisabled } from '../utils/check-command-disabled'; import os from 'os'; import fs from 'fs'; import path from 'path'; @@ -73,10 +72,8 @@ const isValidUrl = (url: string): boolean => { } }; -export default (bot, db) => { +export default (bot) => { bot.command(['yt', 'ytdl', 'sdl', 'video', 'dl'], spamwatchMiddleware, async (ctx) => { - if (await isCommandDisabled(ctx, db, 'youtube-download')) return; - const Strings = getStrings(ctx.from.language_code); const ytDlpPath = getYtDlpPath(); const userId: number = ctx.from.id; @@ -116,7 +113,7 @@ export default (bot, db) => { console.log(`\nDownload Request:\nURL: ${videoUrl}\nYOUTUBE: ${videoIsYoutube}\n`) if (fs.existsSync(path.resolve(__dirname, "../props/cookies.txt"))) { - cmdArgs = "--max-filesize 2G --no-playlist --cookies telegram/props/cookies.txt --merge-output-format mp4 -o"; + cmdArgs = "--max-filesize 2G --no-playlist --cookies src/props/cookies.txt --merge-output-format mp4 -o"; } else { cmdArgs = `--max-filesize 2G --no-playlist --merge-output-format mp4 -o`; } diff --git a/src/locales/english.json b/src/locales/english.json new file mode 100644 index 0000000..0d9f6dd --- /dev/null +++ b/src/locales/english.json @@ -0,0 +1,119 @@ +{ + "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.", + "botAbout": "*About the bot*\n\nThe bot base was originally created by [Lucas Gabriel (lucmsilva)](https://github.com/lucmsilva651), now maintained by several people.\n\nThe bot's purpose is to bring fun to your groups here on Telegram in a relaxed and simple way. The bot also features some very useful commands, which you can see using the help command (/help).\n\nSpecial thanks to @givfnz2 for his many contributions to the bot!\n\nSee the source code: [Click here to go to GitHub]({sourceLink})", + "aboutBot": "About the bot", + "varStrings": { + "varYes": "Yes", + "varNo": "No", + "varTo": "to", + "varIs": "is", + "varWas": "was", + "varNone": "None", + "varUnknown": "Unknown", + "varBack": "Back" + }, + "unexpectedErr": "Some unexpected error occurred during a bot action. Please report it to the developers.\n\n{error}", + "errInvalidOption": "Whoops! Invalid option!", + "kickingMyself": "*Since you don't need me, I'll leave.*", + "kickingMyselfErr": "Error leaving the chat.", + "noPermission": "You don't have permission to run this command.", + "privateOnly": "This command should only be used in private chats, not in groups.", + "groupOnly": "This command should only be used in groups, not in private chats.", + "botNameChanged": "*Bot name changed to* `{botName}`.", + "botNameErr": "*Error changing bot name:*\n`{tgErr}`", + "botDescChanged": "*Bot description changed to* `{botDesc}`.", + "botDescErr": "*Error changing bot description:*\n`{tgErr}`", + "gayAmount": "You are *{randomNum}%* gay!", + "furryAmount": "You are *{randomNum}%* furry!", + "randomNum": "*Generated number (0-10):* `{number}`.", + "userInfo": "*User info*\n\n*Name:* `{userName}`\n*Username:* `{userHandle}`\n*User ID:* `{userId}`\n*Language:* `{userLang}`\n*Premium user:* `{userPremium}`", + "chatInfo": "*Chat info*\n\n*Name:* `{chatName}`\n*Chat ID:* `{chatId}`\n*Handle:* `{chatHandle}`\n*Type:* `{chatType}`\n*Members:* `{chatMembersCount}`\n*Is a forum:* `{isForum}`", + "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.", + "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}`*.*", + "userHasBeenSet": "*Your Last.fm username has been set to:* `{lastUser}`.", + "listeningTo": "{lastfmUser} *{nowPlaying} listening {playCount}*:\n\n{trackName} by {artistName}", + "playCount": "to, for the {plays}th time", + "apiErr": "*Error retrieving data for Last.fm user* {lastfmUser}.\n\n`{err}`" + }, + "gitCurrentCommit": "*Current commit:* `{commitHash}`", + "gitErrRetrievingCommit": "*Error retrieving commit:* {error}", + "weatherStatus": { + "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}`", + "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`", + "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 `